diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcf0a85..18e4b1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 90cda3f..f845fee 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..857dc84 --- /dev/null +++ b/.readthedocs.yaml @@ -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 diff --git a/docs/building.md b/docs/building.md index 78deb68..63940ee 100644 --- a/docs/building.md +++ b/docs/building.md @@ -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. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..ad2e444 --- /dev/null +++ b/docs/requirements.txt @@ -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 diff --git a/hooks/mkdocs_readme.py b/hooks/mkdocs_readme.py new file mode 100644 index 0000000..ab9e026 --- /dev/null +++ b/hooks/mkdocs_readme.py @@ -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) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..8841117 --- /dev/null +++ b/mkdocs.yml @@ -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 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