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
+53 -6
View File
@@ -15,6 +15,7 @@ import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
@@ -83,6 +84,24 @@ function getBestIP(): string {
return best || "localhost";
}
/**
* Extract the <title> from an SVG string, or null if absent.
*/
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
/**
* Derive a human-readable label from a filename.
* "character-sheet" -> "Character Sheet"
*/
function labelFromId(id: string): string {
return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* 扫描目录内的 .md 文件,生成索引
*/
@@ -95,6 +114,7 @@ export function scanDirectory(dir: string): {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
function scan(currentPath: string, relativePath: string) {
@@ -113,7 +133,8 @@ export function scanDirectory(dir: string): {
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn")
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
const content = readFileSync(fullPath, "utf-8");
@@ -123,6 +144,17 @@ export function scanDirectory(dir: string): {
blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates);
blocks.sparkTables.push(...result.blocks.sparkTables);
} 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;
}
@@ -281,6 +313,7 @@ export function createContentServer(
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
let completionsIndex: CompletionsPayload = {
dice: [],
@@ -288,13 +321,14 @@ export function createContentServer(
sparkTables: [],
stats: [],
statTemplates: [],
statSheets: [],
};
/** Re-scan completions from current content index and collected blocks */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
`[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}`,
);
}
@@ -319,7 +353,8 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
@@ -333,6 +368,11 @@ export function createContentServer(
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
recomputeCompletions();
}
}
console.log(`[新增] ${relPath}`);
} catch (e) {
@@ -344,7 +384,8 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
@@ -357,6 +398,11 @@ export function createContentServer(
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
recomputeCompletions();
}
}
console.log(`[更新] ${relPath}`);
} catch (e) {
@@ -368,12 +414,13 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) {
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
recomputeCompletions();