From 3d775adffc5be59e7565e2712595775f05a05eca Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Fri, 2 Oct 2026 15:18:21 +0200 Subject: [PATCH 01/10] scripts: add a check for docs.json redirects that cannot work Catches fragment sources, wildcards shadowed by an earlier wildcard, sources that capture navigation, and chained or off-nav destinations. --- scripts/check_redirects.py | 168 ++++++++++++++++++++++++++++++++ scripts/test_check_redirects.py | 135 +++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 scripts/check_redirects.py create mode 100644 scripts/test_check_redirects.py diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py new file mode 100644 index 0000000000..e512dcee20 --- /dev/null +++ b/scripts/check_redirects.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Fail if a docs.json redirect cannot work as written. + +Mintlify accepts all of these without complaint, and `mint broken-links +--check-redirects` only checks that a destination resolves. This script covers +the rest: + + - a source containing `#`: browsers never send the fragment, so it never matches + - a `:slug*` wildcard that an earlier, broader wildcard always matches first + - a source that captures a path navigation links to + - a destination of `/`, which is itself redirected to the first nav page + - a destination that is itself redirected (a chain) + - a destination that is not a page in navigation + - a `/b/:slug*` destination where /b is not a folder, page or redirect source + +Matching follows Mintlify's precedence: an exact source beats every wildcard +regardless of order; between wildcards the first match in the array wins; a +wildcard also matches its bare prefix (`/a/:slug*` matches `/a`). + +Run from anywhere: python3 scripts/check_redirects.py +""" +import json +import sys +from pathlib import Path + +WILDCARD_SUFFIX = "/:slug*" + + +def nav_pages(navigation) -> set[str]: + """Every page path reachable from navigation, without a leading slash. + + Mirrors scripts/check-orphans.sh: string entries in a "pages" array, plus + internal "href" and "root" values. + """ + pages = set() + + def walk(node): + if isinstance(node, list): + for child in node: + walk(child) + elif isinstance(node, dict): + for entry in node.get("pages", []): + if isinstance(entry, str): + pages.add(entry.strip("/")) + for key in ("href", "root"): + value = node.get(key) + if isinstance(value, str) and not value.startswith("http"): + pages.add(value.split("#")[0].strip("/")) + for value in node.values(): + if isinstance(value, (list, dict)): + walk(value) + + walk(navigation) + pages.discard("") + return pages + + +class Site: + def __init__(self, root: Path): + self.root = root + config = json.loads((root / "docs.json").read_text()) + self.redirects = config.get("redirects", []) + self.nav = nav_pages(config["navigation"]) + self.exact_sources = set() + # (prefix, source) pairs in array order; prefix is the source without /:slug* + self.wildcards = [] + for redirect in self.redirects: + source = redirect["source"] + if source.endswith(WILDCARD_SUFFIX): + self.wildcards.append((source[: -len(WILDCARD_SUFFIX)], source)) + else: + self.exact_sources.add(source) + + def matching_source(self, url: str): + """The redirect source Mintlify would apply to `url`, or None.""" + if url in self.exact_sources: + return url + for prefix, source in self.wildcards: + if url == prefix or url.startswith(prefix + "/"): + return source + return None + + def page_exists(self, path: str) -> bool: + candidates = (f"{path}.mdx", f"{path}.md", f"{path}/index.mdx", f"{path}/index.md") + return any((self.root / candidate).is_file() for candidate in candidates) + + def in_nav(self, path: str) -> bool: + return path in self.nav or f"{path}/index" in self.nav + + +def find_problems(site: Site) -> list[str]: + problems = [] + + for redirect in site.redirects: + source, destination = redirect["source"], redirect["destination"] + + if "#" in source: + problems.append(f"{source}: source contains '#', which browsers never send, so it never matches") + + if destination.startswith(("http://", "https://")): + continue + + if destination.endswith(WILDCARD_SUFFIX): + problems.extend(wildcard_destination_problems(site, source, destination)) + else: + problems.extend(page_destination_problems(site, source, destination)) + + for index, (prefix, source) in enumerate(site.wildcards): + for earlier_prefix, earlier_source in site.wildcards[:index]: + if prefix.startswith(earlier_prefix + "/"): + problems.append( + f"{source}: never matches, because {earlier_source} comes earlier and matches first; " + "move it above" + ) + break + + for page in sorted(site.nav): + source = site.matching_source(f"/{page}") + if source is not None: + problems.append(f"{source}: redirects /{page}, which navigation links to") + + return problems + + +def page_destination_problems(site: Site, source: str, destination: str) -> list[str]: + path = destination.split("#")[0] + if path.strip("/") == "": + return [f"{source} -> {destination}: '/' redirects again to the first nav page; point at that page"] + chained = site.matching_source(path) + if chained is not None: + return [f"{source} -> {destination}: destination is redirected again by {chained}; point at the final page"] + page = path.strip("/") + if not site.page_exists(page): + return [f"{source} -> {destination}: destination page does not exist"] + if not site.in_nav(page): + return [f"{source} -> {destination}: destination page is not in navigation"] + return [] + + +def wildcard_destination_problems(site: Site, source: str, destination: str) -> list[str]: + """A `/b/:slug*` destination needs /b to be a folder, a page or a redirect source. + + Chains through a wildcard destination are not checked: renaming a section + deliberately sends old paths on to the newer redirects, and which URLs a + wildcard is meant to cover is not recorded anywhere. + """ + prefix = destination[: -len(WILDCARD_SUFFIX)].strip("/") + if (site.root / prefix).is_dir() or site.page_exists(prefix): + return [] + if site.matching_source(f"/{prefix}") is not None: + return [] + return [f"{source} -> {destination}: /{prefix} is not a folder, a page or a redirect source"] + + +def main() -> int: + root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent + problems = find_problems(Site(root)) + if not problems: + print("No redirect problems found.") + return 0 + print(f"Found {len(problems)} redirect problem(s) in docs.json:") + for problem in problems: + print(f" {problem}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_check_redirects.py b/scripts/test_check_redirects.py new file mode 100644 index 0000000000..4db245ee59 --- /dev/null +++ b/scripts/test_check_redirects.py @@ -0,0 +1,135 @@ +"""Tests for check_redirects.py. Run: python3 -m unittest discover -s scripts -p 'test_*.py'""" +import json +import tempfile +import unittest +from pathlib import Path + +from check_redirects import Site, find_problems + +PAGES = ["guides/start", "guides/advanced", "tools/index", "orphan"] +NAVIGATION = { + "tabs": [ + {"tab": "Guides", "groups": [{"group": "Guides", "pages": ["guides/start", "guides/advanced"]}]}, + {"tab": "Tools", "groups": [{"group": "Tools", "pages": ["tools/index"]}]}, + ], + "global": {"anchors": [{"anchor": "Start here", "href": "/guides/start"}]}, +} + + +def problems_for(redirects): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for page in PAGES: + (root / page).parent.mkdir(parents=True, exist_ok=True) + (root / f"{page}.mdx").write_text("---\ntitle: Test\n---\n") + (root / "docs.json").write_text(json.dumps({"navigation": NAVIGATION, "redirects": redirects})) + return find_problems(Site(root)) + + +def redirect(source, destination): + return {"source": source, "destination": destination} + + +class ValidRedirects(unittest.TestCase): + def test_no_problems(self): + self.assertEqual( + problems_for([ + redirect("/old/start", "/guides/start"), + redirect("/old/advanced", "/guides/advanced#setup"), + redirect("/old-tools", "/tools"), + redirect("/old-guides/legacy/:slug*", "/guides/start"), + redirect("/old-guides/:slug*", "/guides/:slug*"), + redirect("/external", "https://celo.org/"), + ]), + [], + ) + + def test_exact_source_under_a_wildcard_is_fine(self): + # Exact sources beat wildcards regardless of order. + self.assertEqual( + problems_for([ + redirect("/old/:slug*", "/guides/:slug*"), + redirect("/old/special", "/guides/advanced"), + ]), + [], + ) + + def test_narrower_wildcard_above_broader_is_fine(self): + self.assertEqual( + problems_for([ + redirect("/old/ai/:slug*", "/guides/advanced"), + redirect("/old/:slug*", "/guides/:slug*"), + ]), + [], + ) + + def test_wildcard_destination_chain_is_allowed(self): + self.assertEqual( + problems_for([ + redirect("/older/:slug*", "/old/:slug*"), + redirect("/old/:slug*", "/guides/:slug*"), + ]), + [], + ) + + +class InvalidSources(unittest.TestCase): + def test_fragment_source(self): + [problem] = problems_for([redirect("/faqs#supply", "/guides/start")]) + self.assertIn("/faqs#supply: source contains '#'", problem) + + def test_narrower_wildcard_below_broader_never_matches(self): + [problem] = problems_for([ + redirect("/old/:slug*", "/guides/:slug*"), + redirect("/old/ai/:slug*", "/guides/advanced"), + ]) + self.assertIn("/old/ai/:slug*: never matches, because /old/:slug* comes earlier", problem) + + def test_wildcard_capturing_a_nav_page(self): + self.assertEqual( + problems_for([redirect("/guides/:slug*", "/tools")]), + [ + "/guides/:slug*: redirects /guides/advanced, which navigation links to", + "/guides/:slug*: redirects /guides/start, which navigation links to", + ], + ) + + def test_exact_source_capturing_a_nav_href(self): + problems = problems_for([redirect("/guides/start", "/guides/advanced")]) + self.assertEqual(problems, ["/guides/start: redirects /guides/start, which navigation links to"]) + + +class InvalidDestinations(unittest.TestCase): + def test_root_destination(self): + [problem] = problems_for([redirect("/old", "/")]) + self.assertIn("/old -> /: '/' redirects again", problem) + + def test_chained_destination(self): + [problem] = problems_for([ + redirect("/older", "/old"), + redirect("/old", "/guides/start"), + ]) + self.assertIn("/older -> /old: destination is redirected again by /old", problem) + + def test_destination_chained_through_a_wildcard(self): + [problem] = problems_for([ + redirect("/older", "/old/start"), + redirect("/old/:slug*", "/guides/:slug*"), + ]) + self.assertIn("/older -> /old/start: destination is redirected again by /old/:slug*", problem) + + def test_missing_destination(self): + [problem] = problems_for([redirect("/old", "/guides/gone")]) + self.assertIn("/old -> /guides/gone: destination page does not exist", problem) + + def test_destination_not_in_navigation(self): + [problem] = problems_for([redirect("/old", "/orphan")]) + self.assertIn("/old -> /orphan: destination page is not in navigation", problem) + + def test_missing_wildcard_destination_prefix(self): + [problem] = problems_for([redirect("/old/:slug*", "/gone/:slug*")]) + self.assertIn("/old/:slug* -> /gone/:slug*: /gone is not a folder, a page or a redirect source", problem) + + +if __name__ == "__main__": + unittest.main() From 51d4ff0bde5a7b4a9a230115ffd57516392ab354 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Fri, 2 Oct 2026 15:19:50 +0200 Subject: [PATCH 02/10] docs.json: remove redirects whose source has a #fragment Browsers never send the fragment, so these never matched. Checked in headless Chrome: /celo-gold-holder-guide/ledger#install-the-celo-cli lands on the bare path's destination, not this entry's. --- docs.json | 104 ------------------------------------------------------ 1 file changed, 104 deletions(-) diff --git a/docs.json b/docs.json index 9e2246afea..f72ed73aa5 100644 --- a/docs.json +++ b/docs.json @@ -1327,10 +1327,6 @@ "source": "/celo-codebase/protocol/identity/valora-accounts", "destination": "/learn/celo-l1" }, - { - "source": "/celo-codebase/protocol/identity#using-the-mapping-for-payment", - "destination": "/build/build-on-socialconnect" - }, { "source": "/celo-codebase/protocol/index", "destination": "/learn/protocol" @@ -1411,30 +1407,14 @@ "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/community-fund", "destination": "/learn/protocol/epoch-rewards/community-fund" }, - { - "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/community-fund#bolstering-the-reserve", - "destination": "/learn/protocol/epoch-rewards/community-fund" - }, { "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/locked-gold-rewards", "destination": "/learn/protocol/epoch-rewards/index" }, - { - "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/locked-gold-rewards#introduction-to-locked-celo-rewards", - "destination": "/learn/protocol/epoch-rewards/index" - }, { "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/validator-rewards", "destination": "/learn/protocol/epoch-rewards/index" }, - { - "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/validator-rewards#:~:text=Calculating%20Uptime%20Score,validators%20committing%20the%20previous%20block.", - "destination": "/learn/protocol/epoch-rewards/index" - }, - { - "source": "/celo-codebase/protocol/proof-of-stake/epoch-rewards/validator-rewards#calculating-uptime-score", - "destination": "/learn/protocol/epoch-rewards/index" - }, { "source": "/celo-codebase/protocol/proof-of-stake/index", "destination": "/learn/protocol/staking/index" @@ -1495,10 +1475,6 @@ "source": "/celo-codebase/protocol/stability/stability-fees", "destination": "/learn/celo-l1" }, - { - "source": "/celo-codebase/protocol/stability#stability-of-celos-stablecoin-protocol", - "destination": "/build/build-with-local-stablecoin" - }, { "source": "/celo-codebase/protocol/transactions/erc20-transaction-fees", "destination": "/build/fee-abstraction/overview" @@ -1559,10 +1535,6 @@ "source": "/celo-gold-holder-guide/ledger", "destination": "/build/tools/wallets/ledger/setup" }, - { - "source": "/celo-gold-holder-guide/ledger#install-the-celo-cli", - "destination": "/build/tools/wallets/ledger/to-celo-cli" - }, { "source": "/celo-gold-holder-guide/quick-start", "destination": "/learn/manage/self-custody" @@ -1655,14 +1627,6 @@ "source": "/celo-owner-guide/quick-start", "destination": "/learn/manage/self-custody" }, - { - "source": "/celo-owner-guide/quick-start#lock-celo", - "destination": "/learn/manage/self-custody" - }, - { - "source": "/celo-owner-guide/quick-start#vote-for-a-validator-group", - "destination": "/learn/protocol/staking/voting" - }, { "source": "/celo-owner-guide/release-gold", "destination": "/learn/manage/release-gold" @@ -1795,10 +1759,6 @@ "source": "/command-line-interface/commands", "destination": "/build/tools/libraries-sdks/cli/commands" }, - { - "source": "/command-line-interface/commands/account#celocli-account-new", - "destination": "/build/tools/libraries-sdks/cli/account" - }, { "source": "/command-line-interface/commands/exchange", "destination": "/build/tools/libraries-sdks/cli" @@ -2359,14 +2319,6 @@ "source": "/faqs", "destination": "/learn" }, - { - "source": "/faqs#what-is-celos-supply", - "destination": "/learn" - }, - { - "source": "/faqs#who-are-the-founders-of-celo", - "destination": "/learn/index" - }, { "source": "/general", "destination": "/learn/index" @@ -2527,42 +2479,10 @@ "source": "/getting-started/wallets/using-metamask-with-celo/manual-setup", "destination": "/build/tools/wallets/metamask/use" }, - { - "source": "/getting-started/wallets/using-metamask-with-celo/manual-setup#adding-a-celo-network-to-metamask", - "destination": "/build/tools/wallets/metamask/use" - }, - { - "source": "/getting-started/wallets/using-metamask-with-celo/manual-setup#adding-tokens-eg-cusd-ceur", - "destination": "/build/tools/wallets/metamask/use" - }, - { - "source": "/getting-started/wallets/using-metamask-with-celo/manual-setup#sending-assets-to-metamask", - "destination": "/build/tools/wallets/metamask/use" - }, - { - "source": "/getting-started/wallets/using-metamask-with-celo/manual-setup#setup", - "destination": "/build/tools/wallets/metamask/use" - }, { "source": "/getting-started/wallets/using-metamask-with-celo/using-a-ledger-with-metamask", "destination": "/build/tools/wallets/ledger/setup" }, - { - "source": "/getting-started/wallets/using-metamask-with-celo#importing-via-private-key", - "destination": "/build/tools/wallets/metamask/import" - }, - { - "source": "/getting-started/wallets#celo-compatible-wallets", - "destination": "/learn/wallets" - }, - { - "source": "/getting-started/wallets#celo-native-wallets", - "destination": "/learn/wallets" - }, - { - "source": "/getting-started/wallets#celowalletapp", - "destination": "/learn/wallets" - }, { "source": "/holder/asset", "destination": "/learn/manage/asset" @@ -2595,14 +2515,6 @@ "source": "/important-information/rc-network-disclaimer", "destination": "/learn/network/overview" }, - { - "source": "/network#celo-alfajores-l2-testnet", - "destination": "/learn/network/overview" - }, - { - "source": "/network#celo-baklava-l2-testnet", - "destination": "/learn/network/overview" - }, { "source": "/network/alfajores/run-full-node", "destination": "/operate/operators/run-node" @@ -3003,22 +2915,6 @@ "source": "/why-celo", "destination": "/learn/index" }, - { - "source": "/why-celo#celo-is-mobile-open-and-real", - "destination": "/learn/index" - }, - { - "source": "/why-celo#mobile", - "destination": "/learn/index" - }, - { - "source": "/why-celo#open", - "destination": "/learn/index" - }, - { - "source": "/why-celo#real", - "destination": "/learn/index" - }, { "source": "/overview", "destination": "/learn/index" From 826e77f8f771a357e07766dbf1c1da423335e01b Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Fri, 2 Oct 2026 15:23:27 +0200 Subject: [PATCH 03/10] ci: run the redirect check and its tests on every PR Documents the check and the two rules it enforces in AGENTS.md. --- .github/workflows/docs-validation.yml | 8 ++++++++ AGENTS.md | 5 +++-- scripts/README.md | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 3cf166a7c8..a270a2fca9 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -44,3 +44,11 @@ jobs: - name: Check for orphan pages run: bash scripts/check-orphans.sh + + - name: Test the redirect check + run: python3 -m unittest discover -s scripts -p 'test_*.py' + + # Catches redirects Mintlify accepts but that cannot work: wildcards shadowed by an + # earlier wildcard, chains, `#` in a source, sources that capture navigation paths. + - name: Check redirects + run: python3 scripts/check_redirects.py diff --git a/AGENTS.md b/AGENTS.md index bea911d86f..4a53089cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Structural work in progress is tracked in the restructure epic, [#2266](https:// npm i -g mint # once mint dev # preview at http://localhost:3000 mint broken-links # must pass before every PR; CI runs it on every PR to main +python3 scripts/check_redirects.py # must pass too when you touch redirects; CI runs it mint update # if the CLI misbehaves ``` @@ -27,10 +28,10 @@ mint update # if the CLI misbehaves All four steps, every time. External links and search results break silently when one is skipped. -1. Add a `redirects` entry in `docs.json`: `{ "source": "/old/path", "destination": "/new/path" }` — no `.mdx`, root-relative. Point at the final page, not at another redirect. +1. Add a `redirects` entry in `docs.json`: `{ "source": "/old/path", "destination": "/new/path" }` — no `.mdx`, root-relative. Point at the final page, not at another redirect. A source cannot contain `#`: browsers never send the fragment, so it never matches. Between `:slug*` wildcards the first match wins, so put a narrower wildcard above a broader one. 2. Update the page's entry under `navigation` (or remove it). 3. Update every inbound internal link: `grep -rn --include='*.mdx' "/old/path" .` -4. Run `mint broken-links`. +4. Run `mint broken-links` and `python3 scripts/check_redirects.py`. Deleting a page still needs step 1 — redirect to the nearest page that answers the same question. diff --git a/scripts/README.md b/scripts/README.md index e4350b6ea4..8df2738cd1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,6 +5,11 @@ This directory contains utility scripts for maintaining and updating the Celo do ## Available Scripts - `update_contracts.py` - Updates contract addresses and deployment information in the documentation +- `check_redirects.py` - Fails on `docs.json` redirects that cannot work: `#` in a source, a wildcard + shadowed by an earlier one, a source that captures a navigation path, or a destination that is + `/`, redirected again, or not a page in navigation. CI runs it with its tests + (`python3 -m unittest discover -s scripts -p 'test_*.py'`). +- `check-orphans.sh` - Fails on page files that navigation does not reach. CI runs it. ### Generated pages From 0b4781b5d005e9f11458aa61c8918dfe5e3fad39 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:30:56 +0200 Subject: [PATCH 04/10] scripts: fail closed when check_redirects.py cannot read docs.json It treated any source without a trailing /:slug* as exact, so /a/:path* was never compared with other wildcards, and a missing redirects key passed. --- scripts/check_redirects.py | 38 ++++++++++++++++-- scripts/test_check_redirects.py | 70 ++++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py index e512dcee20..ac94d21d20 100644 --- a/scripts/check_redirects.py +++ b/scripts/check_redirects.py @@ -17,13 +17,26 @@ regardless of order; between wildcards the first match in the array wins; a wildcard also matches its bare prefix (`/a/:slug*` matches `/a`). +The only path parameter the script understands is a trailing `/:slug*`. It +exits with an error on any other (`/a/:path*`, `/a/:id`, `/a/:slug*/b`) rather +than guess how Mintlify matches it, and likewise when `redirects` or +`navigation` is missing or empty. + Run from anywhere: python3 scripts/check_redirects.py """ import json +import re import sys from pathlib import Path WILDCARD_SUFFIX = "/:slug*" +# A Mintlify path parameter: `:` followed by a name or `*`, or a bare `*`. A `:` +# followed by anything else is literal, as in "/blog/.../Celo CLI: A Practical Guide". +PATH_PARAMETER = re.compile(r":[\w*]|\*") + + +class ConfigError(Exception): + """docs.json has a shape this script cannot check.""" def nav_pages(navigation) -> set[str]: @@ -59,15 +72,27 @@ class Site: def __init__(self, root: Path): self.root = root config = json.loads((root / "docs.json").read_text()) - self.redirects = config.get("redirects", []) + for key in ("redirects", "navigation"): + if not config.get(key): + raise ConfigError(f"docs.json has no '{key}', or it is empty") + self.redirects = config["redirects"] self.nav = nav_pages(config["navigation"]) + if not self.nav: + raise ConfigError("docs.json navigation lists no pages") self.exact_sources = set() # (prefix, source) pairs in array order; prefix is the source without /:slug* self.wildcards = [] for redirect in self.redirects: source = redirect["source"] - if source.endswith(WILDCARD_SUFFIX): - self.wildcards.append((source[: -len(WILDCARD_SUFFIX)], source)) + is_wildcard = source.endswith(WILDCARD_SUFFIX) + prefix = source[: -len(WILDCARD_SUFFIX)] if is_wildcard else source + if PATH_PARAMETER.search(prefix): + raise ConfigError( + f"{source}: the only path parameter this script understands is a trailing " + f"{WILDCARD_SUFFIX}; rewrite the source or teach scripts/check_redirects.py its matching" + ) + if is_wildcard: + self.wildcards.append((prefix, source)) else: self.exact_sources.add(source) @@ -154,7 +179,12 @@ def wildcard_destination_problems(site: Site, source: str, destination: str) -> def main() -> int: root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent - problems = find_problems(Site(root)) + try: + site = Site(root) + except ConfigError as error: + print(f"Cannot check redirects: {error}", file=sys.stderr) + return 1 + problems = find_problems(site) if not problems: print("No redirect problems found.") return 0 diff --git a/scripts/test_check_redirects.py b/scripts/test_check_redirects.py index 4db245ee59..50cf2eff4b 100644 --- a/scripts/test_check_redirects.py +++ b/scripts/test_check_redirects.py @@ -1,10 +1,14 @@ """Tests for check_redirects.py. Run: python3 -m unittest discover -s scripts -p 'test_*.py'""" +import io import json import tempfile import unittest +from contextlib import redirect_stderr from pathlib import Path +from unittest import mock -from check_redirects import Site, find_problems +import check_redirects +from check_redirects import ConfigError, Site, find_problems PAGES = ["guides/start", "guides/advanced", "tools/index", "orphan"] NAVIGATION = { @@ -16,16 +20,32 @@ } +def write_site(root, config): + for page in PAGES: + (root / page).parent.mkdir(parents=True, exist_ok=True) + (root / f"{page}.mdx").write_text("---\ntitle: Test\n---\n") + (root / "docs.json").write_text(json.dumps(config)) + + def problems_for(redirects): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - for page in PAGES: - (root / page).parent.mkdir(parents=True, exist_ok=True) - (root / f"{page}.mdx").write_text("---\ntitle: Test\n---\n") - (root / "docs.json").write_text(json.dumps({"navigation": NAVIGATION, "redirects": redirects})) + write_site(root, {"navigation": NAVIGATION, "redirects": redirects}) return find_problems(Site(root)) +def config_error_for(config): + """The ConfigError message Site raises for `config`, or None if it raises nothing.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_site(root, config) + try: + Site(root) + except ConfigError as error: + return str(error) + return None + + def redirect(source, destination): return {"source": source, "destination": destination} @@ -131,5 +151,45 @@ def test_missing_wildcard_destination_prefix(self): self.assertIn("/old/:slug* -> /gone/:slug*: /gone is not a folder, a page or a redirect source", problem) +class UncheckableConfig(unittest.TestCase): + def test_other_path_parameters_are_refused(self): + for source in ("/old/:path*", "/old/:id", "/old/:slug*/end", "/old/*", "/old/v:version"): + with self.subTest(source=source): + message = config_error_for({"navigation": NAVIGATION, "redirects": [redirect(source, "/tools")]}) + self.assertEqual( + message, + f"{source}: the only path parameter this script understands is a trailing /:slug*; " + "rewrite the source or teach scripts/check_redirects.py its matching", + ) + + def test_literal_colon_is_not_a_parameter(self): + # A real docs.json source: the colon is followed by a space, so Mintlify reads it literally. + source = "/blog/2022/03/04/Celo CLI: A Practical Guide" + self.assertEqual(problems_for([redirect(source, "/tools")]), []) + + def test_missing_or_empty_redirects(self): + for config in ({"navigation": NAVIGATION, "rediects": []}, {"navigation": NAVIGATION, "redirects": []}): + with self.subTest(config=config): + self.assertEqual(config_error_for(config), "docs.json has no 'redirects', or it is empty") + + def test_missing_or_empty_navigation(self): + redirects = [redirect("/old", "/tools")] + for config in ({"redirects": redirects}, {"navigation": {}, "redirects": redirects}): + with self.subTest(config=config): + self.assertEqual(config_error_for(config), "docs.json has no 'navigation', or it is empty") + + def test_navigation_without_pages(self): + config = {"navigation": {"tabs": [{"tab": "Empty", "groups": []}]}, "redirects": [redirect("/old", "/tools")]} + self.assertEqual(config_error_for(config), "docs.json navigation lists no pages") + + def test_main_exits_1_with_the_message(self): + with tempfile.TemporaryDirectory() as tmp: + write_site(Path(tmp), {"navigation": NAVIGATION, "redirects": []}) + stderr = io.StringIO() + with mock.patch("sys.argv", ["check_redirects.py", tmp]), redirect_stderr(stderr): + self.assertEqual(check_redirects.main(), 1) + self.assertEqual(stderr.getvalue(), "Cannot check redirects: docs.json has no 'redirects', or it is empty\n") + + if __name__ == "__main__": unittest.main() From 15ab0543364c4affa5ef29e8fa118eb9fb5282c5 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:30:56 +0200 Subject: [PATCH 05/10] scripts: flag a wildcard listed twice in check_redirects.py The later copy never fires, but the shadow test only compared a wildcard with strictly broader ones. --- scripts/check_redirects.py | 5 ++++- scripts/test_check_redirects.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py index ac94d21d20..ba47c0f333 100644 --- a/scripts/check_redirects.py +++ b/scripts/check_redirects.py @@ -6,7 +6,7 @@ the rest: - a source containing `#`: browsers never send the fragment, so it never matches - - a `:slug*` wildcard that an earlier, broader wildcard always matches first + - a `:slug*` wildcard that an earlier wildcard, broader or identical, always matches first - a source that captures a path navigation links to - a destination of `/`, which is itself redirected to the first nav page - a destination that is itself redirected (a chain) @@ -132,6 +132,9 @@ def find_problems(site: Site) -> list[str]: for index, (prefix, source) in enumerate(site.wildcards): for earlier_prefix, earlier_source in site.wildcards[:index]: + if prefix == earlier_prefix: + problems.append(f"{source}: never matches, because the same wildcard comes earlier; delete one") + break if prefix.startswith(earlier_prefix + "/"): problems.append( f"{source}: never matches, because {earlier_source} comes earlier and matches first; " diff --git a/scripts/test_check_redirects.py b/scripts/test_check_redirects.py index 50cf2eff4b..9565263681 100644 --- a/scripts/test_check_redirects.py +++ b/scripts/test_check_redirects.py @@ -105,6 +105,23 @@ def test_narrower_wildcard_below_broader_never_matches(self): ]) self.assertIn("/old/ai/:slug*: never matches, because /old/:slug* comes earlier", problem) + def test_same_wildcard_twice_never_matches(self): + [problem] = problems_for([ + redirect("/old/:slug*", "/guides/:slug*"), + redirect("/old/:slug*", "/tools"), + ]) + self.assertIn("/old/:slug*: never matches, because the same wildcard comes earlier", problem) + + def test_sibling_wildcard_with_shared_text_prefix_is_fine(self): + # /old-guides is not under /old, although the strings share a prefix. + self.assertEqual( + problems_for([ + redirect("/old/:slug*", "/guides/:slug*"), + redirect("/old-guides/:slug*", "/guides/:slug*"), + ]), + [], + ) + def test_wildcard_capturing_a_nav_page(self): self.assertEqual( problems_for([redirect("/guides/:slug*", "/tools")]), From e8ad031065a4136eb282434c6892bf1ec0183f9f Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:30:56 +0200 Subject: [PATCH 06/10] scripts: name the destination when navigation links to a redirected path The old message read like a redirect capturing a real page, so authors went looking for a page that no longer exists instead of fixing the nav link. --- scripts/check_redirects.py | 25 +++++++++++++++++++++---- scripts/test_check_redirects.py | 28 ++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py index ba47c0f333..fc880a142b 100644 --- a/scripts/check_redirects.py +++ b/scripts/check_redirects.py @@ -7,7 +7,8 @@ - a source containing `#`: browsers never send the fragment, so it never matches - a `:slug*` wildcard that an earlier wildcard, broader or identical, always matches first - - a source that captures a path navigation links to + - a source that captures a page in navigation + - a navigation link to a redirected path, which should link to the destination - a destination of `/`, which is itself redirected to the first nav page - a destination that is itself redirected (a chain) - a destination that is not a page in navigation @@ -105,6 +106,14 @@ def matching_source(self, url: str): return source return None + def destination_for(self, url: str, source: str) -> str: + """Where the redirect `source`, which matches `url`, sends it.""" + destination = next(r["destination"] for r in self.redirects if r["source"] == source) + if not (source.endswith(WILDCARD_SUFFIX) and destination.endswith(WILDCARD_SUFFIX)): + return destination + slug = url[len(source) - len(WILDCARD_SUFFIX):] + return destination[: -len(WILDCARD_SUFFIX)] + slug + def page_exists(self, path: str) -> bool: candidates = (f"{path}.mdx", f"{path}.md", f"{path}/index.mdx", f"{path}/index.md") return any((self.root / candidate).is_file() for candidate in candidates) @@ -143,9 +152,17 @@ def find_problems(site: Site) -> list[str]: break for page in sorted(site.nav): - source = site.matching_source(f"/{page}") - if source is not None: - problems.append(f"{source}: redirects /{page}, which navigation links to") + url = f"/{page}" + source = site.matching_source(url) + if source is None: + continue + if site.page_exists(page): + problems.append(f"{source}: redirects {url}, which is a page in navigation") + else: + problems.append( + f"{url}: navigation links to this path, which {source} redirects; " + f"link {site.destination_for(url, source)} instead" + ) return problems diff --git a/scripts/test_check_redirects.py b/scripts/test_check_redirects.py index 9565263681..09cc848695 100644 --- a/scripts/test_check_redirects.py +++ b/scripts/test_check_redirects.py @@ -16,7 +16,13 @@ {"tab": "Guides", "groups": [{"group": "Guides", "pages": ["guides/start", "guides/advanced"]}]}, {"tab": "Tools", "groups": [{"group": "Tools", "pages": ["tools/index"]}]}, ], - "global": {"anchors": [{"anchor": "Start here", "href": "/guides/start"}]}, + "global": { + "anchors": [ + {"anchor": "Start here", "href": "/guides/start"}, + # No page on disk: only fine as long as nothing redirects it. + {"anchor": "Moved", "href": "/moved/advanced"}, + ] + }, } @@ -126,14 +132,28 @@ def test_wildcard_capturing_a_nav_page(self): self.assertEqual( problems_for([redirect("/guides/:slug*", "/tools")]), [ - "/guides/:slug*: redirects /guides/advanced, which navigation links to", - "/guides/:slug*: redirects /guides/start, which navigation links to", + "/guides/:slug*: redirects /guides/advanced, which is a page in navigation", + "/guides/:slug*: redirects /guides/start, which is a page in navigation", ], ) def test_exact_source_capturing_a_nav_href(self): problems = problems_for([redirect("/guides/start", "/guides/advanced")]) - self.assertEqual(problems, ["/guides/start: redirects /guides/start, which navigation links to"]) + self.assertEqual(problems, ["/guides/start: redirects /guides/start, which is a page in navigation"]) + + def test_nav_link_to_a_redirected_path(self): + self.assertEqual( + problems_for([redirect("/moved/advanced", "/guides/advanced")]), + ["/moved/advanced: navigation links to this path, which /moved/advanced redirects; " + "link /guides/advanced instead"], + ) + + def test_nav_link_to_a_path_under_a_redirected_wildcard(self): + self.assertEqual( + problems_for([redirect("/moved/:slug*", "/guides/:slug*")]), + ["/moved/advanced: navigation links to this path, which /moved/:slug* redirects; " + "link /guides/advanced instead"], + ) class InvalidDestinations(unittest.TestCase): From 81c7d64b97046a191e4b498a84f60a35d4b21dc3 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:31:11 +0200 Subject: [PATCH 07/10] ci: limit the docs validation token to reading the repo The job only checks out and reads files, so it does not need the repository's default token scope. --- .github/workflows/docs-validation.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index a270a2fca9..67bedaccc3 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -11,6 +11,9 @@ on: - main workflow_dispatch: +permissions: + contents: read + # Pinned deliberately. `npx mintlify` resolves the `latest` dist-tag at run time, so a # broken upstream publish takes every PR in this repo red at once with a failure that has # nothing to do with the diff. That happened on 4.2.931, which carries a transitive From 32d069adac30a89a911b42e008b0c7dbe9185960 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:31:19 +0200 Subject: [PATCH 08/10] docs: describe what check_redirects.py now flags and refuses Authors should know to write wildcards as /:slug* before CI tells them. --- AGENTS.md | 2 +- scripts/README.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a53089cc3..9962699778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ mint update # if the CLI misbehaves All four steps, every time. External links and search results break silently when one is skipped. -1. Add a `redirects` entry in `docs.json`: `{ "source": "/old/path", "destination": "/new/path" }` — no `.mdx`, root-relative. Point at the final page, not at another redirect. A source cannot contain `#`: browsers never send the fragment, so it never matches. Between `:slug*` wildcards the first match wins, so put a narrower wildcard above a broader one. +1. Add a `redirects` entry in `docs.json`: `{ "source": "/old/path", "destination": "/new/path" }` — no `.mdx`, root-relative. Point at the final page, not at another redirect. A source cannot contain `#`: browsers never send the fragment, so it never matches. Write wildcards as a trailing `/:slug*`; the redirect check refuses any other path parameter. Between `:slug*` wildcards the first match wins, so put a narrower wildcard above a broader one. 2. Update the page's entry under `navigation` (or remove it). 3. Update every inbound internal link: `grep -rn --include='*.mdx' "/old/path" .` 4. Run `mint broken-links` and `python3 scripts/check_redirects.py`. diff --git a/scripts/README.md b/scripts/README.md index 8df2738cd1..844acfc4eb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,9 +6,10 @@ This directory contains utility scripts for maintaining and updating the Celo do - `update_contracts.py` - Updates contract addresses and deployment information in the documentation - `check_redirects.py` - Fails on `docs.json` redirects that cannot work: `#` in a source, a wildcard - shadowed by an earlier one, a source that captures a navigation path, or a destination that is - `/`, redirected again, or not a page in navigation. CI runs it with its tests - (`python3 -m unittest discover -s scripts -p 'test_*.py'`). + shadowed by an earlier or identical one, a source that captures a page in navigation, a navigation + link to a redirected path, or a destination that is `/`, redirected again, or not a page in + navigation. It only understands a trailing `/:slug*` wildcard and stops with an error on any other + path parameter. CI runs it with its tests (`python3 -m unittest discover -s scripts -p 'test_*.py'`). - `check-orphans.sh` - Fails on page files that navigation does not reach. CI runs it. ### Generated pages From 8ced07a3be9bd0581ce0715e6b1a3f907dda356e Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 6 Oct 2026 14:37:36 +0200 Subject: [PATCH 09/10] scripts: ignore the fragment when looking for path parameters A #:~:text= source on main stopped the check with an error instead of being reported as a # source. --- scripts/check_redirects.py | 3 ++- scripts/test_check_redirects.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py index fc880a142b..da9dae8ec7 100644 --- a/scripts/check_redirects.py +++ b/scripts/check_redirects.py @@ -87,7 +87,8 @@ def __init__(self, root: Path): source = redirect["source"] is_wildcard = source.endswith(WILDCARD_SUFFIX) prefix = source[: -len(WILDCARD_SUFFIX)] if is_wildcard else source - if PATH_PARAMETER.search(prefix): + # The fragment is reported as a `#` problem; `#:~:text=` there is not a parameter. + if PATH_PARAMETER.search(prefix.split("#")[0]): raise ConfigError( f"{source}: the only path parameter this script understands is a trailing " f"{WILDCARD_SUFFIX}; rewrite the source or teach scripts/check_redirects.py its matching" diff --git a/scripts/test_check_redirects.py b/scripts/test_check_redirects.py index 09cc848695..d47865dfa0 100644 --- a/scripts/test_check_redirects.py +++ b/scripts/test_check_redirects.py @@ -104,6 +104,11 @@ def test_fragment_source(self): [problem] = problems_for([redirect("/faqs#supply", "/guides/start")]) self.assertIn("/faqs#supply: source contains '#'", problem) + def test_text_fragment_source_is_a_fragment_problem(self): + # A real docs.json source: the `:~` after `#` must not read as a path parameter. + [problem] = problems_for([redirect("/faqs#:~:text=Calculating%20Uptime%20Score", "/guides/start")]) + self.assertIn("/faqs#:~:text=Calculating%20Uptime%20Score: source contains '#'", problem) + def test_narrower_wildcard_below_broader_never_matches(self): [problem] = problems_for([ redirect("/old/:slug*", "/guides/:slug*"), From d7c0fbd20338f94ce85e57d53f74054a8a39f5b1 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 7 Oct 2026 07:10:16 +0200 Subject: [PATCH 10/10] docs.json: point the Docs for AI anchor at its page under /build/agents The anchor still linked the pre-move path, which a wildcard redirect catches, so the redirect check failed on it. --- docs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs.json b/docs.json index f72ed73aa5..e7f1e75a99 100644 --- a/docs.json +++ b/docs.json @@ -542,7 +542,7 @@ "anchors": [ { "anchor": "Docs for AI", - "href": "/build-on-celo/build-with-ai/use-docs-with-ai", + "href": "/build/agents/use-docs-with-ai", "icon": "robot" }, {