From 19c66640b53c3b87e67f608c31dc15c82610b8ef Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 9 Jul 2026 10:00:17 +0800 Subject: [PATCH] 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. --- src/cli/completions/sources/stats.ts | 177 +--------------------- src/cli/completions/stat-parser.ts | 187 +++++++++++++++++++++++ src/cli/completions/types.ts | 19 +-- src/components/journal/completions.ts | 205 +------------------------- tsconfig.cli.json | 4 +- 5 files changed, 202 insertions(+), 390 deletions(-) create mode 100644 src/cli/completions/stat-parser.ts diff --git a/src/cli/completions/sources/stats.ts b/src/cli/completions/sources/stats.ts index f892e1c..44890ca 100644 --- a/src/cli/completions/sources/stats.ts +++ b/src/cli/completions/sources/stats.ts @@ -3,7 +3,8 @@ * blocks from markdown files. */ -import type { CompletionSource, StatDef } from "../types.js"; +import type { CompletionSource } from "../types.js"; +import { parseStatYaml, parseStatCsv } from "../stat-parser.js"; const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; @@ -12,191 +13,23 @@ export const statsSource: CompletionSource = { key: "stats", scan(index) { - const items: StatDef[] = []; + const items: ReturnType = []; for (const [filePath, content] of Object.entries(index)) { if (!filePath.endsWith(".md")) continue; - // YAML blocks statBlockRegex.lastIndex = 0; let match: RegExpExecArray | null; while ((match = statBlockRegex.exec(content)) !== null) { - const parsed = parseStatYaml(match[1], filePath); - items.push(...parsed); + items.push(...parseStatYaml(match[1], filePath)); } - // CSV blocks statCsvRegex.lastIndex = 0; while ((match = statCsvRegex.exec(content)) !== null) { - const parsed = parseStatCsv(match[1], filePath); - items.push(...parsed); + items.push(...parseStatCsv(match[1], filePath)); } } return items; }, }; - -// --------------------------------------------------------------------------- -// YAML parser -// --------------------------------------------------------------------------- - -function parseStatYaml(yaml: string, source: string): StatDef[] { - const defs: StatDef[] = []; - const lines = yaml.split(/\r?\n/); - - let current: Record | 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(); - 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 -// --------------------------------------------------------------------------- - -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"), - formula: get("formula"), - options, - source, - }); - } - - return defs; -} - -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; -} diff --git a/src/cli/completions/stat-parser.ts b/src/cli/completions/stat-parser.ts new file mode 100644 index 0000000..0dcc06c --- /dev/null +++ b/src/cli/completions/stat-parser.ts @@ -0,0 +1,187 @@ +/** + * 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"; + scope: "player" | "global"; + default?: string; + target?: string; + options?: string[]; + roll?: string; + formula?: string; + source: string; +} + +// --------------------------------------------------------------------------- +// YAML parser +// --------------------------------------------------------------------------- + +export function parseStatYaml(yaml: string, source: string): StatDef[] { + const defs: StatDef[] = []; + const lines = yaml.split(/\r?\n/); + + let current: Record | 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(); + 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"), + formula: get("formula"), + options, + source, + }); + } + + return defs; +} + +// --------------------------------------------------------------------------- +// 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; +} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index bb1631c..a1c50d7 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -2,6 +2,10 @@ * Completion source types — shared between CLI scanner and frontend consumer. */ +import type { StatDef } from "./stat-parser.js"; + +export type { StatDef }; + /** A single dice expression found in a markdown file */ export interface DiceCompletion { /** Display text shown in the dropdown */ @@ -36,7 +40,6 @@ export interface SparkTableCompletion { headers: string[]; } -/** Top-level payload served at /__COMPLETIONS.json */ export interface CompletionsPayload { dice: DiceCompletion[]; links: LinkCompletion[]; @@ -44,20 +47,6 @@ export interface CompletionsPayload { stats: StatDef[]; } -/** A single stat definition parsed from a ```yaml/csv role=stat 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; -} - /** * A completion source plugin. Add new sources by implementing this * interface and registering in the barrel. diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 82227f2..b4c2a09 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -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 | 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 diff --git a/tsconfig.cli.json b/tsconfig.cli.json index f64a808..41545b9 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -13,8 +13,8 @@ "declaration": true, "outDir": "./dist", "rootDir": "./src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "DOM", "DOM.Iterable"], }, "include": ["src/cli/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], }