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
34 changes: 34 additions & 0 deletions .github/workflows/validate-redirects.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Validate redirects

permissions:
contents: read

on:
pull_request:
paths:
- "docs/**"
- "scripts/validate-redirects.js"
- "scripts/validate-redirects.test.js"
- ".github/workflows/validate-redirects.yml"

jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit

- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

- name: Test redirect validator
run: node --test scripts/validate-redirects.test.js

- name: Reject newly broken redirects
env:
DOCS_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: node scripts/validate-redirects.js --base-ref "$DOCS_BASE_REF"
22 changes: 22 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Scripts

## Redirect validator

Check every redirect destination against the docs tree and other redirects:

```bash
node scripts/validate-redirects.js
```

The repository has pre-existing broken redirects. CI compares a pull request with its base
revision so existing debt does not block unrelated work while newly broken destinations,
redirect chains, and cycles fail the check:

```bash
node scripts/validate-redirects.js --base-ref <git-ref>
```

Run the dependency-free test suite with:

```bash
node --test scripts/validate-redirects.test.js
```

## MDX Linter

Deterministic linter for MDX documentation files.
Expand Down
215 changes: 215 additions & 0 deletions scripts/validate-redirects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");

const REPO_ROOT = path.join(__dirname, "..");
const DOCS_DIR = path.join(REPO_ROOT, "docs");
const CONFIG_PATH = path.join(DOCS_DIR, "docs.json");

function normalizeRoute(route) {
const pathname = route.split(/[?#]/, 1)[0];
const withLeadingSlash = pathname.startsWith("/") ? pathname : `/${pathname}`;
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/$/, "") : withLeadingSlash;
}

function routesFromFilePaths(filePaths, docsPrefix = "") {
const routes = new Set();

for (const filePath of filePaths) {
let relative = filePath.replaceAll("\\", "/");
if (docsPrefix && relative.startsWith(docsPrefix)) {
relative = relative.slice(docsPrefix.length);
}
if (!relative || relative === "docs.json") continue;

routes.add(normalizeRoute(relative));
if (!/\.mdx?$/.test(relative)) continue;

const withoutExtension = relative.replace(/\.mdx?$/, "");
routes.add(normalizeRoute(withoutExtension));
if (withoutExtension.endsWith("/index")) {
routes.add(normalizeRoute(withoutExtension.slice(0, -"/index".length)));
}
}

return routes;
}

function walkFiles(directory, files = [], root = directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
walkFiles(fullPath, files, root);
} else {
files.push(path.relative(root, fullPath));
}
}
return files;
}

function compileRedirects(redirects) {
const exact = new Map();
const dynamic = [];

for (const redirect of redirects) {
if (!redirect.source.includes(":")) {
exact.set(normalizeRoute(redirect.source), redirect.destination);
continue;
}

const names = [];
const escaped = normalizeRoute(redirect.source)
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/:([A-Za-z][A-Za-z0-9_]*)(\*)?/g, (_, name, wildcard) => {
names.push({ name, wildcard: Boolean(wildcard) });
return wildcard ? "(.*)" : "([^/]+)";
});

dynamic.push({
...redirect,
names,
pattern: new RegExp(`^${escaped}$`),
});
}

return { exact, dynamic };
}

function redirectTarget(route, compiled) {
if (compiled.exact.has(route)) return compiled.exact.get(route);

for (const redirect of compiled.dynamic) {
const match = route.match(redirect.pattern);
if (!match) continue;

let destination = redirect.destination;
redirect.names.forEach(({ name, wildcard }, index) => {
const token = `:${name}${wildcard ? "*" : ""}`;
destination = destination.replace(token, match[index + 1]);
});
return destination;
}

return undefined;
}

function terminalExists(destination, routes) {
if (/^https?:\/\//.test(destination)) return true;

const route = normalizeRoute(destination);
if (routes.has(route)) return true;

const parameterIndex = route.indexOf("/:");
if (parameterIndex === -1) return false;

const prefix = route.slice(0, parameterIndex);
return [...routes].some(candidate => candidate.startsWith(`${prefix}/`));
}

function resolveDestination(destination, compiled, routes) {
let route = normalizeRoute(destination);
const seen = new Set();

for (let depth = 0; depth < 50; depth += 1) {
if (/^https?:\/\//.test(destination) || terminalExists(route, routes)) {
return { valid: true, terminal: route };
}
if (seen.has(route)) {
return { valid: false, reason: "redirect cycle", terminal: route };
}

seen.add(route);
const target = redirectTarget(route, compiled);
if (!target) {
return { valid: false, reason: "missing destination", terminal: route };
}
destination = target;
route = normalizeRoute(target);
}

return { valid: false, reason: "redirect chain exceeds 50 hops", terminal: route };
}

function findInvalidRedirects(redirects, routes) {
const compiled = compileRedirects(redirects);
return redirects.flatMap(redirect => {
const result = resolveDestination(redirect.destination, compiled, routes);
return result.valid ? [] : [{ ...redirect, ...result }];
});
}

function invalidKey(redirect) {
return `${redirect.source}\n${redirect.destination}`;
}

function findNewInvalidRedirects(current, baseline) {
const baselineKeys = new Set(baseline.map(invalidKey));
return current.filter(redirect => !baselineKeys.has(invalidKey(redirect)));
}

function currentRoutes() {
return routesFromFilePaths(walkFiles(DOCS_DIR));
}

function routesAtRef(ref) {
const files = execFileSync("git", ["ls-tree", "-r", "--name-only", ref, "--", "docs"], {
cwd: REPO_ROOT,
encoding: "utf8",
}).trim().split("\n").filter(Boolean);
return routesFromFilePaths(files, "docs/");
}

function configAtRef(ref) {
return JSON.parse(execFileSync("git", ["show", `${ref}:docs/docs.json`], {
cwd: REPO_ROOT,
encoding: "utf8",
}));
}

function printInvalid(redirects) {
for (const redirect of redirects) {
console.error(
`- ${redirect.source} -> ${redirect.destination} (${redirect.reason}: ${redirect.terminal})`,
);
}
}

function main() {
const baseRefIndex = process.argv.indexOf("--base-ref");
const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1];
if (baseRefIndex !== -1 && !baseRef) {
console.error("--base-ref requires a Git ref");
process.exit(2);
}

const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
const invalid = findInvalidRedirects(config.redirects ?? [], currentRoutes());
const failures = baseRef
? findNewInvalidRedirects(
invalid,
findInvalidRedirects(configAtRef(baseRef).redirects ?? [], routesAtRef(baseRef)),
)
: invalid;

if (failures.length > 0) {
console.error(`Found ${failures.length} new invalid redirect${failures.length === 1 ? "" : "s"}:`);
printInvalid(failures);
process.exit(1);
}

const scope = baseRef ? `relative to ${baseRef}` : "in docs.json";
console.log(`Redirect validation passed ${scope}.`);
}

module.exports = {
compileRedirects,
findInvalidRedirects,
findNewInvalidRedirects,
normalizeRoute,
resolveDestination,
routesFromFilePaths,
};

if (require.main === module) main();
77 changes: 77 additions & 0 deletions scripts/validate-redirects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");

const {
findInvalidRedirects,
findNewInvalidRedirects,
routesFromFilePaths,
} = require("./validate-redirects.js");

const routes = routesFromFilePaths([
"docs/apps/index.mdx",
"docs/apps/quickstart/build-app.mdx",
"docs/base-chain/api-reference/ethereum-json-rpc-api/eth_call.mdx",
], "docs/");

describe("redirect validation", () => {
it("accepts page destinations and redirect chains", () => {
const redirects = [
{ source: "/old-apps", destination: "/apps-v1" },
{ source: "/apps-v1", destination: "/apps" },
];

assert.deepEqual(findInvalidRedirects(redirects, routes), []);
});

it("accepts dynamic destinations backed by a docs directory", () => {
const redirects = [{
source: "/rpc/:method",
destination: "/base-chain/api-reference/ethereum-json-rpc-api/:method",
}];

assert.deepEqual(findInvalidRedirects(redirects, routes), []);
});

it("resolves wildcard redirect chains", () => {
const redirects = [
{ source: "/legacy/:slug*", destination: "/moved/:slug*" },
{ source: "/moved/:slug*", destination: "/apps/quickstart/build-app" },
];

assert.deepEqual(findInvalidRedirects(redirects, routes), []);
});

it("reports missing destinations and cycles", () => {
const redirects = [
{ source: "/missing", destination: "/not-a-page" },
{ source: "/cycle-a", destination: "/cycle-b" },
{ source: "/cycle-b", destination: "/cycle-a" },
];

const invalid = findInvalidRedirects(redirects, routes);
assert.equal(invalid.length, 3);
assert.equal(invalid[0].reason, "missing destination");
assert.equal(invalid[1].reason, "redirect cycle");
assert.equal(invalid[2].reason, "redirect cycle");
});

it("fails only invalid redirects introduced after the baseline", () => {
const existing = [{
source: "/known-broken",
destination: "/missing-before-this-change",
reason: "missing destination",
terminal: "/missing-before-this-change",
}];
const current = [
...existing,
{
source: "/new-broken",
destination: "/new-missing-page",
reason: "missing destination",
terminal: "/new-missing-page",
},
];

assert.deepEqual(findNewInvalidRedirects(current, existing), [current[1]]);
});
});