refactor: remove implicit content sniffing and table conversion

- Split loadCSV into parseCSVString (content) and loadCSVFromPath
  (path); drop isCSV/looksLikeCsv heuristics
- Delete coerceSparkTables and markedTable label-header magic;
  plain markdown tables now render as plain tables
- Add explicit markdown role=spark-table fence syntax that converts
  pipe tables to CSV at scan time, with dice-header validation
- Map ESM-only github-slugger and csv-parse browser build to CJS in
  jest config; add content-registry tests
This commit is contained in:
2026-09-08 21:33:12 +08:00
parent 5061c4d19d
commit 256c685f6c
13 changed files with 216 additions and 253 deletions
+45 -95
View File
@@ -114,8 +114,8 @@ export interface DocScanResult {
/**
* Process a single markdown doc:
* - strips/replaces attributed fenced code blocks based on `as`
* - coerces spark-shaped markdown tables to `:md-table` directives
* - collects inline content (role=file, md-* bodies, spark tables, declare)
* - 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
*
* Does NOT touch the path index — the caller assembles the registry.
@@ -167,11 +167,15 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
}
if (effectiveAs.startsWith("md-")) {
const id = deriveContentId("csv", body, attrs.id);
let blockBody = body;
if (attrs.role === "spark-table" && isMarkdownTableLang(attrs.lang)) {
blockBody = markdownTableBodyToCsv(body, docPath);
}
const id = deriveContentId("csv", blockBody, attrs.id);
contentStore[id] = {
id,
kind: "csv",
body,
body: blockBody,
role: attrs.role,
as: effectiveAs,
};
@@ -189,10 +193,7 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
},
);
// ---- Pass 2: coerce spark-shaped markdown tables to :md-table ----
const rewritten = coerceSparkTables(stripped, contentStore);
return { stripped: rewritten, content: contentStore };
return { stripped, content: contentStore };
}
/**
@@ -241,8 +242,8 @@ export function buildRegistryFromIndex(
/**
* 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.
* column). Used to validate `role=spark-table` blocks (CSV or markdown
* pipe-table bodies).
*/
export function isSparkTableHeader(header: string): boolean {
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
@@ -294,74 +295,43 @@ function markdownTableToCsv(
}
/**
* Coerce spark-shaped markdown tables (first column header is a dice formula)
* into `:md-table` directives, storing the CSV in the doc's content store and
* injecting `data-spark` for the reveal feature.
* Languages whose fenced-block bodies are markdown pipe tables. Used with
* `role=spark-table` to convert the table to CSV at scan time — explicitly
* authorized by the role, never by content shape.
*/
function coerceSparkTables(
content: string,
contentStore: Record<string, DocContent>,
): string {
const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
function isMarkdownTableLang(lang: string): boolean {
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
}
let mdMatch: RegExpExecArray | null;
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
if (!isSparkTableHeader(headers[0])) continue;
const bodyRows = bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
if (!csv) continue;
tableMatches.push({
fullMatch: mdMatch[0],
headerRow,
separatorRow,
bodyRowsText,
index: mdMatch.index,
});
/**
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
* blocks. Validates the dice-formula first column (warning only — the role
* already declared intent) and falls back to storing the body as-is when it
* is not a recognizable pipe table.
*/
function markdownTableBodyToCsv(body: string, docPath: string): string {
const lines = body
.trim()
.split(/\r?\n/)
.filter((l) => l.trim().startsWith("|"));
if (lines.length < 2) {
console.warn(
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
);
return body;
}
let rewritten = content;
for (let i = tableMatches.length - 1; i >= 0; i--) {
const m = tableMatches[i];
const bodyRows = m.bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
const id = deriveContentId("csv", csv);
contentStore[id] = {
id,
kind: "csv",
body: csv,
role: "spark-table",
as: "md-table",
};
const slug = sparkSlug(csv);
const directive = `:md-table[./${id}]{data-spark="${slug}"}`;
rewritten =
rewritten.slice(0, m.index) +
directive +
rewritten.slice(m.index + m.fullMatch.length);
const [headerRow, separatorRow, ...rows] = lines;
const headers = splitTableRow(headerRow);
if (!isSparkTableHeader(headers[0] || "")) {
console.warn(
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
);
}
return rewritten;
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
}
// ---------------------------------------------------------------------------
@@ -422,10 +392,12 @@ export function injectSparkDirectives(
/**
* Resolve a content reference from within a doc.
*
* - `ref` is inline CSV → returned as-is.
* - `ref` is an absolute path → path index.
* - `ref` is a relative path → resolved against the doc directory; checks
* the path index first, then the doc's inline content store.
* the doc's inline content store first, then the path index.
*
* Refs are always ids or paths — inline content must be defined in a fenced
* block and referenced by its `./{id}`.
*
* Returns `null` when nothing matches.
*/
@@ -437,9 +409,6 @@ export function resolveContent(
const trimmed = ref.trim();
if (!trimmed) return null;
// Inline CSV body.
if (looksLikeCsv(trimmed)) return trimmed;
if (trimmed.startsWith("/")) {
return registry.pathIndex[trimmed] ?? null;
}
@@ -477,25 +446,6 @@ export function resolveInlineByPath(
return null;
}
/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */
function looksLikeCsv(str: string): boolean {
const trimmed = str.trim();
if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true;
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length < 2) return false;
const separators = [",", "\t", ";", "|"];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
const hasInOthers = lines.slice(1).some((line) => line.includes(sep));
if (hasInOthers) return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// Derived completions
// ---------------------------------------------------------------------------