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:
parent
ef8b56e5b6
commit
19c66640b5
|
|
@ -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<typeof parseStatYaml> = [];
|
||||
|
||||
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<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();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue