+
+
+ 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:
diff --git a/README.md b/README.md
index b7868d7..4c19f2c 100644
--- a/README.md
+++ b/README.md
@@ -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:**
+(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
diff --git a/book.toml b/book.toml
index e8db1ad..f07a628 100644
--- a/book.toml
+++ b/book.toml
@@ -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
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..e586b90
--- /dev/null
+++ b/justfile
@@ -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/
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..5a8e3bb
--- /dev/null
+++ b/mise.toml
@@ -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"
diff --git a/src/00-overview.md b/src/00-overview.md
index dc61d5d..6227acb 100644
--- a/src/00-overview.md
+++ b/src/00-overview.md
@@ -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:
diff --git a/theme/version-picker.css b/theme/version-picker.css
new file mode 100644
index 0000000..e6f8075
--- /dev/null
+++ b/theme/version-picker.css
@@ -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);
+}
diff --git a/theme/version-picker.js b/theme/version-picker.js
new file mode 100644
index 0000000..6b0b059
--- /dev/null
+++ b/theme/version-picker.js
@@ -0,0 +1,101 @@
+// version-picker.js
+//
+// Renders a version dropdown in the mdBook menu bar for multi-version
+// deployments. Reads //versions.json at page load and lets the
+// reader switch to the same page in a different version.
+//
+// The deployed URL layout is //, where
+// 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();
+ }
+})();