diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b2543a8..2ee336a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ - [ ] `assert_valid_plugin(plugin)` passes - [ ] Unit tests included and passing (`make test-plugin PLUGIN=`) - [ ] Plugin installs standalone (`uv pip install -e plugins/`) -- [ ] `docs/catalog.md` regenerated if plugin list or metadata changed (`make catalog`) +- [ ] Plugin docs regenerated if plugin docs, list, or metadata changed (`make plugin-docs`) - [ ] Documentation builds if docs changed (`make docs`) - [ ] `.github/CODEOWNERS` regenerated if ownership changed (`make codeowners`) - [ ] Per-plugin `CODEOWNERS` file included (auto-created by `ddp new`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd6d8f..f55a23d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: with: name: generated-metadata path: | - docs/catalog.md.new + docs/plugins/** + zensical.toml .github/CODEOWNERS.new if-no-files-found: ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a5398c2..9819efe 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,9 @@ on: paths: - ".github/workflows/docs.yml" - "Makefile" + - "devtools/ddp/src/ddp/plugin_docs.py" - "docs/**" + - "plugins/**/docs/**" - "pyproject.toml" - "uv.lock" - "zensical.toml" diff --git a/Makefile b/Makefile index 8c1febb..ba00d36 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -.PHONY: sync lint format test validate docs docs-server catalog codeowners check-catalog check-codeowners check-license-headers update-license-headers check all bump release build-plugin validate-release test-plugin check-owner +.PHONY: sync lint format test validate docs docs-server plugin-docs check-plugin-docs codeowners check-codeowners check-license-headers update-license-headers check all bump release build-plugin validate-release test-plugin check-owner # ── Setup ──────────────────────────────────────────────────────────────── @@ -32,7 +32,7 @@ test: continue; \ fi; \ echo "── Testing $$plugin_name (isolated) ──"; \ - uv venv ".venv-$$plugin_name"; \ + uv venv --clear ".venv-$$plugin_name"; \ . ".venv-$$plugin_name/bin/activate"; \ uv pip install -e "$$plugin_dir"; \ uv pip install pytest; \ @@ -49,26 +49,24 @@ validate: # ── Documentation ─────────────────────────────────────────────────────── -docs: +docs: plugin-docs uv run zensical build --clean --strict DOCS_DEV_ADDR ?= localhost:8000 -docs-server: +docs-server: plugin-docs uv run zensical serve --dev-addr $(DOCS_DEV_ADDR) -# ── Catalog & CODEOWNERS ───────────────────────────────────────────────── +# ── Plugin docs & CODEOWNERS ────────────────────────────────────────────── -catalog: - uv run ddp catalog > docs/catalog.md +plugin-docs: + uv run ddp plugin-docs codeowners: uv run ddp codeowners > .github/CODEOWNERS -check-catalog: - uv run ddp catalog > docs/catalog.md.new - diff docs/catalog.md docs/catalog.md.new - @rm -f docs/catalog.md.new +check-plugin-docs: + uv run ddp plugin-docs --check check-codeowners: uv run ddp codeowners > .github/CODEOWNERS.new @@ -83,7 +81,7 @@ update-license-headers: # ── Aggregate targets ──────────────────────────────────────────────────── -check: check-catalog check-codeowners check-license-headers +check: check-plugin-docs check-codeowners check-license-headers all: lint test validate check docs @@ -106,7 +104,7 @@ validate-release: test-plugin: @if [ -z "$(PLUGIN)" ]; then echo "ERROR: Set PLUGIN="; exit 1; fi - uv venv ".venv-$(PLUGIN)" + uv venv --clear ".venv-$(PLUGIN)" . ".venv-$(PLUGIN)/bin/activate" && \ uv pip install -e "$(PLUGIN_DIR)" && \ uv pip install pytest && \ diff --git a/README.md b/README.md index e739f56..688c473 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Create a new plugin: uv run ddp new my-plugin ``` -This generates a complete plugin skeleton under `plugins/data-designer-my-plugin/` with config, implementation, entry point, tests, and CODEOWNERS. See [docs/authoring.md](docs/authoring.md) for the full authoring guide. +This generates a complete plugin skeleton under `plugins/data-designer-my-plugin/` with config, implementation, entry point, docs, tests, and CODEOWNERS. See [docs/authoring.md](docs/authoring.md) for the full authoring guide. ## Repository Structure @@ -29,7 +29,7 @@ DataDesignerPlugins/ `-- docs/ # Zensical documentation source ``` -Each plugin is an independent Python package with its own `pyproject.toml`, tests, and CODEOWNERS. The root workspace auto-discovers plugins via `plugins/*`. +Each plugin is an independent Python package with its own `pyproject.toml`, docs, tests, and CODEOWNERS. The root workspace auto-discovers plugins via `plugins/*`. ## Development @@ -41,7 +41,8 @@ make lint # Lint and format check (ruff) make format # Auto-fix lint issues and reformat make test # Test each plugin in an isolated venv make validate # Run assert_valid_plugin on all entry points -make check # Verify catalog, CODEOWNERS, and license headers are up to date +make check # Verify generated plugin docs, CODEOWNERS, and license headers are up to date +make plugin-docs # Regenerate docs/plugins/ from per-plugin docs and metadata make docs # Build the Zensical documentation site make docs-server # Serve docs locally at http://localhost:8000 make all # lint + test + validate + check + docs (full local CI) @@ -53,10 +54,10 @@ To test a single plugin in isolation: make test-plugin PLUGIN=data-designer-my-plugin ``` -If you change plugin metadata or ownership, regenerate derived files: +If you change plugin docs, plugin metadata, or ownership, regenerate derived files: ```bash -make catalog # Regenerate docs/catalog.md +make plugin-docs # Regenerate plugin documentation site inputs make codeowners # Regenerate CODEOWNERS make update-license-headers # Fix SPDX headers ``` @@ -69,7 +70,7 @@ The `ddp` command manages the monorepo. Run `uv run ddp --help` to see all subco |---------|-------------| | `ddp new ` | Scaffold a new plugin | | `ddp validate` | Validate all installed plugins | -| `ddp catalog` | Generate plugin catalog to stdout | +| `ddp plugin-docs` | Generate plugin docs site inputs | | `ddp codeowners` | Aggregate CODEOWNERS to stdout | | `ddp license-headers` | Add or check SPDX license headers | | `ddp bump ` | Bump a plugin's semantic version | diff --git a/devtools/ddp/src/ddp/__init__.py b/devtools/ddp/src/ddp/__init__.py index 809b23b..a2aa58e 100644 --- a/devtools/ddp/src/ddp/__init__.py +++ b/devtools/ddp/src/ddp/__init__.py @@ -4,6 +4,6 @@ """Local dev-only monorepo management tooling for data-designer-plugins. This package is NOT a runtime dependency of any plugin. It provides CLI -tools for scaffolding, validation, catalog generation, CODEOWNERS +tools for scaffolding, validation, plugin docs generation, CODEOWNERS aggregation, and license-header management across the monorepo. """ diff --git a/devtools/ddp/src/ddp/catalog.py b/devtools/ddp/src/ddp/catalog.py deleted file mode 100644 index cb3dbb6..0000000 --- a/devtools/ddp/src/ddp/catalog.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Generate a markdown plugin catalog from plugin metadata.""" - -from __future__ import annotations - -from html import escape - -from ddp._repo import find_repo_root, load_toml - - -def format_catalog_row(name: str, version: str, column_type: str, description: str) -> str: - """Format a plugin metadata row for the catalog table. - - Args: - name: Python package name for the plugin. - version: Plugin package version. - column_type: Data Designer column type entry point key. - description: Plugin package description. - - Returns: - An HTML table row for the plugin catalog. - """ - column_type_cell = f"{escape(column_type)}" if column_type else "" - return ( - " \n" - f' {escape(name)}\n' - f' {escape(version)}\n' - f' {column_type_cell}\n' - f' {escape(description)}\n' - " " - ) - - -def format_catalog(rows: list[tuple[str, str, str, str]]) -> str: - """Format plugin metadata as the Markdown catalog page. - - Args: - rows: Plugin catalog rows as package, version, column type, and description. - - Returns: - Markdown with an HTML table for the generated catalog page. - """ - lines = [ - "# Plugin Catalog", - "", - '
', - '', - " ", - " ", - " ", - " ", - " ", - " ", - " ", - " ", - " ", - ] - lines.extend(format_catalog_row(*row) for row in rows) - lines.extend( - [ - " ", - "
PluginVersionColumn TypeDescription
", - "
", - ] - ) - return "\n".join(lines) - - -def main() -> None: - """Generate a markdown table of all plugins and print to stdout.""" - repo_root = find_repo_root() - plugins_dir = repo_root / "plugins" - - rows: list[tuple[str, str, str, str]] = [] - - for toml_path in sorted(plugins_dir.glob("*/pyproject.toml")): - data = load_toml(toml_path) - - project = data.get("project", {}) - name = project.get("name", toml_path.parent.name) - version = project.get("version", "unknown") - description = project.get("description", "") - - entry_points = project.get("entry-points", {}).get("data_designer.plugins", {}) - - if entry_points: - for ep_key in sorted(entry_points): - rows.append((name, version, ep_key, description)) - else: - rows.append((name, version, "", description)) - - rows.sort(key=lambda r: (r[0], r[2])) - - print(format_catalog(rows)) - - -if __name__ == "__main__": - main() diff --git a/devtools/ddp/src/ddp/cli.py b/devtools/ddp/src/ddp/cli.py index 8af3319..cb7861c 100644 --- a/devtools/ddp/src/ddp/cli.py +++ b/devtools/ddp/src/ddp/cli.py @@ -7,6 +7,7 @@ ddp --help # List all subcommands ddp new my-plugin # Scaffold a new plugin + ddp plugin-docs # Generate plugin documentation pages ddp validate # Validate all installed plugins ddp bump patch # Bump a plugin version """ @@ -42,17 +43,21 @@ def build_parser() -> argparse.ArgumentParser: p_new.add_argument("name", help="Plugin name in kebab-case (e.g., my-cool-thing)") p_new.set_defaults(func=_run_new) - # ddp catalog - p_catalog = sub.add_parser( - "catalog", - help="Generate plugin catalog to stdout", + # ddp plugin-docs + p_plugin_docs = sub.add_parser( + "plugin-docs", + help="Generate plugin documentation pages", description=( - "Generate a markdown table of all plugins and their metadata " - "(name, version, column type, description) to stdout. " - "Typically redirected to docs/catalog.md." + "Generate docs/plugins/ from each plugin's docs/ directory and " + "package metadata, then update the generated plugin navigation in zensical.toml." ), ) - p_catalog.set_defaults(func=_run_catalog) + p_plugin_docs.add_argument( + "--check", + action="store_true", + help="Check generated plugin docs without modifying files", + ) + p_plugin_docs.set_defaults(func=_run_plugin_docs) # ddp codeowners p_codeowners = sub.add_parser( @@ -136,11 +141,11 @@ def _run_new(args: argparse.Namespace) -> int: return 0 -def _run_catalog(args: argparse.Namespace) -> int: - from ddp.catalog import main as catalog_main +def _run_plugin_docs(args: argparse.Namespace) -> int: + from ddp.plugin_docs import main as plugin_docs_main - catalog_main() - return 0 + argv = ["--check"] if args.check else [] + return plugin_docs_main(argv) def _run_codeowners(args: argparse.Namespace) -> int: diff --git a/devtools/ddp/src/ddp/plugin_docs.py b/devtools/ddp/src/ddp/plugin_docs.py new file mode 100644 index 0000000..c18fb76 --- /dev/null +++ b/devtools/ddp/src/ddp/plugin_docs.py @@ -0,0 +1,591 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generate top-level documentation pages from per-plugin documentation.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from html import escape +from pathlib import Path + +from ddp._repo import find_repo_root, load_toml + +GENERATED_NOTICE = "" +NAV_START_TOKEN = "# BEGIN GENERATED PLUGIN DOCS NAV" +NAV_END_TOKEN = "# END GENERATED PLUGIN DOCS NAV" + + +@dataclass(frozen=True) +class PluginDocs: + """Metadata needed to publish one plugin's documentation. + + Args: + package_name: Python distribution name from the plugin pyproject. + docs_slug: URL-safe directory name used under ``docs/plugins``. + version: Package version from plugin metadata. + description: Package description from plugin metadata. + column_types: Data Designer entry point keys exposed by the plugin. + plugin_dir: Source plugin package directory. + source_docs_dir: Optional source documentation directory. + """ + + package_name: str + docs_slug: str + version: str + description: str + column_types: tuple[str, ...] + plugin_dir: Path + source_docs_dir: Path | None + + +@dataclass(frozen=True) +class NavPage: + """One generated Zensical navigation page entry. + + Args: + title: Navigation title. + path: Markdown path relative to the top-level docs directory. + """ + + title: str + path: str + + +def normalize_docs_slug(package_name: str) -> str: + """Normalize a package name into a stable docs URL path segment. + + Args: + package_name: Python package distribution name. + + Returns: + URL-safe slug suitable for ``docs/plugins/``. + """ + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", package_name.lower()).strip("-") + return slug or "plugin" + + +def discover_plugin_docs(repo_root: Path) -> list[PluginDocs]: + """Discover plugin metadata and optional documentation source directories. + + Args: + repo_root: Repository root containing ``plugins/``. + + Returns: + Sorted plugin documentation metadata. + """ + plugins_dir = repo_root / "plugins" + plugins: list[PluginDocs] = [] + seen_slugs: set[str] = set() + + for toml_path in sorted(plugins_dir.glob("*/pyproject.toml")): + data = load_toml(toml_path) + project = data.get("project", {}) + package_name = str(project.get("name", toml_path.parent.name)) + docs_slug = normalize_docs_slug(package_name) + if docs_slug in seen_slugs: + raise ValueError(f"Duplicate generated plugin docs slug: {docs_slug}") + seen_slugs.add(docs_slug) + + entry_points = project.get("entry-points", {}).get("data_designer.plugins", {}) + source_docs_dir = toml_path.parent / "docs" + plugins.append( + PluginDocs( + package_name=package_name, + docs_slug=docs_slug, + version=str(project.get("version", "unknown")), + description=str(project.get("description", "")), + column_types=tuple(sorted(str(key) for key in entry_points)), + plugin_dir=toml_path.parent, + source_docs_dir=source_docs_dir if source_docs_dir.is_dir() else None, + ) + ) + + return sorted(plugins, key=lambda plugin: plugin.package_name) + + +def format_markdown_code_list(values: tuple[str, ...]) -> str: + """Format values as a comma-separated Markdown code list. + + Args: + values: Values to format. + + Returns: + Markdown inline code list, or a fallback sentence when empty. + """ + if not values: + return "None declared" + return ", ".join(f"`{value}`" for value in values) + + +def format_html_code_list(values: tuple[str, ...]) -> str: + """Format values as a comma-separated HTML code list. + + Args: + values: Values to format. + + Returns: + HTML inline code list, or a fallback sentence when empty. + """ + if not values: + return "No entry points" + return ", ".join(f"{escape(value)}" for value in values) + + +def format_html_chips(values: tuple[str, ...]) -> str: + """Format values as card badge chips. + + Args: + values: Values to format. + + Returns: + HTML span chips, or a fallback chip when empty. + """ + if not values: + return 'No entry points' + return "".join(f'{escape(value)}' for value in values) + + +def render_plugins_index(plugins: list[PluginDocs], repo_root: Path) -> str: + """Render the generated plugin documentation landing page. + + Args: + plugins: Discovered plugin documentation metadata. + repo_root: Repository root used to format source paths. + + Returns: + Markdown page content. + """ + lines = [ + GENERATED_NOTICE, + "", + "# Plugins", + "", + "Browse available Data Designer plugins by what they add to your data generation workflow.", + "", + ] + + if not plugins: + lines.append("No plugins were discovered under `plugins/`.") + return "\n".join(lines) + "\n" + + lines.append('") + return "\n".join(lines) + "\n" + + +def render_fallback_plugin_page(plugin: PluginDocs, repo_root: Path) -> str: + """Render a metadata fallback page for a plugin without docs content. + + Args: + plugin: Plugin metadata. + repo_root: Repository root. + + Returns: + Markdown page content. + """ + source_path = plugin.plugin_dir.relative_to(repo_root).as_posix() + lines = [ + GENERATED_NOTICE, + "", + f"# {plugin.package_name}", + "", + ] + if plugin.description: + lines.extend([plugin.description, ""]) + + lines.extend( + [ + "This page is generated from package metadata because the plugin has not added", + f"custom documentation under `{source_path}/docs/` yet.", + "", + "## Metadata", + "", + f"- Version: `{plugin.version}`", + f"- Column types: {format_markdown_code_list(plugin.column_types)}", + "", + "## Installation", + "", + "```bash", + f"pip install {plugin.package_name}", + "```", + "", + ] + ) + return "\n".join(lines) + + +def should_copy_doc_file(path: Path) -> bool: + """Return whether a plugin documentation file should be copied. + + Args: + path: Source file path. + + Returns: + True when the file should be included in generated docs. + """ + ignored_parts = {"__pycache__", ".pytest_cache"} + return not any(part in ignored_parts or part.startswith(".") for part in path.parts) + + +def destination_doc_relative_path(source_path: Path, source_root: Path, has_index: bool) -> Path: + """Map a plugin docs source file to its generated relative path. + + Args: + source_path: Source documentation file. + source_root: Root plugin documentation directory. + has_index: Whether the plugin source already contains ``index.md``. + + Returns: + Relative path under ``docs/plugins/``. + """ + relative_path = source_path.relative_to(source_root) + if relative_path == Path("README.md") and not has_index: + return Path("index.md") + return relative_path + + +def copy_plugin_docs(plugin: PluginDocs, destination_dir: Path) -> None: + """Copy a plugin's authored documentation into the generated docs tree. + + Args: + plugin: Plugin metadata and source docs location. + destination_dir: Generated docs directory for this plugin. + """ + if plugin.source_docs_dir is None: + return + + has_index = (plugin.source_docs_dir / "index.md").is_file() + for source_path in sorted(plugin.source_docs_dir.rglob("*")): + if not source_path.is_file() or not should_copy_doc_file(source_path.relative_to(plugin.source_docs_dir)): + continue + relative_path = destination_doc_relative_path(source_path, plugin.source_docs_dir, has_index) + target_path = destination_dir / relative_path + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + + +def write_text(path: Path, content: str) -> None: + """Write UTF-8 text after ensuring the parent directory exists. + + Args: + path: Destination file path. + content: File content. + """ + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def build_plugin_docs_tree(repo_root: Path, plugins: list[PluginDocs], destination_dir: Path) -> None: + """Build the generated ``docs/plugins`` tree. + + Args: + repo_root: Repository root. + plugins: Discovered plugin documentation metadata. + destination_dir: Generated plugin docs output directory. + """ + destination_dir.mkdir(parents=True, exist_ok=True) + write_text(destination_dir / "index.md", render_plugins_index(plugins, repo_root)) + + for plugin in plugins: + plugin_destination = destination_dir / plugin.docs_slug + plugin_destination.mkdir(parents=True, exist_ok=True) + copy_plugin_docs(plugin, plugin_destination) + if not (plugin_destination / "index.md").is_file(): + write_text(plugin_destination / "index.md", render_fallback_plugin_page(plugin, repo_root)) + + +def markdown_title_fallback(relative_path: Path) -> str: + """Create a readable title from a Markdown relative path. + + Args: + relative_path: Markdown path relative to a plugin docs root. + + Returns: + Human-readable fallback title. + """ + if relative_path.name in {"index.md", "README.md"}: + return "Overview" + name = relative_path.with_suffix("").as_posix().replace("/", " / ") + words = re.split(r"[-_ ]+", name) + return " ".join(word.capitalize() for word in words if word) + + +def extract_markdown_title(path: Path, fallback: str) -> str: + """Extract the first H1 title from a Markdown file. + + Args: + path: Markdown source path. + fallback: Title to use when no H1 is present. + + Returns: + Extracted or fallback title. + """ + if path.name in {"index.md", "README.md"}: + return "Overview" + + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + return fallback + + for line in lines: + stripped = line.strip() + if stripped.startswith("# "): + title = stripped[2:].strip().strip("#").strip() + if title: + return title + return fallback + + +def collect_plugin_nav_pages(plugin_docs_dir: Path, docs_root: Path) -> list[NavPage]: + """Collect generated Markdown pages for one plugin's nav section. + + Args: + plugin_docs_dir: Generated docs directory for a plugin. + docs_root: Top-level docs directory. + + Returns: + Zensical navigation pages sorted with index pages first. + """ + pages: list[tuple[tuple[int, str], NavPage]] = [] + for page_path in sorted(plugin_docs_dir.rglob("*.md")): + relative_path = page_path.relative_to(plugin_docs_dir) + fallback = markdown_title_fallback(relative_path) + title = extract_markdown_title(page_path, fallback) + docs_path = page_path.relative_to(docs_root).as_posix() + sort_key = (0 if relative_path.name in {"index.md", "README.md"} else 1, relative_path.as_posix()) + pages.append((sort_key, NavPage(title=title, path=docs_path))) + return [page for _, page in sorted(pages, key=lambda item: item[0])] + + +def toml_string(value: str) -> str: + """Format a string for use in generated TOML. + + Args: + value: String value. + + Returns: + Double-quoted TOML-compatible string. + """ + return json.dumps(value) + + +def render_plugin_nav_entries(plugin_docs_root: Path, docs_root: Path, plugins: list[PluginDocs]) -> list[str]: + """Render plugin entries inside the generated Zensical nav block. + + Args: + plugin_docs_root: Generated ``docs/plugins`` root. + docs_root: Top-level docs directory. + plugins: Discovered plugin documentation metadata. + + Returns: + Lines to insert between nav block markers. + """ + lines: list[str] = [] + for plugin in plugins: + pages = collect_plugin_nav_pages(plugin_docs_root / plugin.docs_slug, docs_root) + lines.append(f" {{{toml_string(plugin.package_name)} = [") + for page in pages: + lines.append(f" {{{toml_string(page.title)} = {toml_string(page.path)}}},") + lines.append(" ]},") + return lines + + +def replace_generated_nav_block(config_text: str, generated_lines: list[str]) -> str: + """Replace the generated plugin docs nav block in ``zensical.toml``. + + Args: + config_text: Current configuration file content. + generated_lines: Replacement lines for the generated block. + + Returns: + Updated configuration text. + """ + lines = config_text.splitlines() + start_indexes = [index for index, line in enumerate(lines) if NAV_START_TOKEN in line] + end_indexes = [index for index, line in enumerate(lines) if NAV_END_TOKEN in line] + + if len(start_indexes) != 1 or len(end_indexes) != 1: + raise ValueError( + f"zensical.toml must contain exactly one {NAV_START_TOKEN!r} and one {NAV_END_TOKEN!r} marker." + ) + + start = start_indexes[0] + end = end_indexes[0] + if end <= start: + raise ValueError(f"{NAV_END_TOKEN!r} must appear after {NAV_START_TOKEN!r}.") + + updated_lines = lines[: start + 1] + generated_lines + lines[end:] + return "\n".join(updated_lines) + "\n" + + +def render_zensical_config( + config_path: Path, + plugin_docs_root: Path, + plugins: list[PluginDocs], + docs_root: Path | None = None, +) -> str: + """Render ``zensical.toml`` with current generated plugin navigation. + + Args: + config_path: Path to ``zensical.toml``. + plugin_docs_root: Generated ``docs/plugins`` root. + plugins: Discovered plugin documentation metadata. + docs_root: Optional top-level docs directory for computing nav paths. + + Returns: + Updated Zensical configuration content. + """ + nav_docs_root = docs_root or config_path.parent / "docs" + generated_lines = render_plugin_nav_entries(plugin_docs_root, nav_docs_root, plugins) + return replace_generated_nav_block(config_path.read_text(encoding="utf-8"), generated_lines) + + +def list_relative_files(root: Path) -> set[Path]: + """List files under a root as relative paths. + + Args: + root: Directory to inspect. + + Returns: + Set of relative file paths. + """ + if not root.exists(): + return set() + return {path.relative_to(root) for path in root.rglob("*") if path.is_file()} + + +def compare_generated_tree(expected_dir: Path, actual_dir: Path) -> list[str]: + """Compare the generated docs tree with the checked-in docs tree. + + Args: + expected_dir: Temporary expected generated tree. + actual_dir: Actual generated tree under the repo docs directory. + + Returns: + Human-readable drift messages. + """ + drift: list[str] = [] + expected_files = list_relative_files(expected_dir) + actual_files = list_relative_files(actual_dir) + + for relative_path in sorted(expected_files - actual_files): + drift.append(f"missing generated docs file: {actual_dir / relative_path}") + for relative_path in sorted(actual_files - expected_files): + drift.append(f"stale generated docs file: {actual_dir / relative_path}") + for relative_path in sorted(expected_files & actual_files): + if (expected_dir / relative_path).read_bytes() != (actual_dir / relative_path).read_bytes(): + drift.append(f"outdated generated docs file: {actual_dir / relative_path}") + + return drift + + +def check_plugin_docs(repo_root: Path) -> list[str]: + """Check whether generated plugin documentation files are current. + + Args: + repo_root: Repository root. + + Returns: + Human-readable drift messages. + """ + plugins = discover_plugin_docs(repo_root) + actual_plugin_docs_root = repo_root / "docs" / "plugins" + + with tempfile.TemporaryDirectory() as tmp_dir: + expected_plugin_docs_root = Path(tmp_dir) / "plugins" + build_plugin_docs_tree(repo_root, plugins, expected_plugin_docs_root) + drift = compare_generated_tree(expected_plugin_docs_root, actual_plugin_docs_root) + expected_config = render_zensical_config( + repo_root / "zensical.toml", + expected_plugin_docs_root, + plugins, + docs_root=expected_plugin_docs_root.parent, + ) + + if (repo_root / "zensical.toml").read_text(encoding="utf-8") != expected_config: + drift.append("outdated generated plugin docs navigation in zensical.toml") + + return drift + + +def sync_plugin_docs(repo_root: Path) -> list[PluginDocs]: + """Regenerate plugin documentation pages and Zensical navigation. + + Args: + repo_root: Repository root. + + Returns: + Discovered plugins included in the generated docs. + """ + plugins = discover_plugin_docs(repo_root) + plugin_docs_root = repo_root / "docs" / "plugins" + + if plugin_docs_root.exists(): + shutil.rmtree(plugin_docs_root) + + build_plugin_docs_tree(repo_root, plugins, plugin_docs_root) + config_path = repo_root / "zensical.toml" + config_path.write_text(render_zensical_config(config_path, plugin_docs_root, plugins), encoding="utf-8") + return plugins + + +def main(args: list[str] | None = None) -> int: + """Run the plugin documentation generator CLI. + + Args: + args: Optional command line arguments. + + Returns: + Process exit code. + """ + parser = argparse.ArgumentParser(description="Generate plugin documentation pages for Zensical") + parser.add_argument( + "--check", + action="store_true", + help="Check generated plugin docs without modifying files", + ) + parsed_args = parser.parse_args(args) + repo_root = find_repo_root() + + if parsed_args.check: + drift = check_plugin_docs(repo_root) + if drift: + print("Generated plugin documentation is out of date. Run `make plugin-docs`.", file=sys.stderr) + for message in drift: + print(f" - {message}", file=sys.stderr) + return 1 + print("Generated plugin documentation is current.") + return 0 + + plugins = sync_plugin_docs(repo_root) + print(f"Generated plugin documentation for {len(plugins)} plugin(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/devtools/ddp/src/ddp/scaffold.py b/devtools/ddp/src/ddp/scaffold.py index 2fe5362..555268d 100644 --- a/devtools/ddp/src/ddp/scaffold.py +++ b/devtools/ddp/src/ddp/scaffold.py @@ -80,7 +80,7 @@ def generate_readme(slug: str) -> str: ## Installation ```bash -pip install data-designer-{slug} +uv add data-designer data-designer-{slug} ``` ## Usage @@ -89,7 +89,28 @@ def generate_readme(slug: str) -> str: [NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). For the full plugin authoring guide, see the -[main repository docs](https://github.com/NVIDIA-NeMo/DataDesignerPlugins/blob/main/docs/adding-a-plugin.md). +[main repository docs](https://nvidia-nemo.github.io/DataDesignerPlugins/authoring/). + +Plugin documentation for the repository site lives in this package's `docs/` +directory. +""" + + +def generate_docs_index(slug: str) -> str: + return f"""# data-designer-{slug} + +Data Designer {slug} plugin. + +## Installation + +```bash +uv add data-designer data-designer-{slug} +``` + +## Usage + +Once installed, the `{slug}` column type is automatically discovered by +[NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). """ @@ -239,10 +260,12 @@ def main(args: list[str] | None = None) -> None: src_dir = plugin_dir / "src" / import_name test_dir = plugin_dir / "tests" + docs_dir = plugin_dir / "docs" # Create directories src_dir.mkdir(parents=True) test_dir.mkdir(parents=True) + docs_dir.mkdir(parents=True) # Write files owner = _discover_owner() @@ -250,6 +273,7 @@ def main(args: list[str] | None = None) -> None: plugin_dir / "pyproject.toml": generate_pyproject(slug, import_name), plugin_dir / "README.md": generate_readme(slug), plugin_dir / "CODEOWNERS": generate_codeowners(owner), + docs_dir / "index.md": generate_docs_index(slug), src_dir / "__init__.py": generate_init(), src_dir / "config.py": generate_config(slug, import_name, class_prefix), src_dir / "impl.py": generate_impl(slug, import_name, class_prefix), @@ -270,8 +294,9 @@ def main(args: list[str] | None = None) -> None: print(f" 1. cd {plugin_dir}") print(f" 2. Edit src/{import_name}/config.py to define your column config") print(f" 3. Edit src/{import_name}/impl.py to implement generation logic") - print(" 4. uv sync --all-packages && uv run pytest tests/") - print(f" 5. make release PLUGIN=data-designer-{slug}") + print(" 4. Edit docs/index.md to document your plugin") + print(" 5. uv sync --all-packages && uv run pytest tests/") + print(f" 6. make release PLUGIN=data-designer-{slug}") if __name__ == "__main__": diff --git a/devtools/ddp/tests/test_catalog.py b/devtools/ddp/tests/test_catalog.py deleted file mode 100644 index c5b21bf..0000000 --- a/devtools/ddp/tests/test_catalog.py +++ /dev/null @@ -1,29 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for ddp.catalog.""" - -from __future__ import annotations - -import io -from contextlib import redirect_stdout - -from ddp.catalog import main - - -def test_main_produces_markdown_table() -> None: - buf = io.StringIO() - with redirect_stdout(buf): - main() - output = buf.getvalue() - assert "# Plugin Catalog" in output - assert 'class="plugin-catalog"' in output - assert "Auto-generated from plugin metadata" not in output - - -def test_main_includes_template_plugin() -> None: - buf = io.StringIO() - with redirect_stdout(buf): - main() - output = buf.getvalue() - assert "data-designer-template" in output diff --git a/devtools/ddp/tests/test_cli.py b/devtools/ddp/tests/test_cli.py index 6fa9b3f..2e05d5d 100644 --- a/devtools/ddp/tests/test_cli.py +++ b/devtools/ddp/tests/test_cli.py @@ -15,7 +15,15 @@ class TestBuildParser: """Tests for build_parser producing correct subcommands.""" - EXPECTED_COMMANDS = ("new", "catalog", "codeowners", "license-headers", "validate", "check-release", "bump") + EXPECTED_COMMANDS = ( + "new", + "plugin-docs", + "codeowners", + "license-headers", + "validate", + "check-release", + "bump", + ) def test_all_subcommands_registered(self) -> None: parser = build_parser() @@ -58,6 +66,11 @@ def test_license_headers_default_no_check(self) -> None: args = parser.parse_args(["license-headers"]) assert args.check is False + def test_plugin_docs_check_flag(self) -> None: + parser = build_parser() + args = parser.parse_args(["plugin-docs", "--check"]) + assert args.check is True + def test_no_command_prints_help(self) -> None: parser = build_parser() args = parser.parse_args([]) @@ -75,11 +88,11 @@ def test_new_dispatches(self, mock_run: MagicMock) -> None: args.func(args) mock_run.assert_called_once_with(args) - @patch("ddp.cli._run_catalog") - def test_catalog_dispatches(self, mock_run: MagicMock) -> None: + @patch("ddp.cli._run_plugin_docs") + def test_plugin_docs_dispatches(self, mock_run: MagicMock) -> None: mock_run.return_value = 0 parser = build_parser() - args = parser.parse_args(["catalog"]) + args = parser.parse_args(["plugin-docs"]) args.func(args) mock_run.assert_called_once_with(args) diff --git a/devtools/ddp/tests/test_plugin_docs.py b/devtools/ddp/tests/test_plugin_docs.py new file mode 100644 index 0000000..689fe66 --- /dev/null +++ b/devtools/ddp/tests/test_plugin_docs.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ddp.plugin_docs.""" + +from __future__ import annotations + +from pathlib import Path + +from ddp.plugin_docs import check_plugin_docs, discover_plugin_docs, sync_plugin_docs + +ZENSICAL_TEMPLATE = """\ +[project] +site_name = "Test" +nav = [ + {"Home" = "index.md"}, + {"Plugins" = [ + {"Overview" = "plugins/index.md"}, + # BEGIN GENERATED PLUGIN DOCS NAV + # END GENERATED PLUGIN DOCS NAV + ]}, +] +""" + + +def write_file(path: Path, content: str) -> None: + """Write a UTF-8 fixture file.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def write_plugin_pyproject(repo_root: Path, package_name: str, column_type: str) -> None: + """Write minimal plugin pyproject metadata.""" + plugin_dir = repo_root / "plugins" / package_name + write_file( + plugin_dir / "pyproject.toml", + f"""\ +[project] +name = "{package_name}" +version = "0.1.0" +description = "{package_name} description" + +[project.entry-points."data_designer.plugins"] +{column_type} = "{package_name.replace("-", "_")}.plugin:plugin" +""", + ) + + +def write_repo_skeleton(repo_root: Path) -> None: + """Write minimal repository files needed by the docs generator.""" + write_file(repo_root / "pyproject.toml", '[project]\nname = "workspace"\n') + write_file(repo_root / "docs" / "index.md", "# Home\n") + write_file(repo_root / "zensical.toml", ZENSICAL_TEMPLATE) + + +def test_discover_plugin_docs_reads_metadata_and_source_docs(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha\n") + + plugins = discover_plugin_docs(tmp_path) + + assert len(plugins) == 1 + assert plugins[0].package_name == "data-designer-alpha" + assert plugins[0].column_types == ("alpha",) + assert plugins[0].source_docs_dir == tmp_path / "plugins" / "data-designer-alpha" / "docs" + + +def test_sync_plugin_docs_copies_plugin_docs_and_updates_nav(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha docs\n") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "usage.md", "# Usage\n") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "assets" / "sample.txt", "asset\n") + + sync_plugin_docs(tmp_path) + + generated_root = tmp_path / "docs" / "plugins" + index_page = (generated_root / "index.md").read_text(encoding="utf-8") + assert "data-designer-alpha" in index_page + assert "Browse available Data Designer plugins" in index_page + assert "make plugin-docs" not in index_page + assert (generated_root / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") == "# Alpha docs\n" + assert (generated_root / "data-designer-alpha" / "usage.md").read_text(encoding="utf-8") == "# Usage\n" + assert (generated_root / "data-designer-alpha" / "assets" / "sample.txt").read_text(encoding="utf-8") == "asset\n" + + zensical = (tmp_path / "zensical.toml").read_text(encoding="utf-8") + assert '{"data-designer-alpha" = [' in zensical + assert '{"Overview" = "plugins/data-designer-alpha/index.md"}' in zensical + assert '{"Usage" = "plugins/data-designer-alpha/usage.md"}' in zensical + + +def test_sync_plugin_docs_generates_fallback_page_without_plugin_docs(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + + sync_plugin_docs(tmp_path) + + page = (tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") + assert "# data-designer-alpha" in page + assert "custom documentation" in page + assert "Column types: `alpha`" in page + + +def test_sync_plugin_docs_maps_root_readme_to_index(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "README.md", "# Alpha README\n") + + sync_plugin_docs(tmp_path) + + page = (tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") + assert page == "# Alpha README\n" + + +def test_check_plugin_docs_detects_stale_generated_content(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha docs\n") + sync_plugin_docs(tmp_path) + write_file(tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md", "# Stale\n") + + drift = check_plugin_docs(tmp_path) + + assert any("outdated generated docs file" in message for message in drift) diff --git a/docs/assets/images/ndd-plugins.png b/docs/assets/images/ndd-plugins.png new file mode 100644 index 0000000..5e4a961 Binary files /dev/null and b/docs/assets/images/ndd-plugins.png differ diff --git a/docs/authoring.md b/docs/authoring.md index 9306eb5..e63f502 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -20,6 +20,8 @@ plugins/data-designer-my-plugin/ |-- pyproject.toml |-- README.md |-- CODEOWNERS +|-- docs/ +| `-- index.md |-- tests/ | `-- test_plugin.py `-- src/ @@ -98,13 +100,33 @@ For a faster loop, run the package tests directly: uv run pytest plugins/data-designer-my-plugin/tests/ -v ``` +## Document the plugin + +Each plugin owns its site documentation under its package directory: + +```text +plugins/data-designer-my-plugin/docs/ +|-- index.md +`-- usage.md +``` + +The top-level docs build copies this content into the generated +`docs/plugins/` tree and adds it to the Zensical navigation. Keep links and +assets relative to the plugin's `docs/` directory so they continue to work after +generation. + +Every plugin gets a generated fallback page from package metadata when it does +not provide docs yet, but plugin-authored pages should be the source of truth +for usage, configuration, and examples. + ## Regenerate metadata -When plugin metadata or ownership changes, regenerate the derived files: +When plugin docs, plugin metadata, or ownership changes, regenerate the derived +files: ```bash -make catalog +make plugin-docs make codeowners ``` -CI verifies that `docs/catalog.md` and `.github/CODEOWNERS` are current. +CI verifies that generated plugin docs and `.github/CODEOWNERS` are current. diff --git a/docs/catalog.md b/docs/catalog.md deleted file mode 100644 index 9a992ac..0000000 --- a/docs/catalog.md +++ /dev/null @@ -1,22 +0,0 @@ -# Plugin Catalog - -
- - - - - - - - - - - - - - - - - -
PluginVersionColumn TypeDescription
data-designer-template0.1.0text-transformTemplate Data Designer plugin — text transform column generator
-
diff --git a/docs/index.md b/docs/index.md index 56d0552..489dc84 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,8 +27,8 @@ DataDesignerPlugins/ documentation builds, and GitHub CI. - [Releasing](releasing.md) covers version bumps, tags, ownership checks, and PyPI publishing. -- [Plugin catalog](catalog.md) lists the plugins currently discovered from - package metadata. +- [Plugins](plugins/index.md) lists generated plugin pages assembled from each + plugin package's own docs and metadata. ## Repository contract @@ -36,5 +36,6 @@ DataDesignerPlugins/ - Keep plugins self-contained. - Target Python 3.10 and newer. - Write tests around public interfaces. -- Regenerate generated metadata when plugin metadata or ownership changes. +- Regenerate generated metadata when plugin docs, plugin metadata, or ownership + changes. - Run the Makefile targets locally before opening or updating a pull request. diff --git a/docs/plugins/data-designer-template/index.md b/docs/plugins/data-designer-template/index.md new file mode 100644 index 0000000..038c36c --- /dev/null +++ b/docs/plugins/data-designer-template/index.md @@ -0,0 +1,28 @@ +# data-designer-template + +The template plugin is the reference implementation for a simple Data Designer +column generator. It adds a `text-transform` column type that writes an output +column by transforming text from an existing source column. + +## Installation + +```bash +uv add data-designer data-designer-template +``` + +## Column type + +Use the `text-transform` column type when a dataset needs a derived text column +using one of the supported string transforms. + +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Output column name. | +| `source_column` | Yes | Existing text column to transform. | +| `transform` | No | One of `upper`, `lower`, or `title`; defaults to `upper`. | + +## Implementation notes + +The package keeps plugin registration in `plugin.py`, configuration in +`config.py`, and generation logic in `impl.py`. New plugins should follow the +same separation unless their behavior needs a different shape. diff --git a/docs/plugins/data-designer-template/usage.md b/docs/plugins/data-designer-template/usage.md new file mode 100644 index 0000000..0133ad7 --- /dev/null +++ b/docs/plugins/data-designer-template/usage.md @@ -0,0 +1,22 @@ +# Usage + +The template plugin is intentionally small so plugin authors can inspect the +full package quickly. Its generator reads a source text column and writes a new +column using the configured transform. + +```python +from data_designer.config.config_builder import DataDesignerConfigBuilder +from data_designer.config.seed_source_dataframe import DataFrameSeedSource + +builder = DataDesignerConfigBuilder() +builder.with_seed_dataset(DataFrameSeedSource(df=seed_df)) +builder.add_column( + name="name_upper", + column_type="text-transform", + source_column="name", + transform="upper", +) +``` + +The package tests cover the public config object, generator behavior, and a +preview flow through Data Designer. diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 0000000..4e54e2e --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,19 @@ + + +# Plugins + +Browse available Data Designer plugins by what they add to your data generation workflow. + + diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index d9a4b81..7ec3311 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,53 +1,109 @@ /* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ /* SPDX-License-Identifier: Apache-2.0 */ -.plugin-catalog-table { - margin: 1rem 0 1.5rem; - overflow-x: auto; - padding-bottom: 0.25rem; +.plugin-doc-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + margin: 1.15rem 0 1.5rem; } -.plugin-catalog { +.md-typeset .plugin-doc-card { + background: var(--md-default-bg-color); + background: + linear-gradient(135deg, color-mix(in srgb, var(--md-accent-fg-color) 14%, transparent), transparent 42%), + var(--md-default-bg-color); border: 1px solid var(--md-typeset-table-color); - border-collapse: collapse; - border-radius: 0.2rem; - display: table; - font-size: 0.68rem; - min-width: 42rem; - width: 100%; + border-radius: 0.35rem; + color: var(--md-typeset-color); + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 10rem; + overflow: hidden; + padding: 0.95rem 1rem; + position: relative; + text-decoration: none !important; + transition: + border-color 160ms ease, + transform 160ms ease; } -.plugin-catalog th, -.plugin-catalog td { - border-top: 1px solid var(--md-typeset-table-color); - padding: 0.7rem 0.8rem; - text-align: left; - vertical-align: top; +.md-typeset .plugin-doc-card:hover { + border-color: var(--md-accent-fg-color); + color: var(--md-accent-fg-color); + transform: translateY(-0.06rem); } -.plugin-catalog th { - border-top: 0; - color: var(--md-default-fg-color--light); +.md-typeset .plugin-doc-card * { + border-bottom: 0 !important; + text-decoration: none !important; +} + +.plugin-doc-card__header { + align-items: flex-start; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.plugin-doc-card__title { + color: var(--md-default-fg-color); + font-size: 0.84rem; font-weight: 700; + line-height: 1.25; } -.plugin-catalog tbody tr:hover { - background: var(--md-typeset-table-color--light); +.plugin-doc-card__version { + background: color-mix(in srgb, var(--md-accent-fg-color) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--md-accent-fg-color) 35%, transparent); + border-radius: 999px; + color: var(--md-accent-fg-color); + flex: 0 0 auto; + font-size: 0.56rem; + font-weight: 700; + line-height: 1; + padding: 0.25rem 0.42rem; } -.plugin-catalog__plugin, -.plugin-catalog__version, -.plugin-catalog__column { - white-space: nowrap; +.plugin-doc-card__description { + color: var(--md-default-fg-color--light); + font-size: 0.68rem; + line-height: 1.45; } -.plugin-catalog__description { - min-width: 18rem; +.plugin-doc-card__section { + display: flex; + flex-direction: column; + gap: 0.35rem; } -@media screen and (min-width: 76.25em) { - .plugin-catalog-table { - margin-right: -10rem; - max-width: calc(100vw - 18rem); - } +.plugin-doc-card__label { + color: var(--md-default-fg-color--light); + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.plugin-doc-card__chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.plugin-doc-chip { + background: color-mix(in srgb, var(--md-code-bg-color) 88%, var(--md-accent-fg-color)); + border: 1px solid var(--md-typeset-table-color); + border-radius: 0.25rem; + color: var(--md-default-fg-color); + font-family: var(--md-code-font-family); + font-size: 0.58rem; + line-height: 1.2; + padding: 0.25rem 0.38rem; +} + +.plugin-doc-chip--muted { + color: var(--md-default-fg-color--light); + font-family: var(--md-text-font-family); } diff --git a/docs/workflow.md b/docs/workflow.md index 044223b..2f2fcf2 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -27,14 +27,15 @@ The target runs: | `make lint` | Ruff linting and formatting. | | `make test` | Each plugin's tests in an isolated virtual environment. | | `make validate` | Installed `data_designer.plugins` entry points with `assert_valid_plugin`. | -| `make check` | Generated catalog, generated CODEOWNERS, and SPDX license headers. | +| `make check` | Generated plugin docs, generated CODEOWNERS, and SPDX license headers. | | `make docs` | Zensical builds the documentation site in strict mode. | | `make docs-server` | Zensical serves the documentation site locally while you edit. | ## Documentation -Documentation source lives under `docs/` and is built by -[Zensical](https://zensical.org/): +Repository documentation source lives under `docs/`, and plugin-specific site +pages live under each plugin's `docs/` directory. The top-level docs build +regenerates `docs/plugins/` before running [Zensical](https://zensical.org/): ```bash make docs @@ -59,15 +60,16 @@ make docs-server DOCS_DEV_ADDR=localhost:8080 ## Generated files -Two files are generated from repository metadata: +Generated site inputs come from repository metadata and plugin docs: ```bash -make catalog +make plugin-docs make codeowners ``` -`docs/catalog.md` is part of the documentation site, but it is generated from -plugin package metadata. Do not edit it manually. +`docs/plugins/` and the plugin section of `zensical.toml` are generated from +plugin package metadata and `plugins/*/docs/`. Do not edit generated plugin site +pages directly. ## GitHub CI diff --git a/plugins/data-designer-template/README.md b/plugins/data-designer-template/README.md index 598b047..543867d 100644 --- a/plugins/data-designer-template/README.md +++ b/plugins/data-designer-template/README.md @@ -5,7 +5,7 @@ Template Data Designer plugin and reference implementation for plugin authors. ## Installation ```bash -pip install data-designer-template +uv add data-designer data-designer-template ``` ## Usage @@ -15,4 +15,7 @@ This plugin provides a `text-transform` column type for Once installed, the column type is automatically discovered by Data Designer. For the full plugin authoring guide, see the -[main repository docs](https://github.com/NVIDIA-NeMo/DataDesignerPlugins/blob/main/docs/adding-a-plugin.md). +[main repository docs](https://nvidia-nemo.github.io/DataDesignerPlugins/authoring/). + +Plugin documentation for the repository site lives in this package's `docs/` +directory. diff --git a/plugins/data-designer-template/docs/index.md b/plugins/data-designer-template/docs/index.md new file mode 100644 index 0000000..038c36c --- /dev/null +++ b/plugins/data-designer-template/docs/index.md @@ -0,0 +1,28 @@ +# data-designer-template + +The template plugin is the reference implementation for a simple Data Designer +column generator. It adds a `text-transform` column type that writes an output +column by transforming text from an existing source column. + +## Installation + +```bash +uv add data-designer data-designer-template +``` + +## Column type + +Use the `text-transform` column type when a dataset needs a derived text column +using one of the supported string transforms. + +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Output column name. | +| `source_column` | Yes | Existing text column to transform. | +| `transform` | No | One of `upper`, `lower`, or `title`; defaults to `upper`. | + +## Implementation notes + +The package keeps plugin registration in `plugin.py`, configuration in +`config.py`, and generation logic in `impl.py`. New plugins should follow the +same separation unless their behavior needs a different shape. diff --git a/plugins/data-designer-template/docs/usage.md b/plugins/data-designer-template/docs/usage.md new file mode 100644 index 0000000..0133ad7 --- /dev/null +++ b/plugins/data-designer-template/docs/usage.md @@ -0,0 +1,22 @@ +# Usage + +The template plugin is intentionally small so plugin authors can inspect the +full package quickly. Its generator reads a source text column and writes a new +column using the configured transform. + +```python +from data_designer.config.config_builder import DataDesignerConfigBuilder +from data_designer.config.seed_source_dataframe import DataFrameSeedSource + +builder = DataDesignerConfigBuilder() +builder.with_seed_dataset(DataFrameSeedSource(df=seed_df)) +builder.add_column( + name="name_upper", + column_type="text-transform", + source_column="name", + transform="upper", +) +``` + +The package tests cover the public config object, generator behavior, and a +preview flow through Data Designer. diff --git a/zensical.toml b/zensical.toml index 7d15faa..3f1af80 100644 --- a/zensical.toml +++ b/zensical.toml @@ -4,7 +4,7 @@ [project] site_name = "Data Designer Plugins" site_url = "https://nvidia-nemo.github.io/DataDesignerPlugins/" -site_description = "Plugin authoring guide and catalog for Data Designer plugins." +site_description = "Plugin authoring guide and documentation for Data Designer plugins." site_author = "NVIDIA" repo_url = "https://github.com/NVIDIA-NeMo/DataDesignerPlugins" repo_name = "NVIDIA-NeMo/DataDesignerPlugins" @@ -16,11 +16,21 @@ nav = [ {"Plugin authoring" = "authoring.md"}, {"Development workflow" = "workflow.md"}, {"Releasing" = "releasing.md"}, - {"Plugin catalog" = "catalog.md"}, + {"Plugins" = [ + {"Overview" = "plugins/index.md"}, + # BEGIN GENERATED PLUGIN DOCS NAV + {"data-designer-template" = [ + {"Overview" = "plugins/data-designer-template/index.md"}, + {"Usage" = "plugins/data-designer-template/usage.md"}, + ]}, + # END GENERATED PLUGIN DOCS NAV + ]}, ] [project.theme] language = "en" +favicon = "assets/images/ndd-plugins.png" +logo = "assets/images/ndd-plugins.png" features = [ "content.action.edit", "content.action.view",