fix: heading scan skips code fences; unify content resolution
- deriveLinks strips fenced code blocks so markdown examples don't produce phantom link completions - Add resolveContentEntry returning body + path + inline flag; scanDocDirectives uses it instead of re-deriving resolution, and parseDirectiveAttrs is replaced by the shared parseBlockAttrs - Fix relative file refs with ./ prefix never resolving through resolveContent (pathIndex lookup joined the ./ verbatim)
This commit is contained in:
@@ -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 {
|
function emptyRegistry(): ContentRegistry {
|
||||||
return { pathIndex: {}, docContent: {} };
|
return { pathIndex: {}, docContent: {} };
|
||||||
@@ -148,4 +155,45 @@ describe("resolveContent", () => {
|
|||||||
resolveContent(registry, "test.md", "d6,Name\n1,Alice"),
|
resolveContent(registry, "test.md", "d6,Name\n1,Alice"),
|
||||||
).toBeNull();
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+70
-58
@@ -317,6 +317,17 @@ function markdownTableBodyToCsv(body: string, docPath: string): string {
|
|||||||
// Resolution
|
// 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.
|
* Resolve a content reference from within a doc.
|
||||||
*
|
*
|
||||||
@@ -329,29 +340,38 @@ function markdownTableBodyToCsv(body: string, docPath: string): string {
|
|||||||
*
|
*
|
||||||
* Returns `null` when nothing matches.
|
* 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(
|
export function resolveContent(
|
||||||
registry: ContentRegistry,
|
registry: ContentRegistry,
|
||||||
docPath: string,
|
docPath: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
const trimmed = ref.trim();
|
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -422,26 +442,18 @@ function scanDocDirectives(
|
|||||||
const dice = scanDice(content, docPath);
|
const dice = scanDice(content, docPath);
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
|
|
||||||
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||||
const [, , ref, extraStr] = m;
|
const [, , ref, extraStr] = m;
|
||||||
const csv = resolveContent(registry, docPath, ref);
|
const resolved = resolveContentEntry(registry, docPath, ref);
|
||||||
if (!csv) continue;
|
if (!resolved) continue;
|
||||||
|
|
||||||
// csvPath: content id for inline content, resolved path for real files.
|
const attrs = parseBlockAttrs(extraStr || "").extra;
|
||||||
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 st = buildSparkTableCompletion(
|
const st = buildSparkTableCompletion(
|
||||||
csv,
|
resolved.body,
|
||||||
docPath,
|
docPath,
|
||||||
csvPath,
|
resolved.path,
|
||||||
attrs["remix"] === "true",
|
attrs["remix"] === "true",
|
||||||
);
|
);
|
||||||
if (st) sparkTables.push(st);
|
if (st) sparkTables.push(st);
|
||||||
@@ -450,31 +462,6 @@ function scanDocDirectives(
|
|||||||
return { dice, sparkTables };
|
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<string, string> {
|
|
||||||
if (!extraStr) return {};
|
|
||||||
const attrs: Record<string, string> = {};
|
|
||||||
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. */
|
/** Derive variable declarations + tag modifiers from the registry. */
|
||||||
export function deriveBlocks(registry: ContentRegistry): {
|
export function deriveBlocks(registry: ContentRegistry): {
|
||||||
declarations: VarDeclaration[];
|
declarations: VarDeclaration[];
|
||||||
@@ -503,12 +490,37 @@ export function deriveBlocks(registry: ContentRegistry): {
|
|||||||
// Derivation helpers
|
// 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. */
|
/** Extract headings from all `.md` files as link completions. */
|
||||||
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
||||||
const items: 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;
|
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 basePath = filePath.replace(/\.md$/, "");
|
||||||
const fileName = fileNameFromPath(basePath);
|
const fileName = fileNameFromPath(basePath);
|
||||||
const slugger = new Slugger();
|
const slugger = new Slugger();
|
||||||
|
|||||||
Reference in New Issue
Block a user