diff --git a/changelog.d/fixed-567-link-rewrite-fences.md b/changelog.d/fixed-567-link-rewrite-fences.md new file mode 100644 index 0000000..147f78a --- /dev/null +++ b/changelog.d/fixed-567-link-rewrite-fences.md @@ -0,0 +1,3 @@ +- Doc sync link rewriting and the internal-link check now skip fenced code + blocks (hivecommons/docs#567). Markdown link examples inside code blocks are + no longer rewritten to blob URLs or reported as broken links. diff --git a/scripts/check-internal-links-helpers.test.ts b/scripts/check-internal-links-helpers.test.ts index bfb6211..0958530 100644 --- a/scripts/check-internal-links-helpers.test.ts +++ b/scripts/check-internal-links-helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ASSET_EXT, PROJECT_FOR_NAV, + extractLinkTargets, isExternalOrAnchor, navEntryRoute, normRoute, @@ -435,3 +436,18 @@ describe("resolveInternalLink", () => { ); }); }); + +describe("extractLinkTargets", () => { + it("collects inline and reference targets outside fences only", () => { + const md = [ + "[a](/docs/a)", + "```md", + "[b](/docs/nope)", + "[r]: /docs/nope2", + "```", + "[r]: /docs/c", + "", + ].join("\n"); + expect(extractLinkTargets(md)).toEqual(["/docs/a", "/docs/c"]); + }); +}); diff --git a/scripts/check-internal-links-helpers.ts b/scripts/check-internal-links-helpers.ts index 3912ff9..7bfa42e 100644 --- a/scripts/check-internal-links-helpers.ts +++ b/scripts/check-internal-links-helpers.ts @@ -9,6 +9,7 @@ */ import path from "node:path"; import ts from "typescript"; +import { mapOutsideFences } from "./markdown-fences"; /** * Convert a human-readable nav title into a URL slug. @@ -273,3 +274,20 @@ export function resolveInternalLink( } return resolved.replace(/\.mdx?$/i, ""); } + +const INLINE_LINK = /\]\(\s*([^)\s]+)(?:\s+[^)]*)?\)/g; +const REF_LINK = /^\s*\[[^\]]+\]:\s*(\S+)/gm; + +/** + * Collect inline and reference-definition link targets from markdown, + * ignoring anything inside fenced code blocks. + */ +export function extractLinkTargets(content: string): string[] { + const targets: string[] = []; + mapOutsideFences(content, text => { + for (const m of text.matchAll(INLINE_LINK)) targets.push(m[1]); + for (const m of text.matchAll(REF_LINK)) targets.push(m[1]); + return text; + }); + return targets; +} diff --git a/scripts/check-internal-links.ts b/scripts/check-internal-links.ts index 8894d32..07c9588 100644 --- a/scripts/check-internal-links.ts +++ b/scripts/check-internal-links.ts @@ -31,6 +31,7 @@ import { PROJECT_FOR_NAV, parseNavStructures, navEntryRoute, + extractLinkTargets, } from "./check-internal-links-helpers"; const contentRoot = path.join(process.cwd(), "docs", "content"); @@ -102,21 +103,13 @@ function fileRoute(fileAbs: string): string { return `/docs/${rel}`; } -const INLINE_LINK = /\]\(\s*([^)\s]+)(?:\s+[^)]*)?\)/g; -const REF_LINK = /^\s*\[[^\]]+\]:\s*(\S+)/gm; - -// INLINE_LINK / REF_LINK regexes remain local (script-only). - function checkFile(fileAbs: string) { const content = fs.readFileSync(fileAbs, "utf8"); const fileRel = path.relative(process.cwd(), fileAbs).replace(/\\/g, "/"); const baseRoute = fileRoute(fileAbs); const baseDir = path.posix.dirname(baseRoute); - const targets: string[] = []; - let m: RegExpExecArray | null; - while ((m = INLINE_LINK.exec(content))) targets.push(m[1]); - while ((m = REF_LINK.exec(content))) targets.push(m[1]); + const targets = extractLinkTargets(content); for (const raw of targets) { if (isExternalOrAnchor(raw)) continue; diff --git a/scripts/link-rewrite.test.ts b/scripts/link-rewrite.test.ts index fae3e88..ff0037d 100644 --- a/scripts/link-rewrite.test.ts +++ b/scripts/link-rewrite.test.ts @@ -122,3 +122,18 @@ describe("rewriteMarkdownLinks", () => { expect(rewriteMarkdownLinks(html, opts)).toBe(html); }); }); + +describe("rewriteMarkdownLinks fences", () => { + it("leaves links inside fenced code blocks untouched", () => { + const fenced = + "```md\n[x](./guide.md)\n![img](./a.png)\n[ref]: ./other.md\n```\n"; + const outside = "[x](./guide.md)\n![img](./a.png)\n[ref]: ./other.md\n"; + const o = { ...opts, rawBase: RAW }; + const out = rewriteMarkdownLinks(`${fenced}\n${outside}`, o); + const [inside, after] = out.split("```\n\n"); + expect(`${inside}\`\`\`\n`).toBe(fenced); + expect(after).toBe( + `[x](${BLOB}/docs/guide/guide.md)\n![img](${RAW}/docs/guide/a.png)\n[ref]: ${BLOB}/docs/guide/other.md\n` + ); + }); +}); diff --git a/scripts/link-rewrite.ts b/scripts/link-rewrite.ts index fc26d65..288a6af 100644 --- a/scripts/link-rewrite.ts +++ b/scripts/link-rewrite.ts @@ -21,6 +21,7 @@ * sources `![alt](src)` are pointed at raw file URLs instead. */ import path from "path"; +import { mapOutsideFences } from "./markdown-fences"; export interface LinkRewriteOptions { /** Repo-root-relative path of the file whose content is being rewritten. */ @@ -101,11 +102,16 @@ export function rewriteImageTarget( } // Apply link rewriting to a file's markdown content. +// Fenced code blocks are left byte-for-byte unchanged. export function rewriteMarkdownLinks( content: string, opts: LinkRewriteOptions ): string { - let out = content; + return mapOutsideFences(content, text => rewriteLinksInText(text, opts)); +} + +function rewriteLinksInText(text: string, opts: LinkRewriteOptions): string { + let out = text; // Images first: they become absolute raw URLs, which the link pass below // then leaves untouched. diff --git a/scripts/markdown-fences.test.ts b/scripts/markdown-fences.test.ts index 2657f76..394117c 100644 --- a/scripts/markdown-fences.test.ts +++ b/scripts/markdown-fences.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { normalizeFences, DEFAULT_FENCE_LANGUAGE } from "./markdown-fences"; +import { + normalizeFences, + splitFencedSegments, + mapOutsideFences, + DEFAULT_FENCE_LANGUAGE, +} from "./markdown-fences"; describe("normalizeFences", () => { it("adds the default language to a bare fence opener (MD040)", () => { @@ -65,3 +70,53 @@ describe("normalizeFences", () => { ); }); }); + +describe("splitFencedSegments", () => { + it("separates fenced and unfenced text and round-trips", () => { + const input = "a\n```js\nb\n```\nc\n"; + const segs = splitFencedSegments(input); + expect(segs).toEqual([ + { text: "a\n", fenced: false }, + { text: "```js\nb\n```\n", fenced: true }, + { text: "c\n", fenced: false }, + ]); + expect(segs.map(s => s.text).join("")).toBe(input); + }); + + it("handles ~~~ fences and does not close on a different marker", () => { + const segs = splitFencedSegments("~~~\n```\nx\n~~~\ny"); + expect(segs).toEqual([ + { text: "~~~\n```\nx\n~~~\n", fenced: true }, + { text: "y", fenced: false }, + ]); + }); + + it("closes on a longer marker but not a shorter one", () => { + const segs = splitFencedSegments("````\n```\n`````\nz"); + expect(segs).toEqual([ + { text: "````\n```\n`````\n", fenced: true }, + { text: "z", fenced: false }, + ]); + }); + + it("runs an unclosed fence to EOF", () => { + expect(splitFencedSegments("a\n```\nb\nc")).toEqual([ + { text: "a\n", fenced: false }, + { text: "```\nb\nc", fenced: true }, + ]); + }); + + it("does not treat a backtick fence with a backtick in its info as a fence", () => { + expect(splitFencedSegments("```a`b\nx\n")).toEqual([ + { text: "```a`b\nx\n", fenced: false }, + ]); + }); +}); + +describe("mapOutsideFences", () => { + it("only transforms unfenced text", () => { + expect(mapOutsideFences("a\n```\na\n```\na", t => t.toUpperCase())).toBe( + "A\n```\na\n```\nA" + ); + }); +}); diff --git a/scripts/markdown-fences.ts b/scripts/markdown-fences.ts index d5ea0b5..c9ae23a 100644 --- a/scripts/markdown-fences.ts +++ b/scripts/markdown-fences.ts @@ -26,6 +26,44 @@ function isFenceCloser(open: OpenFence, marker: string, info: string): boolean { ); } +/** + * Split content into consecutive segments that are either inside a fenced code + * block (opener and closer lines included) or outside one. Concatenating the + * `text` of every segment reproduces `content` exactly. An unclosed fence runs + * to the end of the content. + */ +export function splitFencedSegments( + content: string +): Array<{ text: string; fenced: boolean }> { + const segments: Array<{ text: string; fenced: boolean }> = []; + let open: OpenFence | null = null; + + for (const line of content.split(/(?<=\n)/)) { + const m = line.replace(/\r?\n$/, "").match(FENCE_RE); + let fenced = open !== null; + if (open) { + if (m && isFenceCloser(open, m[2], m[3])) open = null; + } else if (m && !(m[2][0] === "`" && m[3].includes("`"))) { + open = { marker: m[2] }; + fenced = true; + } + const last = segments[segments.length - 1]; + if (last && last.fenced === fenced) last.text += line; + else segments.push({ text: line, fenced }); + } + return segments; +} + +/** Apply `fn` to the text outside fenced code blocks; fences are untouched. */ +export function mapOutsideFences( + content: string, + fn: (text: string) => string +): string { + return splitFencedSegments(content) + .map(seg => (seg.fenced ? seg.text : fn(seg.text))) + .join(""); +} + /** * Ensure every fenced code block declares a language and is surrounded by * blank lines. Fence content is never touched; only opener lines and the