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:
parent
dbe2f9d9d8
commit
981c829d0f
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue