From b7a804f1cfa77ac264217711858774b970284d53 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 8 Sep 2026 22:18:12 +0800 Subject: [PATCH] refactor: remove as= override and render data-spark at runtime - Drop resolveBlockAs and the as= attribute; scanDoc now switches directly on role and warns on unknown roles instead of silently stripping - md-table derives data-spark from its loaded CSV via parseSparkTableCsv; delete injectSparkDirectives and the second registry pass so pathIndex is exactly scanDoc output - Align spark-table dice header regex with isSparkTableHeader --- src/cli/completions/block-scanner.ts | 38 +--- src/cli/completions/variable-system.test.ts | 30 +--- src/cli/content-registry.test.ts | 43 ++++- src/cli/content-registry.ts | 182 ++++++-------------- src/components/journal/completions.ts | 6 +- src/components/md-table.tsx | 22 ++- src/components/utils/spark-table.ts | 4 +- src/markdown/block-attrs.ts | 7 +- 8 files changed, 131 insertions(+), 201 deletions(-) diff --git a/src/cli/completions/block-scanner.ts b/src/cli/completions/block-scanner.ts index 39d5292..53c4728 100644 --- a/src/cli/completions/block-scanner.ts +++ b/src/cli/completions/block-scanner.ts @@ -2,11 +2,15 @@ * Shared block scanning utilities — safe for both CLI and browser. * * Parses fenced code blocks with attributes: - * ```lang id=xxx role=xxx as=xxx + * ```lang id=xxx role=xxx key=value * - * - `role` dispatches to content scanners (declare, spark-table) - * - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) - * - `id` is used for cross-references (file paths) + * The `role` fully determines block behavior (see `scanDoc` in + * `content-registry.ts`): + * - spark-table → stored as CSV, rendered as an `:md-table` directive + * - declare / file → stored, block stripped + * - tag → block kept intact for the yaml-tag render extension + * - unknown role → warned about and stripped + * - no role → kept as a visible code block * * Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render * extensions can share the same syntax. @@ -19,28 +23,4 @@ export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js" // --------------------------------------------------------------------------- /** Matches fenced code blocks with an info string (at least one attr). */ -export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; - -// --------------------------------------------------------------------------- -// as resolution -// --------------------------------------------------------------------------- - -/** - * Determine the effective `as` value. - * - * Defaults: - * - role is set, no explicit as → "none" (strip — it's metadata) - * - role=spark-table → "md-table" (render as md-table directive) - * - role=tag → "codeblock" (kept for the marked yaml-tag extension) - * - no role, no as → "codeblock" (keep as visible code block) - */ -export function resolveBlockAs(role: string | undefined, as: string | undefined): string { - if (as) return as; - // spark-table blocks render as md-table by default - if (role === "spark-table") return "md-table"; - // yaml-defined tag blocks must survive stripping so the - // code-block-yaml-tag marked extension can render them - if (role === "tag") return "codeblock"; - if (role) return "none"; - return "codeblock"; -} \ No newline at end of file +export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; \ 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 de000d8..4b9ae71 100644 --- a/src/cli/completions/variable-system.test.ts +++ b/src/cli/completions/variable-system.test.ts @@ -28,7 +28,7 @@ jest.mock("github-slugger", () => { // --------------------------------------------------------------------------- import { parseDeclareCsv } from "./declare-parser"; -import { parseBlockAttrs, resolveBlockAs } from "./block-scanner"; +import { parseBlockAttrs } from "./block-scanner"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression"; import { initReactivity, @@ -220,10 +220,9 @@ describe("parseBlockAttrs", () => { // parseBlockAttrs receives the info string AFTER the lang. // The lang is extracted from the fenced block regex capture group // and applied separately in content-registry. - const attrs = parseBlockAttrs("id=stats role=declare as=none"); + const attrs = parseBlockAttrs("id=stats role=declare"); expect(attrs.id).toBe("stats"); expect(attrs.role).toBe("declare"); - expect(attrs.as).toBe("none"); }); test("parses quoted values", () => { @@ -242,7 +241,6 @@ describe("parseBlockAttrs", () => { expect(attrs.lang).toBe(""); expect(attrs.id).toBeUndefined(); expect(attrs.role).toBeUndefined(); - expect(attrs.as).toBeUndefined(); }); test("handles empty info string (lang extracted separately)", () => { @@ -252,28 +250,8 @@ describe("parseBlockAttrs", () => { }); }); -describe("resolveBlockAs", () => { - test("returns explicit as value", () => { - expect(resolveBlockAs("declare", "codeblock")).toBe("codeblock"); - }); - - test("defaults to none when role is set", () => { - expect(resolveBlockAs("declare", undefined)).toBe("none"); - expect(resolveBlockAs("file", undefined)).toBe("none"); - }); - - test("defaults to md-table for spark-table role", () => { - expect(resolveBlockAs("spark-table", undefined)).toBe("md-table"); - }); - - test("defaults to codeblock for tag role", () => { - expect(resolveBlockAs("tag", undefined)).toBe("codeblock"); - }); - - test("defaults to codeblock when no role and no as", () => { - expect(resolveBlockAs(undefined, undefined)).toBe("codeblock"); - }); -}); +// Role behavior is covered by the scanDoc tests in +// src/cli/content-registry.test.ts. // --------------------------------------------------------------------------- // variable-expression diff --git a/src/cli/content-registry.test.ts b/src/cli/content-registry.test.ts index 2f97360..d12b9d5 100644 --- a/src/cli/content-registry.test.ts +++ b/src/cli/content-registry.test.ts @@ -80,6 +80,48 @@ describe("scanDoc", () => { expect(stripped).toBe(md); expect(Object.keys(content)).toHaveLength(0); }); + + test("tag blocks are kept intact", () => { + const md = "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n```"; + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toBe(md); + expect(Object.keys(content)).toHaveLength(0); + }); + + test("blocks without role are kept as code blocks", () => { + const md = "```csv\nlabel,body\n1,text\n```"; + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toBe(md); + expect(Object.keys(content)).toHaveLength(0); + }); + + test("unknown roles warn and strip", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const md = "```csv role=frobnicate\nlabel,body\n```"; + const { stripped, content } = scanDoc(md, "test.md"); + + expect(stripped).toBe(""); + expect(Object.keys(content)).toHaveLength(0); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('unknown role "frobnicate"'), + ); + warn.mockRestore(); + }); + + test("legacy as= attribute is ignored", () => { + const md = [ + "```csv role=spark-table as=codeblock", + "d6,Name", + "1,Alice", + "```", + ].join("\n"); + const { stripped } = scanDoc(md, "test.md"); + + // Role wins — the block still becomes a directive. + expect(stripped).toMatch(/^:md-table\[\.\/csv_/); + }); }); describe("resolveContent", () => { @@ -91,7 +133,6 @@ describe("resolveContent", () => { kind: "csv", body: "d6,Name\n1,Alice", role: "spark-table", - as: "md-table", }, }; diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 4105a4d..5081613 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -24,7 +24,6 @@ import { import { FENCED_BLOCK_RE, parseBlockAttrs, - resolveBlockAs, } from "./completions/block-scanner.js"; import type { CompletionsPayload, @@ -47,8 +46,6 @@ export interface DocContent { body: string; /** Origin role that produced this content, for debugging. */ role?: string; - /** Origin `as` value, for debugging. */ - as?: string; } export interface ContentRegistry { @@ -113,83 +110,73 @@ export interface DocScanResult { /** * Process a single markdown doc: - * - strips/replaces attributed fenced code blocks based on `as` + * - dispatches attributed fenced code blocks by `role` * - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV - * - collects inline content (role=file, md-* bodies, declare) - * into the doc's content store + * - collects inline content (spark-table, declare, file) 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, + 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-")) { - let blockBody = body; - if (attrs.role === "spark-table" && isMarkdownTableLang(attrs.lang)) { - blockBody = markdownTableBodyToCsv(body, docPath); + switch (attrs.role) { + case "declare": { + const id = deriveContentId("declare", body, attrs.id); + contentStore[id] = { id, kind: "declare", body, role: attrs.role }; + return ""; } - const id = deriveContentId("csv", blockBody, attrs.id); - contentStore[id] = { - id, - kind: "csv", - body: blockBody, - 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}`; + case "file": { + const id = deriveContentId("text", body, attrs.id); + contentStore[id] = { id, kind: "text", body, role: attrs.role }; + return ""; + } + + case "spark-table": { + let blockBody = body; + if (isMarkdownTableLang(attrs.lang)) { + blockBody = markdownTableBodyToCsv(body, docPath); + } + const id = deriveContentId("csv", blockBody, attrs.id); + contentStore[id] = { + id, + kind: "csv", + body: blockBody, + role: attrs.role, + }; + + const extraStr = Object.entries(attrs.extra) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`; + } + + case "tag": + // Kept intact so the code-block-yaml-tag render extension sees it. + return match; + + case undefined: + // No role declared — plain visible code block. + return match; + + default: + console.warn( + `[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`, + ); + return ""; } - - return ""; }, ); @@ -202,10 +189,12 @@ export function scanDoc(content: string, docPath: string): DocScanResult { * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser * folder scan (`scanClientSide` in `components/journal/completions.ts`) so * both modes produce identical `pathIndex`/`docContent` — including the - * `data-spark` injection pass that only the CLI used to run. + * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser + * folder scan (`scanClientSide` in `components/journal/completions.ts`) so + * both modes produce identical `pathIndex`/`docContent`. * * Keys are normalized via `normalizePathKey`; `.md` files are run through - * `scanDoc` and the spark-injection pass, other extensions are stored raw. + * `scanDoc`, other extensions are stored raw. */ export function buildRegistryFromIndex( index: Record, @@ -223,16 +212,6 @@ export function buildRegistryFromIndex( } } - // Inject data-spark into real-file spark table directives (idempotent). - for (const [relPath, content] of Object.entries(registry.pathIndex)) { - if (!relPath.endsWith(".md")) continue; - registry.pathIndex[relPath] = injectSparkDirectives( - content, - relPath, - registry, - ); - } - return registry; } @@ -334,57 +313,6 @@ function markdownTableBodyToCsv(body: string, docPath: string): string { return markdownTableToCsv(headerRow, separatorRow, rows) ?? body; } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -636,14 +564,6 @@ export function inspectSparkTableCsv(csv: string): string[] | 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, diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index f09a4aa..59c89f3 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -110,7 +110,7 @@ async function scanClientSide(): Promise { const paths = await getPathsByExtension("md"); // Load all .md content into a raw index, then build the registry through - // the same shared pipeline as the CLI (scanDoc + spark injection). + // the same shared pipeline as the CLI (scanDoc). const index: Record = {}; for (const filePath of paths) { const content = await getIndexedData(filePath); @@ -122,8 +122,8 @@ async function scanClientSide(): Promise { 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). + // Article/md-embed render the same content as CLI mode (attributed blocks + // processed by role). for (const [path, content] of Object.entries(registry.pathIndex)) { setIndexedData(path, content); } diff --git a/src/components/md-table.tsx b/src/components/md-table.tsx index 4d2fdcc..b611161 100644 --- a/src/components/md-table.tsx +++ b/src/components/md-table.tsx @@ -10,10 +10,11 @@ import { import { parseMarkdown } from "../markdown"; import { parseCSVString, CSV, processVariables } from "./utils/csv-loader"; import { resolveContentRef } from "./utils/resolve-content"; +import { parseSparkTableCsv } from "./utils/spark-table"; import { areAllLabelsNumeric, weightedRandomIndex, -} from "./utils/weighted-random"; +} from "./utils/weighted-random";; export interface TableProps { roll?: boolean; @@ -59,16 +60,23 @@ customElement( if (content === null) { throw new Error(`Failed to resolve table content: "${ref}"`); } - return parseCSVString(content); + return content; }, ); - // 当数据加载完成后更新 rows + // 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager + // 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。 createEffect(() => { - const data = csvData(); - if (data) { - // 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性 - setRows(data as unknown as CSV); + const content = csvData(); + if (!content) return; + setRows(parseCSVString(content) as unknown as CSV); + if (element) { + const meta = parseSparkTableCsv(content); + if (meta) { + element.setAttribute("data-spark", meta.slug); + } else { + element.removeAttribute("data-spark"); + } } }); diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts index 9059d88..8580fcb 100644 --- a/src/components/utils/spark-table.ts +++ b/src/components/utils/spark-table.ts @@ -53,7 +53,9 @@ export interface SparkTableMeta { // CSV parsing // --------------------------------------------------------------------------- -const DICE_HEADER_RE = /^d\d+$/i; +// Matches the scanner's `isSparkTableHeader` (content-registry): plain dice, +// counts, and modifiers all count as spark-table first columns. +const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i; /** * Parse a CSV string into a SparkTableMeta. diff --git a/src/markdown/block-attrs.ts b/src/markdown/block-attrs.ts index 665eafb..ad434cb 100644 --- a/src/markdown/block-attrs.ts +++ b/src/markdown/block-attrs.ts @@ -10,7 +10,6 @@ export interface BlockAttrs { lang: string; id?: string; role?: string; - as?: string; /** Any other attributes not in the standard set */ extra: Record; } @@ -24,6 +23,8 @@ export function parseBlockAttrs(info: string): BlockAttrs { attrs[m[1]] = m[2].replace(/^"|"$/g, ""); } - const { lang, id, role, as, ...extra } = attrs; - return { lang: lang || "", id, role, as, extra }; + // `as` was a render-target override; roles now fully determine behavior, + // so it is parsed out and ignored (kept out of `extra` for directives). + const { lang, id, role, as: _legacyAs, ...extra } = attrs; + return { lang: lang || "", id, role, extra }; } \ No newline at end of file