MIP-0064: mkdocs for the docs — a real documentation site at marola.dev/docs¶
| Status | Implemented — PRs #459 (mkdocs-build) → #463 (strict-links) → #465 (api-docs-rewire) → #473 (docs grouped by audience, which revisited decision 1) → #475 (docs-live); tasks and v1 decisions: MIP-0064.tasks.md. Verified live per §7.6 |
| Author | Claude (Opus 5), with Bruno |
| Created | 2026-09-28 |
| Phase | 3 — extends site.yml's already-live Pages deploy; no Phase 1 or Phase 2 prerequisite, no cloud spend |
| Related | MIP-0044 §5.6 (the docs index this replaces), MIP-0005 (the site), MIP-0008 (Docker already in the repo) |
| Effort | L — a container-based build (mkdocs/ + a compose stack), a rewired CI workflow, one justfile pair; no Scala, no new module |
| Gain | infra/dev-loop — 90 Markdown files become searchable instead of 90 links to GitHub; user value — the site's Docs tab stops landing on a bare list |
| Effort vs Gain | do next — the Docs tab is already shipped and already disappointing, and every later doc written pays off more once this exists |
| Depends on | Nothing must merge first. It consumes api-docs.yml's scaladoc/pdoc output and replaces scripts/build_docs_index.py, both from MIP-0044; that MIP is already implemented, so this is a rewiring, not a dependency. No Phase 1 gate, no paid resource, so AGENTS.md's cost rule does not bite |
| Blocked by | none |
| Risk | The generated mkdocs/docs/ copy step. Every build copies ../docs/ into a throwaway tree, so links that escape docs/ (../AGENTS.md, ../PHILOSOPHY.md) break silently unless --strict catches them — and --strict turning red on an unrelated doc edit is the failure that makes people bypass the docs build |
| Cost so far | ~$76.42 total — $0.58 for this document (#454) plus ~$75.84 across the implementation PRs: $11.80 (#459), $16.10 (#463), $17.90 (#465), $30.04 (#473), from their Cost: trailers; #475's own figure is in its commit |
1. Summary¶
marola.dev's Docs tab lands on a hand-built index page that links out to GitHub for every Markdown
file: no rendering, no search, no diagrams. This replaces that page with an
mkdocs-material site covering the 18 guides under
docs/ and all 59 MIPs, with Mermaid diagrams rendered server-side by a self-hosted
Kroki and the generated scaladoc/pdoc trees folded into the same output. The
setup is ported from goflink/vrp-solver, which runs exactly this stack in production.
2. Motivation¶
scripts/build_docs_index.py says in its own docstring what it is:
Markdown docs are linked to the repository rather than mirrored into the site: publishing
docs/*.mdis the disclosure decision MIP-0044 §5.5 gates on MIP-0033 §5.1's repo-public checklist, which is not this script's call to make.
That gate has since resolved itself — h0ffmann/marola is public (verified 2026-09-28,
gh repo view --json visibility → PUBLIC), so every file the index links to is already
world-readable. What is left is the cost of the workaround: a reader who clicks Docs gets a list of
90 outbound links to raw-ish GitHub pages, with no search across them, no cross-linking, and no
rendering of the two Mermaid diagrams the docs already contain (docs/2-Building-marola/ARCHITECTURE.md:221,
docs/MIPs/README.md:85 — GitHub renders those, the site does not).
The corpus is big enough that search is the whole point: 18 files at the root of docs/, 72 under
docs/MIPs/ (59 MIPs, 12 .tasks.md companions, the index). "Which MIP decided X" is currently
answered with grep, which is fine for an agent in the repo and useless for anyone reading the
site.
MIP-0044 §4.4 did consider mkdocs and passed on it — but for a different job: generating Python
API docs from docstrings, where it would have needed mkdocstrings and a nav over nine
unpackaged scripts. pdoc won that and keeps winning it. Nothing in that comparison was about prose
documentation, which is what mkdocs is actually for.
3. User-visible change¶
Before — marola.dev/docs/: one page, a <ul> per section, each <li> an outbound link to
github.com/h0ffmann/marola/blob/main/.... No search box. The MIP section is 59 links titled by
de-kebabed filename.
After — marola.dev/docs/: mkdocs-material. docs/index.md (today's docs/README.md) as the
landing page, a left sidebar
built from the file tree (Guides, MIPs, API), a working search box over the full text of every
page, docs/2-Building-marola/ARCHITECTURE.md's pipeline diagram rendered as an SVG, and /docs/api/scala/core/ and
/docs/api/python/ still exactly where they are today, now linked from the sidebar instead of
orphaned.
Locally, new in the justfile:
$ just docs-serve
Docs available at http://localhost:8001
$ just docs
...
INFO - Documentation built in 6.20 seconds
generated-docs/index.html
generated-docs/MIPs/MIP-0064-mkdocs-documentation-site/index.html
Port 8001, not 8000: just site-serve already owns 8000 (justfile:248).
4. Data sources and dependencies reviewed¶
4.1 The reference: goflink/vrp-solver¶
Read 2026-09-28 via gh api. It carries mkdocs/{Dockerfile,mkdocs.yml,docker-compose.yml,
docker-compose.build.yml,docker-compose.serve.yml} and scripts/mkdocs.sh. The Dockerfile is
python:3.13-slim plus four pinned pips (mkdocs==1.6.1, mkdocs-material==9.5.42,
mkdocs-kroki-plugin==0.9.0, mkdocs-render-swagger-plugin==0.1.2). mkdocs.sh detects podman or
docker, wipes mkdocs/docs/ and mkdocs/generated-docs/, copies ../docs/ and ../README.md
into place (marola skips the second copy — §5.2), builds the image, and either builds (compose up, then cp the output out of the
container) or serves on 8000. mkdocs.yml sets site_dir: generated-docs/, the material theme, and
plugins search, offline, render_swagger, kroki. The compose file runs Kroki plus
kroki-mermaid and kroki-excalidraw companions, healthchecked, with KROKI_VERSION=0.25.0.
Taken: the whole shape — the mkdocs/ directory, the Dockerfile, the compose split, and
scripts/mkdocs.sh including its runtime detection and its --serve flag.
Dropped: render_swagger (marola publishes no OpenAPI spec), kroki-excalidraw (nothing uses
it), and notify-docs-rebuild.yml (no aggregator repo consumes marola's docs).
4.2 The pips¶
Verified on PyPI, 2026-09-28:
| Package | Latest | Licence | Notes |
|---|---|---|---|
mkdocs |
1.6.1 (2024-08-30) | not stated in the API response | same version vrp-solver pins |
mkdocs-material |
9.7.7 (2026-07-17) | MIT | vrp-solver is on 9.5.42, ~2 years behind |
mkdocs-kroki-plugin |
1.7.0 (2026-09-10) | MIT | vrp-solver is on 0.9.0 |
We pin the current versions, not vrp-solver's. This matters for the config keys: the plugin's
README (read 2026-09-28) documents server_url / http_method / fence_prefix in snake_case,
whereas vrp-solver's mkdocs.yml uses ServerURL / HttpMethod — the 0.9.0 spelling. Copying
that file verbatim onto 1.7.0 would fail.
Two plugin settings are load-bearing, both from that README:
fence_prefix: ""— the default iskroki-, meaning only```kroki-mermaidfences render. Our two diagrams use plain```mermaidbecause GitHub renders those. Setting the prefix empty makes Kroki claim every diagram fence, so one fence renders in both places and no existing doc is edited.http_method: POST— quoting the README: "OnPOSTthe retrieved images are stored next to the including page in the build directory". With the defaultGETthe plugin emits<img>tags pointing at the Kroki server, which for a self-hosted container means the published page links tolocalhost. POST writes real.svgfiles into the output, so the deployed site is self-contained and keepsscript-src 'self'— the propertyscripts/strip_external_scripts.pyexists to defend (MIP-0044 §4.3).
4.3 Kroki¶
yuzutech/kroki on Docker Hub, checked 2026-09-28: latest tag 0.32.1 (vrp-solver pins 0.25.0).
Kroki is MIT-licensed and the images are published by the project itself. Mermaid support requires
the separate yuzutech/kroki-mermaid companion container, which is why the compose file has it.
We pin 0.32.1 and run only kroki + kroki-mermaid.
Not verified: whether 0.32.1's mermaid companion renders our two specific diagrams without change. That is a §7 check, not an assumption.
4.4 mkdocs' automatic navigation¶
Verified 2026-09-28 against mkdocs' own writing-your-docs.md on GitHub:
If not provided, the navigation will be automatically created by discovering all the Markdown files in the documentation directory. An automatically created navigation configuration will always be sorted alphanumerically by file name (except that index files will always be listed first within a sub-section).
So no nav: key: the sidebar is the file tree. MIP-0001…MIP-0064 sort correctly as strings
because the numbers are zero-padded to four digits. Page titles come from each file's H1.
strict is documented in configuration.md as defaulting to false, settable in mkdocs.yml or
as --strict, and "halt[s] processing when a warning is raised" — which is how a broken internal
link becomes a failed build rather than a 404 on marola.dev.
5. Design¶
5.1 New files¶
mkdocs/
Dockerfile python:3.13-slim + the three pips from §4.2, pinned
mkdocs.yml site_name, theme material, plugins search/offline/kroki; no nav
docker-compose.yml kroki + kroki-mermaid, healthchecked
docker-compose.build.yml one-shot `mkdocs build --strict`
docker-compose.serve.yml `mkdocs serve --dev-addr=0.0.0.0:8000`, published on the host as 8001
scripts/mkdocs.sh ported from vrp-solver; `--serve` flag
mkdocs/docs/ and mkdocs/generated-docs/ are build products and are gitignored.
5.2 What scripts/mkdocs.sh assembles¶
cp -R ../docs/ docs # 18 guides + docs/MIPs/ (59 MIPs) + docs/img, docs/benchmarks
One departure from the reference:
docs/README.md is renamed to docs/index.md, and the repo root README.md is not copied in
at all. The reference does cp ../README.md docs/index.md because vrp-solver has no docs-level
index; marola does, and it is the better landing page — it is a map of what every doc is for,
whereas the root README is an install-and-badges page written for someone standing in the repo.
Copying both would put two files at docs/index.md, one silently overwriting the other. mkdocs
lists index files first within a section, so the rename also makes it the section's first entry
instead of a page called "README" sorted among the rest.
This removes the link-rewriting step an earlier draft of this MIP specified. Verified
2026-09-28: every relative link in docs/*.md is in-tree — 28 of them, all ./<file>.md or
./mips/...; docs/README.md names AGENTS.md and PHILOSOPHY.md in backticks, not as links.
Nothing escapes docs/, so there is nothing to rewrite. Three strings in the prose look like
links and may trip --strict: ](./URL), ](./around:15000,-27.6733,-48.47) and
](effect: A < Sync). They are checked when strict mode goes on, not pre-emptively edited.
5.3 Where the API docs go¶
api-docs.yml already produces out/docs/api/{scala,python} and the site already serves it. mkdocs
cannot put generated HTML in a Markdown-driven nav, so:
- a checked-in
docs/2-Building-marola/API.mddescribes the trees and links toapi/scala/core/,api/scala/local/,api/scala/cli/andapi/python/— this is the page the sidebar shows; - after
mkdocs build, the workflow copiesout/docs/apiintogenerated-docs/api, so the links resolve in the published tree.
strip_external_scripts.py keeps running over the merged output, unchanged.
5.4 The workflow¶
api-docs.yml grows one step and one trigger; site.yml is not touched — it already
git archives docs off the site-data branch into site/dist.
on:
push:
branches: [main]
paths:
- '**/*.scala'
- 'build.sbt'
- 'project/**'
- 'scripts/**.py'
- 'finetune/build_dataset.py'
- 'docs/**' # new — a docs-only edit must republish
- 'mkdocs/**' # new
and, after the pdoc step and before the strip/push steps:
- name: mkdocs (material + kroki), with the API trees folded in
run: |
set -euo pipefail
scripts/mkdocs.sh
cp -r out/docs/api mkdocs/generated-docs/api
rm -rf out/docs && mv mkdocs/generated-docs out/docs
The runners were self-hosted with Docker on the host (AGENTS.md, "Docker itself is the host's").
Superseded by MIP-0065: every job here, api-docs.yml and docs-build included, now runs on
ubuntu-latest, whose image ships Docker and Compose; the paragraph below is the self-hosted record.
Corrected during task 3: this section originally said docker-smoke.yml already relies on
that, and it does not — that workflow pins runs-on: ubuntu-latest precisely because it needs a
daemon. Nothing in this repo had ever run a container on the self-hosted runner, so a reachable
daemon there was an assumption this MIP introduced rather than inherited. Now measured: task
3's docs-build job ran the Kroki + mkdocs stack on self-hosted runner marola-6 and passed in
5m43s (PR #465, run 36471621516). The assumption holds, and that job is what keeps checking it on
every docs change. The timeout-minutes: 30 on api-docs.yml covers those containers on top of
scaladoc and pdoc.
scripts/build_docs_index.py is deleted, along with its --self-test entry in quality-other.
5.5 justfile¶
docs: # build to mkdocs/generated-docs
scripts/mkdocs.sh
docs-serve: # live reload on 8001
scripts/mkdocs.sh --serve
Nothing here is Scala, nothing goes through an LLM, and no marola code path changes.
6. Scoring / safety impact¶
None. No file under core/scoring/ is touched and no user-facing recommendation text changes.
7. Verification plan¶
just docscompletes withmkdocs build --strictgreen — this is the real test, because--strictfails on any unresolved internal link across all 90 pages.generated-docs/containsindex.html,mips/MIP-0063-github-issue-tracking-standard/index.html,API/index.htmlandsearch/search_index.json.- Kroki, checked live, not assumed: the built
architecture/index.htmlcontains an<img>pointing at a local.svgfile (not alocalhost:8001URL), and that SVG is a rendering of the §3 pipeline diagram. This is the §4.3 "not verified" item closing. python3 scripts/strip_external_scripts.py --check out/docspasses over the merged tree.just docs-serveserves on 8001 with live reload whilejust site-serveholds 8000.- After merge:
marola.dev/docs/renders the sidebar and the search box,marola.dev/docs/api/scala/core/still resolves, and the site's Docs tab (site/static/index.html:18) needs no edit — it already points at/docs/. just qualitypasses (hadolintnow seesmkdocs/Dockerfile,shellcheck-via-quality-otherseesscripts/mkdocs.sh).
8. Risks, limitations, and honest caveats¶
--strictis a shared tripwire. Any doc edit anywhere can now break the docs build. That is the point, but it means a Scala PR that renames a doc fails in a workflow its author was not thinking about. Mitigated only by the failure being fast and the message naming the file.- The copy step is not a symlink.
mkdocs/docs/is a throwaway copy, somkdocs serve's live reload watches the copy, not../docs/. Editing a real doc duringjust docs-servedoes not hot reload; the loop is edit → re-run. The reference has the same limitation. - Three containers per docs build. CI gets slower and needs a working Docker daemon for a documentation change. This was the accepted trade in choosing the port over a nix-native build.
- 2.9 MB of
docs/plus ~16 MB of API trees on thesite-databranch, republished whenever docs change rather than only when Scala changes. Still ~2% of GitHub Pages' 1 GB limit (MIP-0044 §4.6, verified there). - Nothing validates that the sidebar reads well. Filesystem order is simple and driftless; it is
not curated. If the MIP list becomes unreadable, the fix is renaming files, not adding a
nav:.
9. Alternatives considered¶
- Do nothing. The index works and costs nothing. It also cannot search, cannot render a diagram, and sends every reader to GitHub — the docs are the main artefact of a project whose README is mostly about how it is built.
- Nix-native mkdocs + public
kroki.io. No Docker, a three-line CI step,just docsin the dev shell. Rejected by the maintainer in favour of matching vrp-solver: the container stack is already proven there, and it keeps diagram rendering off a third-party service. - mkdocs-material's built-in Mermaid (
pymdownx.superfences, no Kroki at all). Simplest possible diagrams, zero containers — but it renders client-side viamermaid.js, which means a third-party script on marola.dev and the exactscript-src 'self'violation MIP-0044 §4.3 catalogued. Kroki rendering server-side to SVG is what keeps that claim true. - A hand-written
nav:. Curated ordering and MIP grouping by status. Rejected: 59 MIPs means every new MIP needs a nav edit nothing enforces, and drift in a nav is worse than alphabetical order. - Extending
build_docs_index.pyto render Markdown itself. A search index, a theme and a Markdown renderer, hand-rolled. That is mkdocs.
11. Open questions¶
- Which docs deserve a section, and named how? Filesystem order gives
docs/*.mdflat at the top andmips/as one bucket. Grouping the guides (concepts/,development/,reference/, as vrp-solver does) means moving files in the repo and updating every inbound link. Worth doing, but as its own change after the site exists and the ordering is visible. AGENTS.md,PHILOSOPHY.md,CONTRIBUTING.mdare scoped out and link-rewritten to GitHub. They are arguably the most-read documents in the repo. Revisit once the site is live.knowledge/— the sourced ocean corpus — is real reference content and would read well as a site section, but it is RAG input with its own provenance rules. Out of scope here; a candidate for its own MIP if the answer is anything other than "copy it in".- Should the dev shell also carry mkdocs?
flake.nixgains nothing today, but an agent without a Docker daemon cannot preview docs at all. Left out until someone hits it.
Appendix¶
Checked live¶
gh repo view h0ffmann/marola --json visibility(2026-09-28) →PUBLIC(that name still resolves; the repo is nowmarola-dev/marola, alsoPUBLIC, pergh repo viewon the remote). This is what retiresbuild_docs_index.py's disclosure caveat.gh api repos/goflink/vrp-solver/git/trees/HEAD?recursive=1(2026-09-28) → themkdocs/layout andscripts/mkdocs.shlisted in §4.1; file contents read viarepos/.../contents/.pypi.org/pypi/{mkdocs,mkdocs-material,mkdocs-kroki-plugin}/json(2026-09-28) → 1.6.1 / 9.7.7 (MIT) / 1.7.0 (MIT), release dates as in §4.2.hub.docker.com/v2/repositories/yuzutech/kroki/tags(2026-09-28) →latest, 0.32.1, 0.32.0, 0.31.2, 0.31.1.raw.githubusercontent.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin/main/README.md(2026-09-28) → the config table quoted in §4.2:fence_prefixdefaultkroki-,http_methoddefaultGETwith the POST note about images "stored next to the including page in the build directory",file_typesdefault[svg],tag_formatdefaultimg.raw.githubusercontent.com/mkdocs/mkdocs/master/docs/user-guide/writing-your-docs.mdandconfiguration.md(2026-09-28) → the automatic-nav paragraph andstrictquoted in §4.4.- In-repo, 2026-09-28:
docs/holds 18.mdat its root and 72 undermips/(59 MIPs, 12.tasks.md, the index);```mermaidfences atdocs/2-Building-marola/ARCHITECTURE.md:221anddocs/MIPs/README.md:85; the Docs tab atsite/static/index.html:18→/docs/;just site-serveon 8000 atjustfile:248.
Not checked¶
- That Kroki 0.32.1's mermaid companion renders marola's two diagrams — §7 item 3 exists to check it, and 0.32.1 is seven minor versions past the one vrp-solver runs in production.
- That
mkdocs-material9.7.7 andmkdocs-kroki-plugin1.7.0 are mutually compatible onmkdocs1.6.1. Each declares support for mkdocs 1.6; the combination was not built here. mkdocs' licence: PyPI's JSON returns nolicensefield for it, so §4.2 says so rather than repeating BSD-2-Clause from memory.mkdocs-materialandmkdocs-kroki-pluginboth report MIT.- Build time and image size for the docs job. The 30-minute timeout is assumed sufficient from the existing job's headroom, not measured.
- GitHub Pages' 1 GB / 100 GB-per-month figures are quoted from MIP-0044 §4.6, verified there on 2026-09-07, not re-fetched.