diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 3c57e69..31f5e42 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -97,8 +97,8 @@ export function scanDirectory(dir: string): { } { const index: ContentIndex = {}; const blocks: ProcessedBlocks = { - stats: [], - statTemplates: [], + declarations: [], + tagModifiers: [], }; const directiveResults: DirectiveScanResult[] = []; const mdFiles: { content: string; relPath: string }[] = []; @@ -127,8 +127,8 @@ export function scanDirectory(dir: string): { if (entry.endsWith(".md")) { const result = processBlocks(content, normalizedRelPath, index); index[normalizedRelPath] = result.stripped; - blocks.stats.push(...result.blocks.stats); - blocks.statTemplates.push(...result.blocks.statTemplates); + blocks.declarations.push(...result.blocks.declarations); + blocks.tagModifiers.push(...result.blocks.tagModifiers); mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); } else { index[normalizedRelPath] = content; @@ -310,23 +310,23 @@ export function createContentServer( ): ContentServer { let contentIndex: ContentIndex = {}; let collectedBlocks: ProcessedBlocks = { - stats: [], - statTemplates: [], + declarations: [], + tagModifiers: [], }; let directiveResults: DirectiveScanResult[] = []; let completionsIndex: CompletionsPayload = { dice: [], links: [], sparkTables: [], - stats: [], - statTemplates: [], + declarations: [], + tagModifiers: [], }; /** 从当前内容索引和已收集的块重新扫描补全数据 */ function recomputeCompletions(): void { completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); console.log( - `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`, + `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`, ); } diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index aba5372..9b777de 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -7,14 +7,7 @@ import { posix } from "path"; import { createHash } from "crypto"; -import { - parseStatYaml, - parseStatCsv, - parseTemplateCsv, - parseStatModifiers, - type StatDef, - type StatTemplate, -} from "./stat-parser.js"; +import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js"; import { FENCED_BLOCK_RE, parseBlockAttrs, @@ -34,8 +27,8 @@ export { // --------------------------------------------------------------------------- export interface ProcessedBlocks { - stats: StatDef[]; - statTemplates: StatTemplate[]; + declarations: VarDeclaration[]; + tagModifiers: TagModifier[]; } export interface BlockResult { @@ -62,7 +55,7 @@ function contentHash(body: string): string { * * - Strips/replaces blocks based on `as` * - Injects `role=file` bodies into the content index - * - Collects stat/template blocks for completions + * - Collects declare blocks for completions * - role=spark-table blocks are converted to :md-table directives * (spark table completions are collected later by the directive scanner) */ @@ -74,8 +67,8 @@ export function processBlocks( const fileDir = posix.dirname(fileRelativePath); const blocks: ProcessedBlocks = { - stats: [], - statTemplates: [], + declarations: [], + tagModifiers: [], }; const stripped = content.replace( @@ -92,29 +85,16 @@ export function processBlocks( // ---- Dispatch by role ---- - if (attrs.role === "stat") { - if (attrs.lang === "yaml" || attrs.lang === "yml") { - blocks.stats.push(...parseStatYaml(body, fileRelativePath)); - } else if (attrs.lang === "csv") { - blocks.stats.push(...parseStatCsv(body, fileRelativePath)); + if (attrs.role === "declare") { + try { + const result = parseDeclareCsv(body, fileRelativePath); + blocks.declarations.push(...result.variables); + blocks.tagModifiers.push(...result.tagModifiers); + } catch (e) { + console.warn(`[block-processor] ${fileRelativePath}: ${e}`); } } - if (attrs.role === "stat-template") { - const name = attrs.id || `_tpl_${contentHash(body)}`; - blocks.statTemplates.push( - parseTemplateCsv(body, fileRelativePath, name), - ); - } - - if (attrs.role === "stat-modifiers") { - const id = attrs.id || `_mod_${contentHash(body)}`; - const result = parseStatModifiers(body, fileRelativePath, id, "player", attrs.extra["label"]); - blocks.stats.push(result.statDef); - blocks.stats.push(...result.modifierDefs); - blocks.statTemplates.push(result.template); - } - if (attrs.role === "file") { const filename = attrs.id ? `${attrs.id}.${attrs.lang || "txt"}` @@ -161,4 +141,4 @@ export function processBlocks( ); return { stripped, blocks }; -} +} \ No newline at end of file diff --git a/src/cli/completions/block-scanner.ts b/src/cli/completions/block-scanner.ts index 7dae76b..83e335c 100644 --- a/src/cli/completions/block-scanner.ts +++ b/src/cli/completions/block-scanner.ts @@ -4,9 +4,9 @@ * Parses fenced code blocks with attributes: * ```lang id=xxx role=xxx as=xxx * - * - `role` dispatches to content scanners (stat, stat-template, spark-table) + * - `role` dispatches to content scanners (declare, spark-table) * - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) - * - `id` is used for cross-references (template names, file paths) + * - `id` is used for cross-references (file paths) */ // --------------------------------------------------------------------------- diff --git a/src/cli/completions/declare-parser.ts b/src/cli/completions/declare-parser.ts new file mode 100644 index 0000000..fc7de5b --- /dev/null +++ b/src/cli/completions/declare-parser.ts @@ -0,0 +1,116 @@ +/** + * Declare parser — parses ```csv role=declare code blocks from markdown. + * + * Shared between CLI completions scanner and browser-side completions. + * + * Format: + * tag,key,expr + * ,$hp,$con*5+$mod_hp ← variable declaration + * ,$ac,10+$dex ← variable declaration + * #warrior,$mod_hp,20 ← tag modifier + * #warrior,$mod_str,1 ← tag modifier + * + * When `tag` is empty: $key is a reactively computed variable. + * When `tag` is present: when #tag is active, $key gets expr added to its base. + */ + +export interface VarDeclaration { + key: string; // "$hp" (always starts with $) + expression: string; // "$con*5+$mod_hp" +} + +export interface TagModifier { + tag: string; // "#warrior" + target: string; // "$mod_hp" + expression: string; // "20" +} + +export interface DeclareResult { + variables: VarDeclaration[]; + tagModifiers: TagModifier[]; +} + +/** + * Parse a ```csv role=declare block. + */ +export function parseDeclareCsv(csv: string, source: string): DeclareResult { + const lines = csv.trim().split(/\r?\n/); + if (lines.length === 0) return { variables: [], tagModifiers: [] }; + + // First line is header; validate it + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + if (headers[0] !== "tag" || headers[1] !== "key" || headers[2] !== "expr") { + throw new Error( + `${source}: role=declare blocks must have headers "tag,key,expr". Got: ${headers.join(",")}`, + ); + } + + const variables: VarDeclaration[] = []; + const tagModifiers: TagModifier[] = []; + + for (let i = 1; i < lines.length; i++) { + const row = lines[i].trim(); + if (!row || row.startsWith("#")) continue; + + const cols = splitCsvRow(row); + if (cols.length < 3) continue; + + const tag = cols[0]?.trim() ?? ""; + const key = cols[1]?.trim() ?? ""; + const expr = cols[2]?.trim() ?? ""; + + if (!key || !expr) { + console.warn(`${source}:${i + 1}: skipping row with empty key or expr`); + continue; + } + + if (tag) { + // Tag modifier + if (!tag.startsWith("#")) { + throw new Error( + `${source}:${i + 1}: tag must start with #, got "${tag}"`, + ); + } + if (!key.startsWith("$")) { + throw new Error( + `${source}:${i + 1}: key must start with $, got "${key}"`, + ); + } + tagModifiers.push({ tag, target: key, expression: expr }); + } else { + // Variable declaration + if (!key.startsWith("$")) { + throw new Error( + `${source}:${i + 1}: key must start with $, got "${key}"`, + ); + } + variables.push({ key, expression: expr }); + } + } + + return { variables, tagModifiers }; +} + +// --------------------------------------------------------------------------- +// CSV row splitter +// --------------------------------------------------------------------------- + +function splitCsvRow(row: string): string[] { + const cols: string[] = []; + let current = ""; + let inQuote = false; + + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === "," && !inQuote) { + cols.push(current); + current = ""; + } else { + current += ch; + } + } + cols.push(current); + return cols; +} \ No newline at end of file diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index 5bc5b66..f6b26f3 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -12,8 +12,8 @@ export type { DiceCompletion, LinkCompletion, SparkTableCompletion, - StatDef, - StatTemplate, + VarDeclaration, + TagModifier, } from "./types.js"; /** @@ -40,7 +40,7 @@ export function scanCompletions( dice, links, sparkTables, - stats: blocks.stats, - statTemplates: blocks.statTemplates, + declarations: blocks.declarations, + tagModifiers: blocks.tagModifiers, }; -} +} \ No newline at end of file diff --git a/src/cli/completions/stat-parser.ts b/src/cli/completions/stat-parser.ts deleted file mode 100644 index 0139512..0000000 --- a/src/cli/completions/stat-parser.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * Shared stat block parser — used by both the CLI completions scanner and - * the client-side completions scanner. - * - * Parses ```yaml role=stat and ```csv role=stat blocks from markdown content. - */ - -export interface StatDef { - key: string; - label: string; - type: "number" | "string" | "enum" | "modifier" | "derived" | "template"; - scope: "player" | "global"; - default?: string; - target?: string; - options?: string[]; - roll?: string; - formula?: string; - /** Name of a StatTemplate to use for template-type stats */ - template?: string; - source: string; -} - -/** A single row in a stat template table */ -export interface TemplateEntry { - range: string; - label: string; - modifiers: Record; -} - -/** A named stat template (from ```csv role=stat-template file=xxx) */ -export interface StatTemplate { - name: string; - /** Dice notation from the first header, e.g. "1d10" */ - notation: string; - entries: TemplateEntry[]; - source: string; -} - -// --------------------------------------------------------------------------- -// YAML parser -// --------------------------------------------------------------------------- - -export function parseStatYaml(yaml: string, source: string): StatDef[] { - const defs: StatDef[] = []; - const lines = yaml.split(/\r?\n/); - - let current: Record | null = null; - let collectingOptions = false; - let options: string[] = []; - - function flushCurrent() { - if (!current || !current.key) return; - const type = (current.type || "number") as StatDef["type"]; - const scope = (current.scope || "global") as StatDef["scope"]; - defs.push({ - key: current.key, - label: current.label || current.key, - type, - scope, - default: current.default, - target: current.target, - template: current.template, - options: - type === "enum" && options.length > 0 - ? [...options] - : current.options - ? current.options - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - : undefined, - roll: current.roll, - formula: current.formula, - source, - }); - current = null; - options = []; - collectingOptions = false; - } - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - - if (trimmed.startsWith("- ")) { - flushCurrent(); - current = {}; - collectingOptions = false; - const rest = trimmed.slice(2); - const colonIdx = rest.indexOf(":"); - if (colonIdx === -1) continue; - const k = rest.slice(0, colonIdx).trim(); - const v = rest.slice(colonIdx + 1).trim(); - current[k] = v; - continue; - } - - if (current && trimmed.match(/^\w/)) { - const colonIdx = trimmed.indexOf(":"); - if (colonIdx === -1) continue; - const k = trimmed.slice(0, colonIdx).trim(); - const v = trimmed.slice(colonIdx + 1).trim(); - - if (k === "options") { - if (v.startsWith("[") && v.endsWith("]")) { - current[k] = v.slice(1, -1); - } else { - collectingOptions = true; - options = []; - } - } else { - current[k] = v; - } - continue; - } - - if (collectingOptions && trimmed.startsWith("- ")) { - options.push(trimmed.slice(2).trim()); - continue; - } - } - - flushCurrent(); - return defs; -} - -// --------------------------------------------------------------------------- -// CSV parser -// --------------------------------------------------------------------------- - -export function parseStatCsv(csv: string, source: string): StatDef[] { - const lines = csv.trim().split(/\r?\n/); - if (lines.length === 0) return []; - - const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); - const idx = (name: string) => { - const i = headers.indexOf(name); - return i === -1 ? null : i; - }; - - const defs: StatDef[] = []; - - for (let i = 1; i < lines.length; i++) { - const row = lines[i].trim(); - if (!row || row.startsWith("#")) continue; - const cols = splitCsvRow(row); - if (cols.length === 0) continue; - - const get = (name: string) => { - const colIdx = idx(name); - if (colIdx === null || colIdx >= cols.length) return undefined; - return cols[colIdx].trim() || undefined; - }; - - const key = get("key"); - if (!key) continue; - - const type = (get("type") || "number") as StatDef["type"]; - const scope = (get("scope") || "player") as StatDef["scope"]; - - let options: string[] | undefined; - const optRaw = get("options"); - if (optRaw) { - options = optRaw - .split("|") - .map((s) => s.trim()) - .filter(Boolean); - } - - defs.push({ - key, - label: get("label") || key, - type, - scope, - default: get("default"), - roll: get("roll"), - target: get("target"), - template: get("template"), - formula: get("formula"), - options, - source, - }); - } - - return defs; -} - -// --------------------------------------------------------------------------- -// Template CSV parser -// --------------------------------------------------------------------------- - -/** - * Parse a ```csv role=stat-template block. - * - * The first header is the dice notation (e.g. "1d10"). - * The second header is "label". - * Remaining headers are modifier keys (bare names, no prefix). - */ -export function parseTemplateCsv( - csv: string, - source: string, - name: string, -): StatTemplate { - const lines = csv.trim().split(/\r?\n/); - if (lines.length === 0) return { name, notation: "", entries: [], source }; - - const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); - if (headers.length < 2) return { name, notation: "", entries: [], source }; - - const notation = headers[0]; - const labelIdx = headers.indexOf("label"); - const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx); - - const entries: TemplateEntry[] = []; - - for (let i = 1; i < lines.length; i++) { - const row = lines[i].trim(); - if (!row || row.startsWith("#")) continue; - const cols = splitCsvRow(row); - if (cols.length === 0) continue; - - const range = cols[0]?.trim(); - if (!range) continue; - - const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range; - - const modifiers: Record = {}; - for (const mk of modKeys) { - const mkIdx = headers.indexOf(mk); - if (mkIdx !== -1 && mkIdx < cols.length) { - const val = cols[mkIdx].trim(); - if (val) modifiers[mk] = val; - } - } - - entries.push({ range, label, modifiers }); - } - - return { name, notation, entries, source }; -} - -// --------------------------------------------------------------------------- -// Stat-modifiers parser -// --------------------------------------------------------------------------- - -/** Result of parsing a stat-modifiers block. */ -export interface StatModifiersResult { - /** The template stat def (type: template) */ - statDef: StatDef; - /** Modifier stat defs (type: modifier, one per value column) */ - modifierDefs: StatDef[]; - /** The template table with prefixed modifier keys */ - template: StatTemplate; -} - -/** - * Parse a ```csv role=stat-modifiers block. - * - * Auto-generates a template stat + modifier stat defs from a single CSV. - * - * The first header is the dice notation (e.g. "1d10"). - * The second header is "label". - * Remaining headers are bare target names (e.g. "mind", "heart"). - * - * Generated modifier keys: {id}_{column} (e.g. "age_mind"). - * Template entries internally map: column → {id}_{column}. - */ -export function parseStatModifiers( - csv: string, - source: string, - id: string, - scope: "player" | "global" = "player", - label?: string, -): StatModifiersResult { - const statLabel = label || id; - const lines = csv.trim().split(/\r?\n/); - const emptyTemplate: StatTemplate = { - name: id, - notation: "", - entries: [], - source, - }; - - if (lines.length === 0) { - return { - statDef: { key: id, label: statLabel, type: "template", scope, template: id, source }, - modifierDefs: [], - template: emptyTemplate, - }; - } - - const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); - if (headers.length < 2) { - return { - statDef: { key: id, label: statLabel, type: "template", scope, template: id, source }, - modifierDefs: [], - template: emptyTemplate, - }; - } - - const notation = headers[0]; - const labelIdx = headers.indexOf("label"); - - // Bare column names (e.g. "mind", "heart") — these become targets - const bareColumns = headers.filter((h, i) => i !== 0 && i !== labelIdx); - - // Generate modifier stat defs: key = {id}_{column}, target = column - const modifierDefs: StatDef[] = bareColumns.map((col) => ({ - key: `${id}_${col}`, - label: col, - type: "modifier" as const, - scope, - target: col, - source, - })); - - // Build template entries with prefixed modifier keys - const entries: TemplateEntry[] = []; - - for (let i = 1; i < lines.length; i++) { - const row = lines[i].trim(); - if (!row || row.startsWith("#")) continue; - const cols = splitCsvRow(row); - if (cols.length === 0) continue; - - const range = cols[0]?.trim(); - if (!range) continue; - - const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range; - - const modifiers: Record = {}; - for (const col of bareColumns) { - const colIdx = headers.indexOf(col); - if (colIdx !== -1 && colIdx < cols.length) { - const val = cols[colIdx].trim(); - if (val) { - modifiers[`${id}_${col}`] = val; - } - } - } - - entries.push({ range, label, modifiers }); - } - - const template: StatTemplate = { name: id, notation, entries, source }; - - const statDef: StatDef = { - key: id, - label: statLabel, - type: "template", - scope, - template: id, - source, - }; - - return { statDef, modifierDefs, template }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -export function splitCsvRow(row: string): string[] { - const cols: string[] = []; - let current = ""; - let inQuote = false; - - for (let i = 0; i < row.length; i++) { - const ch = row[i]; - if (ch === '"') { - inQuote = !inQuote; - } else if (ch === "," && !inQuote) { - cols.push(current); - current = ""; - } else { - current += ch; - } - } - cols.push(current); - return cols; -} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index b49a419..dc25abf 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -2,9 +2,9 @@ * Completion source types — shared between CLI scanner and frontend consumer. */ -import type { StatDef, StatTemplate } from "./stat-parser.js"; +import type { VarDeclaration, TagModifier } from "./declare-parser.js"; -export type { StatDef, StatTemplate }; +export type { VarDeclaration, TagModifier }; /** A single dice expression found in a markdown file */ export interface DiceCompletion { @@ -48,8 +48,8 @@ export interface CompletionsPayload { dice: DiceCompletion[]; links: LinkCompletion[]; sparkTables: SparkTableCompletion[]; - stats: StatDef[]; - statTemplates: StatTemplate[]; + declarations: VarDeclaration[]; + tagModifiers: TagModifier[]; } /** @@ -61,4 +61,4 @@ export interface CompletionSource { key: string; /** Scan the content index and return structured completion items */ scan(index: Record): unknown[]; -} +} \ No newline at end of file diff --git a/src/components/journal/declare-parser.ts b/src/components/journal/declare-parser.ts index 353983d..bec508f 100644 --- a/src/components/journal/declare-parser.ts +++ b/src/components/journal/declare-parser.ts @@ -1,114 +1,10 @@ /** - * Declare parser — parses ```csv role=declare code blocks from markdown. - * - * Format: - * tag,key,expr - * ,$hp,$con*5+$mod_hp ← variable declaration - * ,$ac,10+$dex ← variable declaration - * #warrior,$mod_hp,20 ← tag modifier - * #warrior,$mod_str,1 ← tag modifier - * - * When `tag` is empty: $key is a reactively computed variable. - * When `tag` is present: when #tag is active, $key gets expr added to its base. + * Declare parser re-export — delegates to the shared parser in cli/completions. */ -export interface VarDeclaration { - key: string; // "$hp" (always starts with $) - expression: string; // "$con*5+$mod_hp" -} - -export interface TagModifier { - tag: string; // "#warrior" - target: string; // "$mod_hp" - expression: string; // "20" -} - -export interface DeclareResult { - variables: VarDeclaration[]; - tagModifiers: TagModifier[]; -} - -/** - * Parse a ```csv role=declare block. - */ -export function parseDeclareCsv(csv: string, source: string): DeclareResult { - const lines = csv.trim().split(/\r?\n/); - if (lines.length === 0) return { variables: [], tagModifiers: [] }; - - // First line is header; validate it - const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); - if (headers[0] !== "tag" || headers[1] !== "key" || headers[2] !== "expr") { - throw new Error( - `${source}: role=declare blocks must have headers "tag,key,expr". Got: ${headers.join(",")}`, - ); - } - - const variables: VarDeclaration[] = []; - const tagModifiers: TagModifier[] = []; - - for (let i = 1; i < lines.length; i++) { - const row = lines[i].trim(); - if (!row || row.startsWith("#")) continue; - - const cols = splitCsvRow(row); - if (cols.length < 3) continue; - - const tag = cols[0]?.trim() ?? ""; - const key = cols[1]?.trim() ?? ""; - const expr = cols[2]?.trim() ?? ""; - - if (!key || !expr) { - console.warn(`${source}:${i + 1}: skipping row with empty key or expr`); - continue; - } - - if (tag) { - // Tag modifier - if (!tag.startsWith("#")) { - throw new Error( - `${source}:${i + 1}: tag must start with #, got "${tag}"`, - ); - } - if (!key.startsWith("$")) { - throw new Error( - `${source}:${i + 1}: key must start with $, got "${key}"`, - ); - } - tagModifiers.push({ tag, target: key, expression: expr }); - } else { - // Variable declaration - if (!key.startsWith("$")) { - throw new Error( - `${source}:${i + 1}: key must start with $, got "${key}"`, - ); - } - variables.push({ key, expression: expr }); - } - } - - return { variables, tagModifiers }; -} - -// --------------------------------------------------------------------------- -// CSV row splitter (shared with stat-parser) -// --------------------------------------------------------------------------- - -function splitCsvRow(row: string): string[] { - const cols: string[] = []; - let current = ""; - let inQuote = false; - - for (let i = 0; i < row.length; i++) { - const ch = row[i]; - if (ch === '"') { - inQuote = !inQuote; - } else if (ch === "," && !inQuote) { - cols.push(current); - current = ""; - } else { - current += ch; - } - } - cols.push(current); - return cols; -} \ No newline at end of file +export { + parseDeclareCsv, + type VarDeclaration, + type TagModifier, + type DeclareResult, +} from "../../cli/completions/declare-parser"; \ No newline at end of file