feat: add ReactiveStatManager for markdown template support

Introduce ReactiveStatManager to allow dynamic stat replacement within
rendered markdown content. This component scans the article DOM for
`${key}` patterns and reactively updates them based on the journal
stream.

As part of this change, the SVG-based StatSheet system is removed in
favor of this more flexible text-based approach.
This commit is contained in:
2026-07-12 13:32:49 +08:00
parent 722a8110e6
commit 736bcf4bb2
11 changed files with 306 additions and 515 deletions
+3 -47
View File
@@ -22,7 +22,6 @@ 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,
@@ -31,7 +30,7 @@ import {
scanDirectives,
} from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet };
export type { StatDef, StatTemplate };
// ------------------- Types (mirrors CLI) -------------------
@@ -63,7 +62,6 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
statSheets: StatSheet[];
}
export type CompletionsState =
@@ -93,7 +91,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates
: [],
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
};
} catch {
return null;
@@ -175,46 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
sparkTables.push(...directiveResult.sparkTables);
}
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;
return { dice, links, sparkTables, stats, statTemplates };
}
// ------------------- Init (runs eagerly at import time) -------------------
@@ -231,8 +189,7 @@ const _initPromise: Promise<void> = (async () => {
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
data.statSheets = await scanStatSheets();
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
} else {
setCompletionsState({ status: "empty" });
@@ -283,7 +240,6 @@ export function useJournalCompletions(): {
sparkTables: [],
stats: [],
statTemplates: [],
statSheets: [],
},
};
}