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 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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
* 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue