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:
+70
-58
@@ -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<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. */
|
||||
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<string, string>): 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();
|
||||
|
||||
Reference in New Issue
Block a user