feat: add support for stat sheet SVG rendering

Introduces the ability to discover and render `*.sheet.svg` files.
These files can contain `<text>` elements with `${key}` templates
that reactively update based on the current journal stats.

- Add `StatSheet` type and CLI scanning logic
- Implement `SheetView` for rendering SVGs with live bindings
- Add `parseSheet` utility to extract template patterns from SVG
- Update file indexer to include `.svg` files
This commit is contained in:
2026-07-09 12:04:25 +08:00
parent 4f3b03d082
commit 30d1c5dc1b
9 changed files with 478 additions and 127 deletions
+47 -3
View File
@@ -22,13 +22,14 @@ import {
parseStatModifiers,
} from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import type { StatSheet } from "../../cli/completions/types";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
export type { StatDef, StatTemplate };
export type { StatDef, StatTemplate, StatSheet };
// ------------------- Types (mirrors CLI) -------------------
@@ -58,6 +59,7 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
statSheets: StatSheet[];
}
export type CompletionsState =
@@ -87,6 +89,7 @@ async function tryServer(): Promise<JournalCompletions | null> {
statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates
: [],
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
};
} catch {
return null;
@@ -169,7 +172,46 @@ async function scanClientSide(): Promise<JournalCompletions> {
}
}
return { dice, links, sparkTables, stats, statTemplates };
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
}
// ------------------- Stat sheet helpers -------------------
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
function labelFromId(id: string): string {
return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
async function scanStatSheets(): Promise<StatSheet[]> {
const paths = await getPathsByExtension("svg");
const sheets: StatSheet[] = [];
for (const filePath of paths) {
if (!/\.sheet\.svg$/i.test(filePath)) continue;
try {
const content = await getIndexedData(filePath);
if (!content) continue;
const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
const id = fileName.replace(/\.sheet\.svg$/i, "");
const title = extractSvgTitle(content);
sheets.push({
id,
label: title || labelFromId(id),
svg: content,
source: filePath,
});
} catch {
// skip unreadable files
}
}
return sheets;
}
// ------------------- Init (runs eagerly at import time) -------------------
@@ -186,7 +228,8 @@ const _initPromise: Promise<void> = (async () => {
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
data.statSheets = await scanStatSheets();
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
setCompletionsState({ status: "loaded", data });
} else {
setCompletionsState({ status: "empty" });
@@ -237,6 +280,7 @@ export function useJournalCompletions(): {
sparkTables: [],
stats: [],
statTemplates: [],
statSheets: [],
},
};
}