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.
|
* 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 statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi;
|
||||||
const statCsvRegex = /```csv\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",
|
key: "stats",
|
||||||
|
|
||||||
scan(index) {
|
scan(index) {
|
||||||
const items: StatDef[] = [];
|
const items: ReturnType<typeof parseStatYaml> = [];
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(index)) {
|
for (const [filePath, content] of Object.entries(index)) {
|
||||||
if (!filePath.endsWith(".md")) continue;
|
if (!filePath.endsWith(".md")) continue;
|
||||||
|
|
||||||
// YAML blocks
|
|
||||||
statBlockRegex.lastIndex = 0;
|
statBlockRegex.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = statBlockRegex.exec(content)) !== null) {
|
while ((match = statBlockRegex.exec(content)) !== null) {
|
||||||
const parsed = parseStatYaml(match[1], filePath);
|
items.push(...parseStatYaml(match[1], filePath));
|
||||||
items.push(...parsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV blocks
|
|
||||||
statCsvRegex.lastIndex = 0;
|
statCsvRegex.lastIndex = 0;
|
||||||
while ((match = statCsvRegex.exec(content)) !== null) {
|
while ((match = statCsvRegex.exec(content)) !== null) {
|
||||||
const parsed = parseStatCsv(match[1], filePath);
|
items.push(...parseStatCsv(match[1], filePath));
|
||||||
items.push(...parsed);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
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.
|
* 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 */
|
/** A single dice expression found in a markdown file */
|
||||||
export interface DiceCompletion {
|
export interface DiceCompletion {
|
||||||
/** Display text shown in the dropdown */
|
/** Display text shown in the dropdown */
|
||||||
|
|
@ -36,7 +40,6 @@ export interface SparkTableCompletion {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level payload served at /__COMPLETIONS.json */
|
|
||||||
export interface CompletionsPayload {
|
export interface CompletionsPayload {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
|
|
@ -44,20 +47,6 @@ export interface CompletionsPayload {
|
||||||
stats: StatDef[];
|
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
|
* A completion source plugin. Add new sources by implementing this
|
||||||
* interface and registering in the barrel.
|
* interface and registering in the barrel.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
} from "../../data-loader/file-index";
|
} 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) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -38,20 +42,6 @@ export interface SparkTableCompletion {
|
||||||
headers: string[];
|
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 {
|
export interface JournalCompletions {
|
||||||
dice: DiceCompletion[];
|
dice: DiceCompletion[];
|
||||||
links: LinkCompletion[];
|
links: LinkCompletion[];
|
||||||
|
|
@ -195,193 +185,6 @@ function splitTableRow(line: string): string[] | null {
|
||||||
return inner.split("|").map((c) => c.trim());
|
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) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
||||||
// Using a top-level IIFE so the promise starts immediately
|
// Using a top-level IIFE so the promise starts immediately
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
},
|
},
|
||||||
"include": ["src/cli/**/*"],
|
"include": ["src/cli/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue