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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user