feat(mdbook): multi-version site, mise-pinned tools, justfile

Three intertwined changes — split conceptually but landed together
because they share the same workflow rewrite:

1. MULTI-VERSION SITE. The published site now keeps every prior spec
   version alongside the rolling latest build (docs.rs / readthedocs
   pattern):

   - .github/workflows/mdbook.yml builds latest from current HEAD and
     each v* tag in a git worktree, placing each into _site/<ver>/.
     Each per-version build gets MDBOOK_OUTPUT__HTML__SITE_URL set to
     /meshcore-spec/<ver>/ so the sitemap/canonical links resolve
     correctly per version.
   - Workflow emits _site/versions.json (newest first) and a root
     index.html that redirects to /latest/.
   - theme/version-picker.js is a small client-side dropdown that
     fetches /<base>/versions.json and lets the reader switch to the
     same chapter in any other version. Slots into mdbook's menu bar
     (.right-buttons); falls back to a fixed top-right widget if the
     menu isn't found.
   - theme/version-picker.css styles it to fit the navy theme.
   - book.toml wires the picker files via additional-js /
     additional-css; site-url defaults to /meshcore-spec/latest/ for
     the main build (CI overrides per tag).
   - Old tags built without these files render without a picker, which
     is fine — users won't typically need to navigate AWAY from an
     older version. v0.1.0 onward will all carry the picker.

   Spec versions use independent semver and do NOT mirror MeshCore
   firmware versions; each spec release records the upstream commit
   hash it was validated against in CHANGELOG.md.

2. MISE-PINNED TOOLS. mise.toml pins cargo:mdbook so local dev and
   CI use the same version. The workflow installs everything via
   jdx/mise-action instead of peaceiris/actions-mdbook, making
   mise.toml the single source of truth for tool versions.

3. JUSTFILE. justfile gives short names (build / serve / clean /
   validate / setup) for the common tasks. Recipes run under
   `mise exec -- sh -c …` so they always pick up the mise-pinned
   tools (sh is intentional, keeping recipes POSIX-compatible).

Documentation: README's spec link now mentions the versioning model;
src/00-overview.md gets a "Spec Versions" subsection explaining the
URL layout and version-picker.

Verified locally: `just build` produces a clean book/ with the
version-picker JS/CSS injected (hashed filenames, slotted into
theme/). JS parses clean under `node --check`.

Note for first-time deploy: GitHub Pages source must be set to
"GitHub Actions" in repo settings (one-time, manual). After that,
push to main builds + deploys latest, and pushing a v* tag adds
the snapshot to the site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.7 committed 2026-05-12 09:51:07 -06:00
1 parent 4d6f31d152
commit 9ad31f7470
8 files changed
+294 -9

No files matched your search

+85 -8
View File
@@ -3,6 +3,7 @@ name: mdBook
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
@@ -16,26 +17,102 @@ concurrency:
group: pages
cancel-in-progress: false
env:
# Repo path on GitHub Pages — change here if the repo is renamed.
SITE_BASE: /meshcore-spec
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
uses: peaceiris/actions-mdbook@v2
- name: Checkout (with tags)
uses: actions/checkout@v4
with:
mdbook-version: latest
fetch-depth: 0
- name: Install mise + pinned tools (mdbook, etc.)
uses: jdx/mise-action@v2
with:
install: true
cache: true
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Build book
run: mdbook build
- name: Build all versions
run: |
set -euo pipefail
mkdir -p _site
WORKSPACE="$PWD"
# ---- latest (current ref) ----
echo "::group::build latest"
MDBOOK_OUTPUT__HTML__SITE_URL="${SITE_BASE}/latest/" \
mdbook build "$WORKSPACE" -d "$WORKSPACE/_site/latest"
echo "::endgroup::"
# ---- each v* tag ----
# Tags are built in worktrees so the workflow's own files (this
# file, the picker JS/CSS) don't bleed into older builds.
mapfile -t TAGS < <(git tag --list 'v*' --sort=-v:refname)
for tag in "${TAGS[@]:-}"; do
[ -z "$tag" ] && continue
echo "::group::build $tag"
wt="$(mktemp -d -t "mdbook-${tag//\//-}-XXXXXX")"
git worktree add --detach "$wt" "$tag"
# If a very old tag lacks book.toml, skip it but don't fail.
if [ -f "$wt/book.toml" ]; then
MDBOOK_OUTPUT__HTML__SITE_URL="${SITE_BASE}/${tag}/" \
mdbook build "$wt" -d "$WORKSPACE/_site/${tag}" || {
echo "::warning::mdbook build failed for ${tag}; skipping"
rm -rf "$WORKSPACE/_site/${tag}"
}
else
echo "::warning::${tag} has no book.toml; skipping"
fi
git worktree remove --force "$wt"
echo "::endgroup::"
done
# ---- versions.json (consumed by theme/version-picker.js) ----
# Newest first; "latest" always at the top.
{
printf '['
printf '"latest"'
for tag in "${TAGS[@]:-}"; do
[ -z "$tag" ] && continue
[ -d "_site/${tag}" ] || continue
printf ',"%s"' "$tag"
done
printf ']\n'
} > _site/versions.json
echo "versions.json:"
cat _site/versions.json
# ---- root redirect to /latest/ ----
cat > _site/index.html <<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=./latest/">
<link rel="canonical" href="./latest/">
<title>MeshCore Protocol Specification</title>
</head>
<body>
<p>Redirecting to the <a href="./latest/">latest version</a>&hellip;</p>
</body>
</html>
HTML
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./book
path: ./_site
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
environment:
+2
View File
@@ -4,6 +4,8 @@ A comprehensive specification and conformance test suite for the
[MeshCore](https://github.com/meshcore-dev/MeshCore) mesh networking protocol.
**📖 Read the spec online:** <https://swaits.github.io/meshcore-spec/>
(rolling `latest` build, plus a frozen snapshot per `vX.Y.Z` tag — pick one
from the version-picker in the top-right of any page)
MeshCore Spec aims to be the authoritative, machine-readable definition of the
MeshCore wire format — much like [RubySpec](https://github.com/ruby/spec) became
+5 -1
View File
@@ -10,7 +10,11 @@ default-theme = "navy"
preferred-dark-theme = "navy"
git-repository-url = "https://github.com/swaits/meshcore-spec"
edit-url-template = "https://github.com/swaits/meshcore-spec/edit/main/{path}"
site-url = "/meshcore-spec/"
# CI overrides per-version with MDBOOK_OUTPUT__HTML__SITE_URL.
# Default below targets the "latest" deploy from main.
site-url = "/meshcore-spec/latest/"
additional-js = ["theme/version-picker.js"]
additional-css = ["theme/version-picker.css"]
[output.html.fold]
enable = true
+32
View File
@@ -0,0 +1,32 @@
# meshcore-spec — task runner.
#
# Every recipe runs under `mise exec sh -c …`, so mise-pinned tools
# (see mise.toml) are on PATH and we never accidentally use a system
# mdbook of the wrong version. `sh` is intentional — keep recipes
# POSIX-compatible.
set shell := ["mise", "exec", "--", "sh", "-c"]
# Show the available recipes.
_default:
@just --list
# Install / refresh tools pinned in mise.toml.
setup:
mise install
# Build the spec into ./book/.
build:
mdbook build
# Live-preview the spec at http://localhost:3000 with auto-reload.
serve:
mdbook serve --open --port 3000
# Remove all build outputs (single-version and multi-version).
clean:
rm -rf book _site
# Run the corpus validator.
validate:
python3 tools/validate.py corpus/
+8
View File
@@ -0,0 +1,8 @@
# Tool versions for meshcore-spec.
#
# Pins are used by CI (.github/workflows/mdbook.yml) and by the justfile,
# which runs every recipe under `mise exec sh -c …`. Bump intentionally;
# don't drift.
[tools]
"cargo:mdbook" = "0.5.2"
+18
View File
@@ -34,6 +34,24 @@ upstream firmware. If you find a divergence from upstream, please
[file an issue](https://github.com/swaits/meshcore-spec/issues) — that is
exactly the kind of report this project exists to catch.
### Spec Versions
The published site preserves prior spec versions alongside the rolling
`latest` build. The URL layout is:
- `https://swaits.github.io/meshcore-spec/latest/` — built from the tip of
`main`. May change between any two visits.
- `https://swaits.github.io/meshcore-spec/v0.1.0/` (and likewise for any
future `vX.Y.Z`) — a frozen snapshot built from the matching git tag.
Suitable for citing or for certifying an implementation against.
The version-picker in the top-right of every page lists every published
version; switching keeps you on the same chapter where it exists in the
target version. Spec versions use semver and are **independent** of the
upstream MeshCore firmware's own versioning — each release records the
upstream commit hash it was validated against in
[CHANGELOG.md](https://github.com/swaits/meshcore-spec/blob/main/CHANGELOG.md).
### Scope
This specification covers:
+43
View File
@@ -0,0 +1,43 @@
.version-picker {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin: 0 0.4em;
font-size: 0.85em;
line-height: 1;
}
.version-picker-label {
color: var(--icons);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.version-picker-select {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--icons);
border-radius: 3px;
padding: 0.2em 0.45em;
cursor: pointer;
font: inherit;
line-height: 1.2;
}
.version-picker-select:hover,
.version-picker-select:focus {
border-color: var(--fg);
outline: none;
}
.version-picker-floating {
position: fixed;
top: 0.5em;
right: 0.5em;
z-index: 1000;
background: var(--bg);
padding: 0.4em 0.6em;
border: 1px solid var(--icons);
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
+101
View File
@@ -0,0 +1,101 @@
// version-picker.js
//
// Renders a version dropdown in the mdBook menu bar for multi-version
// deployments. Reads /<base>/versions.json at page load and lets the
// reader switch to the same page in a different version.
//
// The deployed URL layout is <base>/<version>/<page>, where <version>
// is "latest" or a semver-like tag (e.g., "v0.1.0"). When the reader
// chooses a different version, the picker navigates to the matching
// page under the new version subtree; if that page doesn't exist in
// the target version, the browser will show a 404, which is fine for
// a v0.1 deployment.
(function () {
'use strict';
// Match the version segment in a path. Accepts "latest" or "vX[.Y[.Z]][-pre]".
var VERSION_RE = /\/(latest|v\d+(?:\.\d+){0,2}(?:-[A-Za-z0-9.]+)?)(\/|$)/;
function parseLocation() {
var path = window.location.pathname;
var m = path.match(VERSION_RE);
if (!m) return null;
var version = m[1];
var versionStart = m.index;
var basePath = path.substring(0, versionStart + 1);
var pagePath = path.substring(versionStart + 1 + version.length);
return { version: version, basePath: basePath, pagePath: pagePath };
}
function buildPicker(versions, current, basePath, pagePath) {
var wrap = document.createElement('div');
wrap.className = 'version-picker';
wrap.setAttribute('role', 'group');
wrap.setAttribute('aria-label', 'Spec version');
var label = document.createElement('span');
label.className = 'version-picker-label';
label.textContent = 'version:';
wrap.appendChild(label);
var select = document.createElement('select');
select.className = 'version-picker-select';
select.setAttribute('aria-label', 'Choose spec version');
for (var i = 0; i < versions.length; i++) {
var v = versions[i];
var opt = document.createElement('option');
opt.value = v;
opt.textContent = (v === 'latest') ? 'latest (main)' : v;
if (v === current) opt.selected = true;
select.appendChild(opt);
}
select.addEventListener('change', function (e) {
var next = e.target.value;
if (!next || next === current) return;
window.location.pathname = basePath + next + pagePath;
});
wrap.appendChild(select);
return wrap;
}
function install() {
var loc = parseLocation();
if (!loc) return;
fetch(loc.basePath + 'versions.json', { cache: 'no-cache' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
// Accepted shapes:
// ["latest", "v0.1.0"]
// { "versions": ["latest", "v0.1.0"] }
var versions = Array.isArray(data) ? data : (data && data.versions);
if (!versions || !versions.length) return;
var picker = buildPicker(versions, loc.version, loc.basePath, loc.pagePath);
// Prefer slotting into mdBook's menu bar; fall back to floating.
var slot = document.querySelector('.right-buttons');
if (slot) {
slot.insertBefore(picker, slot.firstChild);
} else {
picker.classList.add('version-picker-floating');
document.body.appendChild(picker);
}
})
.catch(function (err) {
// Non-fatal: dev / file:// / pre-deploy will all land here.
if (window.console) console.warn('version-picker:', err);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', install);
} else {
install();
}
})();