Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docs-preview-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docs-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 27 additions & 8 deletions docs/development/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <http://localhost:8000> 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-<number>/` 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-<number>/` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/documentation/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: Documentation
description: Technical guides for projects featured in OpenShell Dev Notes.
agent_markdown: true
---

# Documentation
Expand Down
9 changes: 9 additions & 0 deletions overrides/main.html
Original file line number Diff line number Diff line change
@@ -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 %}
<link rel="alternate" type="text/markdown" href="{{ agent_markdown.href(page) }}" title="Markdown source">
{% endif %}
{% endblock %}
26 changes: 26 additions & 0 deletions overrides/partials/actions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{# Preserve the upstream Zensical 0.0.44 content actions. #}
{% if page.edit_url %}
{% if "content.action.edit" in features %}
<a href="{{ page.edit_url }}" title="{{ lang.t('action.edit') }}" class="md-content__button md-icon" rel="edit">
{% set icon = config.theme.icon.edit or "material/file-edit-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
{% if "content.action.view" in features %}
{% if "/blob/" in page.edit_url %}
{% set part = "/blob/" %}
{% else %}
{% set part = "/edit/" %}
{% endif %}
<a href="{{ page.edit_url | replace(part, '/raw/') }}" title="{{ lang.t('action.view') }}" class="md-content__button md-icon">
{% set icon = config.theme.icon.view or "material/file-eye-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
{% endif %}
{% if page.meta and page.meta.agent_markdown %}
{% import "partials/agent-markdown.html" as agent_markdown with context %}
<a href="{{ agent_markdown.href(page) }}" title="View Markdown" aria-label="View Markdown" class="md-content__button md-icon">
{% include ".icons/material/language-markdown-outline.svg" %}
</a>
{% endif %}
13 changes: 13 additions & 0 deletions overrides/partials/agent-markdown.html
Original file line number Diff line number Diff line change
@@ -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 %}
2 changes: 2 additions & 0 deletions scripts/build-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
138 changes: 138 additions & 0 deletions scripts/publish-agent-markdown.py
Original file line number Diff line number Diff line change
@@ -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<body>.*?)\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())
Loading