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();
+3 -1
View File
@@ -22,7 +22,7 @@ import {
resolveBlockAs,
parseSparkTableCsv,
} from "./block-scanner.js";
import type { SparkTableCompletion } from "./types.js";
import type { SparkTableCompletion, StatSheet } from "./types.js";
// Re-export shared pieces for convenience
export {
@@ -41,6 +41,7 @@ export interface ProcessedBlocks {
stats: StatDef[];
statTemplates: StatTemplate[];
sparkTables: SparkTableCompletion[];
statSheets: StatSheet[];
}
export interface BlockResult {
@@ -81,6 +82,7 @@ export function processBlocks(
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
const stripped = content.replace(
+2
View File
@@ -14,6 +14,7 @@ export type {
SparkTableCompletion,
StatDef,
StatTemplate,
StatSheet,
} from "./types.js";
/**
@@ -33,5 +34,6 @@ export function scanCompletions(
sparkTables: blocks.sparkTables,
stats: blocks.stats,
statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,
};
}
+13
View File
@@ -6,6 +6,18 @@ 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 <title> 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 */
@@ -46,6 +58,7 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
statSheets: StatSheet[];
}
/**