From e8aa7165cb9dcfbe557ed4ada27969614cfac755 Mon Sep 17 00:00:00 2001 From: hyper Date: Fri, 7 Aug 2026 12:17:35 +0800 Subject: [PATCH] refactor(completions): centralize content scanning Add a browser-safe ContentRegistry owning path and inline content stores, then derive completions from it. Remove the old block-processor, directive-scanner, and link-source modules in favor of scanDoc, deriveCompletions, and injectSparkDirectives, and register an inline resolver for the frontend file index. --- src/cli/commands/serve.ts | 153 ++-- src/cli/completions/block-processor.ts | 144 ---- src/cli/completions/declare-parser.ts | 50 +- src/cli/completions/directive-scanner.ts | 341 --------- src/cli/completions/index.ts | 46 -- src/cli/completions/sources/links.ts | 55 -- src/cli/completions/types.ts | 4 +- src/cli/completions/variable-system.test.ts | 143 ++-- src/cli/content-registry.ts | 689 +++++++++++++++++++ src/components/journal/command-dispatcher.ts | 39 +- src/components/journal/completions.ts | 117 ++-- src/components/journal/types/spark.tsx | 29 +- src/data-loader/file-index.ts | 23 + 13 files changed, 985 insertions(+), 848 deletions(-) delete mode 100644 src/cli/completions/block-processor.ts delete mode 100644 src/cli/completions/directive-scanner.ts delete mode 100644 src/cli/completions/index.ts delete mode 100644 src/cli/completions/sources/links.ts create mode 100644 src/cli/content-registry.ts diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 31f5e42..05d38dc 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -8,17 +8,12 @@ import { networkInterfaces } from "os"; import { fileURLToPath } from "url"; import { createJournalServer } from "../journal.js"; import { - scanCompletions, - type CompletionsPayload, -} from "../completions/index.js"; -import { - processBlocks, - type ProcessedBlocks, -} from "../completions/block-processor.js"; -import { - scanDirectives, - type DirectiveScanResult, -} from "../completions/directive-scanner.js"; + scanDoc, + deriveCompletions, + injectSparkDirectives, + type ContentRegistry, +} from "../content-registry.js"; +import type { CompletionsPayload } from "../completions/types.js"; interface ContentIndex { [path: string]: string; @@ -88,20 +83,10 @@ function getBestIP(): string { } /** - * 扫描目录内的 .md 等文件,生成内容索引与块数据 + * 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容) */ -export function scanDirectory(dir: string): { - index: ContentIndex; - blocks: ProcessedBlocks; - directiveResults: DirectiveScanResult[]; -} { - const index: ContentIndex = {}; - const blocks: ProcessedBlocks = { - declarations: [], - tagModifiers: [], - }; - const directiveResults: DirectiveScanResult[] = []; - const mdFiles: { content: string; relPath: string }[] = []; +export function buildRegistry(dir: string): ContentRegistry { + const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; function scan(currentPath: string, relativePath: string) { const entries = readdirSync(currentPath); @@ -125,13 +110,11 @@ export function scanDirectory(dir: string): { try { const content = readFileSync(fullPath, "utf-8"); if (entry.endsWith(".md")) { - const result = processBlocks(content, normalizedRelPath, index); - index[normalizedRelPath] = result.stripped; - blocks.declarations.push(...result.blocks.declarations); - blocks.tagModifiers.push(...result.blocks.tagModifiers); - mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); + const result = scanDoc(content, normalizedRelPath); + registry.pathIndex[normalizedRelPath] = result.stripped; + registry.docContent[normalizedRelPath] = result.content; } else { - index[normalizedRelPath] = content; + registry.pathIndex[normalizedRelPath] = content; } } catch (e) { console.error(`读取文件失败:${fullPath}`, e); @@ -142,31 +125,17 @@ export function scanDirectory(dir: string): { scan(dir, ""); - // ---- Directive scanning pass (after all blocks processed) ---- - const posixDir = dir.split(sep).join("/"); - for (const { content, relPath } of mdFiles) { - const fileDir = posixRelDir(relPath); - const result = scanDirectives(content, relPath, index, fileDir); - - // Apply rewritten content back to index - index[relPath] = result.rewritten; - - // Inject new index entries (inline CSV bodies) - for (const [key, value] of Object.entries(result.newIndexEntries)) { - index[key] = value; - } - - directiveResults.push(result); + // ---- Inject data-spark into real-file spark table directives ---- + for (const [relPath, content] of Object.entries(registry.pathIndex)) { + if (!relPath.endsWith(".md")) continue; + registry.pathIndex[relPath] = injectSparkDirectives( + content, + relPath, + registry, + ); } - return { index, blocks, directiveResults }; -} - -/** Get the POSIX directory of a file path */ -function posixRelDir(filePath: string): string { - const parts = filePath.split("/"); - parts.pop(); - return parts.join("/") || "."; + return registry; } /** @@ -231,6 +200,7 @@ function createRequestHandler( distDir: string, getIndex: () => ContentIndex, getCompletions: () => CompletionsPayload, + getRegistry: () => ContentRegistry, ) { return (req: IncomingMessage, res: ServerResponse) => { const url = req.url || "/"; @@ -248,6 +218,12 @@ function createRequestHandler( return; } + // 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析) + if (filePath === "/__CONTENT_REGISTRY.json") { + sendJson(res, getRegistry()); + return; + } + // 2. 处理 /static/ 目录(从 dist/web) if (filePath.startsWith("/static/")) { if (tryServeStatic(res, filePath, distDir)) { @@ -308,12 +284,7 @@ export function createContentServer( distPath: string = distDir, host: string = "0.0.0.0", ): ContentServer { - let contentIndex: ContentIndex = {}; - let collectedBlocks: ProcessedBlocks = { - declarations: [], - tagModifiers: [], - }; - let directiveResults: DirectiveScanResult[] = []; + let registry: ContentRegistry = { pathIndex: {}, docContent: {} }; let completionsIndex: CompletionsPayload = { dice: [], links: [], @@ -322,21 +293,18 @@ export function createContentServer( tagModifiers: [], }; - /** 从当前内容索引和已收集的块重新扫描补全数据 */ + /** 从当前注册表重新派生补全数据 */ function recomputeCompletions(): void { - completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); + completionsIndex = deriveCompletions(registry); console.log( `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`, ); } - // 扫描内容目录生成索引 + // 扫描内容目录生成注册表 console.log("正在扫描内容目录..."); - const scanResult = scanDirectory(contentDir); - contentIndex = scanResult.index; - collectedBlocks = scanResult.blocks; - directiveResults = scanResult.directiveResults; - console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); + registry = buildRegistry(contentDir); + console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`); recomputeCompletions(); // 监听文件变化 @@ -356,20 +324,10 @@ export function createContentServer( path.endsWith(".svg") ) { try { - const content = readFileSync(path, "utf-8"); - const relPath = "/" + relative(contentDir, path).split(sep).join("/"); - if (relPath.endsWith(".md")) { - const result = processBlocks(content, relPath, contentIndex); - contentIndex[relPath] = result.stripped; - // Re-scan to get fresh blocks (simpler than per-file merge) - const rescan = scanDirectory(contentDir); - collectedBlocks = rescan.blocks; - directiveResults = rescan.directiveResults; - recomputeCompletions(); - } else { - contentIndex[relPath] = content; - } - console.log(`[新增] ${relPath}`); + // 全量重建注册表以刷新跨文件派生(spark 注入) + registry = buildRegistry(contentDir); + recomputeCompletions(); + console.log(`[新增] ${path}`); } catch (e) { console.error(`读取新增文件失败:${path}`, e); } @@ -383,19 +341,9 @@ export function createContentServer( path.endsWith(".svg") ) { try { - const content = readFileSync(path, "utf-8"); - const relPath = "/" + relative(contentDir, path).split(sep).join("/"); - if (relPath.endsWith(".md")) { - const result = processBlocks(content, relPath, contentIndex); - contentIndex[relPath] = result.stripped; - const rescan = scanDirectory(contentDir); - collectedBlocks = rescan.blocks; - directiveResults = rescan.directiveResults; - recomputeCompletions(); - } else { - contentIndex[relPath] = content; - } - console.log(`[更新] ${relPath}`); + registry = buildRegistry(contentDir); + recomputeCompletions(); + console.log(`[更新] ${path}`); } catch (e) { console.error(`读取更新文件失败:${path}`, e); } @@ -408,15 +356,9 @@ export function createContentServer( path.endsWith(".yarn") || path.endsWith(".svg") ) { - const relPath = "/" + relative(contentDir, path).split(sep).join("/"); - delete contentIndex[relPath]; - console.log(`[删除] ${relPath}`); - if (relPath.endsWith(".md")) { - const rescan = scanDirectory(contentDir); - collectedBlocks = rescan.blocks; - directiveResults = rescan.directiveResults; - recomputeCompletions(); - } + registry = buildRegistry(contentDir); + recomputeCompletions(); + console.log(`[删除] ${path}`); } }); @@ -427,8 +369,9 @@ export function createContentServer( const handleRequest = createRequestHandler( contentDir, distPath, - () => contentIndex, + () => registry.pathIndex, () => completionsIndex, + () => registry, ); const server = createServer(handleRequest); @@ -452,7 +395,7 @@ export function createContentServer( return { server, watcher, - index: contentIndex, + index: registry.pathIndex, completions: completionsIndex, close() { console.log("正在关闭内容服务器..."); diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts deleted file mode 100644 index 9b777de..0000000 --- a/src/cli/completions/block-processor.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * CLI block processor — wraps block-scanner with Node-specific index injection - * and content stripping. - * - * Uses Node `crypto` and `path` — not safe for browser import. - */ - -import { posix } from "path"; -import { createHash } from "crypto"; -import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js"; -import { - FENCED_BLOCK_RE, - parseBlockAttrs, - resolveBlockAs, -} from "./block-scanner.js"; - -// Re-export shared pieces for convenience -export { - FENCED_BLOCK_RE, - parseBlockAttrs, - resolveBlockAs, - type BlockAttrs, -} from "./block-scanner.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ProcessedBlocks { - declarations: VarDeclaration[]; - tagModifiers: TagModifier[]; -} - -export interface BlockResult { - /** Content with blocks processed (stripped or replaced with directives) */ - stripped: string; - /** Parsed blocks for completions */ - blocks: ProcessedBlocks; -} - -// --------------------------------------------------------------------------- -// Content hash -// --------------------------------------------------------------------------- - -function contentHash(body: string): string { - return createHash("md5").update(body).digest("hex").slice(0, 8); -} - -// --------------------------------------------------------------------------- -// Main processor -// --------------------------------------------------------------------------- - -/** - * Process all attributed fenced code blocks in a markdown file. - * - * - Strips/replaces blocks based on `as` - * - Injects `role=file` bodies into the content index - * - Collects declare blocks for completions - * - role=spark-table blocks are converted to :md-table directives - * (spark table completions are collected later by the directive scanner) - */ -export function processBlocks( - content: string, - fileRelativePath: string, - index: Record, -): BlockResult { - const fileDir = posix.dirname(fileRelativePath); - - const blocks: ProcessedBlocks = { - declarations: [], - tagModifiers: [], - }; - - const stripped = content.replace( - FENCED_BLOCK_RE, - ( - _match: string, - lang: string, - infoString: string, - body: string, - ): string => { - const attrs = parseBlockAttrs(infoString); - attrs.lang = attrs.lang || lang; - const effectiveAs = resolveBlockAs(attrs.role, attrs.as); - - // ---- Dispatch by role ---- - - if (attrs.role === "declare") { - try { - const result = parseDeclareCsv(body, fileRelativePath); - blocks.declarations.push(...result.variables); - blocks.tagModifiers.push(...result.tagModifiers); - } catch (e) { - console.warn(`[block-processor] ${fileRelativePath}: ${e}`); - } - } - - if (attrs.role === "file") { - const filename = attrs.id - ? `${attrs.id}.${attrs.lang || "txt"}` - : `_inline_${contentHash(body)}.${attrs.lang || "txt"}`; - const resolvedPath = posix.join(fileDir, filename); - index[resolvedPath] = body; - } - - // ---- Render by as ---- - - if (effectiveAs === "codeblock") { - return _match; // keep as-is - } - - if (effectiveAs === "none") { - return ""; // strip - } - - // Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv] - if (effectiveAs.startsWith("md-")) { - const filename = attrs.id - ? `${attrs.id}.${attrs.lang || "txt"}` - : `_inline_${contentHash(body)}.${attrs.lang || "txt"}`; - const resolvedPath = posix.join(fileDir, filename); - - // Ensure body is in the index for directive rendering - if (!index[resolvedPath]) { - index[resolvedPath] = body; - } - - // Collect extra attrs for the directive - const extra = { ...attrs.extra }; - const extraStr = Object.keys(extra).length - ? `{${Object.entries(extra) - .map(([k, v]) => `${k}=${v}`) - .join(" ")}}` - : ""; - return `:${effectiveAs}[./${filename}]${extraStr}`; - } - - // Unknown as → strip - return ""; - }, - ); - - return { stripped, blocks }; -} \ No newline at end of file diff --git a/src/cli/completions/declare-parser.ts b/src/cli/completions/declare-parser.ts index ebff5f1..e836add 100644 --- a/src/cli/completions/declare-parser.ts +++ b/src/cli/completions/declare-parser.ts @@ -16,49 +16,21 @@ */ import { parse } from "csv-parse/browser/esm/sync"; -import { FENCED_BLOCK_RE, parseBlockAttrs } from "./block-scanner.js"; - -/** - * Scan markdown content for ```csv role=declare blocks and return - * parsed declarations and tag modifiers. Shared between CLI and client. - */ -export function scanDeclareBlocks(content: string, filePath: string): DeclareResult { - const variables: VarDeclaration[] = []; - const tagModifiers: TagModifier[] = []; - - FENCED_BLOCK_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = FENCED_BLOCK_RE.exec(content)) !== null) { - const [, , infoString, body] = m; - const attrs = parseBlockAttrs(infoString); - if (attrs.role !== "declare") continue; - - try { - const result = parseDeclareCsv(body, filePath); - variables.push(...result.variables); - tagModifiers.push(...result.tagModifiers); - } catch (e) { - console.warn(`[declare-parser] ${filePath}: ${e}`); - } - } - - return { variables, tagModifiers }; -} // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface VarDeclaration { - key: string; // "$hp" (always starts with $) + key: string; // "$hp" (always starts with $) expression: string; // "$con*5+$mod_hp" } export interface TagModifier { - tag: string; // "#warrior" - target: string; // "$mod_hp" + tag: string; // "#warrior" + target: string; // "$mod_hp" expression: string; // "20" - threshold: number; // minimum tagmap count to activate (default 1) + threshold: number; // minimum tagmap count to activate (default 1) } export interface DeclareResult { @@ -104,14 +76,10 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult { if (tag) { // Tag modifier if (!tag.startsWith("#")) { - throw new Error( - `${source}: tag must start with #, got "${tag}"`, - ); + throw new Error(`${source}: tag must start with #, got "${tag}"`); } if (!key.startsWith("$")) { - throw new Error( - `${source}: key must start with $, got "${key}"`, - ); + throw new Error(`${source}: key must start with $, got "${key}"`); } const thresholdRaw = row.threshold?.trim() ?? ""; const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1; @@ -124,13 +92,11 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult { } else { // Variable declaration if (!key.startsWith("$")) { - throw new Error( - `${source}: key must start with $, got "${key}"`, - ); + throw new Error(`${source}: key must start with $, got "${key}"`); } variables.push({ key, expression: expr }); } } return { variables, tagModifiers }; -} \ No newline at end of file +} diff --git a/src/cli/completions/directive-scanner.ts b/src/cli/completions/directive-scanner.ts deleted file mode 100644 index 1291511..0000000 --- a/src/cli/completions/directive-scanner.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Unified directive scanner — shared between CLI and browser. - * - * One pass over stripped markdown content that: - * 1. Detects markdown tables that look like spark tables → coerces to - * :md-table[./_inline_{hash}.csv] directives - * 2. Scans :md-dice[...] directives → collects DiceCompletion - * 3. Scans :md-table[...] directives → resolves CSV, checks if spark table - * → collects SparkTableCompletion - * 4. Scans :md-card[...] directives → same as md-table - * - * Safe for both Node and browser. No Node-specific imports. - */ - -import Slugger from "github-slugger"; -import type { DiceCompletion, SparkTableCompletion } from "./types.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface DirectiveScanResult { - /** Rewritten content with markdown tables coerced to directives */ - rewritten: string; - /** Dice completions discovered */ - dice: DiceCompletion[]; - /** Spark table completions discovered */ - sparkTables: SparkTableCompletion[]; - /** New index entries for inline CSV bodies (key → CSV content) */ - newIndexEntries: Record; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const DICE_HEADER_RE = /^\d*d\d+$/i; - -function contentHash(body: string): string { - // Simple hash suitable for both Node and browser - let hash = 0; - for (let i = 0; i < body.length; i++) { - const ch = body.charCodeAt(i); - hash = ((hash << 5) - hash + ch) | 0; - } - return Math.abs(hash).toString(16).slice(0, 8); -} - -function looksLikeDice(raw: string): boolean { - if (raw.length > 80) return false; - return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); -} - -/** Parse key=value pairs from directive extra attrs string */ -function parseDirectiveAttrs(extraStr: string | undefined): Record { - if (!extraStr) return {}; - const attrs: Record = {}; - const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(extraStr)) !== null) { - attrs[m[1]] = m[2].replace(/^"|"$/g, ""); - } - return attrs; -} - -// --------------------------------------------------------------------------- -// Markdown table → CSV conversion -// --------------------------------------------------------------------------- - -/** - * Split a markdown table row into cells. - * Handles leading/trailing pipes and trims whitespace. - */ -function splitTableRow(row: string): string[] { - return row - .replace(/^\|/, "") - .replace(/\|$/, "") - .split("|") - .map((c) => c.trim()); -} - -/** - * Escape a cell value for CSV output. - */ -function escapeCsvCell(cell: string): string { - if ( - cell.includes(",") || - cell.includes("\n") || - cell.includes('"') || - cell.includes("#") - ) { - return `"${cell.replace(/"/g, '""')}"`; - } - return cell; -} - -/** - * Convert a markdown table (header + separator + rows) to a CSV string. - */ -function markdownTableToCsv( - headerRow: string, - separatorRow: string, - bodyRows: string[], -): string | null { - const headers = splitTableRow(headerRow); - if (headers.length === 0) return null; - - // Validate separator row (must contain dashes) - const sepCells = splitTableRow(separatorRow); - if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null; - if (sepCells.length !== headers.length) return null; - - const csvHeader = headers.map(escapeCsvCell).join(","); - - const csvRows = bodyRows.map((row) => { - const cells = splitTableRow(row); - // Pad to match header length - while (cells.length < headers.length) cells.push(""); - return cells.slice(0, headers.length).map(escapeCsvCell).join(","); - }); - - return [csvHeader, ...csvRows].join("\n"); -} - -// --------------------------------------------------------------------------- -// Spark table CSV inspection -// --------------------------------------------------------------------------- - -/** - * Check if a CSV body represents a spark table. - * Returns the data column headers (excluding the dice column) if so, or null. - */ -export function inspectSparkTableCsv(csv: string): string[] | null { - const lines = csv.trim().split(/\r?\n/); - if (lines.length < 2) return null; - - const headers = lines[0].split(",").map((h) => h.trim()); - if (headers.length < 2) return null; - if (!DICE_HEADER_RE.test(headers[0])) return null; - - return headers.slice(1); -} - -/** - * Build a SparkTableCompletion from CSV data and file path. - */ -export function buildSparkTableCompletion( - csv: string, - filePath: string, - csvPath: string, - remix: boolean, - slugger: Slugger, -): SparkTableCompletion | null { - const dataHeaders = inspectSparkTableCsv(csv); - if (!dataHeaders) return null; - - const lines = csv.trim().split(/\r?\n/); - const headers = lines[0].split(",").map((h) => h.trim()); - - const slug = dataHeaders - .map((h) => slugger.slug(h.toLowerCase())) - .join("-"); - - const basePath = filePath.replace(/\.md$/, ""); - const fileName = basePath.split("/").filter(Boolean).pop() || basePath; - const combinedSlug = `${fileName}-${slug}`; - - return { - label: `${fileName} § ${slug}`, - notation: headers[0], - slug: combinedSlug, - filePath: basePath, - csvPath, - headers: dataHeaders, - remix, - }; -} - -// --------------------------------------------------------------------------- -// Main scanner -// --------------------------------------------------------------------------- - -/** - * Scan a single markdown file's stripped content for directives and - * spark-shaped markdown tables. - * - * @param content - Stripped markdown content (after block processing) - * @param filePath - The file's path (e.g. "/rules/combat.md") - * @param index - The content index for resolving CSV paths - * @param fileDir - Directory of the file (for resolving relative paths) - */ -export function scanDirectives( - content: string, - filePath: string, - index: Record, - fileDir: string, -): DirectiveScanResult { - const slugger = new Slugger(); - const dice: DiceCompletion[] = []; - const sparkTables: SparkTableCompletion[] = []; - const newIndexEntries: Record = {}; - - // ------------------------------------------------------------------ - // Pass 1: Coerce spark-shaped markdown tables to :md-table directives - // ------------------------------------------------------------------ - - const mdTableRegex = - /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm; - - let rewritten = content; - let mdMatch: RegExpExecArray | null; - - // Collect matches first (rewriting while iterating is tricky with regex) - interface TableMatch { - fullMatch: string; - headerRow: string; - separatorRow: string; - bodyRowsText: string; - index: number; - } - const tableMatches: TableMatch[] = []; - - while ((mdMatch = mdTableRegex.exec(content)) !== null) { - const [, headerRow, separatorRow, bodyRowsText] = mdMatch; - const headers = splitTableRow(headerRow); - - // Check if this looks like a spark table: first column is a dice formula - const isSpark = DICE_HEADER_RE.test(headers[0]); - - if (!isSpark) continue; - - const bodyRows = bodyRowsText - .trim() - .split(/\n/) - .filter((r) => r.trim().startsWith("|")); - - const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows); - if (!csv) continue; - - tableMatches.push({ - fullMatch: mdMatch[0], - headerRow, - separatorRow, - bodyRowsText, - index: mdMatch.index, - }); - } - - // Replace matches from end to start to preserve indices - for (let i = tableMatches.length - 1; i >= 0; i--) { - const m = tableMatches[i]; - const bodyRows = m.bodyRowsText - .trim() - .split(/\n/) - .filter((r) => r.trim().startsWith("|")); - const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!; - - const hash = contentHash(csv); - const filename = `_spark_md_${hash}.csv`; - const resolvedPath = `${fileDir}/${filename}`; - - newIndexEntries[resolvedPath] = csv; - - // Collect spark table completion - const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger); - if (st) { - sparkTables.push(st); - } - - // Replace markdown table with :md-table directive - const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`; - rewritten = - rewritten.slice(0, m.index) + - directive + - rewritten.slice(m.index + m.fullMatch.length); - } - - // ------------------------------------------------------------------ - // Pass 2: Scan :md-dice[...] directives - // ------------------------------------------------------------------ - - const diceRegex = /:md-dice\[([^[\]]+)\]/gi; - let diceMatch: RegExpExecArray | null; - while ((diceMatch = diceRegex.exec(rewritten)) !== null) { - const raw = diceMatch[1].trim(); - if (!raw || !looksLikeDice(raw)) continue; - dice.push({ label: raw, notation: raw, source: filePath }); - } - - // ------------------------------------------------------------------ - // Pass 3: Scan :md-table[...] and :md-card[...] directives - // ------------------------------------------------------------------ - - const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; - let tableMatch: RegExpExecArray | null; - while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) { - const [, /* type */ , path, extraStr] = tableMatch; - - // Resolve the CSV path - const csvPath = path.startsWith("./") - ? `${fileDir}/${path.slice(2)}` - : path; - - let csv = index[csvPath] ?? newIndexEntries[csvPath]; - if (!csv) continue; - - // Parse extra attrs for remix flag - const attrs = parseDirectiveAttrs(extraStr); - const isRemix = attrs["remix"] === "true"; - - const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger); - if (!st) continue; - - // Check if data-spark is already set in extra attrs - if (!extraStr || !extraStr.includes("data-spark=")) { - // Inject data-spark attribute into the directive - const fullMatch = tableMatch[0]; - const insertPos = fullMatch.indexOf("]") + 1; - const before = fullMatch.slice(0, insertPos); - const after = fullMatch.slice(insertPos); - - const sparkAttr = `{data-spark="${st.slug}"}`; - let replacement: string; - if (after.startsWith("{")) { - // Merge into existing attrs - replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `); - } else { - replacement = before + sparkAttr + after; - } - - rewritten = - rewritten.slice(0, tableMatch.index) + - replacement + - rewritten.slice(tableMatch.index + fullMatch.length); - } - - sparkTables.push(st); - } - - return { rewritten, dice, sparkTables, newIndexEntries }; -} \ No newline at end of file diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts deleted file mode 100644 index f6b26f3..0000000 --- a/src/cli/completions/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Completion index — orchestrates all registered completion sources. - */ - -import { linksSource } from "./sources/links.js"; -import type { ProcessedBlocks } from "./block-processor.js"; -import type { CompletionsPayload } from "./types.js"; -import type { DirectiveScanResult } from "./directive-scanner.js"; - -export type { - CompletionsPayload, - DiceCompletion, - LinkCompletion, - SparkTableCompletion, - VarDeclaration, - TagModifier, -} from "./types.js"; - -/** - * Build completions from the content index, pre-collected blocks, - * and directive scan results. - * Called at server startup and on any file change. - */ -export function scanCompletions( - index: Record, - blocks: ProcessedBlocks, - directiveResults: DirectiveScanResult[], -): CompletionsPayload { - const links = linksSource.scan(index) as CompletionsPayload["links"]; - - // Merge all directive scan results - const dice: CompletionsPayload["dice"] = []; - const sparkTables: CompletionsPayload["sparkTables"] = []; - for (const dr of directiveResults) { - dice.push(...dr.dice); - sparkTables.push(...dr.sparkTables); - } - - return { - dice, - links, - sparkTables, - declarations: blocks.declarations, - tagModifiers: blocks.tagModifiers, - }; -} \ No newline at end of file diff --git a/src/cli/completions/sources/links.ts b/src/cli/completions/sources/links.ts deleted file mode 100644 index c59ffc0..0000000 --- a/src/cli/completions/sources/links.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Link completion source — extracts markdown headings from all .md files. - * - * Produces two entries per heading section, plus one for the file itself. - * Uses github-slugger to match marked-gfm-heading-id's generated IDs. - */ - -import Slugger from "github-slugger"; -import type { CompletionSource, LinkCompletion } from "../types.js"; - -export const linksSource: CompletionSource = { - key: "links", - - scan(index) { - const items: LinkCompletion[] = []; - - for (const [filePath, content] of Object.entries(index)) { - if (!filePath.endsWith(".md")) continue; - - // Strip .md extension for the router-friendly path - const basePath = filePath.replace(/\.md$/, ""); - const fileName = fileNameFromPath(basePath); - const slugger = new Slugger(); - - // Add the file itself as a link (whole article) - items.push({ - path: basePath, - label: fileName, - section: null, - }); - - // Parse headings for section-scoped links - const headingRegex = /^(#{1,6})\s+(.+)$/gm; - let match: RegExpExecArray | null; - - while ((match = headingRegex.exec(content)) !== null) { - const title = match[2].trim(); - const id = slugger.slug(title.toLowerCase()); - - items.push({ - path: basePath, - label: `${fileName} § ${title}`, - section: id, - }); - } - } - - return items; - }, -}; - -function fileNameFromPath(path: string): string { - const parts = path.split("/").filter(Boolean); - return parts[parts.length - 1] || path; -} diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index dc25abf..4feb9c5 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -36,6 +36,8 @@ export interface SparkTableCompletion { slug: string; /** File path of the containing .md file (without extension) */ filePath: string; + /** Path of the containing .md file (with extension) — for inline lookup */ + docPath: string; /** Resolved path to the .csv file backing this spark table */ csvPath: string; /** Data column headers for display */ @@ -61,4 +63,4 @@ export interface CompletionSource { key: string; /** Scan the content index and return structured completion items */ scan(index: Record): unknown[]; -} \ No newline at end of file +} diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index b5bd1da..2390474 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -5,7 +5,7 @@ * - variable-expression (expression evaluation) * - var-reactivity (dependency graph, cascade, tag activation) * - command-parser (input parsing) - * - directive-scanner (spark table detection) + * - content-registry (spark table detection) */ // --------------------------------------------------------------------------- @@ -47,8 +47,7 @@ import { parseInput } from "../../components/journal/command-parser"; import { inspectSparkTableCsv, buildSparkTableCompletion, -} from "./directive-scanner"; -import Slugger from "github-slugger"; +} from "../content-registry"; // --------------------------------------------------------------------------- // Helpers @@ -70,7 +69,10 @@ describe("parseDeclareCsv", () => { ,$ac,10+$dex`; const result = parseDeclareCsv(csv, "test.md"); expect(result.variables).toHaveLength(2); - expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" }); + expect(result.variables[0]).toEqual({ + key: "$hp", + expression: "$con*5+$mod_hp", + }); expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" }); expect(result.tagModifiers).toHaveLength(0); }); @@ -154,7 +156,7 @@ describe("parseDeclareCsv", () => { const csv = `tag,key,expr warrior,$mod_hp,20`; expect(() => parseDeclareCsv(csv, "test.md")).toThrow( - 'tag must start with #', + "tag must start with #", ); }); @@ -162,7 +164,7 @@ warrior,$mod_hp,20`; const csv = `tag,key,expr ,hp,$con*5`; expect(() => parseDeclareCsv(csv, "test.md")).toThrow( - 'key must start with $', + "key must start with $", ); }); @@ -170,7 +172,7 @@ warrior,$mod_hp,20`; const csv = `tag,key,expr #warrior,mod_hp,20`; expect(() => parseDeclareCsv(csv, "test.md")).toThrow( - 'key must start with $', + "key must start with $", ); }); @@ -219,8 +221,8 @@ describe("parseBlockAttrs", () => { test("parses standard attributes", () => { // parseBlockAttrs receives the info string AFTER the lang. // The lang is extracted from the fenced block regex capture group - // and applied separately in block-processor. - const attrs = parseBlockAttrs('id=stats role=declare as=none'); + // and applied separately in content-registry. + const attrs = parseBlockAttrs("id=stats role=declare as=none"); expect(attrs.id).toBe("stats"); expect(attrs.role).toBe("declare"); expect(attrs.as).toBe("none"); @@ -233,7 +235,7 @@ describe("parseBlockAttrs", () => { }); test("collects unknown attributes in extra", () => { - const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42'); + const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42"); expect(attrs.extra).toEqual({ foo: "bar", baz: "42" }); }); @@ -282,7 +284,9 @@ describe("evaluateExpression", () => { }); test("evaluates with parentheses", () => { - const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined }); + const result = evaluateExpression("(2 + 3) * 4", { + lookup: () => undefined, + }); expect(result.value).toBe(20); }); @@ -309,11 +313,13 @@ describe("evaluateExpression", () => { evaluateExpression("$class + 5", { lookup: (name) => (name === "class" ? "#warrior" : undefined), }), - ).toThrow('$class is a tag'); + ).toThrow("$class is a tag"); }); test("evaluates floor function", () => { - const result = evaluateExpression("floor(3.7)", { lookup: () => undefined }); + const result = evaluateExpression("floor(3.7)", { + lookup: () => undefined, + }); expect(result.value).toBe(3); }); @@ -323,7 +329,9 @@ describe("evaluateExpression", () => { }); test("evaluates round function", () => { - const result = evaluateExpression("round(3.5)", { lookup: () => undefined }); + const result = evaluateExpression("round(3.5)", { + lookup: () => undefined, + }); expect(result.value).toBe(4); }); @@ -424,9 +432,7 @@ describe("var-reactivity", () => { test("detects self-referencing circular dependency", () => { expect(() => initReactivity({ - declarations: [ - { key: "$a", expression: "$a + 1" }, - ], + declarations: [{ key: "$a", expression: "$a + 1" }], tagModifiers: [], }), ).toThrow("Circular dependency"); @@ -527,14 +533,28 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, - { tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, + { + tag: "#warrior", + target: "$mod_str", + expression: "5", + threshold: 1, + }, ], }); // Set $class to #warrior:1 — should activate both modifiers setBase("$class", "#warrior:1"); - const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" })); + const cascade = computeCascade( + "$class", + undefined, + store({ $class: "#warrior:1" }), + ); // Should produce combined values for both targets const modHp = cascade.find((r) => r.key === "$mod_hp"); @@ -547,7 +567,12 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, ], }); @@ -572,7 +597,12 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, { tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 }, ], }); @@ -600,19 +630,32 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 2, + }, ], }); // Count 1 < threshold 2 — should NOT activate setBase("$class", "#warrior:1"); - const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" })); + const cascade1 = computeCascade( + "$class", + undefined, + store({ $class: "#warrior:1" }), + ); const modHp1 = cascade1.find((r) => r.key === "$mod_hp"); expect(modHp1).toBeUndefined(); // Increase to count 2 >= threshold 2 — should activate setBase("$class", "#warrior:2"); - const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" })); + const cascade2 = computeCascade( + "$class", + "#warrior:1", + store({ $class: "#warrior:2" }), + ); const modHp2 = cascade2.find((r) => r.key === "$mod_hp"); expect(modHp2?.value).toBe("20"); }); @@ -621,7 +664,12 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 2, + }, ], }); @@ -645,13 +693,22 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, { tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 }, ], }); setBase("$class", "#warrior:2;#druid:1"); - const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" })); + const cascade = computeCascade( + "$class", + undefined, + store({ $class: "#warrior:2;#druid:1" }), + ); const modHp = cascade.find((r) => r.key === "$mod_hp"); const modMp = cascade.find((r) => r.key === "$mod_mp"); @@ -663,9 +720,7 @@ describe("var-reactivity", () => { describe("computeCascade — declaration re-evaluation", () => { test("re-evaluates dependents when a dependency changes", () => { initReactivity({ - declarations: [ - { key: "$hp", expression: "$con * 5" }, - ], + declarations: [{ key: "$hp", expression: "$con * 5" }], tagModifiers: [], }); @@ -701,11 +756,14 @@ describe("var-reactivity", () => { test("handles tagmap transition during re-evaluation", () => { initReactivity({ - declarations: [ - { key: "$class", expression: "0" }, - ], + declarations: [{ key: "$class", expression: "0" }], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, { tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 }, ], }); @@ -744,7 +802,12 @@ describe("var-reactivity", () => { initReactivity({ declarations: [], tagModifiers: [ - { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, + { + tag: "#warrior", + target: "$mod_hp", + expression: "20", + threshold: 1, + }, ], }); @@ -861,7 +924,7 @@ describe("parseInput", () => { }); // --------------------------------------------------------------------------- -// directive-scanner +// content-registry (spark tables) // --------------------------------------------------------------------------- describe("inspectSparkTableCsv", () => { @@ -915,13 +978,11 @@ describe("buildSparkTableCompletion", () => { test("builds completion from CSV data", () => { const csv = `d6,Name,Description 1,Alice,The brave`; - const slugger = new Slugger(); const result = buildSparkTableCompletion( csv, "/rules/combat.md", "/rules/combat/test.csv", false, - slugger, ); expect(result).not.toBeNull(); expect(result!.notation).toBe("d6"); @@ -933,13 +994,11 @@ describe("buildSparkTableCompletion", () => { test("returns null for non-spark CSV", () => { const csv = `Name,Value Alice,10`; - const slugger = new Slugger(); const result = buildSparkTableCompletion( csv, "/test.md", "/test.csv", false, - slugger, ); expect(result).toBeNull(); }); @@ -947,13 +1006,11 @@ Alice,10`; test("sets remix flag", () => { const csv = `d6,Result 1,Yes`; - const slugger = new Slugger(); const result = buildSparkTableCompletion( csv, "/test.md", "/test.csv", true, - slugger, ); expect(result!.remix).toBe(true); }); diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts new file mode 100644 index 0000000..86309da --- /dev/null +++ b/src/cli/content-registry.ts @@ -0,0 +1,689 @@ +/** + * Content registry — the single source of truth for all content in a + * TTRPG Tools project. + * + * Two stores: + * - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`) + * - `docContent`: inline content *defined inside* a markdown doc, keyed by + * a stable id and owned by that doc. + * + * Everything structured (completions, declarations, tag modifiers) is a + * *derived* view over this registry — see `deriveCompletions`. + * + * This module is browser-safe (no Node-only imports) so the CLI and the + * frontend share one implementation. The filesystem walk lives in the CLI + * (`buildRegistry` in `commands/serve.ts`). + */ + +import Slugger from "github-slugger"; +import { + parseDeclareCsv, + type VarDeclaration, + type TagModifier, +} from "./completions/declare-parser.js"; +import { + FENCED_BLOCK_RE, + parseBlockAttrs, + resolveBlockAs, +} from "./completions/block-scanner.js"; +import type { + CompletionsPayload, + DiceCompletion, + LinkCompletion, + SparkTableCompletion, +} from "./completions/types.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContentKind = "csv" | "text" | "declare"; + +/** A single piece of inline content defined inside a doc. */ +export interface DocContent { + /** Stable id — author-supplied or derived from the body. */ + id: string; + kind: ContentKind; + body: string; + /** Origin role that produced this content, for debugging. */ + role?: string; + /** Origin `as` value, for debugging. */ + as?: string; +} + +export interface ContentRegistry { + /** Real files on disk, keyed by path. */ + pathIndex: Record; + /** Inline content defined inside each doc, keyed by id. */ + docContent: Record>; +} + +export const EMPTY_REGISTRY: ContentRegistry = { + pathIndex: {}, + docContent: {}, +}; + +// --------------------------------------------------------------------------- +// Id / hash derivation (single source of truth) +// --------------------------------------------------------------------------- + +/** Browser-safe content hash — stable across CLI and frontend. */ +export function contentHash(body: string): string { + let hash = 0; + for (let i = 0; i < body.length; i++) { + const ch = body.charCodeAt(i); + hash = ((hash << 5) - hash + ch) | 0; + } + return Math.abs(hash).toString(16).slice(0, 8); +} + +/** + * Derive a stable content id. + * Author-supplied `id` wins; otherwise `{kind}_{hash}`. + */ +export function deriveContentId( + kind: ContentKind, + body: string, + id?: string, +): string { + if (id) return id; + return `${kind}_${contentHash(body)}`; +} + +// --------------------------------------------------------------------------- +// Per-doc scanning +// --------------------------------------------------------------------------- + +export interface DocScanResult { + /** Content with blocks processed (stripped or replaced with directives). */ + stripped: string; + /** Inline content defined in this doc, keyed by id. */ + content: Record; +} + +/** + * Process a single markdown doc: + * - strips/replaces attributed fenced code blocks based on `as` + * - coerces spark-shaped markdown tables to `:md-table` directives + * - collects inline content (role=file, md-* bodies, spark tables, declare) + * into the doc's content store + * + * Does NOT touch the path index — the caller assembles the registry. + */ +export function scanDoc(content: string, docPath: string): DocScanResult { + const contentStore: Record = {}; + + // ---- Pass 1: attributed fenced code blocks ---- + const stripped = content.replace( + FENCED_BLOCK_RE, + ( + _match: string, + lang: string, + infoString: string, + body: string, + ): string => { + const attrs = parseBlockAttrs(infoString); + attrs.lang = attrs.lang || lang; + const effectiveAs = resolveBlockAs(attrs.role, attrs.as); + + if (attrs.role === "declare") { + const id = deriveContentId("declare", body, attrs.id); + contentStore[id] = { + id, + kind: "declare", + body, + role: attrs.role, + as: effectiveAs, + }; + } + + if (attrs.role === "file") { + const id = deriveContentId("text", body, attrs.id); + contentStore[id] = { + id, + kind: "text", + body, + role: attrs.role, + as: effectiveAs, + }; + } + + if (effectiveAs === "codeblock") { + return _match; + } + + if (effectiveAs === "none") { + return ""; + } + + if (effectiveAs.startsWith("md-")) { + const id = deriveContentId("csv", body, attrs.id); + contentStore[id] = { + id, + kind: "csv", + body, + role: attrs.role, + as: effectiveAs, + }; + + const extra = { ...attrs.extra }; + const extraStr = Object.keys(extra).length + ? `{${Object.entries(extra) + .map(([k, v]) => `${k}=${v}`) + .join(" ")}}` + : ""; + return `:${effectiveAs}[./${id}]${extraStr}`; + } + + return ""; + }, + ); + + // ---- Pass 2: coerce spark-shaped markdown tables to :md-table ---- + const rewritten = coerceSparkTables(stripped, contentStore); + + return { stripped: rewritten, content: contentStore }; +} + +// --------------------------------------------------------------------------- +// Spark table coercion +// --------------------------------------------------------------------------- + +const DICE_HEADER_RE = /^\d*d\d+$/i; + +/** Split a markdown table row into cells. */ +function splitTableRow(row: string): string[] { + return row + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((c) => c.trim()); +} + +/** Escape a cell value for CSV output. */ +function escapeCsvCell(cell: string): string { + if ( + cell.includes(",") || + cell.includes("\n") || + cell.includes('"') || + cell.includes("#") + ) { + return `"${cell.replace(/"/g, '""')}"`; + } + return cell; +} + +/** Convert a markdown table (header + separator + rows) to a CSV string. */ +function markdownTableToCsv( + headerRow: string, + separatorRow: string, + bodyRows: string[], +): string | null { + const headers = splitTableRow(headerRow); + if (headers.length === 0) return null; + + const sepCells = splitTableRow(separatorRow); + if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null; + if (sepCells.length !== headers.length) return null; + + const csvHeader = headers.map(escapeCsvCell).join(","); + const csvRows = bodyRows.map((row) => { + const cells = splitTableRow(row); + while (cells.length < headers.length) cells.push(""); + return cells.slice(0, headers.length).map(escapeCsvCell).join(","); + }); + + return [csvHeader, ...csvRows].join("\n"); +} + +/** + * Coerce spark-shaped markdown tables (first column header is a dice formula) + * into `:md-table` directives, storing the CSV in the doc's content store and + * injecting `data-spark` for the reveal feature. + */ +function coerceSparkTables( + content: string, + contentStore: Record, +): string { + const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm; + + interface TableMatch { + fullMatch: string; + headerRow: string; + separatorRow: string; + bodyRowsText: string; + index: number; + } + const tableMatches: TableMatch[] = []; + + let mdMatch: RegExpExecArray | null; + while ((mdMatch = mdTableRegex.exec(content)) !== null) { + const [, headerRow, separatorRow, bodyRowsText] = mdMatch; + const headers = splitTableRow(headerRow); + if (!DICE_HEADER_RE.test(headers[0])) continue; + + const bodyRows = bodyRowsText + .trim() + .split(/\n/) + .filter((r) => r.trim().startsWith("|")); + const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows); + if (!csv) continue; + + tableMatches.push({ + fullMatch: mdMatch[0], + headerRow, + separatorRow, + bodyRowsText, + index: mdMatch.index, + }); + } + + let rewritten = content; + for (let i = tableMatches.length - 1; i >= 0; i--) { + const m = tableMatches[i]; + const bodyRows = m.bodyRowsText + .trim() + .split(/\n/) + .filter((r) => r.trim().startsWith("|")); + const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!; + + const id = deriveContentId("csv", csv); + contentStore[id] = { + id, + kind: "csv", + body: csv, + role: "spark-table", + as: "md-table", + }; + + const slug = sparkSlug(csv); + const directive = `:md-table[./${id}]{data-spark="${slug}"}`; + rewritten = + rewritten.slice(0, m.index) + + directive + + rewritten.slice(m.index + m.fullMatch.length); + } + + return rewritten; +} + +// --------------------------------------------------------------------------- +// Directive spark injection (real-file `:md-table` / `:md-card` references) +// --------------------------------------------------------------------------- + +/** + * Scan a doc's stripped content for `:md-table[...]` / `:md-card[...]` + * directives that resolve to a spark table, and inject `data-spark` so the + * reveal feature can match them. Idempotent — only injects when missing. + * + * Returns the possibly-rewritten content. + */ +export function injectSparkDirectives( + content: string, + docPath: string, + registry: ContentRegistry, +): string { + const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; + let rewritten = content; + let m: RegExpExecArray | null; + + while ((m = tableDirectiveRegex.exec(content)) !== null) { + const [, , ref, extraStr] = m; + if (extraStr && extraStr.includes("data-spark=")) continue; + + const csv = resolveContent(registry, docPath, ref); + if (!csv) continue; + + const slug = sparkSlug(csv); + if (!slug) continue; + + const fullMatch = m[0]; + const insertPos = fullMatch.indexOf("]") + 1; + const before = fullMatch.slice(0, insertPos); + const after = fullMatch.slice(insertPos); + + let replacement: string; + if (after.startsWith("{")) { + replacement = before + after.replace(/^\{/, `{data-spark="${slug}" `); + } else { + replacement = before + `{data-spark="${slug}"}` + after; + } + + rewritten = + rewritten.slice(0, m.index) + + replacement + + rewritten.slice(m.index + fullMatch.length); + } + + return rewritten; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a content reference from within a doc. + * + * - `ref` is inline CSV → returned as-is. + * - `ref` is an absolute path → path index. + * - `ref` is a relative path → resolved against the doc directory; checks + * the path index first, then the doc's inline content store. + * + * Returns `null` when nothing matches. + */ +export function resolveContent( + registry: ContentRegistry, + docPath: string, + ref: string, +): string | null { + const trimmed = ref.trim(); + if (!trimmed) return null; + + // Inline CSV body. + if (looksLikeCsv(trimmed)) return trimmed; + + if (trimmed.startsWith("/")) { + return registry.pathIndex[trimmed] ?? null; + } + + // Inline content in the same doc (e.g. ./{id}). + const docStore = registry.docContent[docPath]; + if (docStore) { + const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed; + const entry = docStore[id]; + if (entry) return entry.body; + } + + // Relative path resolved against the doc directory. + const resolved = posixJoin(posixDir(docPath), trimmed); + return registry.pathIndex[resolved] ?? null; +} + +/** + * Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content + * by searching every doc's content store for a matching id. Used by the + * frontend when a directive ref has already been resolved to a path. + * + * Returns `null` when no inline content matches. + */ +export function resolveInlineByPath( + registry: ContentRegistry, + resolvedPath: string, +): string | null { + const id = resolvedPath.split("/").filter(Boolean).pop() || ""; + if (!id) return null; + for (const store of Object.values(registry.docContent)) { + const entry = store[id]; + if (entry) return entry.body; + } + return null; +} + +/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */ +function looksLikeCsv(str: string): boolean { + const trimmed = str.trim(); + if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true; + + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== ""); + if (lines.length < 2) return false; + + const separators = [",", "\t", ";", "|"]; + const firstLine = lines[0]; + for (const sep of separators) { + if (firstLine.includes(sep)) { + const hasInOthers = lines.slice(1).some((line) => line.includes(sep)); + if (hasInOthers) return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Derived completions +// --------------------------------------------------------------------------- + +/** + * Derive the completions payload from the registry. + * Pure function — recompute on any file change. + * + * Dice + spark completions come from scanning each doc's stripped content + * for directives (resolving CSV refs through the registry), so both inline + * and real-file spark tables are covered. Declarations come from the doc + * content store. + */ +export function deriveCompletions( + registry: ContentRegistry, +): CompletionsPayload { + const links = deriveLinks(registry.pathIndex); + const dice: DiceCompletion[] = []; + const sparkTables: SparkTableCompletion[] = []; + const declarations: VarDeclaration[] = []; + const tagModifiers: TagModifier[] = []; + + for (const [docPath, content] of Object.entries(registry.pathIndex)) { + if (!docPath.endsWith(".md")) continue; + const found = scanDocDirectives(content, docPath, registry); + dice.push(...found.dice); + sparkTables.push(...found.sparkTables); + } + + const blocks = deriveBlocks(registry); + declarations.push(...blocks.declarations); + tagModifiers.push(...blocks.tagModifiers); + + return { dice, links, sparkTables, declarations, tagModifiers }; +} + +/** + * Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card` + * directives, resolving CSV refs through the registry. + */ +function scanDocDirectives( + content: string, + docPath: string, + registry: ContentRegistry, +): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } { + const dice = scanDice(content, docPath); + const sparkTables: SparkTableCompletion[] = []; + + const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; + let m: RegExpExecArray | null; + while ((m = tableDirectiveRegex.exec(content)) !== null) { + const [, , ref, extraStr] = m; + const csv = resolveContent(registry, docPath, ref); + if (!csv) continue; + + // csvPath: content id for inline content, resolved path for real files. + const docStore = registry.docContent[docPath]; + const id = ref.startsWith("./") ? ref.slice(2) : ref; + const csvPath = + docStore && docStore[id] + ? id + : resolveContentPath(registry, docPath, ref); + + const attrs = parseDirectiveAttrs(extraStr); + const st = buildSparkTableCompletion( + csv, + docPath, + csvPath, + attrs["remix"] === "true", + ); + if (st) sparkTables.push(st); + } + + return { dice, sparkTables }; +} + +/** Resolve a directive ref to a path-index key (for real files). */ +function resolveContentPath( + registry: ContentRegistry, + docPath: string, + ref: string, +): string { + const trimmed = ref.trim(); + if (trimmed.startsWith("/")) return trimmed; + return posixJoin(posixDir(docPath), trimmed.replace(/^\.\//, "")); +} + +/** Parse key=value pairs from a directive extra-attrs string. */ +function parseDirectiveAttrs( + extraStr: string | undefined, +): Record { + if (!extraStr) return {}; + const attrs: Record = {}; + const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(extraStr)) !== null) { + attrs[m[1]] = m[2].replace(/^"|"$/g, ""); + } + return attrs; +} + +/** Derive variable declarations + tag modifiers from the registry. */ +export function deriveBlocks(registry: ContentRegistry): { + declarations: VarDeclaration[]; + tagModifiers: TagModifier[]; +} { + const declarations: VarDeclaration[] = []; + const tagModifiers: TagModifier[] = []; + + for (const [docPath, store] of Object.entries(registry.docContent)) { + for (const entry of Object.values(store)) { + if (entry.kind !== "declare") continue; + try { + const result = parseDeclareCsv(entry.body, docPath); + declarations.push(...result.variables); + tagModifiers.push(...result.tagModifiers); + } catch (e) { + console.warn(`[content-registry] ${docPath}: ${e}`); + } + } + } + + return { declarations, tagModifiers }; +} + +// --------------------------------------------------------------------------- +// Derivation helpers +// --------------------------------------------------------------------------- + +/** Extract headings from all `.md` files as link completions. */ +function deriveLinks(pathIndex: Record): LinkCompletion[] { + const items: LinkCompletion[] = []; + for (const [filePath, content] of Object.entries(pathIndex)) { + if (!filePath.endsWith(".md")) continue; + + const basePath = filePath.replace(/\.md$/, ""); + const fileName = fileNameFromPath(basePath); + const slugger = new Slugger(); + + items.push({ path: basePath, label: fileName, section: null }); + + const headingRegex = /^(#{1,6})\s+(.+)$/gm; + let match: RegExpExecArray | null; + while ((match = headingRegex.exec(content)) !== null) { + const title = match[2].trim(); + const id = slugger.slug(title.toLowerCase()); + items.push({ + path: basePath, + label: `${fileName} § ${title}`, + section: id, + }); + } + } + return items; +} + +const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi; + +function looksLikeDice(raw: string): boolean { + if (raw.length > 80) return false; + return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); +} + +/** Scan a text body for `:md-dice[...]` directives. */ +function scanDice(body: string, source: string): DiceCompletion[] { + const dice: DiceCompletion[] = []; + let m: RegExpExecArray | null; + while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) { + const raw = m[1].trim(); + if (!raw || !looksLikeDice(raw)) continue; + dice.push({ label: raw, notation: raw, source }); + } + return dice; +} + +/** + * Inspect a CSV body and return its data-column headers if it's a spark + * table (first column header is a dice formula), or null otherwise. + */ +export function inspectSparkTableCsv(csv: string): string[] | null { + const lines = csv.trim().split(/\r?\n/); + if (lines.length < 2) return null; + + const headers = lines[0].split(",").map((h) => h.trim()); + if (headers.length < 2) return null; + if (!DICE_HEADER_RE.test(headers[0])) return null; + + return headers.slice(1); +} + +/** Compute the spark slug for a CSV body, or null if it's not a spark table. */ +function sparkSlug(csv: string): string | null { + const dataHeaders = inspectSparkTableCsv(csv); + if (!dataHeaders) return null; + const slugger = new Slugger(); + return dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-"); +} + +/** Build a SparkTableCompletion from a CSV body. */ +export function buildSparkTableCompletion( + csv: string, + docPath: string, + contentId: string, + remix: boolean, +): SparkTableCompletion | null { + const dataHeaders = inspectSparkTableCsv(csv); + if (!dataHeaders) return null; + + const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim(); + const slugger = new Slugger(); + const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-"); + + const basePath = docPath.replace(/\.md$/, ""); + const fileName = basePath.split("/").filter(Boolean).pop() || basePath; + const combinedSlug = `${fileName}-${slug}`; + + return { + label: `${fileName} § ${slug}`, + notation, + slug: combinedSlug, + filePath: basePath, + docPath, + csvPath: contentId, + headers: dataHeaders, + remix, + }; +} + +// --------------------------------------------------------------------------- +// Path helpers (browser-safe posix) +// --------------------------------------------------------------------------- + +function posixDir(path: string): string { + const idx = path.lastIndexOf("/"); + return idx >= 0 ? path.slice(0, idx) : "."; +} + +function posixJoin(dir: string, rel: string): string { + if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`; + const base = dir.replace(/\/+$/, ""); + const r = rel.replace(/^\/+/, ""); + return `${base}/${r}`; +} + +function fileNameFromPath(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts[parts.length - 1] || path; +} diff --git a/src/components/journal/command-dispatcher.ts b/src/components/journal/command-dispatcher.ts index 4ecf2ac..a5cf68a 100644 --- a/src/components/journal/command-dispatcher.ts +++ b/src/components/journal/command-dispatcher.ts @@ -16,7 +16,8 @@ import type { VarDeclaration, TagModifier } from "./declare-parser"; // Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand. const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/; -const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/; +const TAGMAP_PATTERN = + /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/; function isTagMapExpr(expr: string): boolean { const t = expr.trim(); @@ -33,17 +34,16 @@ function normalizeTagMap(expr: string): string { // Result // --------------------------------------------------------------------------- -export type DispatchResult = - | { ok: true } - | { ok: false; error: string }; +export type DispatchResult = { ok: true } | { ok: false; error: string }; /** * Shared signal for dispatch errors from any source (typed or cmd-link clicks). * Components that show errors (JournalInput) read from here; callers that * want errors surfaced (CommandLinkManager) write to it. */ -export const [dispatchError, setDispatchError] = - createSignal(null); +export const [dispatchError, setDispatchError] = createSignal( + null, +); // --------------------------------------------------------------------------- // Main dispatch @@ -57,7 +57,12 @@ export interface DispatchContext { /** The raw text to dispatch (with or without leading `/`) */ command: string; /** Spark table lookup data (from completions) */ - sparkTables: { slug: string; csvPath?: string; remix?: boolean }[]; + sparkTables: { + slug: string; + csvPath?: string; + docPath?: string; + remix?: boolean; + }[]; /** Current runtime variable values */ variables: Record; /** Variable declarations (from role=declare blocks) */ @@ -94,7 +99,9 @@ export async function dispatchCommand( } if (parsed.type === "set" || parsed.type === "rolltag") { - return finish(dispatchSet(parsed.payload as Record, ctx)); + return finish( + dispatchSet(parsed.payload as Record, ctx), + ); } return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" }); @@ -109,8 +116,14 @@ export async function dispatchCommand( if (match) { try { const csvPath = match.csvPath ?? ""; + const docPath = match.docPath; const remix = match.remix ?? false; - const p = await resolveSparkPayload({ key: arg, csvPath, remix }); + const p = await resolveSparkPayload({ + key: arg, + csvPath, + docPath, + remix, + }); const result = sendMessage("spark", p); return finish(unwrap(result)); } catch (e) { @@ -226,7 +239,11 @@ function dispatchSet( try { const cascade = computeCascade(key, oldValue, workingVars); for (const change of cascade) { - sendMessage("var", { action: "set", key: change.key, value: change.value }); + sendMessage("var", { + action: "set", + key: change.key, + value: change.value, + }); } } catch (e) { // Cascade errors are non-fatal — the direct set already succeeded @@ -253,4 +270,4 @@ function unwrap( r: { success: true; msg: R } | { success: false; error: string }, ): DispatchResult { return r.success ? { ok: true } : { ok: false, error: r.error }; -} \ No newline at end of file +} diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 27e2d9e..c0361df 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -10,15 +10,17 @@ */ import { createSignal } from "solid-js"; -import { extractHeadings } from "../../data-loader/toc"; import { getPathsByExtension, getIndexedData, + setInlineResolver, } from "../../data-loader/file-index"; import { - scanDirectives, -} from "../../cli/completions/directive-scanner"; -import { scanDeclareBlocks } from "../../cli/completions/declare-parser"; + scanDoc, + deriveCompletions, + resolveInlineByPath, + type ContentRegistry, +} from "../../cli/content-registry"; import type { CompletionsPayload, DiceCompletion, @@ -50,6 +52,23 @@ const [completionsState, setCompletionsState] = createSignal({ status: "loading", }); +// The registry backing the completions. Populated in both CLI and client +// modes so inline content ids can be resolved at runtime (e.g. spark rolls). +let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} }; + +/** + * The registry backing the current completions. + * In CLI mode this is fetched from the server; in browser mode it is built + * client-side. Used to resolve inline content ids (e.g. spark table CSVs). + */ +export function getRegistry(): ContentRegistry { + return activeRegistry; +} + +// Register the inline-content resolver so `getIndexedData` can resolve +// directive refs (e.g. `./csv_abc123`) that aren't real files. +setInlineResolver((path) => resolveInlineByPath(activeRegistry, path)); + // ------------------- Fetch (CLI mode) ------------------- async function tryServer(): Promise { @@ -61,66 +80,49 @@ async function tryServer(): Promise { dice: Array.isArray(data.dice) ? data.dice : [], links: Array.isArray(data.links) ? data.links : [], sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [], - declarations: Array.isArray(data.declarations) - ? data.declarations - : [], - tagModifiers: Array.isArray(data.tagModifiers) - ? data.tagModifiers - : [], + declarations: Array.isArray(data.declarations) ? data.declarations : [], + tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [], }; } catch { return null; } } +/** Load the content registry from the server (CLI mode). */ +async function tryServerRegistry(): Promise { + try { + const resp = await fetch("/__CONTENT_REGISTRY.json"); + if (!resp.ok) return; + const data = await resp.json(); + activeRegistry = { + pathIndex: data.pathIndex ?? {}, + docContent: data.docContent ?? {}, + }; + } catch { + // Registry unavailable — leave empty; client scan will populate it. + } +} + // ------------------- Client-side fallback scan ------------------- async function scanClientSide(): Promise { const paths = await getPathsByExtension("md"); - const dice: DiceCompletion[] = []; - const links: LinkCompletion[] = []; - const sparkTables: SparkTableCompletion[] = []; - const declarations: VarDeclaration[] = []; - const tagModifiers: TagModifier[] = []; - // Build a temporary index for resolving CSV paths - const tempIndex: Record = {}; + // Build a registry from the in-memory file index, then derive completions + // through the same shared pipeline as the CLI. + const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; - // First pass: load all .md content into temp index + // First pass: load all .md content into the registry. for (const filePath of paths) { const content = await getIndexedData(filePath); - if (content) tempIndex[filePath] = content; - } - - for (const filePath of paths) { - const content = tempIndex[filePath]; if (!content) continue; - - // ---- Links (headings) - from original content ---- - const basePath = filePath.replace(/\.md$/, ""); - const fileName = basePath.split("/").filter(Boolean).pop() || basePath; - links.push({ path: basePath, label: fileName, section: null }); - for (const heading of extractHeadings(content)) { - links.push({ - path: basePath, - label: `${fileName} § ${heading.title}`, - section: heading.id ?? null, - }); - } - - // ---- Declare block scanning (shared with CLI) ---- - const declareResult = scanDeclareBlocks(content, filePath); - declarations.push(...declareResult.variables); - tagModifiers.push(...declareResult.tagModifiers); - - // ---- Directive scanning (dice + spark tables) ---- - const fileDir = filePath.split("/").slice(0, -1).join("/") || "."; - const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir); - dice.push(...directiveResult.dice); - sparkTables.push(...directiveResult.sparkTables); + const result = scanDoc(content, filePath); + registry.pathIndex[filePath] = result.stripped; + registry.docContent[filePath] = result.content; } - return { dice, links, sparkTables, declarations, tagModifiers }; + activeRegistry = registry; + return deriveCompletions(registry); } // ------------------- Init (runs eagerly at import time) ------------------- @@ -131,10 +133,16 @@ const _initPromise: Promise = (async () => { const serverData = await tryServer(); if (serverData) { setCompletionsState({ status: "loaded", data: serverData }); + await tryServerRegistry(); try { - initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers }); + initReactivity({ + declarations: serverData.declarations, + tagModifiers: serverData.tagModifiers, + }); seedDeclaredVariables(); - } catch (e) { console.warn("[completions] reactivity init error:", e); } + } catch (e) { + console.warn("[completions] reactivity init error:", e); + } return; } @@ -144,9 +152,14 @@ const _initPromise: Promise = (async () => { if (data.dice.length > 0 || data.links.length > 0) { setCompletionsState({ status: "loaded", data }); try { - initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers }); + initReactivity({ + declarations: data.declarations, + tagModifiers: data.tagModifiers, + }); seedDeclaredVariables(); - } catch (e) { console.warn("[completions] reactivity init error:", e); } + } catch (e) { + console.warn("[completions] reactivity init error:", e); + } } else { setCompletionsState({ status: "empty" }); } @@ -201,4 +214,4 @@ function seedDeclaredVariables(): void { for (const { key, value } of initial) { sendMessage("var", { action: "set", key, value }); } -} \ No newline at end of file +} diff --git a/src/components/journal/types/spark.tsx b/src/components/journal/types/spark.tsx index f28c662..71c5c06 100644 --- a/src/components/journal/types/spark.tsx +++ b/src/components/journal/types/spark.tsx @@ -16,11 +16,9 @@ import { z } from "zod"; import { For } from "solid-js"; import { registerMessageType } from "../registry"; import { rollFormula } from "../../md-commander/hooks"; -import { - parseSparkTableCsv, - rollSparkTable, -} from "../../utils/spark-table"; +import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table"; import { getIndexedData } from "../../../data-loader/file-index"; +import { getRegistry } from "../../journal/completions"; // --------------------------------------------------------------------------- // Schema @@ -75,12 +73,27 @@ export type SparkPayload = z.infer; export async function resolveSparkPayload(raw: { key: string; csvPath: string; + docPath?: string; remix: boolean; }): Promise { - let csv: string; - try { - csv = await getIndexedData(raw.csvPath); - } catch { + let csv: string | null; + + // Inline content ids resolve through the registry (docPath + content id); + // real file paths fall back to the file index. + const registry = getRegistry(); + const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined; + const inline = docStore?.[raw.csvPath]; + if (inline) { + csv = inline.body; + } else { + try { + csv = await getIndexedData(raw.csvPath); + } catch { + csv = null; + } + } + + if (csv === null) { throw new Error(`Failed to load CSV: "${raw.csvPath}"`); } diff --git a/src/data-loader/file-index.ts b/src/data-loader/file-index.ts index 62ab287..141a2dd 100644 --- a/src/data-loader/file-index.ts +++ b/src/data-loader/file-index.ts @@ -24,6 +24,20 @@ let fileIndex: FileIndex | null = null; let indexLoadPromise: Promise | null = null; let activeSource: "cli" | "folder" | null = null; +/** + * Optional registry for resolving inline content ids (set by the journal + * completions module). When present, `getIndexedData` resolves ids that + * aren't real files through it. + */ +let inlineResolver: ((path: string) => string | null) | null = null; + +/** Register a resolver for inline content ids (see journal/completions). */ +export function setInlineResolver( + fn: ((path: string) => string | null) | null, +): void { + inlineResolver = fn; +} + /** Currently active directory handle (if folder source) */ let activeDirHandle: FileSystemDirectoryHandle | null = null; @@ -181,6 +195,15 @@ export async function getIndexedData(path: string): Promise { if (fileIndex && fileIndex[path]) { return fileIndex[path]; } + // Resolve inline content ids through the registry before fetching. + if (inlineResolver) { + const inline = inlineResolver(path); + if (inline !== null) { + fileIndex = fileIndex || {}; + fileIndex[path] = inline; + return inline; + } + } const res = await fetch(path); const content = await res.text(); fileIndex = fileIndex || {};