From 8a227e545530f90869732e30454439ce660ef23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 19:00:06 +0300 Subject: [PATCH] feat: new libraries ship the agents page and the copy-page control --- .claude/LIBRARY_CREATION.md | 31 ++- NEW_LIBRARY_CHECKLIST.md.jinja | 10 + README.md | 4 +- template/.github/PULL_REQUEST_TEMPLATE.md | 2 +- template/.github/workflows/docs.yml | 1 + template/CONTRIBUTING.md.jinja | 20 ++ template/README.md.jinja | 12 ++ template/docs/agents.md.jinja | 106 ++++++++++ template/docs/assets/javascripts/copy-page.js | 158 +++++++++++++++ .../docs/assets/stylesheets/copy-page.css | 184 ++++++++++++++++++ template/docs/reference/index.md.jinja | 4 + template/overrides/main.html | 84 ++++++++ template/scripts/emit_markdown.py | 87 +++++++++ template/zensical.toml.jinja | 5 + 14 files changed, 702 insertions(+), 6 deletions(-) create mode 100644 template/docs/agents.md.jinja create mode 100644 template/docs/assets/javascripts/copy-page.js create mode 100644 template/docs/assets/stylesheets/copy-page.css create mode 100644 template/overrides/main.html create mode 100644 template/scripts/emit_markdown.py diff --git a/.claude/LIBRARY_CREATION.md b/.claude/LIBRARY_CREATION.md index 16e7961..2d8616b 100644 --- a/.claude/LIBRARY_CREATION.md +++ b/.claude/LIBRARY_CREATION.md @@ -73,7 +73,28 @@ When creating a new library repository: - First `feat:` commit → Release Please creates v0.1.0 - First `fix:` commit → Release Please creates v0.0.1 -## 5. Clean Repository Checklist +## 5. The Agents Page + +`docs/agents.md` ships as a skeleton with `TODO` markers. Fill it in before the initial +commit — it is the page a person hands to a coding assistant instead of the whole site, +so an empty one is a broken promise and a wrong one teaches an API that does not exist. + +Write it from the source you just wrote, not from the README: every name, argument, +default and return type has to be one that exists, and every name a caller is told to +import has to be importable. Check that before committing: + +```bash +uv run python -c "import ; [getattr(, n) for n in ('Name', ...)]" +``` + +`https://bedrock-python.github.io/pg-partsmith/agents/` is the worked example. The +sections that carry the weight are **Scope** (what it does not do stops a model +inventing features), **Rules that hold or break the code** and **Common mistakes**. +Delete a section that has nothing true to say rather than padding it. + +Keep the `` guidance comment out of the committed page. + +## 6. Clean Repository Checklist Before pushing the initial commit, verify: @@ -83,8 +104,9 @@ Before pushing the initial commit, verify: - [ ] No backup files (e.g., `.coverage (копия...)`, `file (copy).py`) - [ ] No cache files (`.mypy_cache`, `.ruff_cache`, `__pycache__`) - should be in `.gitignore` - [ ] `.release-please-manifest.json` contains `"0.0.0"` +- [ ] `docs/agents.md` written from the real API, no `TODO` markers, guidance comment removed -## 6. PyPI Publication +## 7. PyPI Publication Before merging the Release Please PR: @@ -97,15 +119,16 @@ Before merging the Release Please PR: 2. Merge Release Please PR → automatic publication to PyPI -## 7. Common Mistakes to Avoid +## 8. Common Mistakes to Avoid ❌ Including AI agents in commits ❌ Committing `scripts/setup_repo.py` to the library repo ❌ Starting with version `0.1.0` instead of `0.0.0` ❌ Committing backup/cache files ❌ Creating multiple commits before initial push (messy history) +❌ Shipping `docs/agents.md` with its `TODO` markers still in it -## 8. Summary +## 9. Summary **Clean initial commit = Clean repository forever** diff --git a/NEW_LIBRARY_CHECKLIST.md.jinja b/NEW_LIBRARY_CHECKLIST.md.jinja index 701ea08..838d149 100644 --- a/NEW_LIBRARY_CHECKLIST.md.jinja +++ b/NEW_LIBRARY_CHECKLIST.md.jinja @@ -10,6 +10,16 @@ uv run pre-commit install --hook-type commit-msg make check # should pass on a fresh repo ``` +Then fill in `docs/agents.md`. It ships as a skeleton with `TODO` markers, and it is the +page people hand to a coding assistant instead of the whole site — write it from the API +you actually wrote, verify every name it tells a caller to import, and delete the +guidance comment at the top. `https://bedrock-python.github.io/pg-partsmith/agents/` is +the worked example. `CONTRIBUTING.md` keeps it current from there. + +```bash +make docs-build # the page is in the nav, so the build fails without it +``` + ## Step 2 — Create GitHub repo and push ```bash diff --git a/README.md b/README.md index d4d0968..641ad7b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ See [`.claude/LIBRARY_CREATION.md`](.claude/LIBRARY_CREATION.md) for important r - `Makefile` — fmt, check, test-unit, test-integration, test, build, docs-serve, docs-build - `.github/workflows/` — CI (lint + unit + integration), publish to PyPI, deploy docs, Release Please - `.github/` — dependabot, issue templates, PR template -- `docs/` — zensical (MkDocs Material) setup with mkdocstrings +- `docs/` — zensical (MkDocs Material) setup with mkdocstrings, a **Copy page** control that + hands any page to an LLM as Markdown, and `docs/agents.md`, the one-page brief for coding + assistants (ships as a skeleton — fill it in) - `.pre-commit-config.yaml` — ruff, mypy, conventional commits - `release-please-config.json` — automated semver + CHANGELOG generation diff --git a/template/.github/PULL_REQUEST_TEMPLATE.md b/template/.github/PULL_REQUEST_TEMPLATE.md index 5451fe1..5073550 100644 --- a/template/.github/PULL_REQUEST_TEMPLATE.md +++ b/template/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,7 @@ - [ ] Tests added or updated - [ ] `make check` passes locally (`ruff` + `mypy`) - [ ] `CHANGELOG.md` updated under `[Unreleased]` -- [ ] Documentation updated (if the public API changed) +- [ ] Documentation updated (if the public API changed), `docs/agents.md` included ## Related issues diff --git a/template/.github/workflows/docs.yml b/template/.github/workflows/docs.yml index af79c1d..0b71e78 100644 --- a/template/.github/workflows/docs.yml +++ b/template/.github/workflows/docs.yml @@ -18,6 +18,7 @@ jobs: - run: uv sync --no-dev --group docs - run: cp CHANGELOG.md docs/changelog.md - run: uv run zensical build --clean + - run: uv run python scripts/emit_markdown.py - uses: actions/upload-pages-artifact@v5 with: path: site diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 09afa8e..46d2f74 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -52,6 +52,26 @@ Breaking changes: add `!` after the type (`feat!:`) or include a `BREAKING CHANG 4. Run `make check && make test-unit` locally 5. Open a PR against `master` +## The agents page + +`docs/agents.md` is the whole library on one page, written for a coding assistant: the +public API, the rules that break code when they are broken, the mistakes models make, and +a map of which page to fetch for the rest. People hand it to an assistant instead of the +site, which is what makes a stale one worse than none — it teaches a model an API that no +longer exists. + +It is part of the public API, so it changes in the same pull request the API does: a name +added, renamed or removed, a changed default or signature, a new rule a caller has to +obey. A new docs page means a new row in the documentation map. The review check is +mechanical — if the diff changes the public surface and `docs/agents.md` is untouched, the +pull request is not finished. + +The page carries its own weight only if it stays fetchable as text. Every page of the site +is written a second time as raw Markdown next to its HTML by `scripts/emit_markdown.py`, +which the Docs workflow runs after the build; the **Copy page** control above each page +reads those files. A page whose Markdown would not read as the page — the generated API +reference — declines both with `copy_page: false` in its front matter. + ## Releasing (maintainers only) Releases are fully automated via [Release Please](https://github.com/googleapis/release-please). diff --git a/template/README.md.jinja b/template/README.md.jinja index 60dd775..55cfaa3 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -9,6 +9,14 @@ [![codecov](https://codecov.io/gh/{{ github_org }}/{{ project_slug }}/graph/badge.svg)](https://codecov.io/gh/{{ github_org }}/{{ project_slug }}) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://{{ github_org }}.github.io/{{ project_slug }}/) +> [!TIP] +> **Building this with an AI assistant?** Hand it +> **[one page](https://{{ github_org }}.github.io/{{ project_slug }}/agents/)** instead of the +> whole site: the public API, the rules that break code when they are broken, the mistakes +> models make, and a map of which page to fetch for the rest. Every docs page is also served +> as raw Markdown at its own URL, and a **Copy page** button at the top of each one hands it +> straight to a chat window. + ## Installation ```bash @@ -27,6 +35,10 @@ pip install {{ project_slug }} Full documentation at [{{ github_org }}.github.io/{{ project_slug }}](https://{{ github_org }}.github.io/{{ project_slug }}/). +- [Guide](https://{{ github_org }}.github.io/{{ project_slug }}/guide/quickstart/) — quick start, configuration, the advanced corners +- [API reference](https://{{ github_org }}.github.io/{{ project_slug }}/reference/) — every public name, generated from the docstrings +- [For AI agents](https://{{ github_org }}.github.io/{{ project_slug }}/agents/) — the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant + ## License Apache 2.0 — see [LICENSE](LICENSE). diff --git a/template/docs/agents.md.jinja b/template/docs/agents.md.jinja new file mode 100644 index 0000000..36a12db --- /dev/null +++ b/template/docs/agents.md.jinja @@ -0,0 +1,106 @@ + + +# {{ project_name }} for AI agents + +> One page holding everything a coding assistant needs to use {{ project_name }} +> correctly, plus a map of where the rest of the documentation keeps the details it +> leaves out. Give an agent this page rather than the whole site. + +| | | +|---|---| +| Package | `{{ project_slug }}` on PyPI, import root `{{ package_name }}` | +| Requires | Python {{ python_min_version }}+ | +| Install | `pip install {{ project_slug }}` | +| Source | | + +## How to read this page + +Every page of this site is also served as raw Markdown at its own URL with `.md` in +place of the trailing slash — this page is `/agents.md`, the quick start is +`/guide/quickstart.md` — so anything the map below points at can be fetched as plain +text rather than scraped out of HTML. The **Copy page** control at the top of a page +does the same thing for a human with a chat window open. The one exception is the API +reference: its Markdown is a list of instructions to a docstring renderer rather than +the API, so it carries neither the control nor a `.md` twin — read it as HTML, or read +the docstrings in the source. + +Top to bottom before writing code. [Rules that hold or break the code](#rules-that-hold-or-break-the-code) +is the section correctness lives in — those are the things the library will not save +you from. Every name used below is in the public API; if you need something not listed +here, fetch the page the [documentation map](#documentation-map) points at rather than +guessing a method that sounds plausible. + +## Scope + +**It does** TODO: what the library is for, in the vocabulary a caller thinks in. + +**It does not** TODO: the neighbouring problems it deliberately leaves alone. This +paragraph stops a model from inventing a feature; it is worth more than the one above. + +## Mental model + +TODO: the handful of nouns the API is built from and the flow between them. Name each +type once and say what it owns. + +## Wiring + +```python +# TODO: the shortest correct working example, imports included. It has to run. +``` + +## API + +TODO: tables, not prose. Every public name a caller needs, with its arguments, their +defaults and what comes back. Read the defaults out of the source rather than +remembering them. + +| Name | Arguments | Returns | +|---|---|---| +| | | | + +## Rules that hold or break the code + +TODO: numbered, one rule per item, each one a thing the library will not save a caller +from — an object that must not be shared, an argument whose default surprises people, a +call that has to be repeated until it says it is finished, a lifetime that is the +caller's to manage. Write the ones that are true here, not the ones that sound wise. + +## Common mistakes + +```python +# WRONG — TODO: the mistake a model actually makes with this API + +# RIGHT — TODO: the same thing, done properly +``` + +## Errors + +TODO: the exception classes, what each one means, and what a caller should do about it. + +## Documentation map + +Fetch a page when the task is the one named beside it. + +| Page | Read it when | +|---|---| +| [Quick start](guide/quickstart.md) | writing the first integration end to end | +| [Configuration](guide/configuration.md) | every setting, type and default | +| [Advanced](guide/advanced.md) | TODO: what this page actually covers | +| [API reference](reference/index.md) | an exact signature or docstring — HTML only, see above | +| [Changelog](changelog.md) | what changed between versions | diff --git a/template/docs/assets/javascripts/copy-page.js b/template/docs/assets/javascripts/copy-page.js new file mode 100644 index 0000000..bc8a733 --- /dev/null +++ b/template/docs/assets/javascripts/copy-page.js @@ -0,0 +1,158 @@ +/* Behaviour for the "Copy page" control. + * + * Every handler is delegated from `document`, because the theme swaps the + * content in place when instant navigation is on: a listener bound to an + * element of one page would not survive the move to the next. + */ +(function () { + "use strict"; + + var RESET_AFTER_MS = 2000; + + /* Where the Markdown of a page is written, as an absolute URL. Two data + attributes say how far the site root is from here and where this page sits + below it; scripts/emit_markdown.py writes the file to match. The site's own + name is a third, so this file is the same in every project that carries it. */ + function markdownUrl(widget) { + var base = (widget.dataset.copyBase || ".").replace(/\/$/, ""); + var page = widget.dataset.copyPage || ""; + var relative = page === "" ? "index.md" : page.replace(/\/$/, "") + ".md"; + return new URL(base + "/" + relative, window.location.href).href; + } + + function prompt(widget) { + var title = widget.dataset.copyTitle || document.title; + var site = widget.dataset.copySite || "project"; + return ( + "Read " + + markdownUrl(widget) + + ' -- the "' + + title + + '" page of the ' + + site + + " documentation -- so I can ask questions about it." + ); + } + + function destination(widget, name) { + var question = encodeURIComponent(prompt(widget)); + switch (name) { + case "markdown": + return markdownUrl(widget); + case "chatgpt": + return "https://chatgpt.com/?hints=search&q=" + question; + case "claude": + return "https://claude.ai/new?q=" + question; + case "perplexity": + return "https://www.perplexity.ai/search?q=" + question; + default: + return markdownUrl(widget); + } + } + + function write(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + /* Insecure origins have no clipboard API; the old selection dance still + works there, which keeps a local preview usable. */ + return new Promise(function (resolve, reject) { + var area = document.createElement("textarea"); + area.value = text; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + var ok = document.execCommand("copy"); + document.body.removeChild(area); + ok ? resolve() : reject(new Error("copy refused")); + }); + } + + function announce(widget, label, state) { + var slot = widget.querySelector("[data-copy-label]"); + if (slot) slot.textContent = label; + if (state) { + widget.dataset.copyState = state; + } else { + delete widget.dataset.copyState; + } + } + + function copy(widget) { + var reset = function () { + window.setTimeout(function () { + announce(widget, "Copy page", null); + }, RESET_AFTER_MS); + }; + fetch(markdownUrl(widget)) + .then(function (response) { + if (!response.ok) throw new Error(String(response.status)); + return response.text(); + }) + .then(write) + .then(function () { + announce(widget, "Copied", "copied"); + reset(); + }) + .catch(function () { + announce(widget, "Copy failed", null); + reset(); + }); + } + + function close(widget) { + var menu = widget.querySelector("[data-copy-menu]"); + var toggle = widget.querySelector("[data-copy-toggle]"); + if (menu) menu.hidden = true; + if (toggle) toggle.setAttribute("aria-expanded", "false"); + } + + function closeAll(except) { + var widgets = document.querySelectorAll(".md-copy-page"); + for (var i = 0; i < widgets.length; i++) { + if (widgets[i] !== except) close(widgets[i]); + } + } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!(target instanceof Element)) return; + + var widget = target.closest(".md-copy-page"); + if (!widget) { + closeAll(null); + return; + } + closeAll(widget); + + if (target.closest("[data-copy-action]")) { + event.preventDefault(); + close(widget); + copy(widget); + return; + } + + var toggle = target.closest("[data-copy-toggle]"); + if (toggle) { + event.preventDefault(); + var menu = widget.querySelector("[data-copy-menu]"); + if (!menu) return; + /* The destinations are filled in on the way out rather than at load: + the page under the widget may have changed since. */ + var links = menu.querySelectorAll("[data-copy-open]"); + for (var i = 0; i < links.length; i++) { + links[i].href = destination(widget, links[i].dataset.copyOpen); + } + menu.hidden = !menu.hidden; + toggle.setAttribute("aria-expanded", menu.hidden ? "false" : "true"); + return; + } + + if (target.closest("[data-copy-open]")) close(widget); + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") closeAll(null); + }); +})(); diff --git a/template/docs/assets/stylesheets/copy-page.css b/template/docs/assets/stylesheets/copy-page.css new file mode 100644 index 0000000..65d70e3 --- /dev/null +++ b/template/docs/assets/stylesheets/copy-page.css @@ -0,0 +1,184 @@ +/* The "Copy page" control above every page: a split button and its menu. + Everything is drawn from the theme's own custom properties, so both palettes + and both font variants are covered without a second set of rules. */ + +.md-copy-page { + position: relative; + z-index: 2; + float: right; + margin: 0.2rem 0 0.4rem 0.8rem; +} + +.md-copy-page__group { + display: flex; + align-items: stretch; + border: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0.2rem; + background-color: var(--md-default-bg-color); +} + +.md-copy-page__button { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + padding: 0.35rem 0.6rem; + border: none; + background: transparent; + color: var(--md-default-fg-color--light); + font-family: inherit; + font-size: 0.65rem; + line-height: 1; + cursor: pointer; + transition: color 125ms, background-color 125ms; +} + +.md-copy-page__button:hover, +.md-copy-page__button:focus-visible { + color: var(--md-accent-fg-color); + background-color: var(--md-accent-fg-color--transparent); +} + +.md-copy-page__button--main { + border-radius: 0.15rem 0 0 0.15rem; +} + +.md-copy-page__button--toggle { + padding-inline: 0.4rem; + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0 0.15rem 0.15rem 0; +} + +.md-copy-page__button--toggle .md-copy-page__icon { + transition: transform 125ms; +} + +.md-copy-page__button--toggle[aria-expanded="true"] .md-copy-page__icon { + transform: rotate(180deg); +} + +/* Icons are sized here: the bundled SVGs carry no width or height. */ +.md-copy-page__icon svg, +.md-copy-page__item-icon svg, +.md-copy-page__item-arrow svg { + display: block; + width: 0.8rem; + height: 0.8rem; +} + +.md-copy-page__icon { + display: flex; +} + +/* The tick replaces the clipboard for as long as the copy is fresh. */ +.md-copy-page__icon--done, +.md-copy-page[data-copy-state="copied"] .md-copy-page__icon--idle { + display: none; +} + +.md-copy-page[data-copy-state="copied"] .md-copy-page__icon--done { + display: flex; +} + +.md-copy-page[data-copy-state="copied"] .md-copy-page__button--main { + color: var(--md-accent-fg-color); +} + +.md-copy-page__menu { + position: absolute; + inset-inline-end: 0; + top: calc(100% + 0.25rem); + min-width: 15rem; + padding: 0.25rem; + border: 0.05rem solid var(--md-default-fg-color--lightest); + border-radius: 0.25rem; + background-color: var(--md-default-bg-color); + box-shadow: var(--md-shadow-z2); +} + +.md-copy-page__menu[hidden] { + display: none; +} + +/* The menu entries are anchors inside the typeset article, so the theme's own + `.md-typeset a` rules outrank a single class -- hence the prefix. */ +.md-typeset .md-copy-page__item, +.md-copy-page__item { + display: flex; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.4rem 0.5rem; + border: none; + border-radius: 0.15rem; + background: transparent; + color: var(--md-default-fg-color); + font-family: inherit; + font-size: 0.65rem; + text-align: start; + text-decoration: none; + cursor: pointer; + transition: background-color 125ms; +} + +.md-typeset .md-copy-page__item:hover, +.md-typeset .md-copy-page__item:focus-visible, +.md-copy-page__item:hover, +.md-copy-page__item:focus-visible { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + text-decoration: none; +} + +.md-copy-page__item-icon { + display: flex; + padding-top: 0.1rem; + color: var(--md-default-fg-color--light); +} + +.md-copy-page__item-text { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.md-copy-page__item-title { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: 700; + line-height: 1.3; +} + +.md-copy-page__item-arrow { + display: flex; + color: var(--md-default-fg-color--lighter); +} + +.md-copy-page__item-arrow svg { + width: 0.6rem; + height: 0.6rem; +} + +.md-copy-page__item-hint { + color: var(--md-default-fg-color--light); + font-size: 0.6rem; + line-height: 1.3; +} + +/* Narrow screens keep the icons and drop the words. */ +@media screen and (max-width: 44.9375em) { + .md-copy-page__label { + display: none; + } + + .md-copy-page__menu { + min-width: 13rem; + } +} + +@media print { + .md-copy-page { + display: none; + } +} diff --git a/template/docs/reference/index.md.jinja b/template/docs/reference/index.md.jinja index 7db581e..29e4c38 100644 --- a/template/docs/reference/index.md.jinja +++ b/template/docs/reference/index.md.jinja @@ -1,3 +1,7 @@ +--- +copy_page: false +--- + # API Reference Auto-generated from source using [mkdocstrings](https://mkdocstrings.github.io/). diff --git a/template/overrides/main.html b/template/overrides/main.html new file mode 100644 index 0000000..9d91b2d --- /dev/null +++ b/template/overrides/main.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{#- + Adds the "Copy page" control above every page: the Markdown of the page for a + language model, and the handful of places one is usually pasted into. The + Markdown itself is written next to the page by scripts/emit_markdown.py after + the build; the two data attributes are all the script needs to find it. +-#} +{% block content %} + {#- A page whose Markdown would not read as the page declines the control + with `copy_page: false` in its front matter. -#} + {% if page.meta.copy_page != false %} + + {% endif %} + {% include "partials/content.html" %} +{% endblock %} diff --git a/template/scripts/emit_markdown.py b/template/scripts/emit_markdown.py new file mode 100644 index 0000000..c7573e0 --- /dev/null +++ b/template/scripts/emit_markdown.py @@ -0,0 +1,87 @@ +"""Write the Markdown source of every page next to the page it built. + +The docs carry a "Copy page" control that hands a language model the Markdown +behind the page it is looking at. That Markdown has to be fetchable, so after +the site is built each ``docs/.md`` is copied to ``site/.md`` -- +one URL away from ``site//index.html``, which is what the control asks +for. Run it after the build:: + + uv run --no-dev --group docs zensical build --clean + uv run python scripts/emit_markdown.py + +``zensical serve`` rebuilds into the same directory and does not know about +these files, so a preview served that way answers 404 to the control; build +the site to try it. +""" + +from __future__ import annotations + +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DOCS = ROOT / "docs" +SITE = ROOT / "site" + + +def declines(text: str) -> bool: + """Whether a page has asked not to be handed over as Markdown. + + The control above each page is hidden by ``copy_page: false`` in the front + matter, and a page hidden there must not be written here either -- a file + nothing links to is worse than no file when its content would mislead. + This reads that one key rather than the front matter as a whole: the docs + build has no YAML parser of its own, and one key is all that is at stake. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return False + for line in lines[1:]: + if line.strip() == "---": + return False + key, _, value = line.partition(":") + if key.strip() == "copy_page": + return value.strip().lower() == "false" + return False + + +def target_for(source: pathlib.Path) -> pathlib.Path: + """Where the control will look for one page's Markdown. + + It asks for the page's own URL with ``.md`` in place of the trailing + slash, so ``guide/foreign-keys/`` becomes ``guide/foreign-keys.md``. A + section index is the one place where that is not the source path: the URL + of ``reference/index.md`` is ``reference/``, and its Markdown therefore + belongs at ``reference.md``. The site's own index keeps its name. + """ + relative = source.relative_to(DOCS) + if relative.name == "index.md" and relative.parent != pathlib.Path(): + return SITE / relative.parent.with_suffix(".md") + return SITE / relative + + +def main() -> None: + """Copy every documentation page into the built site beside its HTML.""" + if not SITE.is_dir(): + sys.exit(f"{SITE} does not exist -- build the site first") + + written: dict[pathlib.Path, pathlib.Path] = {} + skipped = 0 + for source in sorted(DOCS.rglob("*.md")): + text = source.read_text(encoding="utf-8") + if declines(text): + skipped += 1 + continue + target = target_for(source) + if target in written: + sys.exit(f"{source} and {written[target]} both claim {target}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + written[target] = source + + tail = f", {skipped} declined" if skipped else "" + sys.stdout.write(f"wrote {len(written)} Markdown pages into {SITE.name}/{tail}\n") + + +if __name__ == "__main__": + main() diff --git a/template/zensical.toml.jinja b/template/zensical.toml.jinja index eab728b..27b5cc0 100644 --- a/template/zensical.toml.jinja +++ b/template/zensical.toml.jinja @@ -9,8 +9,12 @@ copyright = "Copyright © {% now 'utc', '%Y' %} {{ author_name }}" docs_dir = "docs" site_dir = "site" +extra_css = ["assets/stylesheets/copy-page.css"] +extra_javascript = ["assets/javascripts/copy-page.js"] + nav = [ { "Home" = "index.md" }, + { "For AI agents" = "agents.md" }, { "Guide" = [ { "Quick start" = "guide/quickstart.md" }, { "Configuration" = "guide/configuration.md" }, @@ -21,6 +25,7 @@ nav = [ ] [project.theme] +custom_dir = "overrides" features = [ "content.code.annotate", "content.code.copy",