Documentation checkers for HaLOS and Hat Labs MkDocs sites: translation status,
stamping, anchor validation, glossary and typography checks. Plus halos-i18n,
a MkDocs plugin that makes a multi-edition site behave like one site.
The same code runs in CI and on a laptop. The lint and test checks that a pull
request must pass run locally with ./run check; only the version checks need
CI.
Add it to a documentation repository's pyproject.toml, pinned to a tag:
dependencies = [
"halos-docs-tools @ git+https://github.com/halos-org/[email protected]+N",
]Use the tag of a stable release, not a _pre pre-release, from the
releases page.
uv sync then puts all six commands on the path and makes the halos-i18n
MkDocs plugin available by name. Each repository pins its own version; upgrading
is a deliberate edit to that pin.
git must be on the path. translation-status, stamp-translation and
check-glossary shell out to it, and it is not something a Python dependency
can bring.
Run them from the root of a documentation repository — they expect docs/ and
mkdocs.yml in the working directory.
| Command | Purpose |
|---|---|
translation-status |
report which translations are missing or out of date |
stamp-translation |
record the English source a translation was written against |
map-anchors |
rewrite English anchor fragments to the translated slugs |
check-glossary |
verify a translation uses the terms its glossary prescribes |
check-typography |
check quotation pairing and unit spacing per language |
check-anchors |
verify every internal anchor in a built site resolves |
A workflow branches on these, so they are part of the interface.
| Status | Meaning |
|---|---|
| 0 | the check passed |
| 1 | the check found problems — broken anchors, unused glossary terms, typography faults, or (with --check) translations that are not current |
| 2 | the check could not run, so a pass would prove nothing: check-anchors was given a site directory holding no built pages, or translation-status found no configured locales, no source pages, markdown outside every configured locale, or a --since ref it could not resolve |
Glossaries and per-language rules stay in the documentation repository. This package brings the checkers, not the terminology.
Some plugins generate a page whose internal fragments the checker cannot
resolve. mkdocs-print-site-plugin is one: on docs.halos.fi its single-page
export accounts for 690 broken fragments while the 36 content pages are clean.
Exclude such pages by path pattern:
check-anchors site --exclude 'print_page/*'
An excluded page contributes no links to the check. Its own headings stay linkable, so other pages may still point into it.
--base, used to resolve root-absolute links, is read from site_url in
mkdocs.yml. A base that does not match the site makes the checker skip every
root-absolute link and report a pass it did not earn, so override it only when
you know the built site differs from the configuration.
mkdocs-static-i18n builds one edition per locale and leaves the rest to the
theme. Three things are then missing, and all three are the same concern, so
they are one plugin:
- A visitor of the default edition is not sent to the edition matching their browser languages. GitHub Pages cannot negotiate content, so that choice has to happen in the browser.
- One
404.htmlis served for every URL that does not resolve, in every edition, and the build can only produce one copy of it. - The search index is merged across editions, so a search from a translated page returns hits in every other language.
Enable it by name:
plugins:
- search
- i18n:
# ...
- halos-i18nIt needs site_url and an i18n block with exactly one default locale, and it
fails the build when either is missing.
A repository needs no hooks:, no theme.custom_dir and no extra.not_found.
The plugin supplies main.html, 404.html and the 404 wording for ten locales.
Only the default edition redirects, so a link shared in one language keeps its
language. A URL fragment suppresses the redirect, because heading ids are
translated and the anchor would not survive the move. A same-origin referrer
suppresses it, because a reader who reached the page from inside the site made
a deliberate choice. Query parameters survive the redirect — Material's own
?q= and ?h= are language-neutral.
A language chosen from the selector is remembered in local storage, under a key
derived from site_url. The sites share an origin, so a shared key would let
one site's choice follow a reader into another.
Norwegian browsers send either the macrolanguage no or the written form nn.
Both are treated as nb. An exact locale match always wins over an alias.
The wording ships with the package. Override a locale to change it, supplying all three strings — a partial override is refused, so a half-translated locale cannot happen:
plugins:
- halos-i18n:
not_found:
fi:
title: "Sivua ei löytynyt"
message: "Pyytämääsi sivua ei ole."
home: "Siirry etusivulle"The plugin inserts its template directory behind anything custom_dir supplies
and ahead of the theme. A repository that needs its own main.html puts
custom_dir back and wins, with no change here.
Everything the templates rest on degrades to omitted output rather than an
error, so a dependency upgrade could ship a site that quietly stops selecting a
language. After the build the plugin asserts what the templates promised: every
page declares a language among the built locales, carries one x-default link,
offers the full locale set, and has a language selector. The 404 page must be
the default edition's and must carry every edition's wording.
A translation records the git blob hash of the English page it was written against, in its own frontmatter:
---
translated_from: <blob hash of docs/en/<path> at translation time>
---The English page carries nothing. Editing it changes its content, which changes its hash, which makes every translation of it report as stale on its own.
translation-status classifies each page in each configured locale as current,
stale, missing, unstamped or orphaned.
translation-status --check
exits non-zero when any page in any configured locale is anything but
current, and names every entry responsible. Without --check the command
only reports, whatever it finds.
--only-pages narrows the report and never the rule.
translation-status --check --since origin/main
Whole-repository --check has a cost that shows up the first time someone
fixes a typo: editing one English page marks every translation of it stale, so
the change cannot go green until all of them land in the same pull request. And
a page somebody left behind last month fails a pull request that touched no
documentation at all.
--since REF gates on stale only for English pages whose content differs
from REF. missing, unstamped and orphaned still fail wherever they came
from — those are structural, and none of them asks an author for translation
work their change did not create.
What it passed over is printed, so a green run is not mistaken for a clean
repository. If REF does not resolve — an unknown ref, or a clone too shallow
to hold it — the command exits 2 rather than forgiving everything it could not
measure.
One consequence is worth knowing before you meet it: adding a locale to
mkdocs.yml makes every page missing in that locale immediately. A new
locale therefore arrives in a single pull request, together with its pages.
translation-status --comment > body.md
writes a comment body describing every entry the gate fails on, with the English changes since each translation was written, collapsed. The command writes a body and nothing else; posting it belongs to whatever holds the token.
The body carries a <!-- translation-status --> marker so a workflow can find
and update its own previous comment rather than adding another one. If the body
would exceed GitHub's 65536-character limit, the diffs come out and the reader
is pointed at the job summary for them.
./run deps install dependencies
./run test run the test suite
./run lint check with ruff
./run check lint and test, as CI does
MIT. Copyright Hat Labs Oy.