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>
102 lines
3.5 KiB
JavaScript
102 lines
3.5 KiB
JavaScript
// 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();
|
|
}
|
|
})();
|