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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user