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<fence>`{3,}|~{3,})") +INLINE_CODE_PATTERN = re.compile(r"(?<!`)`[^`\n]+`(?!`)") +MARKDOWN_DECORATION_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. </Tip> <Tip> 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. </Tip> ### 💻 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 <Tip> 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. </Tip> ## 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 }} <Tip> 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. </Tip> <Note> 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 <Authors ids={["etramel"]} /> -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): <MetricsTable headers={["Model", "CSV", "JSON", "TOML", "XML", "YAML", "Overall"]} diff --git a/fern/versions/latest/pages/index.mdx b/fern/versions/latest/pages/index.mdx index 7d0587ece..cd9d7f983 100644 --- a/fern/versions/latest/pages/index.mdx +++ b/fern/versions/latest/pages/index.mdx @@ -18,7 +18,7 @@ import { BadgeLinks } from "@/components/BadgeLinks"; **Configure** columns and models → **Preview** samples and iterate → **Create** your full dataset at scale. -Unlike raw LLM calls, Data Designer gives you statistical diversity, field correlations, automated validation, and reproducible workflows. For details, see [Architecture & Performance](/concepts/architecture-and-performance). +Unlike raw LLM calls, Data Designer gives you statistical diversity, field correlations, automated validation, and reproducible workflows. For details, see [Architecture & Performance](/concepts/architecture-performance). 📝 Want to hear from the team? Check out our **[Dev Notes](/dev-notes/overview)** for deep dives, best practices, and insights. @@ -143,4 +143,4 @@ Forwarded arguments are supported only for local `.py` config modules. YAML, JSO - **[Deployment Options: Library vs. Microservice](/concepts/deployment-options)** – Library vs. NeMo Microservice - **[Model Configuration](/concepts/models/default-model-settings)** – Configure LLM providers and models -- **[Architecture & Performance](/concepts/architecture-and-performance)** – Optimize for throughput and scale +- **[Architecture & Performance](/concepts/architecture-performance)** – Optimize for throughput and scale diff --git a/fern/versions/latest/pages/plugins/example.mdx b/fern/versions/latest/pages/plugins/example.mdx index efa0b77fb..963b0608e 100644 --- a/fern/versions/latest/pages/plugins/example.mdx +++ b/fern/versions/latest/pages/plugins/example.mdx @@ -6,7 +6,7 @@ position: 2 # Example Plugin: Column Generator -Data Designer supports three plugin types: **column generators**, **seed readers**, and **processors**. This page walks through a complete column generator example. For filesystem-backed seed reader plugins, see [FileSystemSeedReader Plugins](/plugins/filesystemseedreader-plugins). +Data Designer supports three plugin types: **column generators**, **seed readers**, and **processors**. This page walks through a complete column generator example. For filesystem-backed seed reader plugins, see [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins). A Data Designer plugin is implemented as a Python package with three main components: diff --git a/fern/versions/latest/pages/plugins/overview.mdx b/fern/versions/latest/pages/plugins/overview.mdx index c03a5db35..4d81ca99c 100644 --- a/fern/versions/latest/pages/plugins/overview.mdx +++ b/fern/versions/latest/pages/plugins/overview.mdx @@ -18,7 +18,7 @@ Data Designer supports three plugin types: A Data Designer plugin is just a Python package configured with an [entry point](https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-package-metadata) that points to a Data Designer `Plugin` object. Once installed, plugins are automatically discovered and ready to use — no additional registration or configuration needed. -Use the Data Designer CLI to discover and install published plugin packages from catalogs. See [Discover Plugins](/plugins/discover) for the catalog workflow. See the [example plugin](/plugins/example-plugin) for a complete authoring walkthrough, or jump to [FileSystemSeedReader Plugins](/plugins/filesystemseedreader-plugins) for filesystem-backed seed reader authoring. +Use the Data Designer CLI to discover and install published plugin packages from catalogs. See [Discover Plugins](/plugins/discover) for the catalog workflow. See the [example plugin](/plugins/example-plugin) for a complete authoring walkthrough, or jump to [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins) for filesystem-backed seed reader authoring. ## How do you create plugins? @@ -77,5 +77,5 @@ my_processor_plugin = Plugin( **Ready to get started?** - See the [Example Plugin](/plugins/example-plugin) for a column generator walkthrough -- See [FileSystemSeedReader Plugins](/plugins/filesystemseedreader-plugins) for filesystem-backed seed reader plugins +- See [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins) for filesystem-backed seed reader plugins - See the [Markdown Section Seed Reader recipe](/recipes/plugin-development/markdown-section-seed-reader-plugin) for a runnable single-file `1:N` filesystem reader example diff --git a/fern/versions/latest/pages/recipes/plugin_development/markdown_seed_reader.mdx b/fern/versions/latest/pages/recipes/plugin_development/markdown_seed_reader.mdx index 0026ad89c..f59bdffd0 100644 --- a/fern/versions/latest/pages/recipes/plugin_development/markdown_seed_reader.mdx +++ b/fern/versions/latest/pages/recipes/plugin_development/markdown_seed_reader.mdx @@ -12,7 +12,7 @@ This keeps the example focused on the actual seed reader contract: - declaring `output_columns` for the hydrated schema - keeping `IndexRange` selection manifest-based -Because the example reuses `DirectorySeedSource`, it does not register a brand-new `seed_type`. If you later want to package the same reader as an installable plugin, see [FileSystemSeedReader Plugins](/plugins/filesystemseedreader-plugins). +Because the example reuses `DirectorySeedSource`, it does not register a brand-new `seed_type`. If you later want to package the same reader as an installable plugin, see [FileSystemSeedReader Plugins](/plugins/file-system-seed-reader-plugins). ## Run the Recipe