Files
roboco/scripts/gen_docs_redirects.py
5936c2bdea Docs split Phase 1: docs.roboco.tech becomes canonical — redirect stubs, user tree removed, MkDocs retired (#299)
* docs: replace MkDocs deploy with static redirect stubs

docs.roboco.tech (roboco-website) is now the canonical user-facing docs
site (spec: docs/internal/specs/2026-07-03-docs-site-split.md). Every URL
this repo's Pages site published needs to keep resolving, so
scripts/gen_docs_redirects.py generates one meta-refresh + rel=canonical
stub per page — derived from mkdocs.yml's nav while it still lists every
page — into the committed docs-redirects/ directory. All 58 stub targets
verify against the website repo's nav.ts (0 unmapped); the only rename is
how-to/* -> tour/* per the spec's slug map.

Rewrite .github/workflows/docs.yml to deploy docs-redirects/ directly
instead of running `mkdocs build`. The user-facing docs/ tree and
mkdocs.yml itself are untouched here — deleting them is the next step,
gated on these stubs resolving correctly.

* docs: delete the MkDocs user-facing tree, docs.roboco.tech is canonical

Per docs/internal/specs/2026-07-03-docs-site-split.md decision (1):
Material->MDX is not verbatim-portable, so repo A's user-facing docs are
deleted rather than kept as a permanently-drifting mirror. Deletes
index/get-started/company/how-to/panel/models/operations/optional/deploy/
api/troubleshooting plus images/videos/assets. KEEPS docs/rag/ (indexed
agent corpus), docs/map/, docs/internal/, and the team buckets
(backend/frontend/ux_ui) — none of these were ever in mkdocs.yml's nav.

mkdocs.yml's entire nav mapped 1:1 onto the deleted tree, so pruning it
"accordingly" leaves nothing — remove it outright, along with the now-dead
`docs` optional-dependency group (mkdocs/mkdocs-material/mkdocstrings/
pymarkdownlnt — mkdocstrings was already unused, not wired into any mkdocs
plugin), the matching deptry DEP002 ignore entries, .pymarkdown.json, and
the serve-docs/build-docs/lint-docs/fix-docs Makefile targets (all scoped
only to the deleted paths). Add regen-docs-redirects as the one remaining
docs Makefile target.

Repointed everything that linked into the deleted tree or the old Pages
URL: README's hero video/gif and walkthrough links now hit the docs.roboco.tech-hosted
copies (already duplicated there per the spec's ground truth), usage.md /
deployment.md's jump-links, pyproject's Documentation URL, and CLAUDE.md's
Blueprint Reference paragraph.

* chore: sync uv.lock after removing the docs optional-dependency group

Follow-up to the mkdocs.yml / docs extra removal — mkdocs, mkdocs-material,
mkdocstrings, pymarkdownlnt, and their transitive-only dependencies drop
out of the lockfile now that nothing in pyproject.toml declares them.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-03 02:04:59 +02:00

178 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Generate GitHub Pages redirect stubs for every page MkDocs used to publish.
Docs-site split Phase 1 (docs/internal/specs/2026-07-03-docs-site-split.md):
docs.roboco.tech (the `roboco-website` repo) is now the canonical user-facing
docs site; this repo's MkDocs build retires. Every URL MkDocs published under
https://rennf93.github.io/roboco/ must keep resolving — old links, bookmarks,
and search results don't get to 404 — so instead of real content each gets a
tiny static stub: a meta-refresh + `rel=canonical` pointing at the
docs.roboco.tech equivalent.
The stub set is generated once, while `mkdocs.yml`'s nav still lists every
published page, and the output is committed under `docs-redirects/` (NOT
gitignored, unlike `site/`) because `mkdocs.yml`'s nav is deleted later in
this same phase — nothing durable would be left for a future run to read.
`.github/workflows/docs.yml` deploys that committed directory as-is; it does
not re-run this script.
Section rename: the mirror's "how-to" tour section is called "tour" on
docs.roboco.tech (per the spec's slug map). Every other section slug is
identical between the two sites — verified with --verify-nav-ts against the
website repo's nav.ts before this stub set was committed.
Usage:
uv run python scripts/gen_docs_redirects.py
uv run python scripts/gen_docs_redirects.py \\
--verify-nav-ts ../roboco-website/src/content/docs/nav.ts
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
MKDOCS_YML = Path("mkdocs.yml")
OUT_DIR = Path("docs-redirects")
DEST_BASE = "https://docs.roboco.tech/docs"
SECTION_RENAME = {"how-to": "tour"}
STUB_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{title}</title>
<meta http-equiv="refresh" content="0; url={dest}">
<link rel="canonical" href="{dest}">
</head>
<body>
<p>This page has moved to <a href="{dest}">{dest}</a>.</p>
</body>
</html>
"""
@dataclass(frozen=True)
class Page:
title: str
md_path: str # as written in mkdocs.yml nav, e.g. "optional/http-security.md"
class _TolerantLoader(yaml.SafeLoader):
"""mkdocs.yml's `markdown_extensions` block carries `!!python/name:...`
tags (pymdownx wiring). We only read `nav:`, so tolerate them instead of
failing to parse the file."""
_TolerantLoader.add_multi_constructor(
"tag:yaml.org,2002:python/name:", lambda _loader, suffix, _node: suffix
)
def load_nav_pages(mkdocs_yml: Path) -> list[Page]:
config = yaml.load(mkdocs_yml.read_text(), Loader=_TolerantLoader)
return list(_walk_nav(config["nav"]))
def _walk_nav(items: list[Any], section_title: str | None = None) -> Any:
for item in items:
if isinstance(item, str):
yield Page(title=section_title or "RoboCo Docs", md_path=item)
elif isinstance(item, dict):
for title, value in item.items():
if isinstance(value, str):
yield Page(title=title, md_path=value)
else:
yield from _walk_nav(value, section_title=title)
def _slug_parts(md_path: str) -> tuple[str, ...]:
"""Directory/stem parts of an md path with a trailing index/README
collapsed onto the enclosing directory — mirrors MkDocs'
`use_directory_urls` scheme (the default, and what this site used)."""
path = Path(md_path)
parts = path.parts[:-1]
if path.stem not in ("index", "README"):
parts = (*parts, path.stem)
return parts
def source_dir(md_path: str) -> Path:
"""Output directory (relative to OUT_DIR) holding this page's stub —
the exact directory GitHub Pages already serves it from today."""
return Path(*_slug_parts(md_path))
def dest_url(md_path: str) -> str:
parts = _slug_parts(md_path)
if not parts:
return DEST_BASE
renamed = (SECTION_RENAME.get(parts[0], parts[0]), *parts[1:])
return f"{DEST_BASE}/{'/'.join(renamed)}"
def render_stub(title: str, dest: str) -> str:
return STUB_TEMPLATE.format(title=title, dest=dest)
def generate(pages: list[Page], out_dir: Path) -> list[tuple[str, str]]:
"""Write one stub per page; return [(source_url_path, dest_url), ...]."""
mapping = []
for page in pages:
rel_dir = source_dir(page.md_path)
dest = dest_url(page.md_path)
out_path = out_dir / rel_dir / "index.html"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(render_stub(page.title, dest))
source_url_path = "/" if str(rel_dir) == "." else f"/{rel_dir.as_posix()}/"
mapping.append((source_url_path, dest))
return mapping
def verify_against_nav_ts(mapping: list[tuple[str, str]], nav_ts: Path) -> list[str]:
"""Every stub's destination must resolve to a real docs.roboco.tech page.
Returns the unmapped destinations (empty = every stub target exists)."""
nav_slugs = set(re.findall(r'slug:\s*"([^"]+)"', nav_ts.read_text()))
unmapped = []
for _source, dest in mapping:
slug = dest.removeprefix(f"{DEST_BASE}/").removeprefix(DEST_BASE)
if slug and slug not in nav_slugs:
unmapped.append(dest)
return unmapped
def main() -> int:
args = sys.argv[1:]
verify_path = (
Path(args[args.index("--verify-nav-ts") + 1])
if "--verify-nav-ts" in args
else None
)
pages = load_nav_pages(MKDOCS_YML)
mapping = generate(pages, OUT_DIR)
print(f"Generated {len(mapping)} redirect stub(s) under {OUT_DIR}/:")
for source, dest in mapping:
print(f" {source:55s} -> {dest}")
if verify_path is not None:
unmapped = verify_against_nav_ts(mapping, verify_path)
if unmapped:
print(f"\n{len(unmapped)} unmapped target(s) (no matching nav.ts entry):")
for target in unmapped:
print(f" {target}")
return 1
print(f"\nAll {len(mapping)} stub targets verified against {verify_path}.")
return 0
if __name__ == "__main__":
raise SystemExit(main())