Skip to content
Open
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
15 changes: 15 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand All @@ -456,6 +462,10 @@ npx tsx scripts/link-validation.ts --no-ignore-fragments
- `--output <file>` - 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
Expand Down
105 changes: 105 additions & 0 deletions scripts/link-anchors.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<a className="anchor" id='legacy'></a>

<APIItem
functionSignature="balance(owner: Address) -> u256"
kind="external"
id = "Token-balance"
>
Description.
</APIItem>

<APIItemCompact id='Token-transfer' circuitSig="transfer()" kind="export" />`;
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<APIItem id="shared" />\n\n<a id="shared"></a>',
),
["shared"],
);
});

test("ignores example code, comments, dynamic IDs and unrelated components", () => {
const content =
'```mdx\n<APIItem id="example" />\n## Heading [#example-heading]\n```\n\n' +
'`<a id="inline-example"></a>`\n\n' +
'{/* <APIItem id="comment-example" /> */}\n\n' +
'<APIItem id={someValue} />\n\n<Widget id="not-an-anchor" />\n\n<a id=""></a>';
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 =
'<APIItem id="Token-balance" />\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"],
);
});
41 changes: 41 additions & 0 deletions scripts/link-anchors.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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];
}
9 changes: 2 additions & 7 deletions scripts/link-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 id="([^"]+)"><\/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])];
Expand Down
Loading