docs/ rendered on GitHub and nowhere else. It is a built, versioned site now.

The alternative was a wiki, and a wiki is a separate git repo: a page would
stop moving in the same commit and the same review as the code it describes,
with nothing able to catch the drift, and there would be exactly one live
version of it. This project ships a package per tag and tells owners which
checkboxes have touched hardware; the docs for the build someone flashed have
to be readable, so Read the Docs builds this repo per tag and `latest` off
master, with the version switcher that follows from that.

Nothing moved. The audience split that the pages already have -- owner pages
against build-and-test pages -- is the nav in mkdocs.yml, so a reader lands in
their own section and never falls out of it, while the 30+ relative links
between README.md, docs/*.md and docs/notes/ keep working in the repo view and
the links people have already shared keep resolving. notes/70-error-codes.md
sits in the owner section because README.md was already sending owners there.

Two things had to hold for that to be true, and hooks/mkdocs_readme.py is
both. Links out of docs/ -- `../payload/klipper/extras/ff_tool.py` and 18
others in toolchange.md -- are written relative because that is what works
where a contributor reads them, in the repo; they become GitHub URLs at build
time, and a link whose target does not exist is left alone so the build still
fails on it. README.md is the front door and stays it: docs/index.md is
generated from it on every build and gitignored, rather than being a second
front page free to drift from the first.

Anchors use GitHub's slug rules for the same reason. `#runout--clog` is what
GitHub makes of `## Runout / clog`, python-markdown would have made
`#runout-clog`, and toolchange.md:340 already pointed at the first.

CI builds it with --strict, which is the cheap half of keeping docs true: a
rename in payload/ now breaks the build instead of quietly leaving a page
pointing at nothing. It cannot check that the prose is still right. Only a
reader can.

The site is not up yet -- the project has to be created on readthedocs.org and
its versions activated -- so nothing links to it from README.md. The slug is
free; mkdocs.yml claims it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-28 22:02:43 +03:00
1 parent d3df810189
commit 7347f56418
7 files changed
+248

No files matched your search

+24
View File
@@ -289,3 +289,27 @@ jobs:
-v /var/run/docker.sock:/var/run/docker.sock \
-e REQUIRE_PRINTER_SIM=1 \
creator5-fw-build ./test/run-tests.py
# The docs make claims about a machine that can be left unbootable, and the
# pages cross-link each other and the payload sources heavily -- 30+ links
# between README.md, docs/*.md and docs/notes/. This is the cheap half of
# keeping them true: --strict fails on a link whose target has moved or been
# deleted, so a rename in payload/ cannot quietly leave a doc pointing at
# nothing. It cannot check that the prose is still right; only a reader can.
#
# Read the Docs builds the same mkdocs.yml with fail_on_warning, so this job
# going red is also the warning that the published site is about to fail.
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12" # what .readthedocs.yaml builds with
- name: Install the docs toolchain
run: pip install -r docs/requirements.txt
- name: Build the site
run: mkdocs build --strict
+4
View File
@@ -8,3 +8,7 @@ dist/
# Python bytecode from editing the klippy extras in payload/klipper/extras/
__pycache__/
*.pyc
# Generated from README.md by hooks/mkdocs_readme.py at docs build time.
docs/index.md
site/
+20
View File
@@ -0,0 +1,20 @@
# Read the Docs builds this repo's docs/ directory as-is: same commit, same
# review, and one built version per git tag, so an owner running an older
# package can read that package's docs rather than master's.
version: 2
build:
os: ubuntu-24.04
tools:
python: "3.12"
mkdocs:
configuration: mkdocs.yml
# A broken cross-link fails the build. The docs make claims about a machine
# that can be left unbootable; a link that quietly stops resolving is the
# cheapest of those claims to check, so it is checked.
fail_on_warning: true
python:
install:
- requirements: docs/requirements.txt
+39
View File
@@ -287,3 +287,42 @@ printer, with a traceback that names cffi and not the build. Both a stale
prebuilt .so (v0.12 binary under a v0.13 tree) and a package that skipped
the fork entirely (v20260824-nova-kakhovka) have shipped; the compile-fresh
rule plus these gates are what make the third time structurally hard.
## The docs site
These pages are the site at
[reforge.readthedocs.io](https://reforge.readthedocs.io/). Read the Docs
builds `mkdocs.yml` out of this repo on every push to `master` and once per
tag, so a page and the code it describes move in the same commit and the same
review, and an owner running an older package can read that package's docs
from the version switcher rather than master's.
To preview locally:
```sh
python3 -m venv .venv && .venv/bin/pip install -r docs/requirements.txt
.venv/bin/mkdocs serve # http://127.0.0.1:8000
.venv/bin/mkdocs build --strict # what CI and RTD run
```
Three things about it are worth knowing before you add a page.
**The nav is split by audience, and the split is the point.** `mkdocs.yml` has
a *Using Reforge* section for someone who owns the printer and a *For
contributors* section for someone building or testing the firmware, with the
`docs/notes/` internals nested inside the second. A new page that is not in
that nav is built but unreachable. Put it in the section its reader is
standing in.
**Links stay repo-relative.** Write `../payload/klipper/extras/ff_tool.py` and
`notes/40-offsets.md` exactly as you would for someone reading the page on
GitHub; `hooks/mkdocs_readme.py` turns the ones that leave `docs/` into GitHub
URLs at build time. Anchors use GitHub's slug rules, so `#runout--clog` for
`## Runout / clog` works in both places. The same hook generates
`docs/index.md` from `README.md` on every build — edit the README, never that
file, which is gitignored.
**A dead link fails the build.** CI's `docs` job and RTD both build with
`--strict`, so renaming a file in `payload/` breaks the build rather than
leaving a doc pointing at nothing. It cannot check that the prose is still
true; only a reader can.
+4
View File
@@ -0,0 +1,4 @@
# Docs toolchain. Pinned for the same reason the stock packages in CI are
# pinned by sha: an unpinned build can change under us and start rendering
# something other than what was reviewed.
mkdocs-material==9.7.7
+68
View File
@@ -0,0 +1,68 @@
"""Two jobs, both about keeping one source of truth.
`on_pre_build` publishes README.md as the site's front page. The README is the
project's front door on GitHub and has to stay that; a hand-copied
docs/index.md would be a second front page, free to drift from the first, so
the build generates index.md on every run and .gitignore keeps it out of the
tree.
`on_page_markdown` rewrites the links that point out of docs/ -- at
`../payload/klipper/extras/ff_tool.py` and friends -- into GitHub URLs. Those
links are written relative because that is what works when the page is read in
the repo, which is where a contributor reads it; on the site they would 404.
Rewriting them here means nobody has to remember two link styles, and a link
whose target does not exist at all is still left alone, so mkdocs --strict can
fail the build over it.
"""
import pathlib
import re
REPO = "https://github.com/Klipper4FlashForge/firmware"
BRANCH = "master"
ROOT = pathlib.Path(__file__).resolve().parent.parent
DOCS = ROOT / "docs"
# A markdown link target that is not absolute, an anchor, or a mail address.
LINK = re.compile(r"\]\((?!https?:|#|mailto:)([^)\s]+)\)")
def on_pre_build(config, **kwargs):
text = (ROOT / "README.md").read_text(encoding="utf-8")
# The README's paths are relative to the repo root, the site's to docs/:
# resolve from the root first, so `gcode/` and `docs/notes/` become GitHub
# URLs, then strip the prefix from what is left, which is the site itself.
text = _rewrite_links(text, ROOT)
text = re.sub(r"\]\(docs/", "](", text)
text = re.sub(r'(src|href)="docs/', r"\1=\"", text)
index = DOCS / "index.md"
# Only write on a real change: `mkdocs serve` watches docs/, and touching
# index.md every build would rebuild forever.
if not index.exists() or index.read_text(encoding="utf-8") != text:
index.write_text(text, encoding="utf-8")
def on_page_markdown(markdown, page, **kwargs):
return _rewrite_links(markdown, (DOCS / page.file.src_path).parent)
def _rewrite_links(markdown, here):
def rewrite(match):
target = match.group(1)
path, _, fragment = target.partition("#")
if not path:
return match.group(0)
resolved = (here / path).resolve()
try:
relative = resolved.relative_to(ROOT)
except ValueError:
return match.group(0) # outside the repo; not ours to fix
if not resolved.exists():
return match.group(0) # broken: let --strict say so
if resolved.is_file() and DOCS in resolved.parents:
return match.group(0) # a page or an image; mkdocs has it
kind = "tree" if resolved.is_dir() else "blob"
suffix = f"#{fragment}" if fragment else ""
return f"]({REPO}/{kind}/{BRANCH}/{relative}{suffix})"
return LINK.sub(rewrite, markdown)
+89
View File
@@ -0,0 +1,89 @@
# The docs are built from this repo, not a wiki, so a page and the code it
# describes move in the same commit and the same review. CI builds this with
# --strict (see .github/workflows/ci.yml): a cross-link that stops resolving
# fails the build rather than rotting in place.
site_name: Reforge
site_description: >-
Custom firmware for the FlashForge Creator 5 and Creator 5 Pro.
site_url: https://reforge.readthedocs.io/
repo_url: https://github.com/Klipper4FlashForge/firmware
repo_name: Klipper4FlashForge/firmware
edit_uri: edit/master/docs/
theme:
name: material
features:
- navigation.sections # the public/contributor split, always visible
- navigation.top
- content.code.copy
- search.suggest
- toc.follow
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/weather-night
name: Dark
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/weather-sunny
name: Light
markdown_extensions:
- admonition
- attr_list
- md_in_html # the <img> pairs in README.md and toolchange.md
- tables
- toc:
permalink: true
# GitHub's heading slugs, so an anchor written for the repo view --
# `#runout--clog` for `## Runout / clog` -- resolves on the site too.
slugify: !!python/object/apply:pymdownx.slugs.slugify {kwds: {case: lower}}
- pymdownx.details
- pymdownx.superfences
# docs/index.md is generated from README.md at build time; see the hook.
hooks:
- hooks/mkdocs_readme.py
# Release notes are published on the Releases page, which is where the README
# sends people. They are built but stay out of the nav rather than growing it
# by one entry per tag.
not_in_nav: |
releases/*
# Two audiences, and a reader should never fall from one into the other. The
# first section is for someone who owns the printer; the second is for someone
# building or testing the firmware.
nav:
- Home: index.md
- Using Reforge:
- After the flash: hardware-testing.md
- Toolchanges: toolchange.md
- Nozzle-offset calibration: calibration.md
- How it works: how-it-works.md
- Error codes: notes/70-error-codes.md
- For contributors:
- Building packages: building.md
- Testing: testing.md
- The printer replica: printer-replica.md
- Internals:
- Overview: notes/00-overview.md
- Hardware and MCU topology: notes/10-hardware.md
- The FlashForge Klipper fork: notes/20-klipper-fork.md
- firmwareExe vs Klipper: notes/25-app-vs-klipper-ownership.md
- Toolchange sequences: notes/30-toolchange.md
- Offsets and per-unit config: notes/40-offsets.md
- Offset calibration, the port: notes/45-tool-offset-calibration.md
- Offset calibration, recovered: notes/46-offset-calibration-recovered.md
- Filament load, recovered: notes/47-filament-load-recovered.md
- Filament load, the port: notes/48-filament-load-port.md
- Runout and clog handling: notes/49-runout-recovered.md
- Print lifecycle: notes/50-print-lifecycle.md
- Nozzle clean, recovered: notes/50a-nozzle-clean-recovered.md
- Nozzle clean, the port: notes/50b-nozzle-clean-port.md
- Filament, calibration, persistent state: notes/60-background.md
- Migrating to s6: notes/80-s6-migration.md