refactor: extract stat parsing logic to shared module

Move YAML and CSV stat parsing logic from CLI and journal components
into a new shared `stat-parser.ts` module to enable reuse between
the CLI scanner and the client-side frontend.
This commit is contained in:
2026-07-09 10:00:17 +08:00
parent ef8b56e5b6
commit 19c66640b5
5 changed files with 202 additions and 390 deletions
+4 -201
View File
@@ -15,6 +15,10 @@ import {
getPathsByExtension,
getIndexedData,
} from "../../data-loader/file-index";
import { parseStatYaml, parseStatCsv } from "../../cli/completions/stat-parser";
import type { StatDef } from "../../cli/completions/stat-parser";
export type { StatDef };
// ------------------- Types (mirrors CLI) -------------------
@@ -38,20 +42,6 @@ export interface SparkTableCompletion {
headers: string[];
}
/** A single stat definition parsed from a ```stat YAML block */
export interface StatDef {
key: string;
label: string;
type: "number" | "string" | "enum" | "modifier" | "derived";
scope: "player" | "global";
default?: string;
target?: string;
options?: string[];
roll?: string;
formula?: string;
source: string;
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
@@ -195,193 +185,6 @@ function splitTableRow(line: string): string[] | null {
return inner.split("|").map((c) => c.trim());
}
// ---------------------------------------------------------------------------
// Stat YAML parser
// ---------------------------------------------------------------------------
/**
* Parse a ```yaml role=stat block into StatDef entries.
* Handles a minimal YAML subset: list of objects with key/value pairs.
*/
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,
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();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) continue;
// New entry: "- key: value"
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;
}
// Continuation of current entry: " key: value"
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") {
// Inline options: options: [a, b, c]
if (v.startsWith("[") && v.endsWith("]")) {
current[k] = v.slice(1, -1);
} else {
// Multi-line options list
collectingOptions = true;
options = [];
}
} else {
current[k] = v;
}
continue;
}
// Options list item: " - value"
if (collectingOptions && trimmed.startsWith("- ")) {
options.push(trimmed.slice(2).trim());
continue;
}
}
flushCurrent();
return defs;
}
// ---------------------------------------------------------------------------
// Stat CSV parser
// ---------------------------------------------------------------------------
/**
* Parse a ```csv role=stat block into StatDef entries.
*
* Columns: key, label, type, scope, default, roll, target, formula, options
* Only `key` is required. `type` defaults to "number", `scope` to "player".
* `options` column uses pipe-separated values: "a|b|c".
*/
function parseStatCsv(csv: string, source: string): StatDef[] {
const lines = csv.trim().split(/\r?\n/);
if (lines.length === 0) return [];
// Parse header
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"),
formula: get("formula"),
options,
source,
});
}
return defs;
}
/** Split a CSV row respecting quoted fields. */
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;
}
// ------------------- Init (runs eagerly at import time) -------------------
// Using a top-level IIFE so the promise starts immediately