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:
+45
-95
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user