From c03528a293ed9b137d79042220cbbb1b76b57854 Mon Sep 17 00:00:00 2001 From: hyper Date: Fri, 7 Aug 2026 13:15:51 +0800 Subject: [PATCH] feat: Unify spark table detection and content resolution Export isSparkTableHeader from content-registry and use it in both CLI and frontend table parsing. Add resolveContentRef to resolve content references consistently. Write processed registry content back to the file index so browser mode renders identically to CLI mode. --- src/cli/completions/variable-system.test.ts | 27 ++++++++++++++++ src/cli/content-registry.ts | 13 ++++++-- src/components/journal/completions.ts | 9 ++++++ src/components/md-table.tsx | 31 ++++++++++-------- src/components/utils/resolve-content.ts | 36 +++++++++++++++++++++ src/data-loader/file-index.ts | 13 +++++++- src/markdown/table.ts | 3 +- 7 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 src/components/utils/resolve-content.ts diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts index 2390474..839dbba 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -47,6 +47,7 @@ import { parseInput } from "../../components/journal/command-parser"; import { inspectSparkTableCsv, buildSparkTableCompletion, + isSparkTableHeader, } from "../content-registry"; // --------------------------------------------------------------------------- @@ -927,6 +928,32 @@ describe("parseInput", () => { // content-registry (spark tables) // --------------------------------------------------------------------------- +describe("isSparkTableHeader", () => { + test("detects plain dice formula", () => { + expect(isSparkTableHeader("d6")).toBe(true); + expect(isSparkTableHeader("d20")).toBe(true); + expect(isSparkTableHeader("d100")).toBe(true); + }); + + test("detects dice count and modifiers", () => { + expect(isSparkTableHeader("2d6")).toBe(true); + expect(isSparkTableHeader("3d6+5")).toBe(true); + expect(isSparkTableHeader("1d8-2")).toBe(true); + }); + + test("rejects non-dice headers", () => { + expect(isSparkTableHeader("Name")).toBe(false); + expect(isSparkTableHeader("d")).toBe(false); + expect(isSparkTableHeader("6")).toBe(false); + expect(isSparkTableHeader("d6x")).toBe(false); + expect(isSparkTableHeader("roll")).toBe(false); + }); + + test("trims surrounding whitespace", () => { + expect(isSparkTableHeader(" d20 ")).toBe(true); + }); +}); + describe("inspectSparkTableCsv", () => { test("detects spark table from CSV headers", () => { const csv = `d6,Name,Description diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 26e21e1..683a4b8 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -239,7 +239,14 @@ export function buildRegistryFromIndex( // Spark table coercion // --------------------------------------------------------------------------- -const DICE_HEADER_RE = /^\d*d\d+$/i; +/** + * Whether a table header cell is a dice formula (a "spark table" first + * column). Single source of truth shared by the CLI scanner and the frontend + * `markedTable` renderer so both agree on what counts as a spark table. + */ +export function isSparkTableHeader(header: string): boolean { + return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim()); +} /** Split a markdown table row into cells. */ function splitTableRow(row: string): string[] { @@ -310,7 +317,7 @@ function coerceSparkTables( while ((mdMatch = mdTableRegex.exec(content)) !== null) { const [, headerRow, separatorRow, bodyRowsText] = mdMatch; const headers = splitTableRow(headerRow); - if (!DICE_HEADER_RE.test(headers[0])) continue; + if (!isSparkTableHeader(headers[0])) continue; const bodyRows = bodyRowsText .trim() @@ -674,7 +681,7 @@ export function inspectSparkTableCsv(csv: string): string[] | 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; + if (!isSparkTableHeader(headers[0])) return null; return headers.slice(1); } diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 0fbbebf..f09a4aa 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -13,6 +13,7 @@ import { createSignal } from "solid-js"; import { getPathsByExtension, getIndexedData, + setIndexedData, setInlineResolver, } from "../../data-loader/file-index"; import { @@ -119,6 +120,14 @@ async function scanClientSide(): Promise { const registry = buildRegistryFromIndex(index); activeRegistry = registry; + + // Write the processed (stripped) content back into the file index so + // Article/md-embed render the same content as CLI mode (spark tables + // coerced, attributed blocks processed, data-spark injected). + for (const [path, content] of Object.entries(registry.pathIndex)) { + setIndexedData(path, content); + } + return deriveCompletions(registry); } diff --git a/src/components/md-table.tsx b/src/components/md-table.tsx index 186fa60..58e7022 100644 --- a/src/components/md-table.tsx +++ b/src/components/md-table.tsx @@ -7,9 +7,9 @@ import { createMemo, createResource, } from "solid-js"; -import { marked } from "../markdown"; -import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader"; -import { resolvePath } from "./utils/path"; +import { parseMarkdown } from "../markdown"; +import { loadCSV, CSV, processVariables } from "./utils/csv-loader"; +import { resolveContentRef } from "./utils/resolve-content"; import { areAllLabelsNumeric, weightedRandomIndex, @@ -51,13 +51,17 @@ customElement( const articleEl = element?.closest("article[data-src]"); const articlePath = articleEl?.getAttribute("data-src") || ""; - // 如果是 inline CSV,直接使用;否则解析相对路径 - const contentOrPath = isCSV(rawContent) - ? rawContent - : resolvePath(articlePath, rawContent); - - // 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载 - const [csvData] = createResource(() => contentOrPath, loadCSV); + // 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id) + const [csvData] = createResource( + () => ({ ref: rawContent, docPath: articlePath }), + async ({ ref, docPath }) => { + const content = await resolveContentRef(ref, docPath); + if (content === null) { + throw new Error(`Failed to resolve table content: "${ref}"`); + } + return loadCSV(content); + }, + ); // 当数据加载完成后更新 rows createEffect(() => { @@ -97,10 +101,11 @@ customElement( // 处理 body 内容中的 {{prop}} 语法并解析 markdown const processBody = (body: string, currentRow: TableRow): string => { - // 使用 marked 解析 markdown - return marked.parse( + // 使用 parseMarkdown 统一入口(设置图标 base path 等) + return parseMarkdown( processVariables(body, currentRow, rows(), filteredRows(), props.remix), - ) as string; + articlePath, + ); }; // 更新 body 内容 diff --git a/src/components/utils/resolve-content.ts b/src/components/utils/resolve-content.ts new file mode 100644 index 0000000..0057bb4 --- /dev/null +++ b/src/components/utils/resolve-content.ts @@ -0,0 +1,36 @@ +import { resolveContent } from "../../cli/content-registry"; +import { getRegistry } from "../journal/completions"; +import { getIndexedData } from "../../data-loader/file-index"; +import { resolvePath } from "./path"; + +/** + * Resolve a content reference (a directive body) to raw content. + * + * Prefers the shared content-registry resolution, which understands: + * - inline CSV bodies (returned as-is) + * - inline doc content refs like `./csv_abc123` (scoped to the current doc) + * - absolute paths and relative paths resolved against the current doc + * + * Falls back to path resolution + the file index when the registry isn't + * populated yet (e.g. before completions load) or the ref isn't indexed. + * + * Returns `null` when nothing matches. + */ +export async function resolveContentRef( + ref: string, + docPath: string, +): Promise { + const trimmed = ref.trim(); + if (!trimmed) return null; + + const resolved = resolveContent(getRegistry(), docPath, trimmed); + if (resolved !== null) return resolved; + + // Fallback: resolve relative path and fetch through the file index. + const path = resolvePath(docPath, trimmed); + try { + return await getIndexedData(path); + } catch { + return null; + } +} diff --git a/src/data-loader/file-index.ts b/src/data-loader/file-index.ts index 141a2dd..0f5107d 100644 --- a/src/data-loader/file-index.ts +++ b/src/data-loader/file-index.ts @@ -17,6 +17,7 @@ import { removeHandle, ensurePermission, } from "./file-index-db"; +import { normalizePathKey } from "../cli/content-registry"; type FileIndex = Record; @@ -99,7 +100,7 @@ async function scanDirectory( Object.assign(index, sub); } else if (entry.kind === "file" && acceptedExt.test(name)) { const file = await (entry as FileSystemFileHandle).getFile(); - const path = prefix + name; + const path = normalizePathKey(prefix + name); index[path] = await file.text(); } } @@ -211,6 +212,16 @@ export async function getIndexedData(path: string): Promise { return content; } +/** + * 写入/覆盖索引中的文件内容。 + * 用于将处理后的内容(如 registry 的 stripped markdown)写回索引, + * 使浏览器模式与 CLI 模式渲染一致。 + */ +export function setIndexedData(path: string, content: string): void { + fileIndex = fileIndex || {}; + fileIndex[normalizePathKey(path)] = content; +} + /** * 获取指定扩展名的文件路径 */ diff --git a/src/markdown/table.ts b/src/markdown/table.ts index ecf0c8c..735401c 100644 --- a/src/markdown/table.ts +++ b/src/markdown/table.ts @@ -1,4 +1,5 @@ import type { MarkedExtension, Tokens } from "marked"; +import { isSparkTableHeader } from "../cli/content-registry"; /** * 将表格数据转换为 CSV 格式字符串 @@ -35,7 +36,7 @@ export default function markedTable(): MarkedExtension { let remix = ""; const labelIndex = header.findIndex((cell) => { - if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) { + if (cell.text === "md-roll-label" || isSparkTableHeader(cell.text)) { roll = " roll=true"; return true; } else if (cell.text === "md-remix-label") {