diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index d4b41c4e..91d56ef0 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -97,7 +97,9 @@ jobs: '.github/workflows/docs-preview-deploy.yml', 'requirements-docs.txt', 'scripts/build-docs.sh', + 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', + 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', 'tests/test_render_dev_notes.py', 'zensical.toml', diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 2a14e41e..dcc1ce2c 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -45,7 +45,9 @@ jobs: '.github/workflows/docs-preview-deploy.yml', 'requirements-docs.txt', 'scripts/build-docs.sh', + 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', + 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', 'tests/test_render_dev_notes.py', 'zensical.toml', diff --git a/docs/dev-notes/posts/2026-07-20-policy-controlling-reachy-mini-with-openshell.md b/docs/dev-notes/posts/2026-07-20-policy-controlling-reachy-mini-with-openshell.md index a967caeb..aec0d17c 100644 --- a/docs/dev-notes/posts/2026-07-20-policy-controlling-reachy-mini-with-openshell.md +++ b/docs/dev-notes/posts/2026-07-20-policy-controlling-reachy-mini-with-openshell.md @@ -3,6 +3,7 @@ title: "Bringing Privacy and Security to the Edge with OpenShell" date: 2026-07-20 updated: 2026-07-20 description: "Edge agents handle sensitive data and make decisions with physical consequences. Reachy Mini shows why privacy and safety controls must be deterministic and local." +agent_markdown: true hero_image: "../../assets/reachy-mini-openshell/hero.svg" categories: - Edge AI diff --git a/docs/development/index.md b/docs/development/index.md index 37985f8f..7871d111 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -19,6 +19,22 @@ root. Use Python 3.10 or newer. `docs/documentation/`, link it to the originating Dev Note, and add it to `zensical.toml`. +## Agent-readable Markdown + +Every canonical content page under `docs/dev-notes/posts/` and +`docs/documentation/` must declare `agent_markdown: true` in its front matter. +The clean site build copies those sources byte-for-byte into `site/` at the same +path relative to `docs/`, and each rendered page links to its same-origin +Markdown source for people and agents. The generated copies under `site/` must +not be edited. + +Every page in the two canonical content directories is published, including +the Documentation index. Keep presentation-only landing pages such as the +homepage and Dev Notes card index, redirect-only pages, obsolete or orphan +project pages, internal development documentation, and the 404 page outside +those directories and do not add the marker to them. Those pages are not +canonical content for agent consumption. + ## Dev Notes Dev Notes are Markdown posts under `docs/dev-notes/posts/`. Each post requires @@ -75,19 +91,22 @@ scripts/build-docs.sh renders Dev Notes metadata, and runs `zensical build --clean --strict`. Do not report success unless it completes without issues. -For documentation-site changes, serve the site before handing the task back: +For documentation-site changes, serve the complete built artifact before +handing the task back: ```sh -.venv-docs/bin/zensical serve +python3 -m http.server 8000 --directory site ``` Confirm is reachable and report the URL and command being -served. Pull requests from branches in this repository that change documentation -inputs publish the built site under `/pr-preview/pr-/` and receive a -comment linking to that browser-accessible preview. The preview is updated when -the PR changes and removed when the PR closes or no longer changes documentation. -Fork and Dependabot pull requests validate with read-only credentials but do not -publish previews on the production documentation origin. +served. Plain `zensical serve` does not run the post-build Markdown publisher, +so it is not an artifact-faithful preview. Pull requests from branches in this +repository that change documentation inputs publish the built site under +`/pr-preview/pr-/` and receive a comment linking to that +browser-accessible preview. The preview is updated when the PR changes and +removed when the PR closes or no longer changes documentation. Fork and +Dependabot pull requests validate with read-only credentials but do not publish +previews on the production documentation origin. The `gh-pages` branch stores the composite production site and active previews; GitHub Pages remains configured with **GitHub Actions** as its publishing source. diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 1da691e8..628d196b 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -1,6 +1,7 @@ --- title: Documentation description: Technical guides for projects featured in OpenShell Dev Notes. +agent_markdown: true --- # Documentation diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 00000000..f922ff93 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% import "partials/agent-markdown.html" as agent_markdown with context %} + +{% block extrahead %} + {{ super() }} + {% if page.meta and page.meta.agent_markdown %} + + {% endif %} +{% endblock %} diff --git a/overrides/partials/actions.html b/overrides/partials/actions.html new file mode 100644 index 00000000..62ec6c16 --- /dev/null +++ b/overrides/partials/actions.html @@ -0,0 +1,26 @@ +{# Preserve the upstream Zensical 0.0.44 content actions. #} +{% if page.edit_url %} + {% if "content.action.edit" in features %} + + {% set icon = config.theme.icon.edit or "material/file-edit-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} + {% if "content.action.view" in features %} + {% if "/blob/" in page.edit_url %} + {% set part = "/blob/" %} + {% else %} + {% set part = "/edit/" %} + {% endif %} + + {% set icon = config.theme.icon.view or "material/file-eye-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} +{% endif %} +{% if page.meta and page.meta.agent_markdown %} + {% import "partials/agent-markdown.html" as agent_markdown with context %} + + {% include ".icons/material/language-markdown-outline.svg" %} + +{% endif %} diff --git a/overrides/partials/agent-markdown.html b/overrides/partials/agent-markdown.html new file mode 100644 index 00000000..5fd6aaac --- /dev/null +++ b/overrides/partials/agent-markdown.html @@ -0,0 +1,13 @@ +{% macro href(page) -%} + {%- if page.edit_url.split is defined -%} + {%- set parts = page.edit_url.split("/") -%} + {%- else -%} + {%- set parts = page.edit_url | split("/") -%} + {%- endif -%} + {%- set filename = parts | last -%} + {%- if filename == "index.md" -%} + index.md + {%- else -%} + ../{{ filename }} + {%- endif -%} +{%- endmacro %} diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 41c9292e..16bee84a 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -32,4 +32,6 @@ python -m pip install -r requirements-docs.txt python scripts/render-dev-notes.py zensical build --clean --strict +python scripts/publish-agent-markdown.py +REQUIRE_RENDERED_AGENT_MARKDOWN=1 python tests/test_agent_markdown.py REQUIRE_RENDERED_404=1 python tests/test_docs_404.py diff --git a/scripts/publish-agent-markdown.py b/scripts/publish-agent-markdown.py new file mode 100644 index 00000000..9d26868b --- /dev/null +++ b/scripts/publish-agent-markdown.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Publish canonical documentation sources beside their rendered HTML pages.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import shutil + + +ROOT = Path(__file__).resolve().parents[1] +ELIGIBLE_DIRECTORIES = ( + Path("dev-notes/posts"), + Path("documentation"), +) +FRONT_MATTER = re.compile( + rb"\A---[ \t]*\r?\n(?P.*?)\r?\n(?:---|\.\.\.)[ \t]*\r?\n", + re.DOTALL, +) +AGENT_MARKDOWN = re.compile(rb"^agent_markdown:[ \t]*true[ \t]*$", re.MULTILINE) + + +def _within(path: Path, directory: Path) -> Path: + """Return a resolved path relative to a resolved directory.""" + + resolved_directory = directory.resolve() + resolved_path = path.resolve() + try: + return resolved_path.relative_to(resolved_directory) + except ValueError as error: + raise ValueError(f"path escapes {resolved_directory}: {path}") from error + + +def has_agent_markdown(source: Path) -> bool: + """Return whether a source has the canonical agent Markdown marker.""" + + match = FRONT_MATTER.match(source.read_bytes()) + return bool(match and len(AGENT_MARKDOWN.findall(match.group("body"))) == 1) + + +def eligible_sources(docs_dir: Path) -> list[Path]: + """Find and validate every canonical content source.""" + + _within(docs_dir, docs_dir) + sources: list[Path] = [] + for relative_directory in ELIGIBLE_DIRECTORIES: + directory = docs_dir / relative_directory + _within(directory, docs_dir) + if not directory.is_dir(): + raise ValueError(f"eligible content directory does not exist: {directory}") + sources.extend(path for path in directory.rglob("*.md") if path.is_file()) + + sources.sort() + for source in sources: + _within(source, docs_dir) + missing = [source for source in sources if not has_agent_markdown(source)] + if missing: + paths = "\n".join(f" - {source}" for source in missing) + raise ValueError( + "canonical content pages must declare `agent_markdown: true` in " + f"front matter:\n{paths}" + ) + return sources + + +def markdown_destination(source: Path, docs_dir: Path, site_dir: Path) -> Path: + """Map a safe documentation source to its same-path site export.""" + + relative_source = _within(source, docs_dir) + if source.suffix != ".md": + raise ValueError(f"agent Markdown source must end in .md: {source}") + destination = site_dir / relative_source + _within(destination, site_dir) + return destination + + +def rendered_html(source: Path, docs_dir: Path, site_dir: Path) -> Path: + """Return the expected directory-URL HTML output for a source.""" + + relative_source = _within(source, docs_dir) + if source.suffix != ".md": + raise ValueError(f"rendered source must end in .md: {source}") + if source.name == "index.md": + rendered = site_dir / relative_source.with_suffix(".html") + else: + rendered = site_dir / relative_source.with_suffix("") / "index.html" + _within(rendered, site_dir) + return rendered + + +def markdown_href(source: Path) -> str: + """Return the relative Markdown URL used by an eligible rendered page.""" + + if source.name == "index.md": + return "index.md" + return f"../{source.name}" + + +def publish(docs_dir: Path, site_dir: Path) -> list[Path]: + """Validate and copy eligible Markdown sources into the built site.""" + + sources = eligible_sources(docs_dir) + if not site_dir.is_dir(): + raise ValueError(f"site directory does not exist: {site_dir}") + + destinations: list[Path] = [] + for source in sources: + rendered = rendered_html(source, docs_dir, site_dir) + if not rendered.is_file(): + raise ValueError(f"rendered HTML does not exist for {source}: {rendered}") + destinations.append(markdown_destination(source, docs_dir, site_dir)) + + for source, destination in zip(sources, destinations, strict=True): + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + return destinations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--docs-dir", type=Path, default=ROOT / "docs") + parser.add_argument("--site-dir", type=Path, default=ROOT / "site") + args = parser.parse_args() + + try: + destinations = publish(args.docs_dir, args.site_dir) + except ValueError as error: + parser.error(str(error)) + print(f"Published {len(destinations)} agent-readable Markdown page(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_agent_markdown.py b/tests/test_agent_markdown.py new file mode 100644 index 00000000..089acf05 --- /dev/null +++ b/tests/test_agent_markdown.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from html.parser import HTMLParser +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +SITE = ROOT / "site" +SCRIPT = ROOT / "scripts" / "publish-agent-markdown.py" + +SPEC = importlib.util.spec_from_file_location("publish_agent_markdown", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"could not load {SCRIPT}") +PUBLISHER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PUBLISHER) + + +class MarkdownDiscoveryParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.actions: list[str] = [] + self.alternates: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if ( + tag == "a" + and attributes.get("title") == "View Markdown" + and attributes.get("aria-label") == "View Markdown" + and {"md-content__button", "md-icon"} + <= set((attributes.get("class") or "").split()) + ): + self.actions.append(attributes.get("href") or "") + if ( + tag == "link" + and "alternate" in (attributes.get("rel") or "").split() + and attributes.get("type") == "text/markdown" + and attributes.get("title") == "Markdown source" + ): + self.alternates.append(attributes.get("href") or "") + + +def parse_discovery(html: Path) -> MarkdownDiscoveryParser: + parser = MarkdownDiscoveryParser() + parser.feed(html.read_text(encoding="utf-8")) + return parser + + +class AgentMarkdownUnitTests(unittest.TestCase): + def test_all_canonical_content_opts_in(self) -> None: + sources = PUBLISHER.eligible_sources(DOCS) + + self.assertTrue(sources) + self.assertTrue(all(PUBLISHER.has_agent_markdown(path) for path in sources)) + + def test_missing_front_matter_marker_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + docs = Path(temporary_directory) / "docs" + (docs / "dev-notes/posts").mkdir(parents=True) + (docs / "documentation").mkdir() + (docs / "dev-notes/posts/note.md").write_text( + "---\ntitle: Note\n---\n\n# Note\n", encoding="utf-8" + ) + (docs / "documentation/index.md").write_text( + "---\nagent_markdown: true\n---\n\n# Docs\n", encoding="utf-8" + ) + + with self.assertRaisesRegex(ValueError, "agent_markdown: true"): + PUBLISHER.eligible_sources(docs) + + def test_relative_href_mapping(self) -> None: + self.assertEqual(PUBLISHER.markdown_href(Path("documentation/index.md")), "index.md") + self.assertEqual( + PUBLISHER.markdown_href(Path("dev-notes/posts/example.md")), + "../example.md", + ) + + def test_path_traversal_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + docs = root / "docs" + site = root / "site" + docs.mkdir() + site.mkdir() + + with self.assertRaisesRegex(ValueError, "path escapes"): + PUBLISHER.markdown_destination(root / "outside.md", docs, site) + + def test_publish_copies_sources_byte_for_byte(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + docs = root / "docs" + site = root / "site" + post = docs / "dev-notes/posts/example.md" + documentation = docs / "documentation/index.md" + post.parent.mkdir(parents=True) + documentation.parent.mkdir(parents=True) + site.mkdir() + post_bytes = b"---\r\nagent_markdown: true\r\n---\r\n\r\n# Post\r\n" + documentation_bytes = b"---\nagent_markdown: true\n---\n\n# Documentation\n" + post.write_bytes(post_bytes) + documentation.write_bytes(documentation_bytes) + for source in (post, documentation): + rendered = PUBLISHER.rendered_html(source, docs, site) + rendered.parent.mkdir(parents=True, exist_ok=True) + rendered.write_text("", encoding="utf-8") + + destinations = PUBLISHER.publish(docs, site) + + self.assertEqual(len(destinations), 2) + self.assertEqual((site / "dev-notes/posts/example.md").read_bytes(), post_bytes) + self.assertEqual( + (site / "documentation/index.md").read_bytes(), documentation_bytes + ) + + +@unittest.skipUnless( + os.environ.get("REQUIRE_RENDERED_AGENT_MARKDOWN") == "1", + "rendered output is checked after the documentation build", +) +class AgentMarkdownRenderedTests(unittest.TestCase): + def test_eligible_pages_publish_matching_discovery_links(self) -> None: + for source in PUBLISHER.eligible_sources(DOCS): + with self.subTest(source=source.relative_to(DOCS)): + rendered = PUBLISHER.rendered_html(source, DOCS, SITE) + export = PUBLISHER.markdown_destination(source, DOCS, SITE) + self.assertTrue(rendered.is_file(), f"missing {rendered}") + self.assertTrue(export.is_file(), f"missing {export}") + self.assertEqual(export.read_bytes(), source.read_bytes()) + + discovery = parse_discovery(rendered) + expected_href = PUBLISHER.markdown_href(source) + self.assertEqual(discovery.actions, [expected_href]) + self.assertEqual(discovery.alternates, [expected_href]) + linked_export = (rendered.parent / expected_href).resolve() + self.assertEqual(linked_export, export.resolve()) + self.assertTrue(linked_export.is_file()) + + def test_ineligible_pages_have_no_markdown_discovery_links(self) -> None: + eligible_html = { + PUBLISHER.rendered_html(source, DOCS, SITE).resolve() + for source in PUBLISHER.eligible_sources(DOCS) + } + rendered_pages = sorted(SITE.rglob("*.html")) + self.assertTrue(rendered_pages) + + for rendered in rendered_pages: + if rendered.resolve() in eligible_html: + continue + with self.subTest(rendered=rendered.relative_to(SITE)): + discovery = parse_discovery(rendered) + self.assertEqual(discovery.actions, []) + self.assertEqual(discovery.alternates, []) + + +if __name__ == "__main__": + unittest.main()