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 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}`,
);
}

View File

@ -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"}`

View File

@ -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)
*/
// ---------------------------------------------------------------------------

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

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.
*/
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[];
}
/**

View File

@ -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;
}
export {
parseDeclareCsv,
type VarDeclaration,
type TagModifier,
type DeclareResult,
} from "../../cli/completions/declare-parser";