Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/fixed-567-link-rewrite-fences.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions scripts/check-internal-links-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
ASSET_EXT,
PROJECT_FOR_NAV,
extractLinkTargets,
isExternalOrAnchor,
navEntryRoute,
normRoute,
Expand Down Expand Up @@ -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"]);
});
});
18 changes: 18 additions & 0 deletions scripts/check-internal-links-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
11 changes: 2 additions & 9 deletions scripts/check-internal-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
PROJECT_FOR_NAV,
parseNavStructures,
navEntryRoute,
extractLinkTargets,
} from "./check-internal-links-helpers";

const contentRoot = path.join(process.cwd(), "docs", "content");
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions scripts/link-rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
});
});
8 changes: 7 additions & 1 deletion scripts/link-rewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 56 additions & 1 deletion scripts/markdown-fences.test.ts
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand Down Expand Up @@ -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"
);
});
});
38 changes: 38 additions & 0 deletions scripts/markdown-fences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading