diff --git a/.agents/skills/datadesigner-docs/SKILL.md b/.agents/skills/datadesigner-docs/SKILL.md
index df9a4effd..139c7dfb1 100644
--- a/.agents/skills/datadesigner-docs/SKILL.md
+++ b/.agents/skills/datadesigner-docs/SKILL.md
@@ -425,7 +425,7 @@ fern check # YAML + frontmatter + MDX validation
fern docs dev # localhost:3000 hot-reload preview
```
-`fern check` must pass before commit. The local broken-link checker has known false positives — it computes URLs from file paths instead of from slugified nav titles, so cross-section absolute links sometimes flag incorrectly. Spot-check by clicking through the dev server.
+`fern check` must pass before commit. `make check-fern-docs` also validates internal links and fragments in `latest` against URLs derived from its active Fern navigation titles and configured redirects. Historical release snapshots on `docs-website` are intentionally excluded from this authoring check. Internal-link failures are actionable; external URLs are intentionally outside this deterministic build check.
## Commit & Preview
diff --git a/Makefile b/Makefile
index 40869ae18..f6c0b4cba 100644
--- a/Makefile
+++ b/Makefile
@@ -85,7 +85,8 @@ help:
@echo " prepare-fern-release VERSION=X.Y.Z - Add or refresh Fern version files for release preview"
@echo " check-fern-release-version VERSION=X.Y.Z - Verify Fern has a version entry for release publishing"
@echo " prepare-fern-docs - Generate local Fern artifacts"
- @echo " check-fern-docs - Generate local Fern artifacts and run fern check"
+ @echo " check-fern-links - Validate latest navigation-derived internal Fern links"
+ @echo " check-fern-docs - Generate artifacts, validate links, and run fern check"
@echo " check-fern-docs-locally - Install deps, generate Fern artifacts, and run fern check"
@echo " serve-fern-docs-locally - Generate local Fern artifacts and serve Fern docs"
@echo " check-license-headers - Check if all files have license headers"
@@ -507,7 +508,11 @@ endif
prepare-fern-docs: generate-fern-notebooks
@echo "✅ Fern local artifacts ready"
+check-fern-links:
+ $(DOCS_PYTHON) fern/scripts/check-internal-links.py --root fern --version latest
+
check-fern-docs: prepare-fern-docs
+ @$(MAKE) check-fern-links
cd fern && $(FERN) check
check-fern-docs-locally:
@@ -753,7 +758,7 @@ clean-test-coverage:
.PHONY: bench-cli-startup bench-cli-startup-verbose \
build build-config build-engine build-interface \
check-all check-all-fix check-config check-engine check-interface \
- check-dependency-licenses check-fern-docs check-fern-docs-locally check-fern-release-version check-fern-theme-access check-license-headers \
+ check-dependency-licenses check-fern-docs check-fern-docs-locally check-fern-links check-fern-release-version check-fern-theme-access check-license-headers \
clean clean-dist clean-notebooks clean-pycache clean-test-coverage \
convert-execute-notebooks \
coverage coverage-config coverage-engine coverage-interface \
diff --git a/docs/colab_notebooks/3-seeding-with-a-dataset.ipynb b/docs/colab_notebooks/3-seeding-with-a-dataset.ipynb
index c1af8d2b4..616c79404 100644
--- a/docs/colab_notebooks/3-seeding-with-a-dataset.ipynb
+++ b/docs/colab_notebooks/3-seeding-with-a-dataset.ipynb
@@ -196,7 +196,7 @@
"\n",
"- We will _seed_ the generation process with a [symptom-to-diagnosis dataset](https://huggingface.co/datasets/gretelai/symptom_to_diagnosis).\n",
"\n",
- "- We already have the dataset downloaded in the [data](../data) directory of this repository.\n",
+ "- The notebook downloads the source CSV before generation.\n",
"\n",
"
\n",
"\n",
diff --git a/docs/notebook_source/3-seeding-with-a-dataset.py b/docs/notebook_source/3-seeding-with-a-dataset.py
index 10c6fb64d..e6784ea96 100644
--- a/docs/notebook_source/3-seeding-with-a-dataset.py
+++ b/docs/notebook_source/3-seeding-with-a-dataset.py
@@ -101,7 +101,7 @@
#
# - We will _seed_ the generation process with a [symptom-to-diagnosis dataset](https://huggingface.co/datasets/gretelai/symptom_to_diagnosis).
#
-# - We already have the dataset downloaded in the [data](../data) directory of this repository.
+# - The notebook downloads the source CSV before generation.
#
#
#
diff --git a/fern/README.md b/fern/README.md
index af7704e0a..c1d3e9ed0 100644
--- a/fern/README.md
+++ b/fern/README.md
@@ -159,7 +159,7 @@ Primary local commands:
| Command | Purpose |
|---------|---------|
-| `make check-fern-docs-locally` | Install docs dependencies, generate Fern artifacts, and run `fern check` |
+| `make check-fern-docs-locally` | Install docs dependencies, generate Fern artifacts, validate internal links, and run `fern check` |
| `make serve-fern-docs-locally` | Generate local Fern artifacts and serve local docs |
| `make generate-fern-notebooks-with-outputs` | Full notebook pipeline: execute (needs `NVIDIA_API_KEY`) → colabify → convert |
| `make prepare-fern-release VERSION=X.Y.Z` | Add or refresh Fern version files for release preview |
@@ -172,7 +172,8 @@ Support and CI targets:
| `make install-docs-deps` | Install docs and notebook dependencies |
| `make generate-fern-notebooks` | Refresh gitignored notebook output from `docs/notebook_source/*.py` |
| `make prepare-fern-docs` | Generate local Fern notebook artifacts |
-| `make check-fern-docs` | Generate local Fern notebook artifacts and run `fern check` |
+| `make check-fern-links` | Validate internal routes and fragments in `latest` against navigation-derived Fern URLs and configured redirects |
+| `make check-fern-docs` | Generate local Fern notebook artifacts, validate internal links, and run `fern check` |
Raw Fern CLI commands, normally wrapped by Make:
diff --git a/fern/scripts/check-internal-links.py b/fern/scripts/check-internal-links.py
new file mode 100644
index 000000000..e60a099ee
--- /dev/null
+++ b/fern/scripts/check-internal-links.py
@@ -0,0 +1,351 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import html
+import posixpath
+import re
+import sys
+import unicodedata
+import urllib.parse
+from collections import defaultdict
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+MARKDOWN_LINK_PATTERN = re.compile(
+ r"(?[^>\n]+)>|(?P[^)\s]+))"
+ r"(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)",
+ re.MULTILINE,
+)
+HREF_PATTERN = re.compile(r"\bhref\s*=\s*(?:\{\s*)?([\"'])(?P.+?)\1\s*\}?", re.MULTILINE)
+EXPLICIT_ID_PATTERN = re.compile(r"\bid\s*=\s*(?:\{\s*)?([\"'])(?P.+?)\1\s*\}?")
+HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(?P.+?)\s*#*\s*$", re.MULTILINE)
+FENCE_PATTERN = re.compile(r"^\s{0,3}(?P`{3,}|~{3,})")
+INLINE_CODE_PATTERN = re.compile(r"(?]+>|!?(?:\[([^\]]*)\]\([^)]+\))")
+EXTERNAL_SCHEMES = {"http", "https", "mailto", "tel", "data", "javascript"}
+DOCS_HOSTS = {"docs.nvidia.com", "datadesigner.docs.buildwithfern.com"}
+
+
+@dataclass
+class Page:
+ source: Path
+ route: str
+ version_slug: str
+ anchors: set[str]
+
+
+@dataclass(frozen=True)
+class Link:
+ source: Path
+ line: int
+ source_route: str
+ target: str
+
+
+@dataclass(frozen=True)
+class LinkError:
+ link: Link
+ message: str
+
+
+@dataclass
+class DocsIndex:
+ root: Path
+ base_path: str
+ pages: list[Page] = field(default_factory=list)
+ routes: dict[str, Page] = field(default_factory=dict)
+ redirects: list[str] = field(default_factory=list)
+
+
+def slugify(value: str) -> str:
+ """Approximate Fern's title-to-route conversion."""
+ normalized = unicodedata.normalize("NFKD", html.unescape(value)).encode("ascii", "ignore").decode()
+ normalized = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", normalized)
+ normalized = re.sub(r"['’]", "", normalized.casefold())
+ return re.sub(r"[^a-z0-9]+", "-", normalized).strip("-")
+
+
+def anchor_slugify(value: str) -> str:
+ """Convert a rendered heading to the anchor format used by Fern."""
+ normalized = unicodedata.normalize("NFKD", html.unescape(value)).encode("ascii", "ignore").decode()
+ normalized = re.sub(r"['’]", "", normalized.casefold())
+ return re.sub(r"[^a-z0-9]+", "-", normalized).strip("-")
+
+
+def strip_code(text: str) -> str:
+ """Mask fenced and inline code while preserving offsets and line numbers."""
+ masked_lines: list[str] = []
+ active_fence: str | None = None
+ for line in text.splitlines(keepends=True):
+ fence_match = FENCE_PATTERN.match(line)
+ if fence_match:
+ marker = fence_match.group("fence")
+ if active_fence is None:
+ active_fence = marker[0]
+ elif marker[0] == active_fence:
+ active_fence = None
+ masked_lines.append("".join("\n" if character == "\n" else " " for character in line))
+ elif active_fence is not None:
+ masked_lines.append("".join("\n" if character == "\n" else " " for character in line))
+ else:
+ masked_lines.append(line)
+ return INLINE_CODE_PATTERN.sub(lambda match: " " * len(match.group(0)), "".join(masked_lines))
+
+
+def heading_anchor(title: str) -> str:
+ title = MARKDOWN_DECORATION_PATTERN.sub(lambda match: match.group(1) or "", title)
+ return anchor_slugify(title)
+
+
+def extract_anchors(text: str) -> set[str]:
+ masked = strip_code(text)
+ anchors = {match.group("identifier") for match in EXPLICIT_ID_PATTERN.finditer(masked)}
+ counts: dict[str, int] = defaultdict(int)
+ for match in HEADING_PATTERN.finditer(masked):
+ anchor = heading_anchor(match.group("title"))
+ if not anchor:
+ continue
+ duplicate = counts[anchor]
+ anchors.add(anchor if duplicate == 0 else f"{anchor}-{duplicate}")
+ counts[anchor] += 1
+ return anchors
+
+
+def extract_links(source: Path, source_route: str) -> list[Link]:
+ text = source.read_text(encoding="utf-8")
+ masked = strip_code(text)
+ matches: list[tuple[int, str]] = []
+ for match in MARKDOWN_LINK_PATTERN.finditer(masked):
+ matches.append((match.start(), match.group("angle") or match.group("plain")))
+ for match in HREF_PATTERN.finditer(masked):
+ matches.append((match.start(), match.group("target")))
+ return [
+ Link(
+ source=source,
+ line=text.count("\n", 0, offset) + 1,
+ source_route=source_route,
+ target=html.unescape(target),
+ )
+ for offset, target in sorted(set(matches))
+ ]
+
+
+def version_routes(version_config: dict[str, Any], version_slug: str, config_path: Path) -> list[Page]:
+ pages: list[Page] = []
+
+ def walk(items: list[dict[str, Any]], parents: tuple[str, ...]) -> None:
+ for item in items:
+ if "section" in item:
+ section_parents = parents
+ if not item.get("skip-slug", False):
+ section_parents += (item.get("slug") or slugify(str(item["section"])),)
+ walk(item.get("contents", []), section_parents)
+ continue
+ if "page" not in item or "path" not in item:
+ continue
+ page_slug = item.get("slug") or slugify(str(item["page"]))
+ relative_route = "/" + "/".join((*parents, page_slug))
+ route = relative_route if version_slug == "latest" else f"/{version_slug}{relative_route}"
+ source = (config_path.parent / str(item["path"])).resolve()
+ anchors = extract_anchors(source.read_text(encoding="utf-8")) if source.is_file() else set()
+ pages.append(Page(source=source, route=route, version_slug=version_slug, anchors=anchors))
+
+ walk(version_config.get("navigation", []), ())
+ return pages
+
+
+def docs_base_path(docs_config: dict[str, Any]) -> str:
+ instances = docs_config.get("instances", [])
+ if not instances:
+ return ""
+ instance_url = str(instances[0].get("custom-domain") or instances[0].get("url") or "")
+ parsed = urllib.parse.urlsplit(instance_url if "://" in instance_url else f"https://{instance_url}")
+ return parsed.path.rstrip("/")
+
+
+def build_index(root: Path, version_slugs: set[str] | None = None) -> DocsIndex:
+ root = root.resolve()
+ docs_config = yaml.safe_load((root / "docs.yml").read_text(encoding="utf-8"))
+ index = DocsIndex(root=root, base_path=docs_base_path(docs_config))
+ configured_versions = docs_config.get("versions", [])
+ configured_slugs = {str(version["slug"]) for version in configured_versions}
+ if version_slugs is not None:
+ unknown_slugs = version_slugs - configured_slugs
+ if unknown_slugs:
+ raise ValueError(f"unknown Fern version slug(s): {', '.join(sorted(unknown_slugs))}")
+ for version in configured_versions:
+ if version_slugs is not None and str(version["slug"]) not in version_slugs:
+ continue
+ version_config_path = (root / str(version["path"])).resolve()
+ version_config = yaml.safe_load(version_config_path.read_text(encoding="utf-8"))
+ pages = version_routes(version_config, str(version["slug"]), version_config_path)
+ index.pages.extend(pages)
+ for page in pages:
+ index.routes[page.route] = page
+ if page.version_slug == "latest":
+ index.routes[f"/latest{page.route}"] = page
+ latest_pages = [page for page in index.pages if page.version_slug == "latest"]
+ if latest_pages:
+ index.routes["/"] = latest_pages[0]
+ index.routes["/latest"] = latest_pages[0]
+ index.redirects = [
+ normalize_configured_path(str(item["source"]), index.base_path) for item in docs_config.get("redirects", [])
+ ]
+ return index
+
+
+def normalize_configured_path(path: str, base_path: str) -> str:
+ parsed = urllib.parse.urlsplit(path)
+ if parsed.scheme or parsed.netloc:
+ return path
+ normalized = parsed.path or "/"
+ if base_path and (normalized == base_path or normalized.startswith(f"{base_path}/")):
+ normalized = normalized[len(base_path) :] or "/"
+ return normalized.rstrip("/") or "/"
+
+
+def redirect_matches(path: str, redirect: str) -> bool:
+ expression = re.escape(redirect)
+ expression = re.sub(r":([A-Za-z][A-Za-z0-9_]*)\\\*", r".*", expression)
+ expression = re.sub(r":([A-Za-z][A-Za-z0-9_]*)", r"[^/]+", expression)
+ return re.fullmatch(expression, path) is not None
+
+
+def normalize_target(link: Link, index: DocsIndex) -> tuple[str, str] | None:
+ parsed = urllib.parse.urlsplit(link.target)
+ if parsed.scheme in EXTERNAL_SCHEMES:
+ if parsed.scheme not in {"http", "https"} or parsed.hostname not in DOCS_HOSTS:
+ return None
+ path = parsed.path
+ elif parsed.scheme or parsed.netloc:
+ return None
+ else:
+ path = parsed.path
+ if not path:
+ normalized_path = link.source_route
+ elif path.startswith("/"):
+ normalized_path = normalize_configured_path(path, index.base_path)
+ else:
+ normalized_path = posixpath.normpath(posixpath.join(posixpath.dirname(link.source_route), path))
+ if not normalized_path.startswith("/"):
+ normalized_path = f"/{normalized_path}"
+ return normalized_path.rstrip("/") or "/", urllib.parse.unquote(parsed.fragment)
+
+
+def asset_exists(path: str, index: DocsIndex) -> bool:
+ relative_path = path.lstrip("/")
+ if not relative_path or relative_path.startswith("."):
+ return False
+ return (index.root / relative_path).is_file()
+
+
+def closest_route(path: str, routes: dict[str, Page]) -> str | None:
+ matches = difflib.get_close_matches(path, routes, n=1, cutoff=0.55)
+ return matches[0] if matches else None
+
+
+def validate_links(index: DocsIndex, extra_sources: list[tuple[Path, str]] | None = None) -> list[LinkError]:
+ sources = [(page.source, page.route) for page in index.pages]
+ if extra_sources:
+ sources.extend(extra_sources)
+ errors: list[LinkError] = []
+ seen: set[tuple[Path, int, str]] = set()
+ for source, source_route in sources:
+ if not source.is_file():
+ errors.append(LinkError(Link(source, 1, source_route, ""), "navigation points to a missing source file"))
+ continue
+ for link in extract_links(source, source_route):
+ identity = (link.source, link.line, link.target)
+ if identity in seen:
+ continue
+ seen.add(identity)
+ normalized = normalize_target(link, index)
+ if normalized is None:
+ continue
+ target_path, fragment = normalized
+ page = index.routes.get(target_path)
+ if page is None:
+ if asset_exists(target_path, index) or any(
+ redirect_matches(target_path, redirect) for redirect in index.redirects
+ ):
+ continue
+ suggestion = closest_route(target_path, index.routes)
+ message = f"internal target does not exist: {target_path}"
+ if suggestion:
+ message += f" (did you mean {suggestion}?)"
+ errors.append(LinkError(link, message))
+ continue
+ if fragment and fragment not in page.anchors:
+ errors.append(LinkError(link, f"fragment does not exist on {target_path}: #{fragment}"))
+ return errors
+
+
+def notebook_markdown(text: str) -> str:
+ markdown_lines: list[str] = []
+ in_markdown_cell = False
+ for line in text.splitlines(keepends=True):
+ if line.startswith("# %%"):
+ in_markdown_cell = "[markdown]" in line
+ continue
+ if not in_markdown_cell:
+ continue
+ if line.startswith("# "):
+ markdown_lines.append(line[2:])
+ elif line.startswith("#"):
+ markdown_lines.append(line[1:])
+ return "".join(markdown_lines)
+
+
+def notebook_sources(index: DocsIndex, repository_root: Path) -> list[tuple[Path, str]]:
+ wrapper_pages = {page.source.stem: page for page in index.pages if "/notebooks/" in page.source.as_posix()}
+ sources: list[tuple[Path, str]] = []
+ for source in sorted((repository_root / "docs/notebook_source").glob("*.py")):
+ wrapper_stem = "README" if source.stem in {"README", "_README"} else source.stem
+ page = wrapper_pages.get(wrapper_stem)
+ if page:
+ text = source.read_text(encoding="utf-8")
+ page.anchors.update(extract_anchors(notebook_markdown(text)))
+ sources.append((source, page.route))
+ return sources
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Validate internal links against Fern navigation-derived routes.")
+ parser.add_argument("--root", type=Path, default=Path(__file__).parents[1], help="Fern documentation root")
+ parser.add_argument(
+ "--version",
+ dest="versions",
+ action="append",
+ help="Fern version slug to validate (repeatable; validates all configured versions when omitted)",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ index = build_index(args.root, set(args.versions) if args.versions else None)
+ except ValueError as error:
+ print(error, file=sys.stderr)
+ return 2
+ repository_root = index.root.parent
+ errors = validate_links(index, notebook_sources(index, repository_root))
+ if not errors:
+ print(f"Checked internal links across {len(index.pages)} Fern pages.")
+ return 0
+ print(f"Found {len(errors)} broken internal link(s):", file=sys.stderr)
+ for error in errors:
+ relative_source = error.link.source.relative_to(repository_root)
+ print(f" {relative_source}:{error.link.line}: {error.message} [{error.link.target}]", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/fern/scripts/tests/test_check_internal_links.py b/fern/scripts/tests/test_check_internal_links.py
new file mode 100644
index 000000000..49c9796b4
--- /dev/null
+++ b/fern/scripts/tests/test_check_internal_links.py
@@ -0,0 +1,215 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+import yaml
+
+SCRIPT_PATH = Path(__file__).parents[1] / "check-internal-links.py"
+SPEC = importlib.util.spec_from_file_location("check_internal_links", SCRIPT_PATH)
+assert SPEC is not None and SPEC.loader is not None
+check_internal_links = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = check_internal_links
+SPEC.loader.exec_module(check_internal_links)
+
+
+@pytest.fixture
+def docs_root(tmp_path: Path) -> Path:
+ root = tmp_path / "fern"
+ pages = root / "versions/latest/pages"
+ pages.mkdir(parents=True)
+ (root / "docs.yml").write_text(
+ yaml.safe_dump(
+ {
+ "instances": [{"custom-domain": "docs.example.com/nemo/datadesigner"}],
+ "versions": [{"display-name": "Latest", "path": "versions/latest.yml", "slug": "latest"}],
+ "redirects": [
+ {
+ "source": "/nemo/datadesigner/old/:path*",
+ "destination": "/nemo/datadesigner/concepts/columns",
+ }
+ ],
+ },
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
+ (root / "versions/latest.yml").write_text(
+ yaml.safe_dump(
+ {
+ "navigation": [
+ {
+ "section": "Concepts",
+ "contents": [
+ {"page": "Columns", "path": "./latest/pages/columns.mdx"},
+ {
+ "section": "Tool Use & MCP",
+ "contents": [{"page": "Safety & Limits", "path": "./latest/pages/safety.mdx"}],
+ },
+ ],
+ },
+ {
+ "section": "Tutorials",
+ "contents": [{"page": "The Basics", "path": "./latest/pages/notebooks/the-basics.mdx"}],
+ },
+ {
+ "section": "Dev Notes",
+ "contents": [
+ {
+ "section": "Older Posts",
+ "skip-slug": True,
+ "contents": [{"page": "Design Principles", "path": "./latest/pages/design.mdx"}],
+ }
+ ],
+ },
+ ]
+ },
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
+ (pages / "columns.mdx").write_text(
+ "# Columns\n\n## LLM columns\n\n[Safety](/concepts/tool-use-mcp/safety-limits#budgets)\n",
+ encoding="utf-8",
+ )
+ (pages / "safety.mdx").write_text("# Safety & Limits\n\n## Budgets\n", encoding="utf-8")
+ (pages / "design.mdx").write_text("# Design Principles\n", encoding="utf-8")
+ (pages / "notebooks").mkdir()
+ (pages / "notebooks/the-basics.mdx").write_text("# The Basics\n", encoding="utf-8")
+ return root
+
+
+@pytest.mark.parametrize(
+ ("title", "expected"),
+ [
+ ("Architecture & Performance", "architecture-performance"),
+ ("Tool Use & MCP", "tool-use-mcp"),
+ ("Text-to-SQL for Nemotron Super", "text-to-sql-for-nemotron-super"),
+ ("FileSystemSeedReader Plugins", "file-system-seed-reader-plugins"),
+ ("What's New?", "whats-new"),
+ ],
+)
+def test_slugify_matches_fern_routes(title: str, expected: str) -> None:
+ assert check_internal_links.slugify(title) == expected
+
+
+def test_anchor_slugify_preserves_camel_case_boundaries() -> None:
+ assert check_internal_links.anchor_slugify("LocalStdioMCPProvider (Subprocess)") == (
+ "localstdiomcpprovider-subprocess"
+ )
+
+
+def test_build_index_uses_navigation_titles_and_skip_slug(docs_root: Path) -> None:
+ index = check_internal_links.build_index(docs_root)
+
+ assert "/concepts/columns" in index.routes
+ assert "/concepts/tool-use-mcp/safety-limits" in index.routes
+ assert "/dev-notes/design-principles" in index.routes
+ assert "/dev-notes/older-posts/design-principles" not in index.routes
+ assert "/latest/concepts/columns" in index.routes
+ assert index.routes["/"].route == "/concepts/columns"
+
+
+def test_build_index_can_limit_validation_to_latest(docs_root: Path) -> None:
+ historical_pages = docs_root / "versions/v0.6.0/pages"
+ historical_pages.mkdir(parents=True)
+ (historical_pages / "legacy.mdx").write_text("# Legacy\n\n[Broken](/removed-page)\n", encoding="utf-8")
+ historical_config = docs_root / "versions/v0.6.0.yml"
+ historical_config.write_text(
+ yaml.safe_dump(
+ {"navigation": [{"page": "Legacy", "path": "./v0.6.0/pages/legacy.mdx"}]},
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
+ docs_config_path = docs_root / "docs.yml"
+ docs_config = yaml.safe_load(docs_config_path.read_text(encoding="utf-8"))
+ docs_config["versions"].append({"display-name": "0.6.0", "path": "versions/v0.6.0.yml", "slug": "v0.6.0"})
+ docs_config_path.write_text(yaml.safe_dump(docs_config, sort_keys=False), encoding="utf-8")
+
+ latest_index = check_internal_links.build_index(docs_root, {"latest"})
+
+ assert {page.version_slug for page in latest_index.pages} == {"latest"}
+ assert check_internal_links.validate_links(latest_index) == []
+ assert check_internal_links.validate_links(check_internal_links.build_index(docs_root))
+
+
+def test_build_index_rejects_unknown_version(docs_root: Path) -> None:
+ with pytest.raises(ValueError, match="unknown Fern version slug"):
+ check_internal_links.build_index(docs_root, {"missing"})
+
+
+def test_validate_links_accepts_routes_fragments_relative_links_and_redirects(docs_root: Path) -> None:
+ columns = docs_root / "versions/latest/pages/columns.mdx"
+ columns.write_text(
+ "# Columns\n\n"
+ "[Current fragment](#llm-columns)\n\n"
+ "## LLM columns\n\n"
+ "[Nested route](/concepts/tool-use-mcp/safety-limits#budgets)\n\n"
+ "[Version route](/latest/concepts/columns)\n\n"
+ "[Base-prefixed route](/nemo/datadesigner/concepts/columns)\n\n"
+ "[Configured redirect](/old/legacy-page)\n",
+ encoding="utf-8",
+ )
+ index = check_internal_links.build_index(docs_root)
+
+ assert check_internal_links.validate_links(index) == []
+
+
+def test_validate_links_reports_missing_route_with_suggestion(docs_root: Path) -> None:
+ columns = docs_root / "versions/latest/pages/columns.mdx"
+ columns.write_text("# Columns\n\n[Broken](/concepts/tool-use-and-mcp/safety-and-limits)\n", encoding="utf-8")
+ index = check_internal_links.build_index(docs_root)
+
+ errors = check_internal_links.validate_links(index)
+
+ assert len(errors) == 1
+ assert errors[0].link.line == 3
+ assert "internal target does not exist" in errors[0].message
+ assert "/concepts/tool-use-mcp/safety-limits" in errors[0].message
+
+
+def test_validate_links_reports_missing_fragment(docs_root: Path) -> None:
+ columns = docs_root / "versions/latest/pages/columns.mdx"
+ columns.write_text("# Columns\n\n[Broken](#missing)\n", encoding="utf-8")
+ index = check_internal_links.build_index(docs_root)
+
+ errors = check_internal_links.validate_links(index)
+
+ assert len(errors) == 1
+ assert errors[0].message == "fragment does not exist on /concepts/columns: #missing"
+
+
+def test_validate_links_ignores_code_blocks_and_external_urls(docs_root: Path) -> None:
+ columns = docs_root / "versions/latest/pages/columns.mdx"
+ columns.write_text(
+ "# Columns\n\n[External](https://example.com/missing)\n\n```markdown\n[Example](/does-not-exist)\n```\n",
+ encoding="utf-8",
+ )
+ index = check_internal_links.build_index(docs_root)
+
+ assert check_internal_links.validate_links(index) == []
+
+
+def test_notebook_sources_add_generated_heading_anchors(docs_root: Path, tmp_path: Path) -> None:
+ notebook_source = tmp_path / "docs/notebook_source/the-basics.py"
+ notebook_source.parent.mkdir(parents=True)
+ notebook_source.write_text("# %% [markdown]\n# ## Generated heading\n", encoding="utf-8")
+ columns = docs_root / "versions/latest/pages/columns.mdx"
+ columns.write_text("# Columns\n\n[Notebook heading](/tutorials/the-basics#generated-heading)\n", encoding="utf-8")
+ index = check_internal_links.build_index(docs_root)
+
+ extra_sources = check_internal_links.notebook_sources(index, tmp_path)
+
+ assert check_internal_links.validate_links(index, extra_sources) == []
+
+
+def test_notebook_markdown_extracts_only_markdown_cells() -> None:
+ source = "# %% [markdown]\n# ## Included heading\n#\n# Text\n# %%\n# ## Python comment\nvalue = 1\n"
+
+ assert check_internal_links.notebook_markdown(source) == "## Included heading\n\nText\n"
diff --git a/fern/versions/latest/pages/concepts/columns.mdx b/fern/versions/latest/pages/concepts/columns.mdx
index 31fce016d..4b46a87f1 100644
--- a/fern/versions/latest/pages/concepts/columns.mdx
+++ b/fern/versions/latest/pages/concepts/columns.mdx
@@ -82,12 +82,12 @@ dd.LLMTextColumnConfig(
)
```
-When `tool_alias` is set, the model can request tool calls during generation. Data Designer executes the tools via configured MCP providers and feeds results back until the model produces a final answer. See [Tool Use & MCP](/concepts/tool-use-and-mcp/overview) for full configuration details.
+When `tool_alias` is set, the model can request tool calls during generation. Data Designer executes the tools via configured MCP providers and feeds results back until the model produces a final answer. See [Tool Use & MCP](/concepts/tool-use-mcp/overview) for full configuration details.
Performance
-LLM columns are parallelized within each batch using `max_parallel_requests` from your model's inference parameters. See the [Architecture & Performance](/concepts/architecture-and-performance) guide for optimization strategies.
+LLM columns are parallelized within each batch using `max_parallel_requests` from your model's inference parameters. See the [Architecture & Performance](/concepts/architecture-performance) guide for optimization strategies.
### 💻 LLM-Code Columns
diff --git a/fern/versions/latest/pages/concepts/mcp/configure-mcp-cli.mdx b/fern/versions/latest/pages/concepts/mcp/configure-mcp-cli.mdx
index fe0b52ea6..fc70a402a 100644
--- a/fern/versions/latest/pages/concepts/mcp/configure-mcp-cli.mdx
+++ b/fern/versions/latest/pages/concepts/mcp/configure-mcp-cli.mdx
@@ -139,6 +139,6 @@ After manual edits, the changes take effect the next time you initialize `DataDe
## See Also
-- **[MCP Providers](/concepts/tool-use-and-mcp/mcp-providers)**: Learn about provider configuration options
-- **[Tool Configurations](/concepts/tool-use-and-mcp/tool-configs)**: Learn about tool config options
+- **[MCP Providers](/concepts/tool-use-mcp/mcp-providers)**: Learn about provider configuration options
+- **[Tool Configurations](/concepts/tool-use-mcp/tool-configs)**: Learn about tool config options
- **[Configure Model Settings with the CLI](/concepts/models/configure-with-the-cli)**: CLI guide for model configuration
diff --git a/fern/versions/latest/pages/concepts/mcp/enabling-tools.mdx b/fern/versions/latest/pages/concepts/mcp/enabling-tools.mdx
index 0113d3d2b..90639717e 100644
--- a/fern/versions/latest/pages/concepts/mcp/enabling-tools.mdx
+++ b/fern/versions/latest/pages/concepts/mcp/enabling-tools.mdx
@@ -103,6 +103,6 @@ results = data_designer.preview(builder, num_records=5)
## See Also
-- **[Tool Configurations](/concepts/tool-use-and-mcp/tool-configs)**: Configure tool access and limits
+- **[Tool Configurations](/concepts/tool-use-mcp/tool-configs)**: Configure tool access and limits
- **[Message Traces](/concepts/traces)**: Capture and inspect tool call history
-- **[MCP Providers](/concepts/tool-use-and-mcp/mcp-providers)**: Configure MCP server connections
+- **[MCP Providers](/concepts/tool-use-mcp/mcp-providers)**: Configure MCP server connections
diff --git a/fern/versions/latest/pages/concepts/mcp/mcp-providers.mdx b/fern/versions/latest/pages/concepts/mcp/mcp-providers.mdx
index b4a67958c..ccb1a9ea3 100644
--- a/fern/versions/latest/pages/concepts/mcp/mcp-providers.mdx
+++ b/fern/versions/latest/pages/concepts/mcp/mcp-providers.mdx
@@ -157,6 +157,6 @@ data_designer = DataDesigner(mcp_providers=providers)
## See Also
-- **[Tool Configurations](/concepts/tool-use-and-mcp/tool-configs)**: Configure tool access with ToolConfig
-- **[Configure MCP with the CLI](/concepts/tool-use-and-mcp/cli-configuration)**: Use the CLI to manage MCP providers
-- **[Enabling Tools on Columns](/concepts/tool-use-and-mcp/enabling-tools)**: Use tools in LLM columns
+- **[Tool Configurations](/concepts/tool-use-mcp/tool-configs)**: Configure tool access with ToolConfig
+- **[Configure MCP with the CLI](/concepts/tool-use-mcp/cli-configuration)**: Use the CLI to manage MCP providers
+- **[Enabling Tools on Columns](/concepts/tool-use-mcp/enabling-tools)**: Use tools in LLM columns
diff --git a/fern/versions/latest/pages/concepts/mcp/safety-and-limits.mdx b/fern/versions/latest/pages/concepts/mcp/safety-and-limits.mdx
index 0257780d4..554f4b8e9 100644
--- a/fern/versions/latest/pages/concepts/mcp/safety-and-limits.mdx
+++ b/fern/versions/latest/pages/concepts/mcp/safety-and-limits.mdx
@@ -145,5 +145,5 @@ tool_config = dd.ToolConfig(
## See Also
-- **[Tool Configurations](/concepts/tool-use-and-mcp/tool-configs)**: Complete ToolConfig reference
+- **[Tool Configurations](/concepts/tool-use-mcp/tool-configs)**: Complete ToolConfig reference
- **[Message Traces](/concepts/traces)**: Monitor tool usage patterns
diff --git a/fern/versions/latest/pages/concepts/mcp/tool-configs.mdx b/fern/versions/latest/pages/concepts/mcp/tool-configs.mdx
index e83e5d743..5d14bfa71 100644
--- a/fern/versions/latest/pages/concepts/mcp/tool-configs.mdx
+++ b/fern/versions/latest/pages/concepts/mcp/tool-configs.mdx
@@ -111,7 +111,7 @@ When the turn limit is reached, Data Designer gracefully refuses additional tool
## See Also
-- **[MCP Providers](/concepts/tool-use-and-mcp/mcp-providers)**: Configure connections to MCP servers
-- **[Enabling Tools on Columns](/concepts/tool-use-and-mcp/enabling-tools)**: Reference tool configs from LLM columns
-- **[Safety and Limits](/concepts/tool-use-and-mcp/safety-and-limits)**: Detailed guide on tool safety controls
-- **[Configure MCP with the CLI](/concepts/tool-use-and-mcp/cli-configuration)**: Use the CLI to manage tool configurations
+- **[MCP Providers](/concepts/tool-use-mcp/mcp-providers)**: Configure connections to MCP servers
+- **[Enabling Tools on Columns](/concepts/tool-use-mcp/enabling-tools)**: Reference tool configs from LLM columns
+- **[Safety and Limits](/concepts/tool-use-mcp/safety-limits)**: Detailed guide on tool safety controls
+- **[Configure MCP with the CLI](/concepts/tool-use-mcp/cli-configuration)**: Use the CLI to manage tool configurations
diff --git a/fern/versions/latest/pages/concepts/models/inference-parameters.mdx b/fern/versions/latest/pages/concepts/models/inference-parameters.mdx
index 0ce05be58..90131dc44 100644
--- a/fern/versions/latest/pages/concepts/models/inference-parameters.mdx
+++ b/fern/versions/latest/pages/concepts/models/inference-parameters.mdx
@@ -129,7 +129,7 @@ The `max_parallel_requests` parameter controls how many concurrent API calls Dat
Performance Tuning
-For recommended values by deployment type (NVIDIA API Catalog, vLLM, OpenAI, NIMs) and detailed optimization strategies, see the [Architecture & Performance](/concepts/architecture-and-performance) guide.
+For recommended values by deployment type (NVIDIA API Catalog, vLLM, OpenAI, NIMs) and detailed optimization strategies, see the [Architecture & Performance](/concepts/architecture-performance) guide.
## Embedding Inference Parameters
@@ -192,4 +192,4 @@ dd.ModelConfig(
- **[Custom Model Settings](/concepts/models/custom-model-settings)**: Learn how to create custom providers and model configurations
- **[Model Configurations](/concepts/models/model-configs)**: Learn about configuring model settings
- **[Model Providers](/concepts/models/model-providers)**: Learn about configuring model providers
-- **[Architecture & Performance](/concepts/architecture-and-performance)**: Understanding separation of concerns and optimizing concurrency
+- **[Architecture & Performance](/concepts/architecture-performance)**: Understanding separation of concerns and optimizing concurrency
diff --git a/fern/versions/latest/pages/concepts/models/model-configs.mdx b/fern/versions/latest/pages/concepts/models/model-configs.mdx
index 90834e8ff..86c421c31 100644
--- a/fern/versions/latest/pages/concepts/models/model-configs.mdx
+++ b/fern/versions/latest/pages/concepts/models/model-configs.mdx
@@ -156,4 +156,4 @@ Note that skipping health checks means errors will only be discovered during act
- **[Default Model Settings](/concepts/models/default-model-settings)**: Pre-configured model settings included with Data Designer
- **[Custom Model Settings](/concepts/models/custom-model-settings)**: Learn how to create custom providers and model configurations
- **[Configure Model Settings With the CLI](/concepts/models/configure-with-the-cli)**: Use the CLI to manage model settings
-- **[Architecture & Performance](/concepts/architecture-and-performance)**: Understanding separation of concerns and optimizing concurrency
+- **[Architecture & Performance](/concepts/architecture-performance)**: Understanding separation of concerns and optimizing concurrency
diff --git a/fern/versions/latest/pages/concepts/seed-datasets.mdx b/fern/versions/latest/pages/concepts/seed-datasets.mdx
index 5e948b228..d717d7e3e 100644
--- a/fern/versions/latest/pages/concepts/seed-datasets.mdx
+++ b/fern/versions/latest/pages/concepts/seed-datasets.mdx
@@ -179,7 +179,7 @@ Path: {{ relative_path }}
Custom Filesystem Readers
-If you need custom row construction, fan-out behavior, or expensive hydration logic for any directory-backed seed source, build a custom `FileSystemSeedReader` and pass it via `DataDesigner(seed_readers=[...])`. See the [FileSystemSeedReader Plugins](/plugins/filesystemseedreader-plugins) guide.
+If you need custom row construction, fan-out behavior, or expensive hydration logic for any directory-backed seed source, build a custom `FileSystemSeedReader` and pass it via `DataDesigner(seed_readers=[...])`. See the [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins) guide.
diff --git a/fern/versions/latest/pages/concepts/tool_use_and_mcp.mdx b/fern/versions/latest/pages/concepts/tool_use_and_mcp.mdx
index 7ea2ee8a4..acedc6dba 100644
--- a/fern/versions/latest/pages/concepts/tool_use_and_mcp.mdx
+++ b/fern/versions/latest/pages/concepts/tool_use_and_mcp.mdx
@@ -7,9 +7,9 @@ Tool use lets LLM columns call external tools during generation (e.g., lookups,
## Quick Start
-1. Configure an MCP provider ([Local](/concepts/tool-use-and-mcp/mcp-providers#localstdiomcpprovider-subprocess) or [Remote](/concepts/tool-use-and-mcp/mcp-providers#mcpprovider-remote-sse))
-2. Create a [ToolConfig](/concepts/tool-use-and-mcp/tool-configs) referencing your provider
-3. Add `tool_alias` to your [LLM column](/concepts/tool-use-and-mcp/enabling-tools)
+1. Configure an MCP provider ([Local](/concepts/tool-use-mcp/mcp-providers#localstdiomcpprovider-subprocess) or [Remote](/concepts/tool-use-mcp/mcp-providers#mcpprovider-remote))
+2. Create a [ToolConfig](/concepts/tool-use-mcp/tool-configs) referencing your provider
+3. Add `tool_alias` to your [LLM column](/concepts/tool-use-mcp/enabling-tools)
```python
import data_designer.config as dd
@@ -56,12 +56,12 @@ builder.add_column(
| Guide | Description |
|-------|-------------|
-| **[MCP Providers](/concepts/tool-use-and-mcp/mcp-providers)** | Configure local subprocess or remote SSE providers |
-| **[Tool Configurations](/concepts/tool-use-and-mcp/tool-configs)** | Define tool permissions and limits |
-| **[Enabling Tools on Columns](/concepts/tool-use-and-mcp/enabling-tools)** | Use tools in LLM generation |
-| **[Configure via CLI](/concepts/tool-use-and-mcp/cli-configuration)** | Interactive CLI configuration |
+| **[MCP Providers](/concepts/tool-use-mcp/mcp-providers)** | Configure local subprocess or remote SSE providers |
+| **[Tool Configurations](/concepts/tool-use-mcp/tool-configs)** | Define tool permissions and limits |
+| **[Enabling Tools on Columns](/concepts/tool-use-mcp/enabling-tools)** | Use tools in LLM generation |
+| **[Configure via CLI](/concepts/tool-use-mcp/cli-configuration)** | Interactive CLI configuration |
| **[Message Traces](/concepts/traces)** | Capture full conversation history |
-| **[Safety & Limits](/concepts/tool-use-and-mcp/safety-and-limits)** | Allowlists, budgets, timeouts |
+| **[Safety & Limits](/concepts/tool-use-mcp/safety-limits)** | Allowlists, budgets, timeouts |
## Example
diff --git a/fern/versions/latest/pages/concepts/traces.mdx b/fern/versions/latest/pages/concepts/traces.mdx
index 47c51c85b..64193269f 100644
--- a/fern/versions/latest/pages/concepts/traces.mdx
+++ b/fern/versions/latest/pages/concepts/traces.mdx
@@ -216,4 +216,4 @@ The `extract_reasoning_content` option is available on all LLM column types:
## See Also
- **[Agent Rollout Ingestion](/concepts/agent-rollout-ingestion)**: Import external agent traces from disk into normalized seed rows
-- **[Safety and Limits](/concepts/tool-use-and-mcp/safety-and-limits)**: Understand turn limits and timeout behavior
+- **[Safety and Limits](/concepts/tool-use-mcp/safety-limits)**: Understand turn limits and timeout behavior
diff --git a/fern/versions/latest/pages/devnotes/posts/async-all-the-way-down.mdx b/fern/versions/latest/pages/devnotes/posts/async-all-the-way-down.mdx
index ed7cc04fd..9806b11ca 100644
--- a/fern/versions/latest/pages/devnotes/posts/async-all-the-way-down.mdx
+++ b/fern/versions/latest/pages/devnotes/posts/async-all-the-way-down.mdx
@@ -272,7 +272,7 @@ The dependencies were always per-cell. Now the engine schedules them that way.
## **Update**
-The async engine is now the only execution path. The [Architecture & Performance](/concepts/architecture-and-performance#async-engine) page covers the configuration knobs and behaviors worth knowing about.
+The async engine is now the only execution path. The [Architecture & Performance](/concepts/architecture-performance#async-engine) page covers the configuration knobs and behaviors worth knowing about.
---
diff --git a/fern/versions/latest/pages/devnotes/posts/deep-research-trajectories.mdx b/fern/versions/latest/pages/devnotes/posts/deep-research-trajectories.mdx
index 23a1ae6cb..2dff941be 100644
--- a/fern/versions/latest/pages/devnotes/posts/deep-research-trajectories.mdx
+++ b/fern/versions/latest/pages/devnotes/posts/deep-research-trajectories.mdx
@@ -10,7 +10,7 @@ import trajectory from "@/components/devnotes/deep-research-trajectories/example
-Data Designer [v0.5.0](https://github.com/NVIDIA-NeMo/DataDesigner/releases/tag/v0.5.0)'s MCP [tool-use support](/concepts/tool-use-and-mcp/overview) lets you generate multi-turn research trajectories, the kind of data needed to train deep research agents that iteratively search, read, and synthesize evidence before answering a question.
+Data Designer [v0.5.0](https://github.com/NVIDIA-NeMo/DataDesigner/releases/tag/v0.5.0)'s MCP [tool-use support](/concepts/tool-use-mcp/overview) lets you generate multi-turn research trajectories, the kind of data needed to train deep research agents that iteratively search, read, and synthesize evidence before answering a question.
{/* more */}
diff --git a/fern/versions/latest/pages/devnotes/posts/have-it-your-way.mdx b/fern/versions/latest/pages/devnotes/posts/have-it-your-way.mdx
index 851a73c42..da2502078 100644
--- a/fern/versions/latest/pages/devnotes/posts/have-it-your-way.mdx
+++ b/fern/versions/latest/pages/devnotes/posts/have-it-your-way.mdx
@@ -258,8 +258,8 @@ This provides a foundation for a rich Data Designer plugin ecosystem: the core f
Interested in building your own plugin? Here are some resources to get you started:
1. [Plugins overview](/plugins/overview) — learn how plugins fit into Data Designer
-2. [Build Your Own](/plugins/build-your-own) — follow the authoring guide for seed readers, column generators, and processors
-3. [Using Models in Plugins](/plugins/using-models-in-plugins) — call configured models from plugin code
+2. [Example Plugin](/plugins/example-plugin) — follow a complete column generator authoring walkthrough
+3. [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins) — build plugins for directory-backed seed sources
4. [Markdown Section Seed Reader recipe](/recipes/plugin-development/markdown-section-seed-reader-plugin) — study the complete version of the example from this post
5. [Discover Plugins](/plugins/discover) — learn how to discover and install plugins
6. [DataDesignerPlugins on GitHub](https://github.com/NVIDIA-NeMo/DataDesignerPlugins) — explore first-party plugin packages
diff --git a/fern/versions/latest/pages/devnotes/posts/structured-outputs-from-nemotron.mdx b/fern/versions/latest/pages/devnotes/posts/structured-outputs-from-nemotron.mdx
index 4d7e6a6a5..07df3dfb6 100644
--- a/fern/versions/latest/pages/devnotes/posts/structured-outputs-from-nemotron.mdx
+++ b/fern/versions/latest/pages/devnotes/posts/structured-outputs-from-nemotron.mdx
@@ -40,7 +40,7 @@ Training Nemotron Nano v3 with this data improved JSONSchemaBench accuracy from
rows={[["Baseline SFT Model", 80.2], ["Nemotron Nano v3 (trained with this data)", 86.9], ["Baseline Qwen3-30B-A3B-Thinking", 92.8], ["Baseline GPT-OSS-20B", 95.8]]}
/>
-[**StructEval-Text**](https://github.com/StructEval/StructEval) tests structured output across multiple formats (CSV, JSON, TOML, XML, YAML):
+[**StructEval-Text**](https://github.com/TIGER-AI-Lab/StructEval) tests structured output across multiple formats (CSV, JSON, TOML, XML, YAML):