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") {