Add a browser-safe ContentRegistry owning path and inline content stores, then derive completions from it. Remove the old block-processor, directive-scanner, and link-source modules in favor of scanDoc, deriveCompletions, and injectSparkDirectives, and register an inline resolver for the frontend file index.
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
/**
|
|
* Declare parser — parses ```csv role=declare code blocks from markdown.
|
|
*
|
|
* Shared between CLI completions scanner and browser-side completions.
|
|
*
|
|
* Format (columns can be in any order; `tag` and `threshold` are optional):
|
|
* tag,threshold,key,expr
|
|
* ,,$hp,$con*5+$mod_hp ← variable declaration
|
|
* ,,$ac,10+$dex ← variable declaration
|
|
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
|
|
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
|
|
*
|
|
* When `tag` is empty: $key is a reactively computed variable.
|
|
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
|
* $key gets expr added to its base.
|
|
*/
|
|
|
|
import { parse } from "csv-parse/browser/esm/sync";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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"
|
|
threshold: number; // minimum tagmap count to activate (default 1)
|
|
}
|
|
|
|
export interface DeclareResult {
|
|
variables: VarDeclaration[];
|
|
tagModifiers: TagModifier[];
|
|
}
|
|
|
|
/**
|
|
* Parse a single ```csv role=declare block body.
|
|
*/
|
|
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|
const trimmed = csv.trim();
|
|
if (!trimmed) return { variables: [], tagModifiers: [] };
|
|
|
|
// Validate that required columns exist (order-agnostic, tag is optional)
|
|
const firstLine = trimmed.split(/\r?\n/)[0];
|
|
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
|
if (!headers.includes("key") || !headers.includes("expr")) {
|
|
throw new Error(
|
|
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
|
|
);
|
|
}
|
|
|
|
const records = parse(trimmed, {
|
|
columns: true,
|
|
trim: true,
|
|
skipEmptyLines: true,
|
|
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
|
|
|
const variables: VarDeclaration[] = [];
|
|
const tagModifiers: TagModifier[] = [];
|
|
|
|
for (const row of records) {
|
|
const tag = row.tag ?? "";
|
|
const key = row.key ?? "";
|
|
const expr = row.expr ?? "";
|
|
|
|
if (!key || !expr) {
|
|
console.warn(`${source}: skipping row with empty key or expr`);
|
|
continue;
|
|
}
|
|
|
|
if (tag) {
|
|
// Tag modifier
|
|
if (!tag.startsWith("#")) {
|
|
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
|
}
|
|
if (!key.startsWith("$")) {
|
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
}
|
|
const thresholdRaw = row.threshold?.trim() ?? "";
|
|
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
|
if (isNaN(threshold) || threshold < 1) {
|
|
throw new Error(
|
|
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
|
|
);
|
|
}
|
|
tagModifiers.push({ tag, target: key, expression: expr, threshold });
|
|
} else {
|
|
// Variable declaration
|
|
if (!key.startsWith("$")) {
|
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
|
}
|
|
variables.push({ key, expression: expr });
|
|
}
|
|
}
|
|
|
|
return { variables, tagModifiers };
|
|
}
|