diff --git a/src/App.tsx b/src/App.tsx
index 6871f38..f25fcc8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ import {
DocDialog,
DataSourceDialog,
RevealManager,
+ ReactiveStatManager,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
@@ -156,6 +157,7 @@ const App: Component = () => {
src={currentPath()}
>
+
diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts
index fbedb1a..3c57e69 100644
--- a/src/cli/commands/serve.ts
+++ b/src/cli/commands/serve.ts
@@ -19,7 +19,6 @@ import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
-import type { StatSheet } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
@@ -88,22 +87,6 @@ function getBestIP(): string {
return best || "localhost";
}
-/**
- * 从 SVG 字符串中提取
内容,无则返回 null
- */
-function extractSvgTitle(svg: string): string | null {
- const m = /]*>([\s\S]*?)<\/title>/i.exec(svg);
- return m ? m[1].trim() : null;
-}
-
-/**
- * 从文件名生成可读标签
- * 如 "character-sheet" -> "Character Sheet"
- */
-function labelFromId(id: string): string {
- return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
-}
-
/**
* 扫描目录内的 .md 等文件,生成内容索引与块数据
*/
@@ -116,7 +99,6 @@ export function scanDirectory(dir: string): {
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
- statSheets: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
@@ -148,17 +130,6 @@ export function scanDirectory(dir: string): {
blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
- } else if (entry.endsWith(".sheet.svg")) {
- index[normalizedRelPath] = content;
- const id = entry.replace(/\.sheet\.svg$/i, "");
- const title = extractSvgTitle(content);
- const sheet: StatSheet = {
- id,
- label: title || labelFromId(id),
- svg: content,
- source: normalizedRelPath,
- };
- blocks.statSheets.push(sheet);
} else {
index[normalizedRelPath] = content;
}
@@ -341,7 +312,6 @@ export function createContentServer(
let collectedBlocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
- statSheets: [],
};
let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = {
@@ -350,14 +320,13 @@ export function createContentServer(
sparkTables: [],
stats: [],
statTemplates: [],
- statSheets: [],
};
/** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log(
- `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`,
+ `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
);
}
@@ -399,12 +368,6 @@ export function createContentServer(
recomputeCompletions();
} else {
contentIndex[relPath] = content;
- if (relPath.endsWith(".sheet.svg")) {
- const rescan = scanDirectory(contentDir);
- collectedBlocks = rescan.blocks;
- directiveResults = rescan.directiveResults;
- recomputeCompletions();
- }
}
console.log(`[新增] ${relPath}`);
} catch (e) {
@@ -431,12 +394,6 @@ export function createContentServer(
recomputeCompletions();
} else {
contentIndex[relPath] = content;
- if (relPath.endsWith(".sheet.svg")) {
- const rescan = scanDirectory(contentDir);
- collectedBlocks = rescan.blocks;
- directiveResults = rescan.directiveResults;
- recomputeCompletions();
- }
}
console.log(`[更新] ${relPath}`);
} catch (e) {
@@ -454,7 +411,7 @@ export function createContentServer(
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
- if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
+ if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts
index 78ed48a..aba5372 100644
--- a/src/cli/completions/block-processor.ts
+++ b/src/cli/completions/block-processor.ts
@@ -20,7 +20,6 @@ import {
parseBlockAttrs,
resolveBlockAs,
} from "./block-scanner.js";
-import type { StatSheet } from "./types.js";
// Re-export shared pieces for convenience
export {
@@ -37,7 +36,6 @@ export {
export interface ProcessedBlocks {
stats: StatDef[];
statTemplates: StatTemplate[];
- statSheets: StatSheet[];
}
export interface BlockResult {
@@ -78,7 +76,6 @@ export function processBlocks(
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
- statSheets: [],
};
const stripped = content.replace(
diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts
index b585b96..5bc5b66 100644
--- a/src/cli/completions/index.ts
+++ b/src/cli/completions/index.ts
@@ -14,7 +14,6 @@ export type {
SparkTableCompletion,
StatDef,
StatTemplate,
- StatSheet,
} from "./types.js";
/**
@@ -43,6 +42,5 @@ export function scanCompletions(
sparkTables,
stats: blocks.stats,
statTemplates: blocks.statTemplates,
- statSheets: blocks.statSheets,
};
}
diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts
index ba1c110..b49a419 100644
--- a/src/cli/completions/types.ts
+++ b/src/cli/completions/types.ts
@@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js";
export type { StatDef, StatTemplate };
-/** A stat sheet discovered from a *.sheet.svg file */
-export interface StatSheet {
- /** Unique id — filename sans .sheet.svg */
- id: string;
- /** Display label — from or derived from filename */
- label: string;
- /** Raw SVG content */
- svg: string;
- /** Source file path for attribution */
- source: string;
-}
-
/** A single dice expression found in a markdown file */
export interface DiceCompletion {
/** Display text shown in the dropdown */
@@ -62,7 +50,6 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
- statSheets: StatSheet[];
}
/**
diff --git a/src/components/index.ts b/src/components/index.ts
index b7bb825..b455d86 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -15,6 +15,7 @@ import "./md-yarn-spinner";
export { Article } from "./Article";
export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
+export { ReactiveStatManager } from "./journal/ReactiveStatManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree";
diff --git a/src/components/journal/ReactiveStatManager.tsx b/src/components/journal/ReactiveStatManager.tsx
new file mode 100644
index 0000000..02f1777
--- /dev/null
+++ b/src/components/journal/ReactiveStatManager.tsx
@@ -0,0 +1,183 @@
+/**
+ * ReactiveStatManager — mounts as a child of and reactively
+ * replaces ${key} template patterns in the rendered markdown DOM with
+ * live stat values from the journal stream.
+ *
+ * On mount, walks all text nodes in the article content looking for
+ * ${key} placeholders. Each match is wrapped in a with a
+ * data-reactive-stat attribute so that subsequent updates (driven by
+ * a Solid effect) only touch those spans.
+ */
+
+import { Component, createEffect, onCleanup, createMemo } from "solid-js";
+import { useArticleDom } from "../Article";
+import { useJournalStream } from "../stores/journalStream";
+import { useJournalCompletions } from "../journal/completions";
+import { fullKey } from "../journal/stat-helpers";
+import type { StatDef } from "../journal/completions";
+
+const TEMPLATE_RE = /\$\{(\w+)\}/g;
+
+/** Tag name used for marker spans — must stay in sync with the scan pass. */
+const MARKER = "data-reactive-stat";
+
+export const ReactiveStatManager: Component = () => {
+ const contentDom = useArticleDom();
+ const stream = useJournalStream();
+ const comp = useJournalCompletions();
+
+ const statDefs = createMemo(() => comp.data.stats);
+ const values = createMemo(() => stream.stats);
+
+ /** Build a lookup: bare key -> StatDef (for scope-aware resolution) */
+ const bareDefMap = createMemo(() => {
+ const map = new Map();
+ for (const def of statDefs()) {
+ if (!map.has(def.key)) {
+ map.set(def.key, def);
+ }
+ }
+ return map;
+ });
+
+ /**
+ * Resolve a bare key from markdown template to its runtime value.
+ * Uses StatDef scoping when available, falls back to player-first, global-second.
+ */
+ function resolveBareKey(bareKey: string): string {
+ const bareDef = bareDefMap().get(bareKey);
+ if (bareDef) {
+ const fk = fullKey(bareDef, stream.myName);
+ return values()[fk] ?? bareDef.default ?? "";
+ }
+ const pk = `${stream.myName}:${bareKey}`;
+ if (values()[pk] !== undefined) return values()[pk];
+ return values()[bareKey] ?? "";
+ }
+
+ // ---- Initial scan: wrap ${key} text in marker spans ----
+
+ createEffect(() => {
+ const dom = contentDom();
+ if (!dom) return;
+
+ // Only scan once — after the first scan, the spans exist and we
+ // switch to the reactive update effect below.
+ if (dom.querySelector(`[${MARKER}]`)) return;
+
+ scanAndWrap(dom);
+ });
+
+ onCleanup(() => {
+ const dom = contentDom();
+ if (dom) unwrapAll(dom);
+ });
+
+ // ---- Reactive updates: whenever stat values change, update all spans ----
+
+ createEffect(() => {
+ const dom = contentDom();
+ if (!dom) return;
+
+ const v = values();
+ const spans = dom.querySelectorAll(`[${MARKER}]`);
+
+ for (const span of spans) {
+ const template = span.getAttribute(MARKER) ?? "";
+ let result = template;
+ TEMPLATE_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = TEMPLATE_RE.exec(template)) !== null) {
+ result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1]));
+ }
+ if (span.textContent !== result) {
+ span.textContent = result;
+ }
+ }
+ });
+
+ return null; // renders nothing — works purely via DOM manipulation
+};
+
+// ---------------------------------------------------------------------------
+// DOM helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Walk all text nodes in a subtree and wrap `${key}` patterns in
+ * `` elements.
+ */
+function scanAndWrap(root: Element): void {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const replacements: { node: Text; html: string }[] = [];
+
+ let textNode: Text | null;
+ while ((textNode = walker.nextNode() as Text | null)) {
+ const text = textNode.textContent;
+ if (!text) continue;
+
+ TEMPLATE_RE.lastIndex = 0;
+ if (!TEMPLATE_RE.test(text)) continue;
+
+ // Build replacement HTML: split on ${key} and wrap each key span
+ TEMPLATE_RE.lastIndex = 0;
+ const parts: string[] = [];
+ let lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = TEMPLATE_RE.exec(text)) !== null) {
+ if (m.index > lastIndex) {
+ parts.push(escapeHtml(text.slice(lastIndex, m.index)));
+ }
+ parts.push(
+ `${escapeHtml(m[0])}`,
+ );
+ lastIndex = TEMPLATE_RE.lastIndex;
+ }
+ if (lastIndex < text.length) {
+ parts.push(escapeHtml(text.slice(lastIndex)));
+ }
+
+ replacements.push({ node: textNode, html: parts.join("") });
+ }
+
+ for (const { node, html } of replacements) {
+ const wrapper = document.createElement("span");
+ wrapper.innerHTML = html;
+ node.replaceWith(wrapper);
+ }
+}
+
+/**
+ * Reverse scanAndWrap — remove marker spans, restoring plain text.
+ * Called on cleanup so subsequent remounts get a clean slate.
+ */
+function unwrapAll(root: Element): void {
+ const spans = root.querySelectorAll(`[${MARKER}]`);
+ // Process in reverse to avoid DOM mutation issues
+ for (let i = spans.length - 1; i >= 0; i--) {
+ const span = spans[i];
+ const parent = span.parentNode;
+ if (!parent) continue;
+ const text = span.getAttribute(MARKER) ?? span.textContent ?? "";
+ span.replaceWith(document.createTextNode(text));
+ }
+ // Normalize to merge adjacent text nodes
+ root.normalize();
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+function escapeAttr(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+export default ReactiveStatManager;
diff --git a/src/components/journal/SheetView.tsx b/src/components/journal/SheetView.tsx
deleted file mode 100644
index eb7f6b0..0000000
--- a/src/components/journal/SheetView.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * SheetView — renders a stat sheet SVG with live reactive text bindings.
- *
- * Parses the SVG once via DOMParser, then uses a Solid effect to
- * update only elements containing ${key} templates whenever
- * the stats store changes.
- */
-
-import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js";
-import { useJournalStream } from "../stores/journalStream";
-import { useJournalCompletions } from "./completions";
-import { fullKey } from "./stat-helpers";
-import type { StatDef } from "./completions";
-import { parseSheet } from "./stat-sheet";
-
-export const SheetView: Component<{ sheetId: string }> = (props) => {
- const stream = useJournalStream();
- const comp = useJournalCompletions();
-
- const statDefs = createMemo(() => comp.data.stats);
- const values = createMemo(() => stream.stats);
-
- /** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */
- const bareDefMap = createMemo(() => {
- const map = new Map();
- for (const def of statDefs()) {
- if (!map.has(def.key)) {
- map.set(def.key, def);
- }
- }
- return map;
- });
-
- /**
- * Resolve a bare key from an SVG template to its runtime value.
- * Uses StatDef scoping when available, falls back to player-first, global-second.
- */
- function resolveBareKey(bareKey: string): string {
- const bareDef = bareDefMap().get(bareKey);
- if (bareDef) {
- const fk = fullKey(bareDef, stream.myName);
- return values()[fk] ?? bareDef.default ?? "";
- }
- const pk = `${stream.myName}:${bareKey}`;
- if (values()[pk] !== undefined) return values()[pk];
- return values()[bareKey] ?? "";
- }
-
- const sheet = createMemo(() => {
- const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId);
- return s ?? null;
- });
-
- let containerRef: HTMLDivElement | undefined;
- let disposeEffect: (() => void) | undefined;
-
- createEffect(() => {
- const s = sheet();
- const container = containerRef;
-
- disposeEffect?.();
- disposeEffect = undefined;
-
- if (!container || !s) return;
-
- container.innerHTML = "";
- const parsed = parseSheet(s.svg);
- // Force full-width, auto-height
- parsed.svgElement.setAttribute("width", "100%");
- parsed.svgElement.removeAttribute("height");
- parsed.svgElement.style.display = "block";
- container.appendChild(parsed.svgElement);
-
- // Center template nodes so text stays centered as values change length
- for (const tpl of parsed.templates) {
- const el = tpl.el;
- try {
- const bbox = el.getBBox();
- const cx = bbox.x + bbox.width / 2;
- el.setAttribute("text-anchor", "middle");
- el.setAttribute("x", String(cx));
- } catch {
- // getBBox may fail if the node has no layout — skip silently
- }
- }
-
- if (parsed.templates.length === 0) return;
-
- const dispose = createRoot((disposer) => {
- createEffect(() => {
- const v = values();
- for (const tpl of parsed.templates) {
- let result = tpl.template;
- for (const key of tpl.keys) {
- result = result.replace(`\${${key}}`, resolveBareKey(key));
- }
- tpl.el.textContent = result;
- }
- });
- return disposer;
- });
-
- disposeEffect = dispose;
- });
-
- onCleanup(() => disposeEffect?.());
-
- const fallback = (
- 未找到属性卡: {props.sheetId}
- );
-
- return (
-
- {sheet() ? (
-
- ) : (
- fallback
- )}
-
- );
-};
\ No newline at end of file
diff --git a/src/components/journal/StatsView.tsx b/src/components/journal/StatsView.tsx
index bb22dfb..01b3cce 100644
--- a/src/components/journal/StatsView.tsx
+++ b/src/components/journal/StatsView.tsx
@@ -1,21 +1,15 @@
/**
- * StatsView — table view of all stat key-value pairs, plus optional
- * stat sheet rendering from *.sheet.svg files.
+ * StatsView — table view of all stat key-value pairs.
*
* Groups stats by scope (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
- *
- * A floating button at bottom-right opens a sheet picker dropdown.
- * Selecting a sheet delegates to SheetView.
*/
-import { Component, For, createMemo, createSignal, Show } from "solid-js";
-import { useSearchParams } from "@solidjs/router";
+import { Component, For, createMemo, Show } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import { fullKey, modifierLabel } from "./stat-helpers";
import type { StatDef } from "./completions";
-import { SheetView } from "./SheetView";
export const StatsView: Component = () => {
const stream = useJournalStream();
@@ -23,15 +17,6 @@ export const StatsView: Component = () => {
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
- const sheets = createMemo(() => comp.data.statSheets);
-
- /** Currently selected sheet id, or null for table view. Persisted in URL. */
- const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>();
- const selectedSheetId = () => searchParams.sheet || null;
- const setSelectedSheetId = (id: string | null) => {
- setSearchParams({ sheet: id || undefined });
- };
- const [pickerOpen, setPickerOpen] = createSignal(false);
/** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => {
@@ -124,191 +109,126 @@ export const StatsView: Component = () => {
});
return (
-
+
0}
fallback={
- 0}
- fallback={
-
- 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
-
- }
- >
-
- {(group) => (
-
-
-
- {group.label}
-
-
-
-
- {({ fullKey: fk, def }) => {
- const val = () => values()[fk];
- const comp = () => computedValue(fk);
- const hasModifiers = () => {
- for (const [mfk, md] of defMap()) {
- if (
- md.type === "modifier" &&
- md.target &&
- fullKeyTarget(md.target, md, def)
- ) {
- return true;
- }
- }
- return false;
- };
- const canRoll = () =>
- def.roll ||
- def.formula ||
- def.type === "enum" ||
- def.type === "template";
-
- return (
-
-
-
- {modifierLabel(def, statDefs())}
-
-
- {def.key}
-
-
-
- →{def.target}
-
-
-
-
-
-
- {def.default ?? "—"}
-
- }
- >
-
- {val()}
-
-
-
-
-
- = {comp()}
-
-
-
-
-
-
-
-
- );
- }}
-
-
-
- )}
-
-
+
+ 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
+
}
>
-
-
+
+ {(group) => (
+
+
+
+ {group.label}
+
+
+
+
+ {({ fullKey: fk, def }) => {
+ const val = () => values()[fk];
+ const comp = () => computedValue(fk);
+ const hasModifiers = () => {
+ for (const [mfk, md] of defMap()) {
+ if (
+ md.type === "modifier" &&
+ md.target &&
+ fullKeyTarget(md.target, md, def)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+ const canRoll = () =>
+ def.roll ||
+ def.formula ||
+ def.type === "enum" ||
+ def.type === "template";
- {/* Sheet picker — only show if sheets are available */}
- 0}>
-
- {/* Dropdown trigger */}
-
+ return (
+
+
+
+ {modifierLabel(def, statDefs())}
+
+
+ {def.key}
+
+
+
+ →{def.target}
+
+
+
- {/* Dropdown menu */}
-
-
-
-
-
- {(sheet) => (
-
- )}
-
+
+
+ {def.default ?? "—"}
+
+ }
+ >
+
+ {val()}
+
+
+
+
+
+ = {comp()}
+
+
+
+
+
+
+
+
+ );
+ }}
+
+
-
-
- {/* Backdrop to close picker */}
-
- setPickerOpen(false)}
- />
-
-
+ )}
+
);
-};
\ No newline at end of file
+};
diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts
index 56277b8..fc47fb6 100644
--- a/src/components/journal/completions.ts
+++ b/src/components/journal/completions.ts
@@ -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
{
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 {
sparkTables.push(...directiveResult.sparkTables);
}
- return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
-}
-
-// ------------------- Stat sheet helpers -------------------
-
-function extractSvgTitle(svg: string): string | null {
- const m = /]*>([\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 {
- 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 = (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: [],
},
};
}
diff --git a/src/components/journal/stat-sheet.ts b/src/components/journal/stat-sheet.ts
deleted file mode 100644
index 485e767..0000000
--- a/src/components/journal/stat-sheet.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Stat sheet template engine — parses *.sheet.svg files and extracts
- * text nodes containing ${key} template patterns.
- *
- * Walks elements. If a has children, we bind to
- * each individually (preserving their position attributes).
- * If a has no children, we bind to the directly.
- */
-
-/** Regex matching ${key} template patterns (captures the key name). */
-const TEMPLATE_RE = /\$\{(\w+)\}/g;
-
-/** A single template binding targeting a specific text-bearing leaf node. */
-export interface TextTemplate {
- /** The DOM node to update — a (no children) or a . */
- el: SVGTextContentElement;
- /** Original text content with ${key} placeholders. */
- template: string;
- /** Bare key names extracted from the template. */
- keys: string[];
-}
-
-/** The result of parsing a stat sheet SVG string. */
-export interface ParsedSheet {
- svgElement: SVGSVGElement;
- templates: TextTemplate[];
-}
-
-/**
- * Check if a text node's content has template patterns, and if so,
- * register a binding.
- */
-function scanNode(el: Element, templates: TextTemplate[]): void {
- const content = el.textContent;
- if (!content) return;
-
- TEMPLATE_RE.lastIndex = 0;
- const keys: string[] = [];
- let m: RegExpExecArray | null;
- while ((m = TEMPLATE_RE.exec(content)) !== null) {
- keys.push(m[1]);
- }
-
- if (keys.length > 0) {
- templates.push({
- el: el as SVGTextContentElement,
- template: content,
- keys,
- });
- }
-}
-
-/**
- * Parse an SVG string into a live DOM element and extract all template
- * bindings from and leaf nodes.
- */
-export function parseSheet(svgString: string): ParsedSheet {
- const parser = new DOMParser();
- const doc = parser.parseFromString(svgString, "image/svg+xml");
- const svgElement = doc.documentElement as unknown as SVGSVGElement;
-
- if (!svgElement || svgElement.tagName !== "svg") {
- return {
- svgElement: document.createElementNS(
- "http://www.w3.org/2000/svg",
- "svg",
- ) as SVGSVGElement,
- templates: [],
- };
- }
-
- const templates: TextTemplate[] = [];
-
- for (const textEl of svgElement.querySelectorAll("text")) {
- const tspans = textEl.querySelectorAll("tspan");
-
- if (tspans.length > 0) {
- // Has children — bind to each individually
- for (const tspan of tspans) {
- scanNode(tspan, templates);
- }
- } else {
- // Leaf — bind directly
- scanNode(textEl, templates);
- }
- }
-
- return { svgElement, templates };
-}
\ No newline at end of file