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
+2 -45
View File
@@ -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 字符串中提取 <title> 内容,无则返回 null
*/
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\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;
-3
View File
@@ -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(
-2
View File
@@ -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,
};
}
-13
View File
@@ -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 <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 */
@@ -62,7 +50,6 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
statSheets: StatSheet[];
}
/**