diff --git a/.github/workflows/mdbook.yml b/.github/workflows/mdbook.yml index 0f82fd5..1f8af1e 100644 --- a/.github/workflows/mdbook.yml +++ b/.github/workflows/mdbook.yml @@ -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' + + + + + + + MeshCore Protocol Specification + + +

Redirecting to the latest version…

+ + + 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(); + } +})();