Skip to content

ADR-0030: A documentation website built from docs/ — MkDocs with Material, no plugin, one page per language

Status: Accepted (2026-10-05, asked by the author: « a real site, pages, a menu on the left with sub-menus », reusing the same files so nothing is maintained twice) Date: 2026-10-05

Context

The GitHub Pages site (ADR-0022) had two hand-written pages: a showcase and the web installer. Everything else was Markdown read on github.com: long pages, each holding the English text and then the French one (« ## Version Française »), with no menu across pages and no search. The showcase already repeated parts of the README by hand.

Checked on 2026-10-05:

  • MkDocs 1.6.1 (PyPI, 30 August 2024) has had no release since. The Material theme announced a « MkDocs 2.0 » that breaks every theme and plugin (Material's blog, 18 February 2026, shown by Material 9.7.7 itself at each build).
  • Material for MkDocs 9.7.7 (PyPI, 17 July 2026) is in maintenance since 5 November 2025, with fixes promised for at least 12 months (announcement). Its authors move to Zensical (0.0.67 on 30 September 2026), which reads mkdocs.yml but does not run MkDocs plugins (compatibility); nothing confirmed two languages with a language selector there.
  • The usual i18n plugin, mkdocs-static-i18n, is frozen « because of an unmaintained and uncertain MkDocs upstream » (repository).

Decision

  • docs/ stays the only source. No file moves, so every link already given (README, Home Assistant packages and dashboard, blueprint, forum posts) keeps working on github.com. The site is built from those files at deploy time; nothing is copied into the repository.
  • tools/site/construire.py does the work, MkDocs only lays out plain Markdown. For each language it writes the pages, then runs MkDocs with Material in strict mode:
  • a bilingual file is cut at « ## Version Française »: English under en/, French under fr/ (same paths, so the language selector and hreflang lead to the same page). A file written in one language (langue: in the menu: arcade.md in French, the ADRs in English) is shown on both sites with a note saying so;
  • links: a published page → its page on the site; an anchor of the other language's heading (a French paragraph pointing to an English title of the same file, fine on GitHub) → the heading at the same rank; an image of docs/ → copied with the page; any other file of the repository → github.com; a missing file → the build fails;
  • a block between <!-- hors-site --> and <!-- /hors-site --> is read on GitHub only (« these pages are also a website »);
  • each page gets a description (its first real paragraph) for search engines.
  • One menu, tools/site/menu.yml, English and French titles side by side. A file of docs/ is either in it or listed on purpose in tests/test_site_doc.py (HORS_SITE: test inventory, 1.x changelog, press drafts).
  • No plugin besides Material's search, no MkDocs hook. The language split, the links and the anchors are ours; the theme override (tools/site/theme/) only adds the sharing tags, page-level hreflang and a language selector that keeps the page. Another generator that reads mkdocs.yml (Zensical, a MkDocs 1.x fork) would only have plain Markdown to lay out.
  • Pinned with hashes. tools/site/requirements.txt (generated by uv pip compile --generate-hashes), installed with --require-hashes --only-binary :all: by site.yml, whose job can write the public site. requirements-dev.txt takes the same two versions for pytest.
  • Same deployment as before. site.yml builds the documentation, then tools/publication/pages.py assembler --doc copies en/, fr/ and the English 404.html next to the showcase, the installer and the channel folders. It refuses a documentation that would overwrite anything else: published firmwares read their updates at stable/ and beta/. One sitemap.xml for the whole site (MkDocs' own are dropped), image URLs resolved from each page.
  • Look: the showcase's colours, system fonts (no request to Google Fonts), light and dark following the system.

Alternatives rejected

  • Splitting each file in two (x.md and x.fr.md) in the repository: simpler build, but every link already published and the « one file, both languages » habit of this repository would change, for no gain to the reader.
  • mkdocs-static-i18n: frozen, and ignored by Zensical.
  • Starlight, Docusaurus, VitePress: native i18n, but a Node toolchain beside a Python one, and the same Markdown would need front matter in every file.
  • A hand-written generator: no dependency, but search, the mobile menu and dark mode would have to be written and kept.

Consequences

  • pytest builds the whole site (~3 s): a broken link or anchor in docs/ turns the python job red on the pull request, before the deployment would stop on main. tests/test_site_doc.py also checks the language split, the links, the page tags (title, description, canonical, hreflang, sharing image) and the hashes.
  • More commits of main are deployed (site.yml now runs for docs/** and tools/site/**). GitHub Pages keeps one deployment per commit (ADR-0022): a release must still target a commit that was never deployed, usually the merge of its chore(release) pull request, which does not touch docs/.
  • A new page: write it in docs/ (English, then « ## Version Française »), add it to menu.yml.
  • Material's maintenance ends around November 2026 without a promise after: the versions stay pinned, and the switch is a configuration change, not a rewrite.

Amendment (2026-10-05): the README is the home page, the showcase is gone

Asked by the author the same day: the site's address still opened the showcase, without the menu.

  • The root of the site sends to en/ or fr/, after the browser's language (web/index.html, location.replace); without JavaScript it shows both links. It keeps its title, description, canonical, Open Graph and the hreflang of both languages, with itself as x-default.
  • README.md is the home page of both languages (page: README.md in menu.yml, written as index.md): one text, read on GitHub and on the site. The showcase was a second copy of it, kept in step by hand. The README took its content, shorter: pitch, install links, what the screen does with a link to each page of the user manual, rain and warnings, gallery. Badges and « this page is also a website » are hors-site. Its <title> (TITRES_ACCUEIL in construire.py) and the JSON-LD SoftwareSourceCode come from the theme override. docs/README.md, the contents of the documentation, becomes documentation/.
  • What left the README: the long feature list (in docs/screens.md and the user manual), the arcade table (docs/arcade.md), the key design decisions (docs/architecture.md, whose counts tests/test_doc_comptes.py now reads there), the voice section (docs/voice_assistant.md), the repository layout (CARTOGRAPHIE_TAB5.md; the README's copy still counted 45 UI components) and the full personal note (docs/story.md).
  • Pictures: only the sharing image keeps a descriptive name under images/ (IMAGES); the others are the documentation's own, under en/images/ and fr/images/, listed in sitemap.xml with their page. The demo video is a plain link: no thumbnail is loaded from YouTube.
  • site.yml also runs for README.md. tests/test_site_doc.py checks that the menu starts with the README, the home page's title and JSON-LD, and the root's links.