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
This commit is contained in:
2026-09-08 22:18:12 +08:00
parent 256c685f6c
commit b7a804f1cf
8 changed files with 131 additions and 201 deletions
+8 -28
View File
@@ -2,11 +2,15 @@
* Shared block scanning utilities — safe for both CLI and browser. * Shared block scanning utilities — safe for both CLI and browser.
* *
* Parses fenced code blocks with attributes: * 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) * The `role` fully determines block behavior (see `scanDoc` in
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) * `content-registry.ts`):
* - `id` is used for cross-references (file paths) * - 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 * Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
* extensions can share the same syntax. * extensions can share the same syntax.
@@ -20,27 +24,3 @@ export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js"
/** Matches fenced code blocks with an info string (at least one attr). */ /** 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; 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";
}
+4 -26
View File
@@ -28,7 +28,7 @@ jest.mock("github-slugger", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser"; 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 { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import { import {
initReactivity, initReactivity,
@@ -220,10 +220,9 @@ describe("parseBlockAttrs", () => {
// parseBlockAttrs receives the info string AFTER the lang. // parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group // The lang is extracted from the fenced block regex capture group
// and applied separately in content-registry. // 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.id).toBe("stats");
expect(attrs.role).toBe("declare"); expect(attrs.role).toBe("declare");
expect(attrs.as).toBe("none");
}); });
test("parses quoted values", () => { test("parses quoted values", () => {
@@ -242,7 +241,6 @@ describe("parseBlockAttrs", () => {
expect(attrs.lang).toBe(""); expect(attrs.lang).toBe("");
expect(attrs.id).toBeUndefined(); expect(attrs.id).toBeUndefined();
expect(attrs.role).toBeUndefined(); expect(attrs.role).toBeUndefined();
expect(attrs.as).toBeUndefined();
}); });
test("handles empty info string (lang extracted separately)", () => { test("handles empty info string (lang extracted separately)", () => {
@@ -252,28 +250,8 @@ describe("parseBlockAttrs", () => {
}); });
}); });
describe("resolveBlockAs", () => { // Role behavior is covered by the scanDoc tests in
test("returns explicit as value", () => { // src/cli/content-registry.test.ts.
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");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// variable-expression // variable-expression
+42 -1
View File
@@ -80,6 +80,48 @@ describe("scanDoc", () => {
expect(stripped).toBe(md); expect(stripped).toBe(md);
expect(Object.keys(content)).toHaveLength(0); 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", () => { describe("resolveContent", () => {
@@ -91,7 +133,6 @@ describe("resolveContent", () => {
kind: "csv", kind: "csv",
body: "d6,Name\n1,Alice", body: "d6,Name\n1,Alice",
role: "spark-table", role: "spark-table",
as: "md-table",
}, },
}; };
+35 -115
View File
@@ -24,7 +24,6 @@ import {
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
resolveBlockAs,
} from "./completions/block-scanner.js"; } from "./completions/block-scanner.js";
import type { import type {
CompletionsPayload, CompletionsPayload,
@@ -47,8 +46,6 @@ export interface DocContent {
body: string; body: string;
/** Origin role that produced this content, for debugging. */ /** Origin role that produced this content, for debugging. */
role?: string; role?: string;
/** Origin `as` value, for debugging. */
as?: string;
} }
export interface ContentRegistry { export interface ContentRegistry {
@@ -113,62 +110,43 @@ export interface DocScanResult {
/** /**
* Process a single markdown doc: * 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 * - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
* - collects inline content (role=file, md-* bodies, declare) * - collects inline content (spark-table, declare, file) into the doc's
* into the doc's content store * content store
* *
* Does NOT touch the path index — the caller assembles the registry. * Does NOT touch the path index — the caller assembles the registry.
*/ */
export function scanDoc(content: string, docPath: string): DocScanResult { export function scanDoc(content: string, docPath: string): DocScanResult {
const contentStore: Record<string, DocContent> = {}; const contentStore: Record<string, DocContent> = {};
// ---- Pass 1: attributed fenced code blocks ----
const stripped = content.replace( const stripped = content.replace(
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
( (
_match: string, match: string,
lang: string, lang: string,
infoString: string, infoString: string,
body: string, body: string,
): string => { ): string => {
const attrs = parseBlockAttrs(infoString); const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang; attrs.lang = attrs.lang || lang;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
if (attrs.role === "declare") { switch (attrs.role) {
case "declare": {
const id = deriveContentId("declare", body, attrs.id); const id = deriveContentId("declare", body, attrs.id);
contentStore[id] = { contentStore[id] = { id, kind: "declare", body, role: attrs.role };
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 ""; return "";
} }
if (effectiveAs.startsWith("md-")) { 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; let blockBody = body;
if (attrs.role === "spark-table" && isMarkdownTableLang(attrs.lang)) { if (isMarkdownTableLang(attrs.lang)) {
blockBody = markdownTableBodyToCsv(body, docPath); blockBody = markdownTableBodyToCsv(body, docPath);
} }
const id = deriveContentId("csv", blockBody, attrs.id); const id = deriveContentId("csv", blockBody, attrs.id);
@@ -177,19 +155,28 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
kind: "csv", kind: "csv",
body: blockBody, body: blockBody,
role: attrs.role, role: attrs.role,
as: effectiveAs,
}; };
const extra = { ...attrs.extra }; const extraStr = Object.entries(attrs.extra)
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(" ")}}` .join(" ");
: ""; return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
return `:${effectiveAs}[./${id}]${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 * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so * folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent` — including the * 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 * 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( export function buildRegistryFromIndex(
index: Record<string, string>, index: Record<string, string>,
@@ -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; return registry;
} }
@@ -334,57 +313,6 @@ function markdownTableBodyToCsv(body: string, docPath: string): string {
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body; 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 // Resolution
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -636,14 +564,6 @@ export function inspectSparkTableCsv(csv: string): string[] | null {
return headers.slice(1); 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. */ /** Build a SparkTableCompletion from a CSV body. */
export function buildSparkTableCompletion( export function buildSparkTableCompletion(
csv: string, csv: string,
+3 -3
View File
@@ -110,7 +110,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); const paths = await getPathsByExtension("md");
// Load all .md content into a raw index, then build the registry through // 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<string, string> = {}; const index: Record<string, string> = {};
for (const filePath of paths) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
@@ -122,8 +122,8 @@ async function scanClientSide(): Promise<JournalCompletions> {
activeRegistry = registry; activeRegistry = registry;
// Write the processed (stripped) content back into the file index so // Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (spark tables // Article/md-embed render the same content as CLI mode (attributed blocks
// coerced, attributed blocks processed, data-spark injected). // processed by role).
for (const [path, content] of Object.entries(registry.pathIndex)) { for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content); setIndexedData(path, content);
} }
+15 -7
View File
@@ -10,10 +10,11 @@ import {
import { parseMarkdown } from "../markdown"; import { parseMarkdown } from "../markdown";
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader"; import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolveContentRef } from "./utils/resolve-content"; import { resolveContentRef } from "./utils/resolve-content";
import { parseSparkTableCsv } from "./utils/spark-table";
import { import {
areAllLabelsNumeric, areAllLabelsNumeric,
weightedRandomIndex, weightedRandomIndex,
} from "./utils/weighted-random"; } from "./utils/weighted-random";;
export interface TableProps { export interface TableProps {
roll?: boolean; roll?: boolean;
@@ -59,16 +60,23 @@ customElement(
if (content === null) { if (content === null) {
throw new Error(`Failed to resolve table content: "${ref}"`); throw new Error(`Failed to resolve table content: "${ref}"`);
} }
return parseCSVString(content); return content;
}, },
); );
// 当数据加载完成后更新 rows // 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => { createEffect(() => {
const data = csvData(); const content = csvData();
if (data) { if (!content) return;
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性 setRows(parseCSVString(content) as unknown as CSV<TableRow>);
setRows(data as unknown as CSV<TableRow>); if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
} }
}); });
+3 -1
View File
@@ -53,7 +53,9 @@ export interface SparkTableMeta {
// CSV parsing // 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. * Parse a CSV string into a SparkTableMeta.
+4 -3
View File
@@ -10,7 +10,6 @@ export interface BlockAttrs {
lang: string; lang: string;
id?: string; id?: string;
role?: string; role?: string;
as?: string;
/** Any other attributes not in the standard set */ /** Any other attributes not in the standard set */
extra: Record<string, string>; extra: Record<string, string>;
} }
@@ -24,6 +23,8 @@ export function parseBlockAttrs(info: string): BlockAttrs {
attrs[m[1]] = m[2].replace(/^"|"$/g, ""); attrs[m[1]] = m[2].replace(/^"|"$/g, "");
} }
const { lang, id, role, as, ...extra } = attrs; // `as` was a render-target override; roles now fully determine behavior,
return { lang: lang || "", id, role, as, extra }; // 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 };
} }