refactor: replace stat-parser with declare-parser

Replace the old stat-parser logic with a new declare-parser to handle
variable declarations and tag modifiers. This includes moving the
declare-parser logic to a shared location in cli/completions to avoid
duplication between the CLI and the journal component.
This commit is contained in:
hypercross 2026-07-12 19:14:35 +08:00
parent dbe2f9d9d8
commit 981c829d0f
8 changed files with 158 additions and 547 deletions

View File

@ -97,8 +97,8 @@ export function scanDirectory(dir: string): {
} { } {
const index: ContentIndex = {}; const index: ContentIndex = {};
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], declarations: [],
statTemplates: [], tagModifiers: [],
}; };
const directiveResults: DirectiveScanResult[] = []; const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = []; const mdFiles: { content: string; relPath: string }[] = [];
@ -127,8 +127,8 @@ export function scanDirectory(dir: string): {
if (entry.endsWith(".md")) { if (entry.endsWith(".md")) {
const result = processBlocks(content, normalizedRelPath, index); const result = processBlocks(content, normalizedRelPath, index);
index[normalizedRelPath] = result.stripped; index[normalizedRelPath] = result.stripped;
blocks.stats.push(...result.blocks.stats); blocks.declarations.push(...result.blocks.declarations);
blocks.statTemplates.push(...result.blocks.statTemplates); blocks.tagModifiers.push(...result.blocks.tagModifiers);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
} else { } else {
index[normalizedRelPath] = content; index[normalizedRelPath] = content;
@ -310,23 +310,23 @@ export function createContentServer(
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let contentIndex: ContentIndex = {};
let collectedBlocks: ProcessedBlocks = { let collectedBlocks: ProcessedBlocks = {
stats: [], declarations: [],
statTemplates: [], tagModifiers: [],
}; };
let directiveResults: DirectiveScanResult[] = []; let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
dice: [], dice: [],
links: [], links: [],
sparkTables: [], sparkTables: [],
stats: [], declarations: [],
statTemplates: [], tagModifiers: [],
}; };
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log( 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}`,
); );
} }

View File

@ -7,14 +7,7 @@
import { posix } from "path"; import { posix } from "path";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js";
parseStatYaml,
parseStatCsv,
parseTemplateCsv,
parseStatModifiers,
type StatDef,
type StatTemplate,
} from "./stat-parser.js";
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
@ -34,8 +27,8 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface ProcessedBlocks { export interface ProcessedBlocks {
stats: StatDef[]; declarations: VarDeclaration[];
statTemplates: StatTemplate[]; tagModifiers: TagModifier[];
} }
export interface BlockResult { export interface BlockResult {
@ -62,7 +55,7 @@ function contentHash(body: string): string {
* *
* - Strips/replaces blocks based on `as` * - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index * - 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 * - role=spark-table blocks are converted to :md-table directives
* (spark table completions are collected later by the directive scanner) * (spark table completions are collected later by the directive scanner)
*/ */
@ -74,8 +67,8 @@ export function processBlocks(
const fileDir = posix.dirname(fileRelativePath); const fileDir = posix.dirname(fileRelativePath);
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], declarations: [],
statTemplates: [], tagModifiers: [],
}; };
const stripped = content.replace( const stripped = content.replace(
@ -92,29 +85,16 @@ export function processBlocks(
// ---- Dispatch by role ---- // ---- Dispatch by role ----
if (attrs.role === "stat") { if (attrs.role === "declare") {
if (attrs.lang === "yaml" || attrs.lang === "yml") { try {
blocks.stats.push(...parseStatYaml(body, fileRelativePath)); const result = parseDeclareCsv(body, fileRelativePath);
} else if (attrs.lang === "csv") { blocks.declarations.push(...result.variables);
blocks.stats.push(...parseStatCsv(body, fileRelativePath)); 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") { if (attrs.role === "file") {
const filename = attrs.id const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}` ? `${attrs.id}.${attrs.lang || "txt"}`
@ -161,4 +141,4 @@ export function processBlocks(
); );
return { stripped, blocks }; return { stripped, blocks };
} }

View File

@ -4,9 +4,9 @@
* Parses fenced code blocks with attributes: * Parses fenced code blocks with attributes:
* ```lang id=xxx role=xxx as=xxx * ```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) * - `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)
*/ */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -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;
}

View File

@ -12,8 +12,8 @@ export type {
DiceCompletion, DiceCompletion,
LinkCompletion, LinkCompletion,
SparkTableCompletion, SparkTableCompletion,
StatDef, VarDeclaration,
StatTemplate, TagModifier,
} from "./types.js"; } from "./types.js";
/** /**
@ -40,7 +40,7 @@ export function scanCompletions(
dice, dice,
links, links,
sparkTables, sparkTables,
stats: blocks.stats, declarations: blocks.declarations,
statTemplates: blocks.statTemplates, tagModifiers: blocks.tagModifiers,
}; };
} }

View File

@ -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<string, string>;
}
/** 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<string, string> | 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<string, string> = {};
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<string, string> = {};
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;
}

View File

@ -2,9 +2,9 @@
* Completion source types shared between CLI scanner and frontend consumer. * 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 */ /** A single dice expression found in a markdown file */
export interface DiceCompletion { export interface DiceCompletion {
@ -48,8 +48,8 @@ export interface CompletionsPayload {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; declarations: VarDeclaration[];
statTemplates: StatTemplate[]; tagModifiers: TagModifier[];
} }
/** /**
@ -61,4 +61,4 @@ export interface CompletionSource {
key: string; key: string;
/** Scan the content index and return structured completion items */ /** Scan the content index and return structured completion items */
scan(index: Record<string, string>): unknown[]; scan(index: Record<string, string>): unknown[];
} }

View File

@ -1,114 +1,10 @@
/** /**
* Declare parser parses ```csv role=declare code blocks from markdown. * Declare parser re-export delegates to the shared parser in cli/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 { export {
key: string; // "$hp" (always starts with $) parseDeclareCsv,
expression: string; // "$con*5+$mod_hp" type VarDeclaration,
} type TagModifier,
type DeclareResult,
export interface TagModifier { } from "../../cli/completions/declare-parser";
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;
}