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:
8 files changed
+294
-9
No files matched your search
@@ -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>…</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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user