diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index e469a49..7994ae5 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -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 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(); diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index 5633d55..9c83383 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -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( diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index bd55528..22c3137 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -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, }; } diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index b81b661..6ced9da 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -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[]; } /** diff --git a/src/components/journal/SheetView.tsx b/src/components/journal/SheetView.tsx new file mode 100644 index 0000000..c1e1b00 --- /dev/null +++ b/src/components/journal/SheetView.tsx @@ -0,0 +1,104 @@ +/** + * 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 <text> 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<string, StatDef>(); + 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); + container.appendChild(parsed.svgElement); + + 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 = ( + <p class="text-center text-gray-400 text-xs py-8">未找到属性卡: {props.sheetId}</p> + ); + + return ( + <div class="w-full h-full flex items-center justify-center p-2"> + {sheet() ? ( + <div ref={containerRef} class="w-full h-full" /> + ) : ( + fallback + )} + </div> + ); +}; \ No newline at end of file diff --git a/src/components/journal/StatsView.tsx b/src/components/journal/StatsView.tsx index b69ea96..3b04166 100644 --- a/src/components/journal/StatsView.tsx +++ b/src/components/journal/StatsView.tsx @@ -1,15 +1,20 @@ /** - * StatsView — table view of all stat key-value pairs + * StatsView — table view of all stat key-value pairs, plus optional + * stat sheet rendering from *.sheet.svg files. * * 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, Show } from "solid-js"; +import { Component, For, createMemo, createSignal, 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(); @@ -17,6 +22,11 @@ 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. */ + const [selectedSheetId, setSelectedSheetId] = createSignal<string | null>(null); + const [pickerOpen, setPickerOpen] = createSignal(false); /** Build a lookup: fullKey -> StatDef */ const defMap = createMemo(() => { @@ -109,126 +119,191 @@ export const StatsView: Component = () => { }); return ( - <div class="h-full overflow-y-auto bg-gray-50"> + <div class="h-full overflow-y-auto bg-gray-50 relative"> <Show - when={statDefs().length > 0} + when={selectedSheetId()} fallback={ - <p class="text-center text-gray-400 text-xs py-8"> - 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。 - </p> + <Show + when={statDefs().length > 0} + fallback={ + <p class="text-center text-gray-400 text-xs py-8"> + 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。 + </p> + } + > + <For each={groups()}> + {(group) => ( + <div class="mb-3"> + <div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200"> + <span class="text-xs font-semibold text-gray-600"> + {group.label} + </span> + </div> + <div class="divide-y divide-gray-100"> + <For each={group.entries}> + {({ 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 ( + <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> + <div class="flex-1 min-w-0 flex items-center gap-1.5"> + <span class="text-sm text-gray-800 truncate"> + {modifierLabel(def, statDefs())} + </span> + <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0"> + {def.key} + </span> + <Show when={def.type === "modifier"}> + <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0"> + →{def.target} + </span> + </Show> + </div> + + <div class="flex items-center gap-2 shrink-0"> + <Show + when={val() !== undefined} + fallback={ + <span class="text-sm text-gray-300"> + {def.default ?? "—"} + </span> + } + > + <span class="text-sm font-mono text-gray-700"> + {val()} + </span> + </Show> + + <Show + when={ + hasModifiers() && + comp() !== (val() ?? def.default ?? "") + } + > + <span class="text-xs text-gray-400"> + = {comp()} + </span> + </Show> + + <Show when={canRoll()}> + <button + class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors" + title="掷骰" + onClick={() => { + const textarea = + document.querySelector<HTMLTextAreaElement>( + "#journal-input-textarea", + ); + if (textarea) { + const nativeInputValueSetter = + Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call( + textarea, + `/stat roll ${def.key}`, + ); + textarea.dispatchEvent( + new Event("input", { bubbles: true }), + ); + textarea.focus(); + } + }} + > + 🎲 + </button> + </Show> + </div> + </div> + ); + }} + </For> + </div> + </div> + )} + </For> + </Show> } > - <For each={groups()}> - {(group) => ( - <div class="mb-3"> - <div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200"> - <span class="text-xs font-semibold text-gray-600"> - {group.label} - </span> - </div> - <div class="divide-y divide-gray-100"> - <For each={group.entries}> - {({ 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"; + <SheetView sheetId={selectedSheetId()!} /> + </Show> - return ( - <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> - <div class="flex-1 min-w-0 flex items-center gap-1.5"> - <span class="text-sm text-gray-800 truncate"> - {modifierLabel(def, statDefs())} - </span> - <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0"> - {def.key} - </span> - <Show when={def.type === "modifier"}> - <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0"> - →{def.target} - </span> - </Show> - </div> + {/* Sheet picker — only show if sheets are available */} + <Show when={sheets().length > 0}> + <div class="absolute bottom-3 right-3"> + {/* Dropdown trigger */} + <button + class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500" + onClick={() => setPickerOpen((v) => !v)} + title="选择属性卡" + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="3" y="3" width="18" height="18" rx="2" /> + <line x1="9" y1="3" x2="9" y2="21" /> + </svg> + </button> - <div class="flex items-center gap-2 shrink-0"> - <Show - when={val() !== undefined} - fallback={ - <span class="text-sm text-gray-300"> - {def.default ?? "—"} - </span> - } - > - <span class="text-sm font-mono text-gray-700"> - {val()} - </span> - </Show> - - <Show - when={ - hasModifiers() && - comp() !== (val() ?? def.default ?? "") - } - > - <span class="text-xs text-gray-400"> - = {comp()} - </span> - </Show> - - <Show when={canRoll()}> - <button - class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors" - title="掷骰" - onClick={() => { - const textarea = - document.querySelector<HTMLTextAreaElement>( - "#journal-input-textarea", - ); - if (textarea) { - const nativeInputValueSetter = - Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call( - textarea, - `/stat roll ${def.key}`, - ); - textarea.dispatchEvent( - new Event("input", { bubbles: true }), - ); - textarea.focus(); - } - }} - > - 🎲 - </button> - </Show> - </div> - </div> - ); - }} - </For> - </div> + {/* Dropdown menu */} + <Show when={pickerOpen()}> + <div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50"> + <button + class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ + selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700" + }`} + onClick={() => { + setSelectedSheetId(null); + setPickerOpen(false); + }} + > + 📋 表格 + </button> + <div class="border-t border-gray-100 my-1" /> + <For each={sheets()}> + {(sheet) => ( + <button + class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ + selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700" + }`} + onClick={() => { + setSelectedSheetId(sheet.id); + setPickerOpen(false); + }} + > + {sheet.label} + </button> + )} + </For> </div> - )} - </For> + </Show> + + {/* Backdrop to close picker */} + <Show when={pickerOpen()}> + <div + class="fixed inset-0 z-40" + onClick={() => setPickerOpen(false)} + /> + </Show> + </div> </Show> </div> ); -}; +}; \ No newline at end of file diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 4a463a7..1f9969f 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -22,13 +22,14 @@ 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, parseSparkTableCsv, } from "../../cli/completions/block-scanner"; -export type { StatDef, StatTemplate }; +export type { StatDef, StatTemplate, StatSheet }; // ------------------- Types (mirrors CLI) ------------------- @@ -58,6 +59,7 @@ export interface JournalCompletions { sparkTables: SparkTableCompletion[]; stats: StatDef[]; statTemplates: StatTemplate[]; + statSheets: StatSheet[]; } export type CompletionsState = @@ -87,6 +89,7 @@ async function tryServer(): Promise<JournalCompletions | null> { statTemplates: Array.isArray(data.statTemplates) ? data.statTemplates : [], + statSheets: Array.isArray(data.statSheets) ? data.statSheets : [], }; } catch { return null; @@ -169,7 +172,46 @@ async function scanClientSide(): Promise<JournalCompletions> { } } - return { dice, links, sparkTables, stats, statTemplates }; + return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; +} + +// ------------------- Stat sheet helpers ------------------- + +function extractSvgTitle(svg: string): string | null { + const m = /<title[^>]*>([\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<StatSheet[]> { + 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; } // ------------------- Init (runs eagerly at import time) ------------------- @@ -186,7 +228,8 @@ const _initPromise: Promise<void> = (async () => { // 2. Fall back to client-side scan (dev/browser mode) try { const data = await scanClientSide(); - if (data.dice.length > 0 || data.links.length > 0) { + data.statSheets = await scanStatSheets(); + if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) { setCompletionsState({ status: "loaded", data }); } else { setCompletionsState({ status: "empty" }); @@ -237,6 +280,7 @@ export function useJournalCompletions(): { sparkTables: [], stats: [], statTemplates: [], + statSheets: [], }, }; } diff --git a/src/components/journal/stat-sheet.ts b/src/components/journal/stat-sheet.ts new file mode 100644 index 0000000..8fb21c8 --- /dev/null +++ b/src/components/journal/stat-sheet.ts @@ -0,0 +1,64 @@ +/** + * Stat sheet template engine — parses *.sheet.svg files and extracts + * <text> elements containing ${key} template patterns. + * + * The SVG element is parsed once via DOMParser. The caller (StatsView) + * reactively updates the text nodes whenever the stats store changes. + */ + +/** Regex matching ${key} template patterns (captures the key name). */ +const TEMPLATE_RE = /\$\{(\w+)\}/g; + +/** A single template binding in a <text> element. */ +export interface TextTemplate { + el: SVGTextElement; + template: string; + keys: string[]; +} + +/** The result of parsing a stat sheet SVG string. */ +export interface ParsedSheet { + svgElement: SVGSVGElement; + templates: TextTemplate[]; +} + +/** + * Parse an SVG string into a live DOM element and extract all text + * elements that contain ${key} template patterns. + */ +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[] = []; + const textEls = svgElement.querySelectorAll("text"); + + for (const el of textEls) { + const content = el.textContent; + if (!content) continue; + + 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 SVGTextElement, template: content, keys }); + } + } + + return { svgElement, templates }; +} \ No newline at end of file diff --git a/src/data-loader/file-index.ts b/src/data-loader/file-index.ts index 7e60ab4..9009121 100644 --- a/src/data-loader/file-index.ts +++ b/src/data-loader/file-index.ts @@ -50,7 +50,7 @@ function ensureIndexLoaded(): Promise<void> { try { const context = import.meta.webpackContext("../../content", { recursive: true, - regExp: /\.md|\.yarn|\.csv$/i, + regExp: /\.md|\.yarn|\.csv|\.svg$/i, }); const keys = context.keys(); const index: FileIndex = {}; @@ -94,7 +94,7 @@ async function scanDirectory( prefix = "", ): Promise<FileIndex> { const index: FileIndex = {}; - const acceptedExt = /\.(md|yarn|csv)$/i; + const acceptedExt = /\.(md|yarn|csv|svg)$/i; for await (const [name, entry] of (handle as any).entries()) { if (entry.kind === "directory") {