diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md index 99feaefc54..64df8b9ebe 100644 --- a/.agents/skills/update-docs/SKILL.md +++ b/.agents/skills/update-docs/SKILL.md @@ -108,6 +108,7 @@ Write the doc update following the rules in `docs/CONTRIBUTING.mdx`. Key reminde - **Use `sidebar-title` for short nav labels**. For explicit navigation entries, keep relative `slug` values in `docs/index.yml` instead of page frontmatter. - **Keep explicit `page:` entries in `docs/index.yml`**. Fern still requires them. If the page defines `sidebar-title`, set `page:` to that value. Otherwise set `page:` to the page frontmatter `title`. - **Use `skip-slug: true` in `docs/index.yml`** when a child page should live at the parent section path. +- **Keep each page URL equal to its file path** under `docs/`. Rename the file when you rename a page, add a `fern/docs.yml` redirect for the old URL, and set a relative `slug:` when the nav label does not produce the file name. `mise run docs` runs `docs:nav`, which fails otherwise. - **Use `keywords` as a comma-separated string**. - **Do not add a duplicate H1**. Fern renders the page title from frontmatter. - **Always write NVIDIA in all caps.** Wrong: Nvidia, nvidia. diff --git a/.github/workflows/branch-docs.yml b/.github/workflows/branch-docs.yml index 9333a6ee1e..139b14f75b 100644 --- a/.github/workflows/branch-docs.yml +++ b/.github/workflows/branch-docs.yml @@ -7,6 +7,9 @@ on: - "fern/**" - "mise.toml" - "tasks/docs.toml" + - "tasks/test.toml" + - "tasks/scripts/check_docs_nav.py" + - "tasks/scripts/check_docs_nav_test.py" - ".github/workflows/branch-docs.yml" - ".github/workflows/release-tag.yml" @@ -47,6 +50,18 @@ jobs: working-directory: ./fern run: fern check + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.13" + + - name: Test docs navigation check + run: uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py + + - name: Check docs navigation matches file paths + run: uv run tasks/scripts/check_docs_nav.py + - name: Generate preview URL if: ${{ steps.fern-preview.outputs.enabled == 'true' }} id: generate-docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4213239b35..60a9d3f15c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -487,7 +487,7 @@ field design, and schema-evolution rules. ## Documentation -If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides. +If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides. Keep every page URL equal to its file path under `docs/`; `mise run docs` checks this with `docs:nav`. To ensure your doc changes follow NVIDIA documentation style, use the `update-docs-from-commits` skill. It scans commits, identifies doc pages that need updates, and drafts content that follows the style guide in `docs/CONTRIBUTING.mdx`. diff --git a/architecture/build.md b/architecture/build.md index a164f9aaa7..6ae89c9481 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -584,8 +584,10 @@ See `CI.md` for the contributor workflow, labels, and maintainer merge-queue wor Published docs live in `docs/`. Navigation lives in `docs/index.yml`. Fern site configuration, components, theme assets, and publish settings live in `fern/`. -Use `mise run docs` for strict validation and `mise run docs:serve` for local -preview. PR previews are produced by `.github/workflows/branch-docs.yml` when +Use `mise run docs` for Fern validation and navigation-to-file-path consistency, +and `mise run docs:serve` for local preview. The docs PR workflow also runs the +navigation check's unit tests (`mise run test:docs-nav`). +PR previews are produced by `.github/workflows/branch-docs.yml` when Fern credentials are available. Production docs publish from the release tag workflow. diff --git a/docs/CONTRIBUTING.mdx b/docs/CONTRIBUTING.mdx index 383473918e..dec4f21e95 100644 --- a/docs/CONTRIBUTING.mdx +++ b/docs/CONTRIBUTING.mdx @@ -89,6 +89,8 @@ keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing" For explicit entries in `docs/index.yml`, keep `page:`. Fern still requires it. If the page defines `sidebar-title`, set `page:` to that value. Otherwise set `page:` to the frontmatter `title`. +Keep every page URL equal to its file path under `docs/`, without `.mdx`. Fern builds URLs from nav labels and slugs, not from file paths, so rename the file when you rename a page, and add a redirect in `fern/docs.yml` for the old URL. When a nav label does not produce the file name, for example `TypeScript`, which Fern turns into `type-script`, set a relative `slug:` on the entry in `docs/index.yml`. `mise run docs` runs `mise run docs:nav`, which fails when a page URL differs from its file path, when a nav label differs from the page's sidebar name, when a page is missing from the navigation, or when a redirect points to a page that does not exist. + ### Page Structure 1. Frontmatter `title` and `description`, plus any relevant page metadata. diff --git a/docs/about/run-an-agent.mdx b/docs/about/run-your-first-agent.mdx similarity index 100% rename from docs/about/run-an-agent.mdx rename to docs/about/run-your-first-agent.mdx diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index 7abb989276..3dbf6e524f 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 title: "Gateway Authentication" +sidebar-title: "Authentication" description: "Gateway resolution, authentication modes, connection flow, OIDC support, and credential file layout." keywords: "Generative AI, Cybersecurity, Gateway, Authentication, mTLS, OIDC, OpenID Connect, Edge Authentication, Reference" position: 1 diff --git a/docs/index.yml b/docs/index.yml index defa2106e6..596ac87172 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -9,14 +9,14 @@ navigation: - section: "About NVIDIA OpenShell" slug: about contents: - - page: "Why OpenShell" + - page: "Overview" path: about/overview.mdx - page: "Architecture" path: about/architecture.mdx - page: "Installation" path: about/installation.mdx - page: "Run Your First Agent" - path: about/run-an-agent.mdx + path: about/run-your-first-agent.mdx - page: "Support Matrix" path: about/support-matrix.mdx - section: "How It Works" @@ -102,6 +102,7 @@ navigation: title: "Kubernetes" - section: "Tutorials" slug: tutorials + path: tutorials/index.mdx contents: - page: "Run Pi with OpenRouter" path: tutorials/run-pi-with-openrouter.mdx @@ -109,7 +110,7 @@ navigation: - page: "First Network Policy" path: tutorials/first-network-policy.mdx - page: "GitHub Push Access" - path: tutorials/github-sandbox.mdx + path: tutorials/github-push-access.mdx - page: "Microsoft Graph Provider Refresh" path: tutorials/microsoft-graph-provider-refresh.mdx - section: "SDK Reference" @@ -123,6 +124,7 @@ navigation: path: sdk/python.mdx - page: "TypeScript" path: sdk/typescript.mdx + slug: typescript - page: "API Errors" path: sdk/api-errors.mdx - page: "Protobuf Time Types" diff --git a/docs/sdk/protobuf-time-types.mdx b/docs/sdk/protobuf-time-types.mdx index f658abf776..72de767ddd 100644 --- a/docs/sdk/protobuf-time-types.mdx +++ b/docs/sdk/protobuf-time-types.mdx @@ -1,5 +1,6 @@ --- title: Protobuf time types +sidebar-title: "Protobuf Time Types" description: Timestamp and duration representation in the OpenShell API --- diff --git a/docs/security/verifying-images.mdx b/docs/security/verify-image-contents.mdx similarity index 100% rename from docs/security/verifying-images.mdx rename to docs/security/verify-image-contents.mdx diff --git a/docs/tutorials/github-sandbox.mdx b/docs/tutorials/github-push-access.mdx similarity index 100% rename from docs/tutorials/github-sandbox.mdx rename to docs/tutorials/github-push-access.mdx diff --git a/fern/docs.yml b/fern/docs.yml index 9066e2cc47..a36a9ac1e3 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -80,15 +80,28 @@ redirects: - source: "/openshell/dev/providers/google-vertex-ai" destination: "/openshell/dev/how-it-works/providers/google#vertex-ai" - source: "/openshell/latest/about/supported-agents" - destination: "/openshell/latest/about/run-an-agent" + destination: "/openshell/latest/about/run-your-first-agent" - source: "/openshell/dev/about/supported-agents" - destination: "/openshell/dev/about/run-an-agent" + destination: "/openshell/dev/about/run-your-first-agent" - source: "/openshell/latest/get-started/quickstart" - destination: "/openshell/latest/about/run-an-agent" + destination: "/openshell/latest/about/run-your-first-agent" - source: "/openshell/dev/get-started/quickstart" - destination: "/openshell/dev/about/run-an-agent" + destination: "/openshell/dev/about/run-your-first-agent" - source: "/openshell/latest/sandboxes/providers-v2" destination: "/openshell/latest/how-it-works/providers/profiles" + # Preserve published URLs when page names and slugs change. + - source: "/openshell/about/why-open-shell" + destination: "/openshell/latest/about/overview" + - source: "/openshell/sdk/type-script" + destination: "/openshell/latest/sdk/typescript" + - source: "/openshell/latest/about/why-open-shell" + destination: "/openshell/latest/about/overview" + - source: "/openshell/dev/about/why-open-shell" + destination: "/openshell/dev/about/overview" + - source: "/openshell/latest/sdk/type-script" + destination: "/openshell/latest/sdk/typescript" + - source: "/openshell/dev/sdk/type-script" + destination: "/openshell/dev/sdk/typescript" # Paths are relative to the site root; subpath prefix matches instances + custom-domain. # Legacy HTML URLs used .../path/to/page/index.html; Fern canonical URLs omit index.html. # List explicit /index.html routes before :path*/index.html so empty path segments do not diff --git a/tasks/docs.toml b/tasks/docs.toml index bc9534fa0d..b1cea2ee2a 100644 --- a/tasks/docs.toml +++ b/tasks/docs.toml @@ -5,7 +5,7 @@ ["docs"] description = "Validate Fern documentation" -depends = ["docs:build:strict"] +depends = ["docs:build:strict", "docs:nav"] ["docs:deps"] description = "Resolve Fern CLI for docs tasks" @@ -23,6 +23,10 @@ cd fern npx --yes "fern-api@${FERN_VERSION}" check """ +["docs:nav"] +description = "Check that docs page URLs match their file paths" +run = "uv run tasks/scripts/check_docs_nav.py" + ["docs:serve"] description = "Serve Fern docs locally" depends = ["docs:deps"] diff --git a/tasks/scripts/check_docs_nav.py b/tasks/scripts/check_docs_nav.py new file mode 100644 index 0000000000..f92dbcadd5 --- /dev/null +++ b/tasks/scripts/check_docs_nav.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "PyYAML==6.0.2", +# ] +# /// + +"""Check that the published docs navigation mirrors the docs folder structure. + +Fern does not build page URLs from file paths. It uses, in order, the page's +frontmatter `slug`, the nav entry's `slug`, or the nav label, and it splits +camel case in labels, so "TypeScript" becomes `type-script`. Pages in a +`folder:` section use their file names. A URL can therefore drift away from +where its file lives without any link breaking. + +This check fails when: + +- a page's URL differs from its file path under docs/, without `.mdx`, and + without `/index` for a section's own page; +- a nav page's sidebar name, its `sidebar-title` or else its `title`, differs + from its nav label. Fern shows the sidebar name but builds the URL from the + label, so the two must agree; +- a docs page is not reachable from the navigation; or +- a redirect for this docs version points to a URL that does not exist, or its + source is still a live page. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +# Files under docs/ that are intentionally not published as pages. +UNPUBLISHED = {"CONTRIBUTING.mdx"} + + +def fern_slug(label: str) -> str: + """Slugify a nav label the way Fern does, including camel-case splits.""" + split = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", label) + return re.sub(r"[^a-z0-9]+", "-", split.lower()).strip("-") + + +def path_url(rel_path: str) -> str: + """Return the URL that a docs file path implies.""" + url = "/" + re.sub(r"\.mdx$", "", rel_path) + return re.sub(r"/index$", "", url) + + +def frontmatter(path: Path) -> dict: + match = re.match(r"---\n(.*?)\n---", path.read_text(encoding="utf-8"), re.S) + if not match: + return {} + return yaml.safe_load(match.group(1)) or {} + + +@dataclass +class NavCheck: + docs_root: Path + urls: dict[str, str] = field(default_factory=dict) # URL -> docs-relative path + reachable: set[str] = field(default_factory=set) + issues: list[str] = field(default_factory=list) + + def page_url(self, rel_path: str, default_url: str) -> str: + slug = frontmatter(self.docs_root / rel_path).get("slug") + return "/" + str(slug).strip("/") if slug else default_url + + def record(self, rel_path: str, url: str, how: str) -> None: + self.reachable.add(rel_path) + if url in self.urls and self.urls[url] != rel_path: + self.issues.append( + f"{rel_path}: URL {url} is also used by {self.urls[url]}" + ) + self.urls[url] = rel_path + expected = path_url(rel_path) + if url != expected: + self.issues.append( + f"{rel_path}: URL {url} ({how}) does not match its file path " + f"{expected}. Rename the file, change the nav label, or set a " + "relative slug in docs/index.yml. For folder-discovered pages, " + f'use frontmatter slug: "{expected.lstrip("/")}".' + ) + + def walk(self, items: list, prefix: str) -> None: + for item in items or []: + if "section" in item: + url = prefix + "/" + item.get("slug", fern_slug(item["section"])) + if item.get("skip-slug"): + url = prefix + if "path" in item: + rel = item["path"] + self.record(rel, self.page_url(rel, url), "section page") + self.walk(item.get("contents", []), url) + elif "page" in item: + rel, label = item["path"], item["page"] + if not (self.docs_root / rel).exists(): + self.issues.append( + f"docs/index.yml: nav page {label!r} points to missing {rel}" + ) + continue + if item.get("skip-slug"): + default, how = prefix, "skip-slug" + elif "slug" in item: + default, how = prefix + "/" + item["slug"], "nav slug" + else: + default, how = ( + prefix + "/" + fern_slug(label), + f"nav label {label!r}", + ) + meta = frontmatter(self.docs_root / rel) + how = "frontmatter slug" if meta.get("slug") else how + self.record(rel, self.page_url(rel, default), how) + shown_key = "sidebar-title" if "sidebar-title" in meta else "title" + shown = meta.get(shown_key) + if shown != label: + self.issues.append( + f"{rel}: {shown_key} {shown!r} does not match nav label {label!r}" + ) + elif "folder" in item: + folder = item["folder"].strip("./").rstrip("/") + url = ( + prefix + + "/" + + item.get("slug", fern_slug(item.get("title", folder))) + ) + if item.get("skip-slug"): + url = prefix + for page in sorted((self.docs_root / folder).rglob("*.mdx")): + rel = page.relative_to(self.docs_root).as_posix() + stem = ( + page.relative_to(self.docs_root / folder) + .with_suffix("") + .as_posix() + ) + default = re.sub(r"/index$", "", url + "/" + stem) + how = ( + "frontmatter slug" + if frontmatter(page).get("slug") + else "folder file name" + ) + self.record(rel, self.page_url(rel, default), how) + + def check_orphans(self) -> None: + for page in sorted(self.docs_root.rglob("*.mdx")): + rel = page.relative_to(self.docs_root).as_posix() + if rel in UNPUBLISHED or rel in self.reachable or rel.startswith("_"): + continue + self.issues.append(f"{rel}: not reachable from docs/index.yml") + + def check_redirects(self, redirects: list, prefix: str) -> None: + live = set(self.urls) | {""} + for redirect in redirects or []: + source, dest = redirect.get("source", ""), redirect.get("destination", "") + if dest.startswith(prefix + "/") or dest == prefix: + target = dest[len(prefix) :].split("#", 1)[0].rstrip("/") + if ":" not in target and target not in live: + self.issues.append( + f"fern/docs.yml: redirect {source} -> {dest}: destination does not exist" + ) + if source.startswith(prefix + "/") and ":" not in source: + origin = source[len(prefix) :].rstrip("/") + if origin in live: + self.issues.append( + f"fern/docs.yml: redirect source {source} is still a live page" + ) + + +def redirect_prefix(fern_config: dict, nav_file: Path, config_dir: Path) -> str | None: + """Return the URL prefix, such as /openshell/dev, for the checked docs version.""" + instances = fern_config.get("instances") or [] + url = instances[0].get("url", "") if instances else "" + base = "/" + url.split("/", 1)[1].strip("/") if "/" in url else "" + for version in fern_config.get("versions") or []: + if (config_dir / version.get("path", "")).resolve() == nav_file.resolve(): + return f"{base}/{version['slug']}".replace("//", "/") + return None + + +def run(repo_root: Path) -> list[str]: + docs_root = repo_root / "docs" + nav_file = docs_root / "index.yml" + nav = yaml.safe_load(nav_file.read_text(encoding="utf-8")) + check = NavCheck(docs_root) + landing = (nav.get("landing-page") or {}).get("path") + if landing: + check.reachable.add(landing) + check.walk(nav.get("navigation", []), "") + check.check_orphans() + fern_file = repo_root / "fern" / "docs.yml" + if fern_file.exists(): + fern_config = yaml.safe_load(fern_file.read_text(encoding="utf-8")) + prefix = redirect_prefix(fern_config, nav_file, fern_file.parent) + if prefix: + check.check_redirects(fern_config.get("redirects"), prefix) + return check.issues + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check that the published docs navigation mirrors the docs folder structure." + ) + parser.add_argument( + "--repo-root", type=Path, default=Path(__file__).resolve().parents[2] + ) + args = parser.parse_args() + issues = run(args.repo_root) + for issue in issues: + print(f"docs nav: {issue}", file=sys.stderr) + if issues: + print(f"docs nav: {len(issues)} problem(s) found", file=sys.stderr) + return 1 + print("docs nav: every page URL matches its file path") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/check_docs_nav_test.py b/tasks/scripts/check_docs_nav_test.py new file mode 100644 index 0000000000..80cd28b2fd --- /dev/null +++ b/tasks/scripts/check_docs_nav_test.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING + +import check_docs_nav +import pytest + +if TYPE_CHECKING: + from pathlib import Path + +FERN_CONFIG = """\ +instances: + - url: example.docs.buildwithfern.com/openshell +versions: + - display-name: Dev + path: ../docs/index.yml + slug: dev +""" + + +def page(title: str, **extra: str) -> str: + lines = ( + ["---", f'title: "{title}"'] + + [f'{k}: "{v}"' for k, v in extra.items()] + + ["---", ""] + ) + return "\n".join(lines) + + +def build(tmp_path: Path, nav: str, pages: dict[str, str], redirects: str = "") -> Path: + docs = tmp_path / "docs" + for rel, content in pages.items(): + (docs / rel).parent.mkdir(parents=True, exist_ok=True) + (docs / rel).write_text(content) + (docs / "index.yml").write_text(textwrap.dedent(nav)) + (tmp_path / "fern").mkdir() + (tmp_path / "fern" / "docs.yml").write_text( + FERN_CONFIG + textwrap.dedent(redirects) + ) + return tmp_path + + +def test_fern_slug_splits_camel_case() -> None: + assert check_docs_nav.fern_slug("TypeScript") == "type-script" + assert check_docs_nav.fern_slug("Why OpenShell") == "why-open-shell" + assert check_docs_nav.fern_slug("API Errors") == "api-errors" + assert check_docs_nav.fern_slug("0.1.0") == "0-1-0" + + +def test_matching_nav_passes(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - section: "Guides" + slug: guides + path: guides/index.mdx + contents: + - page: "Setup" + path: guides/setup.mdx + - page: "Policy Prover" + path: guides/prover.mdx + slug: prover + """, + { + "guides/index.mdx": page("Guides"), + "guides/setup.mdx": page("Set Up", **{"sidebar-title": "Setup"}), + "guides/prover.mdx": page("Policy Prover"), + }, + ) + assert check_docs_nav.run(root) == [] + + +def test_label_url_that_differs_from_file_path_fails(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - section: "SDK" + slug: sdk + contents: + - page: "TypeScript" + path: sdk/typescript.mdx + """, + {"sdk/typescript.mdx": page("TypeScript")}, + ) + issues = check_docs_nav.run(root) + assert len(issues) == 1 + assert "URL /sdk/type-script" in issues[0] + assert "relative slug in docs/index.yml" in issues[0] + + +def test_frontmatter_slug_can_pin_the_url_to_the_file_path(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - section: "SDK" + slug: sdk + contents: + - page: "TypeScript" + path: sdk/typescript.mdx + """, + {"sdk/typescript.mdx": page("TypeScript", slug="sdk/typescript")}, + ) + assert check_docs_nav.run(root) == [] + + +def test_sidebar_name_must_match_nav_label(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - section: "About" + slug: about + contents: + - page: "Overview" + path: about/overview.mdx + """, + {"about/overview.mdx": page("Overview of the Product")}, + ) + issues = check_docs_nav.run(root) + assert issues == [ + "about/overview.mdx: title 'Overview of the Product' does not match nav label 'Overview'" + ] + + +@pytest.mark.parametrize( + ("nav", "rel"), + [ + ( + """ + navigation: + - section: Guides + slug: ignored + skip-slug: true + contents: + - page: Setup + path: setup.mdx + """, + "setup.mdx", + ), + ( + """ + navigation: + - section: Guides + contents: + - page: Setup + slug: ignored + skip-slug: true + path: guides/index.mdx + """, + "guides/index.mdx", + ), + ( + """ + navigation: + - section: Guides + contents: + - folder: guides + slug: ignored + skip-slug: true + """, + "guides/setup.mdx", + ), + ], +) +def test_skip_slug_omits_navigation_level(tmp_path: Path, nav: str, rel: str) -> None: + root = build(tmp_path, nav, {rel: page("Setup")}) + assert check_docs_nav.run(root) == [] + + +def test_skip_slug_detects_url_that_differs_from_file_path(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - section: Guides + skip-slug: true + contents: + - page: Setup + path: guides/setup.mdx + """, + {"guides/setup.mdx": page("Setup")}, + ) + issues = check_docs_nav.run(root) + assert len(issues) == 1 + assert "URL /setup" in issues[0] + assert "does not match its file path /guides/setup" in issues[0] + + +def test_folder_pages_use_file_names_and_orphans_fail(tmp_path: Path) -> None: + root = build( + tmp_path, + """ + navigation: + - folder: kubernetes + title: "Kubernetes" + """, + { + "kubernetes/openshift.mdx": page("OpenShift"), + "stray.mdx": page("Stray"), + }, + ) + assert check_docs_nav.run(root) == ["stray.mdx: not reachable from docs/index.yml"] + + +@pytest.mark.parametrize( + ("redirect", "expected"), + [ + ( + ' - source: "/openshell/dev/old"\n destination: "/openshell/dev/missing"\n', + "destination does not exist", + ), + ( + ' - source: "/openshell/dev/guides/setup"\n destination: "/openshell/dev/guides/setup"\n', + "is still a live page", + ), + ], +) +def test_dev_redirects_must_point_to_live_pages( + tmp_path: Path, redirect: str, expected: str +) -> None: + root = build( + tmp_path, + """ + navigation: + - section: "Guides" + slug: guides + contents: + - page: "Setup" + path: guides/setup.mdx + """, + {"guides/setup.mdx": page("Setup")}, + "redirects:\n" + redirect, + ) + issues = check_docs_nav.run(root) + assert len(issues) == 1 and expected in issues[0] diff --git a/tasks/test.toml b/tasks/test.toml index 39dd070204..c45826f1db 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -20,6 +20,7 @@ depends = [ "test:qualification-summary", "test:codex-security-release-range", "test:docs-website", + "test:docs-nav", ] ["test:docs-website"] @@ -28,6 +29,10 @@ description = "Test the docs-website sync script" # dependencies and the script's runtime dependency, which live outside the project env. run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +["test:docs-nav"] +description = "Test the docs navigation check" +run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py" + ["test:sbom"] description = "Run SBOM tooling tests" run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" deploy/sbom/"