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.
*
* 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.
@@ -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). */
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 { 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
+42 -1
View File
@@ -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",
},
};
+51 -131
View File
@@ -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<string, DocContent> = {};
// ---- 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<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;
}
@@ -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,
+3 -3
View File
@@ -110,7 +110,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
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<string, string> = {};
for (const filePath of paths) {
const content = await getIndexedData(filePath);
@@ -122,8 +122,8 @@ async function scanClientSide(): Promise<JournalCompletions> {
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);
}
+15 -7
View File
@@ -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) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
const content = csvData();
if (!content) return;
setRows(parseCSVString(content) 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
// ---------------------------------------------------------------------------
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.
+4 -3
View File
@@ -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<string, string>;
}
@@ -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 };
}