diff --git a/src/cli/content-registry.test.ts b/src/cli/content-registry.test.ts index d12b9d5..9393ca6 100644 --- a/src/cli/content-registry.test.ts +++ b/src/cli/content-registry.test.ts @@ -1,4 +1,11 @@ -import { scanDoc, resolveContent, type ContentRegistry } from "./content-registry"; +import { + scanDoc, + resolveContent, + resolveContentEntry, + buildRegistryFromIndex, + deriveCompletions, + type ContentRegistry, +} from "./content-registry"; function emptyRegistry(): ContentRegistry { return { pathIndex: {}, docContent: {} }; @@ -148,4 +155,45 @@ describe("resolveContent", () => { resolveContent(registry, "test.md", "d6,Name\n1,Alice"), ).toBeNull(); }); + + test("resolveContentEntry reports inline vs file resolution", () => { + const registry = emptyRegistry(); + registry.pathIndex["/test.md"] = "# Doc"; + registry.pathIndex["/data/table.csv"] = "d6,Name\n1,Alice"; + registry.docContent["/test.md"] = { + csv_abc: { + id: "csv_abc", + kind: "csv", + body: "d6,Name\n1,Bob", + role: "spark-table", + }, + }; + + const inline = resolveContentEntry(registry, "/test.md", "./csv_abc"); + expect(inline).toMatchObject({ path: "csv_abc", inline: true }); + + const file = resolveContentEntry(registry, "/test.md", "./data/table.csv"); + expect(file).toMatchObject({ path: "/data/table.csv", inline: false }); + }); +}); + +describe("deriveCompletions", () => { + test("headings inside fenced code blocks do not become link completions", () => { + const md = [ + "# Real Heading", + "", + "```markdown", + "# Fake Heading In Code", + "```", + ].join("\n"); + const registry = buildRegistryFromIndex({ "test.md": md }); + const { links } = deriveCompletions(registry); + + const testLinks = links.filter((l) => l.path === "/test"); + expect(testLinks.map((l) => l.label)).toEqual([ + "test", + "test § Real Heading", + ]); + expect(testLinks.some((l) => l.label.includes("Fake"))).toBe(false); + }); }); \ No newline at end of file diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 5081613..948b4ab 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -317,6 +317,17 @@ function markdownTableBodyToCsv(body: string, docPath: string): string { // Resolution // --------------------------------------------------------------------------- +/** + * A resolved content reference: the body plus how it was addressed. + */ +export interface ResolvedContent { + body: string; + /** Content id for inline content, path-index key for real files. */ + path: string; + /** True when resolved from the doc's inline content store. */ + inline: boolean; +} + /** * Resolve a content reference from within a doc. * @@ -329,29 +340,38 @@ function markdownTableBodyToCsv(body: string, docPath: string): string { * * Returns `null` when nothing matches. */ +export function resolveContentEntry( + registry: ContentRegistry, + docPath: string, + ref: string, +): ResolvedContent | null { + const trimmed = ref.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith("/")) { + const body = registry.pathIndex[trimmed]; + return body == null ? null : { body, path: trimmed, inline: false }; + } + + // Inline content in the same doc (e.g. ./{id}). + const docStore = registry.docContent[docPath]; + const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed; + const entry = docStore?.[id]; + if (entry) return { body: entry.body, path: id, inline: true }; + + // Relative path resolved against the doc directory (`./` already stripped). + const resolved = posixJoin(posixDir(docPath), id); + const body = registry.pathIndex[resolved]; + return body != null ? { body, path: resolved, inline: false } : null; +} + +/** Resolve a content reference to its body only. */ export function resolveContent( registry: ContentRegistry, docPath: string, ref: string, ): string | null { - const trimmed = ref.trim(); - if (!trimmed) return null; - - if (trimmed.startsWith("/")) { - return registry.pathIndex[trimmed] ?? null; - } - - // Inline content in the same doc (e.g. ./{id}). - const docStore = registry.docContent[docPath]; - if (docStore) { - const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed; - const entry = docStore[id]; - if (entry) return entry.body; - } - - // Relative path resolved against the doc directory. - const resolved = posixJoin(posixDir(docPath), trimmed); - return registry.pathIndex[resolved] ?? null; + return resolveContentEntry(registry, docPath, ref)?.body ?? null; } /** @@ -422,26 +442,18 @@ function scanDocDirectives( const dice = scanDice(content, docPath); const sparkTables: SparkTableCompletion[] = []; - const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi; + const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi; let m: RegExpExecArray | null; while ((m = tableDirectiveRegex.exec(content)) !== null) { const [, , ref, extraStr] = m; - const csv = resolveContent(registry, docPath, ref); - if (!csv) continue; + const resolved = resolveContentEntry(registry, docPath, ref); + if (!resolved) continue; - // csvPath: content id for inline content, resolved path for real files. - const docStore = registry.docContent[docPath]; - const id = ref.startsWith("./") ? ref.slice(2) : ref; - const csvPath = - docStore && docStore[id] - ? id - : resolveContentPath(registry, docPath, ref); - - const attrs = parseDirectiveAttrs(extraStr); + const attrs = parseBlockAttrs(extraStr || "").extra; const st = buildSparkTableCompletion( - csv, + resolved.body, docPath, - csvPath, + resolved.path, attrs["remix"] === "true", ); if (st) sparkTables.push(st); @@ -450,31 +462,6 @@ function scanDocDirectives( return { dice, sparkTables }; } -/** Resolve a directive ref to a path-index key (for real files). */ -function resolveContentPath( - registry: ContentRegistry, - docPath: string, - ref: string, -): string { - const trimmed = ref.trim(); - if (trimmed.startsWith("/")) return trimmed; - return posixJoin(posixDir(docPath), trimmed.replace(/^\.\//, "")); -} - -/** Parse key=value pairs from a directive extra-attrs string. */ -function parseDirectiveAttrs( - extraStr: string | undefined, -): Record { - if (!extraStr) return {}; - const attrs: Record = {}; - const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(extraStr)) !== null) { - attrs[m[1]] = m[2].replace(/^"|"$/g, ""); - } - return attrs; -} - /** Derive variable declarations + tag modifiers from the registry. */ export function deriveBlocks(registry: ContentRegistry): { declarations: VarDeclaration[]; @@ -503,12 +490,37 @@ export function deriveBlocks(registry: ContentRegistry): { // Derivation helpers // --------------------------------------------------------------------------- +/** + * Remove fenced code blocks (backtick or tilde) from content, so text + * scanners (headings, dice directives) don't match example code. + */ +function stripFencedBlocks(content: string): string { + const out: string[] = []; + let fence: string | null = null; + for (const line of content.split(/\r?\n/)) { + const fenceMatch = /^(`{3,}|~{3,})/.exec(line); + if (fence) { + if (line.startsWith(fence)) fence = null; + continue; + } + if (fenceMatch) { + fence = fenceMatch[1]; + continue; + } + out.push(line); + } + return out.join("\n"); +} + /** Extract headings from all `.md` files as link completions. */ function deriveLinks(pathIndex: Record): LinkCompletion[] { const items: LinkCompletion[] = []; - for (const [filePath, content] of Object.entries(pathIndex)) { + for (const [filePath, rawContent] of Object.entries(pathIndex)) { if (!filePath.endsWith(".md")) continue; + // Headings inside fenced code blocks (e.g. markdown examples) are not + // real headings — exclude them from link completions. + const content = stripFencedBlocks(rawContent); const basePath = filePath.replace(/\.md$/, ""); const fileName = fileNameFromPath(basePath); const slugger = new Slugger();