diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4497e769..bde056ba 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,5 +36,20 @@ jobs: - name: Run lint and format checks run: pnpm run check + - name: Test MDX anchor extraction + run: node --import tsx --test scripts/link-anchors.test.ts + + # Output mode reports the existing fragment backlog without failing the PR. + # Parsing or execution failures still fail this step. + - name: Report broken fragment links + run: pnpm run lint:links --no-ignore-fragments --output link-errors.txt + + - name: Upload link report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: link-errors + path: link-errors.txt + if-no-files-found: error + - name: Build check run: pnpm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9ff3655..d84edd32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -446,8 +446,14 @@ npx tsx scripts/link-validation.ts --scope "/contracts/*" # Output results to a file npx tsx scripts/link-validation.ts --output link-errors.txt -# Disable fragment checking +# Include fragment checking (disabled by default) npx tsx scripts/link-validation.ts --no-ignore-fragments + +# Produce a report without failing on known broken links +npx tsx scripts/link-validation.ts --no-ignore-fragments --output link-errors.txt + +# Run the MDX anchor regression tests +node --import tsx --test scripts/link-anchors.test.ts ``` **Options:** @@ -456,6 +462,10 @@ npx tsx scripts/link-validation.ts --no-ignore-fragments - `--output ` - Write results to a file instead of console - `--no-ignore-fragments` - Include fragment validation (hash links) +`--output` reports validation errors without making them fail the command. Parsing +and execution errors still fail. The PR lint workflow uploads a `link-errors` +artifact using this mode, while keeping its existing checks unchanged. + **What it validates:** 1. **File links**: All markdown links in `.mdx` files diff --git a/scripts/link-anchors.test.ts b/scripts/link-anchors.test.ts new file mode 100644 index 00000000..6a92b946 --- /dev/null +++ b/scripts/link-anchors.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { validateFiles } from "next-validate-link"; +import { extractAnchorIds } from "./link-anchors"; + +test("recognizes legacy anchors and multiline API components", () => { + const content = ` + + +Description. + + +`; + assert.deepEqual(extractAnchorIds(content), [ + "legacy", + "Token-balance", + "Token-transfer", + ]); +}); + +test("collects explicit heading IDs even when hidden from the TOC", () => { + assert.deepEqual( + extractAnchorIds( + "## Interfaces [#interfaces]\n\n#### Functions [!toc] [#Token-Functions]", + ), + ["interfaces", "Token-Functions"], + ); +}); + +test("deduplicates IDs shared by headings and API components", () => { + assert.deepEqual( + extractAnchorIds( + '#### [!toc] [#shared]\n\n\n\n', + ), + ["shared"], + ); +}); + +test("ignores example code, comments, dynamic IDs and unrelated components", () => { + const content = + '```mdx\n\n## Heading [#example-heading]\n```\n\n' + + '``\n\n' + + '{/* */}\n\n' + + '\n\n\n\n'; + assert.deepEqual(extractAnchorIds(content), []); +}); + +test("recognizes the current Cairo introspection API anchors", async () => { + const content = await readFile( + new URL( + "../content/contracts-cairo/4.x/api/introspection.mdx", + import.meta.url, + ), + "utf8", + ); + const ids = extractAnchorIds(content); + assert.ok(ids.includes("ISRC5-supports_interface")); + assert.ok(ids.includes("SRC5Component-SRC5Impl")); + assert.ok(ids.includes("SRC5Component-register_interface")); + assert.ok(ids.includes("SRC5Component-deregister_interface")); +}); + +test("fragment validation accepts real MDX anchors and rejects absent ones", async () => { + const content = + '\n\n' + + "#### Functions [!toc] [#Token-Functions]"; + const links = [ + "/api/example#Token-balance", + "/api/example#Token-Functions", + "/api/example#missing", + ]; + const file = { + path: "guide.mdx", + url: "/guide", + content: links.map((url) => `[Link](${url})`).join(" "), + }; + const baseline = await validateFiles([file], { + scanned: { + urls: new Map([["/api/example", { hashes: [] }]]), + fallbackUrls: [], + }, + ignoreFragment: false, + }); + assert.deepEqual( + baseline.flatMap((result) => result.errors).map((error) => error.url), + links, + ); + const results = await validateFiles([file], { + scanned: { + urls: new Map([["/api/example", { hashes: extractAnchorIds(content) }]]), + fallbackUrls: [], + }, + ignoreFragment: false, + checkRelativePaths: "as-url", + }); + assert.deepEqual( + results.flatMap((result) => result.errors).map((error) => error.url), + ["/api/example#missing"], + ); +}); diff --git a/scripts/link-anchors.ts b/scripts/link-anchors.ts new file mode 100644 index 00000000..d834e37b --- /dev/null +++ b/scripts/link-anchors.ts @@ -0,0 +1,41 @@ +import { remark } from "remark"; +import remarkMath from "remark-math"; +import remarkMdx from "remark-mdx"; +import { visit } from "unist-util-visit"; + +/** Collect rendered MDX anchors that are not necessarily present in the TOC. */ +export function extractAnchorIds(content: string): string[] { + const tree = remark().use(remarkMath).use(remarkMdx).parse(content); + const ids = new Set(); + + visit(tree, (node) => { + if (node.type === "heading") { + const last = node.children.at(-1); + // Match Fumadocs' trailing custom-ID syntax, including [!toc] headings. + if (last?.type === "text") { + const match = /\s*\[#([^\]]+)]\s*$/.exec(last.value); + if (match?.[1]) ids.add(match[1]); + } + } else if ( + (node.type === "mdxJsxFlowElement" || + node.type === "mdxJsxTextElement") && + (node.name === "a" || + node.name === "APIItem" || + node.name === "APIItemCompact") + ) { + const id = node.attributes.find( + (attribute) => + attribute.type === "mdxJsxAttribute" && attribute.name === "id", + ); + if ( + id?.type === "mdxJsxAttribute" && + typeof id.value === "string" && + id.value + ) { + ids.add(id.value); + } + } + }); + + return [...ids]; +} diff --git a/scripts/link-validation.ts b/scripts/link-validation.ts index 1d8c7bdf..48a10f75 100644 --- a/scripts/link-validation.ts +++ b/scripts/link-validation.ts @@ -8,6 +8,7 @@ import remarkMath from "remark-math"; import type { InferPageType } from "fumadocs-core/source"; import { source } from "@/lib/source"; import { writeFileSync } from "fs"; +import { extractAnchorIds } from "./link-anchors"; import { arbitrumStylusTree, ethereumEvmTree, @@ -137,13 +138,7 @@ async function getHeadings({ // Also extract actual anchor IDs from the content for API reference pages const content = await data.getText("raw"); - const anchorRegex = /<\/a>/g; - const anchorIds: string[] = []; - let match: any; - - while ((match = anchorRegex.exec(content)) !== null) { - anchorIds.push(match[1]); - } + const anchorIds = extractAnchorIds(content); // Combine TOC headings and actual anchor IDs, removing duplicates const allHeadings = [...new Set([...tocHeadings, ...anchorIds])];