From d804437b0606695ae13ae1d7f0a8b76568471e66 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 16 Jun 2026 15:02:59 +0000 Subject: [PATCH 1/2] Fix biome lint, dead preferred-label code, and output-input docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version-switcher.mjs: satisfy biome (template literal in withTrailingSlash, optional chain in directive run); make the pydata `preferred` entry render a star marker instead of a dead no-op ternary - test-url-logic.mjs: cover entryLabel (the gap that hid the dead code) - docs/index.md + CLAUDE.md: the switcher action's `output` is required, not defaulted — pass it explicitly in the consuming examples - biome.json: respect .gitignore (skip docs/_build) and pin tab/double-quote formatter defaults; reformat package.json + devcontainer.json to match Also includes the in-flight biome reformatting of the source/test files. Co-Authored-By: Claude Opus 4.8 --- .devcontainer/devcontainer.json | 87 ++- CLAUDE.md | 5 +- biome.json | 6 + docs/index.md | 2 +- package.json | 48 +- plugins/version-switcher/version-switcher.mjs | 543 +++++++++--------- switcher/make-switcher.mjs | 96 ++-- test/test-make-switcher.mjs | 84 +-- test/test-url-logic.mjs | 312 ++++++---- 9 files changed, 648 insertions(+), 535 deletions(-) create mode 100644 biome.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 20d5157..44ecbee 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,48 +1,45 @@ // For format details, see https://containers.dev/implementors/json_reference/ { - "name": "myst-version-switcher-plugin Developer Container", - "build": { - "dockerfile": "../Dockerfile", - "target": "developer" - }, - "remoteEnv": { - // Allow X11 apps to run inside the container - "DISPLAY": "${localEnv:DISPLAY}", - // Put things that allow it in the persistent cache - "PRE_COMMIT_HOME": "/cache/pre-commit", - "npm_config_cache": "/cache/npm-cache" - }, - "customizations": { - "vscode": { - "extensions": [ - "biomejs.biome", - "ms-azuretools.vscode-docker" - ] - } - }, - // Create the config folder for the bash-config feature - "initializeCommand": "mkdir -p ${localEnv:HOME}/.config/terminal-config", - "postCreateCommand": "npm install && npx prek install", - "runArgs": [ - // Allow the container to access the host X11 display and EPICS CA - "--net=host", - // Make sure SELinux does not disable with access to host filesystems like tmp - "--security-opt=label=disable" - ], - "mounts": [ - // Mount in the user terminal config folder so it can be edited - { - "source": "${localEnv:HOME}/.config/terminal-config", - "target": "/user-terminal-config", - "type": "bind" - }, - // Keep a persistent cross-container cache for pre-commit and npm - { - "source": "devcontainer-shared-cache", - "target": "/cache", - "type": "volume" - } - ], - // Mount the parent as /workspaces so sibling repos are accessible - "workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind", + "name": "myst-version-switcher-plugin Developer Container", + "build": { + "dockerfile": "../Dockerfile", + "target": "developer" + }, + "remoteEnv": { + // Allow X11 apps to run inside the container + "DISPLAY": "${localEnv:DISPLAY}", + // Put things that allow it in the persistent cache + "PRE_COMMIT_HOME": "/cache/pre-commit", + "npm_config_cache": "/cache/npm-cache" + }, + "customizations": { + "vscode": { + "extensions": ["biomejs.biome", "ms-azuretools.vscode-docker"] + } + }, + // Create the config folder for the bash-config feature + "initializeCommand": "mkdir -p ${localEnv:HOME}/.config/terminal-config", + "postCreateCommand": "npm install && npx prek install", + "runArgs": [ + // Allow the container to access the host X11 display and EPICS CA + "--net=host", + // Make sure SELinux does not disable with access to host filesystems like tmp + "--security-opt=label=disable" + ], + "mounts": [ + // Mount in the user terminal config folder so it can be edited + { + "source": "${localEnv:HOME}/.config/terminal-config", + "target": "/user-terminal-config", + "type": "bind" + }, + // Keep a persistent cross-container cache for pre-commit and npm + { + "source": "devcontainer-shared-cache", + "target": "/cache", + "type": "volume" + } + ], + // Mount the parent as /workspaces so sibling repos are accessible + "workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind" } diff --git a/CLAUDE.md b/CLAUDE.md index 148db6c..967dd04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,9 @@ the action is consumed from the repo tree at the same tag. ## Key design decisions ### `switcher` action is write-only -The action writes `.github/pages/switcher.json` and nothing else. It does NOT `mv` +The action writes `switcher.json` to the caller-supplied `output` path and nothing +else (the consuming docs use `.github/pages/switcher.json`; this repo's own CI +writes to `_staging/switcher.json`). It does NOT `mv` the built docs, does NOT `git fetch`. Staging the versioned dir (`mv`) and `fetch-depth: 0` (for tags + `origin/gh-pages`) are the caller's responsibility (pattern lifted from `python-copier-template-example`). @@ -128,6 +130,7 @@ site: with: version: ${{ env.DOCS_VERSION }} repo: ${{ github.repository }} + output: .github/pages/switcher.json # required: where to write it - uses: peaceiris/actions-gh-pages@v4 with: publish_dir: .github/pages diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f766bff --- /dev/null +++ b/biome.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "formatter": { "indentStyle": "tab" }, + "javascript": { "formatter": { "quoteStyle": "double" } } +} diff --git a/docs/index.md b/docs/index.md index 5bb2be3..c1fbc2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -91,7 +91,7 @@ Wire it into your docs workflow after staging the built HTML and before publishi with: version: ${{ env.DOCS_VERSION }} repo: ${{ github.repository }} - # output defaults to .github/pages/switcher.json + output: .github/pages/switcher.json # where to write it (required) - uses: peaceiris/actions-gh-pages@v4 with: publish_dir: .github/pages diff --git a/package.json b/package.json index bb31d09..be5d0bc 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,26 @@ { - "name": "myst-version-switcher-plugin", - "private": true, - "description": "A pydata-style version switcher for MyST, as a single anywidget plugin, plus a CI action to generate switcher.json.", - "type": "module", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/DiamondLightSource/myst-version-switcher-plugin.git" - }, - "scripts": { - "test": "node test/test-url-logic.mjs && node test/test-make-switcher.mjs", - "docs": "cd docs && myst build --html", - "docs-dev": "cd docs && myst start" - }, - "files": [ - "plugins/version-switcher/version-switcher.mjs", - "switcher/make-switcher.mjs", - "switcher/action.yml" - ], - "devDependencies": { - "@biomejs/biome": "^1.9.0", - "@j178/prek": "^0.3.0", - "mystmd": "1.10.1" - } + "name": "myst-version-switcher-plugin", + "private": true, + "description": "A pydata-style version switcher for MyST, as a single anywidget plugin, plus a CI action to generate switcher.json.", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/DiamondLightSource/myst-version-switcher-plugin.git" + }, + "scripts": { + "test": "node test/test-url-logic.mjs && node test/test-make-switcher.mjs", + "docs": "cd docs && myst build --html", + "docs-dev": "cd docs && myst start" + }, + "files": [ + "plugins/version-switcher/version-switcher.mjs", + "switcher/make-switcher.mjs", + "switcher/action.yml" + ], + "devDependencies": { + "@biomejs/biome": "^1.9.0", + "@j178/prek": "^0.3.0", + "mystmd": "1.10.1" + } } diff --git a/plugins/version-switcher/version-switcher.mjs b/plugins/version-switcher/version-switcher.mjs index bdab751..3de5ebb 100644 --- a/plugins/version-switcher/version-switcher.mjs +++ b/plugins/version-switcher/version-switcher.mjs @@ -28,26 +28,26 @@ const PLUGIN_PATH = new URL(import.meta.url).pathname; /** POSIX relative path from `fromDir` to `toFile` (both absolute, no Node deps). */ export function relativePath(fromDir, toFile) { - const from = String(fromDir).split('/').filter(Boolean); - const to = String(toFile).split('/').filter(Boolean); - let i = 0; - while (i < from.length && i < to.length && from[i] === to[i]) i += 1; - const up = from.slice(i).map(() => '..'); - return [...up, ...to.slice(i)].join('/') || '.'; + const from = String(fromDir).split("/").filter(Boolean); + const to = String(toFile).split("/").filter(Boolean); + let i = 0; + while (i < from.length && i < to.length && from[i] === to[i]) i += 1; + const up = from.slice(i).map(() => ".."); + return [...up, ...to.slice(i)].join("/") || "."; } /* ----------------------------- pure helpers ----------------------------- */ /** Ensure a pathname ends with exactly one trailing slash. */ export function withTrailingSlash(pathname) { - if (!pathname) return '/'; - return pathname.endsWith('/') ? pathname : pathname + '/'; + if (!pathname) return "/"; + return pathname.endsWith("/") ? pathname : `${pathname}/`; } -/** Display label for an entry. */ +/** Display label for an entry; the pydata `preferred` (stable) entry gets a star. */ export function entryLabel(entry) { - const base = entry.name || entry.version || entry.url; - return entry.preferred ? `${base}` : base; + const base = entry.name || entry.version || entry.url; + return entry.preferred ? `${base} ★` : base; } /** @@ -64,42 +64,44 @@ export function entryLabel(entry) { * @returns {object|null} the active entry, or null if none matched. */ export function detectCurrent(entries, locationPathname, versionMatch) { - if (!Array.isArray(entries) || entries.length === 0) return null; - - if (versionMatch) { - const exact = entries.find((e) => e.version === versionMatch); - if (exact) return exact; - const loose = entries.find( - (e) => e.version && String(versionMatch).startsWith(e.version), - ); - if (loose) return loose; - } - - let best = null; - let bestLen = -1; - for (const e of entries) { - let base; - try { - base = withTrailingSlash(new URL(e.url, 'http://x').pathname); - } catch { - continue; - } - const hay = withTrailingSlash(locationPathname); - if (hay.startsWith(base) && base.length > bestLen) { - best = e; - bestLen = base.length; - } - } - return best; + if (!Array.isArray(entries) || entries.length === 0) return null; + + if (versionMatch) { + const exact = entries.find((e) => e.version === versionMatch); + if (exact) return exact; + const loose = entries.find( + (e) => e.version && String(versionMatch).startsWith(e.version), + ); + if (loose) return loose; + } + + let best = null; + let bestLen = -1; + for (const e of entries) { + let base; + try { + base = withTrailingSlash(new URL(e.url, "http://x").pathname); + } catch { + continue; + } + const hay = withTrailingSlash(locationPathname); + if (hay.startsWith(base) && base.length > bestLen) { + best = e; + bestLen = base.length; + } + } + return best; } /** Is this a local dev host (localhost / 127.0.0.1 / ::1 / *.localhost)? */ export function isLocalHost(hostname) { - return hostname === 'localhost' - || hostname === '127.0.0.1' - || hostname === '[::1]' - || hostname === '::1' - || (typeof hostname === 'string' && hostname.endsWith('.localhost')); + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "::1" || + (typeof hostname === "string" && hostname.endsWith(".localhost")) + ); } /** @@ -116,13 +118,13 @@ export function isLocalHost(hostname) { * @returns {{entries: Array, current: object|null}} */ export function withLocalFallback(entries, current, location) { - if (current || !isLocalHost(location.hostname)) return { entries, current }; - const local = { - version: 'local', - name: 'local (dev)', - url: new URL('/', location.origin).href, - }; - return { entries: [local, ...entries], current: local }; + if (current || !isLocalHost(location.hostname)) return { entries, current }; + const local = { + version: "local", + name: "local (dev)", + url: new URL("/", location.origin).href, + }; + return { entries: [local, ...entries], current: local }; } /** @@ -138,18 +140,25 @@ export function withLocalFallback(entries, current, location) { * @param {boolean} preservePath * @returns {string} absolute href to navigate to */ -export function computeTargetUrl(targetEntry, currentEntry, location, preservePath) { - const target = new URL(targetEntry.url); - target.pathname = withTrailingSlash(target.pathname); - - if (preservePath && currentEntry) { - const currentBase = withTrailingSlash(new URL(currentEntry.url).pathname); - const here = location.pathname || ''; - const rel = here.startsWith(currentBase) ? here.slice(currentBase.length) : ''; - target.pathname = withTrailingSlash(target.pathname) + rel; - if (location.hash) target.hash = location.hash; - } - return target.href; +export function computeTargetUrl( + targetEntry, + currentEntry, + location, + preservePath, +) { + const target = new URL(targetEntry.url); + target.pathname = withTrailingSlash(target.pathname); + + if (preservePath && currentEntry) { + const currentBase = withTrailingSlash(new URL(currentEntry.url).pathname); + const here = location.pathname || ""; + const rel = here.startsWith(currentBase) + ? here.slice(currentBase.length) + : ""; + target.pathname = withTrailingSlash(target.pathname) + rel; + if (location.hash) target.hash = location.hash; + } + return target.href; } /** @@ -168,21 +177,26 @@ export function computeTargetUrl(targetEntry, currentEntry, location, preservePa * @returns {Promise} absolute href to navigate to */ export async function resolveTargetUrl({ - targetEntry, - currentEntry, - location, - preservePath, - pageExists, + targetEntry, + currentEntry, + location, + preservePath, + pageExists, }) { - const candidate = computeTargetUrl(targetEntry, currentEntry, location, preservePath); - - // Nothing to probe: we're already heading to the version root. - if (!preservePath || !currentEntry) return candidate; - const root = computeTargetUrl(targetEntry, currentEntry, location, false); - if (candidate === root) return candidate; - - const found = await pageExists(candidate); - return found === false ? root : candidate; + const candidate = computeTargetUrl( + targetEntry, + currentEntry, + location, + preservePath, + ); + + // Nothing to probe: we're already heading to the version root. + if (!preservePath || !currentEntry) return candidate; + const root = computeTargetUrl(targetEntry, currentEntry, location, false); + if (candidate === root) return candidate; + + const found = await pageExists(candidate); + return found === false ? root : candidate; } /** @@ -193,210 +207,227 @@ export async function resolveTargetUrl({ * @returns {Promise} */ export async function pageExists(url) { - // `cache: 'no-store'` forces a full response every time. Without it a cache hit - // can come back as a 304 revalidation, and gh-pages/Fastly strips - // `Access-Control-Allow-Origin` from 304s — which makes a *cross-origin* probe - // (e.g. a preview origin → gh-pages) get blocked by the browser. A fresh 200 - // always carries the CORS header. (Same-origin, the production case, is fine - // either way.) - const init = { method: 'HEAD', credentials: 'omit', redirect: 'follow', cache: 'no-store' }; - try { - let res = await fetch(url, init); - if (res.status === 405 || res.status === 501) { - res = await fetch(url, { ...init, method: 'GET' }); - } - if (res.ok) return true; - if (res.status === 404) return false; - return null; - } catch { - return null; // network / CORS — can't tell - } + // `cache: 'no-store'` forces a full response every time. Without it a cache hit + // can come back as a 304 revalidation, and gh-pages/Fastly strips + // `Access-Control-Allow-Origin` from 304s — which makes a *cross-origin* probe + // (e.g. a preview origin → gh-pages) get blocked by the browser. A fresh 200 + // always carries the CORS header. (Same-origin, the production case, is fine + // either way.) + const init = { + method: "HEAD", + credentials: "omit", + redirect: "follow", + cache: "no-store", + }; + try { + let res = await fetch(url, init); + if (res.status === 405 || res.status === 501) { + res = await fetch(url, { ...init, method: "GET" }); + } + if (res.ok) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; // network / CORS — can't tell + } } /* ------------------------------- rendering ------------------------------ */ function buildSelect(entries, currentEntry, onPick) { - const wrap = document.createElement('div'); - wrap.className = 'myst-version-switcher'; - Object.assign(wrap.style, { - display: 'inline-flex', - alignItems: 'center', - fontFamily: 'inherit', - fontSize: '0.875rem', - }); - - const select = document.createElement('select'); - // Match the sibling navbar controls (search pill / theme toggle): a soft - // translucent fill + border derived from the inherited text colour, so it - // reads correctly in both light and dark without hard-coding theme colours. - select.setAttribute('aria-label', 'Select documentation version'); - Object.assign(select.style, { - font: 'inherit', - color: 'inherit', - background: 'color-mix(in srgb, currentColor 6%, transparent)', - border: '1px solid color-mix(in srgb, currentColor 22%, transparent)', - borderRadius: '0.5rem', - padding: '0.35em 1.6em 0.35em 0.6em', - cursor: 'pointer', - maxWidth: '16em', - }); - - if (!currentEntry) { - const placeholder = document.createElement('option'); - placeholder.textContent = 'Choose version…'; - placeholder.value = ''; - placeholder.disabled = true; - placeholder.selected = true; - select.appendChild(placeholder); - } - - entries.forEach((entry, i) => { - const opt = document.createElement('option'); - opt.value = String(i); - opt.textContent = entryLabel(entry); - if (currentEntry && entry === currentEntry) opt.selected = true; - select.appendChild(opt); - }); - - select.addEventListener('change', () => { - const idx = Number(select.value); - if (Number.isInteger(idx) && entries[idx]) onPick(entries[idx], select); - }); - - wrap.appendChild(select); - return wrap; + const wrap = document.createElement("div"); + wrap.className = "myst-version-switcher"; + Object.assign(wrap.style, { + display: "inline-flex", + alignItems: "center", + fontFamily: "inherit", + fontSize: "0.875rem", + }); + + const select = document.createElement("select"); + // Match the sibling navbar controls (search pill / theme toggle): a soft + // translucent fill + border derived from the inherited text colour, so it + // reads correctly in both light and dark without hard-coding theme colours. + select.setAttribute("aria-label", "Select documentation version"); + Object.assign(select.style, { + font: "inherit", + color: "inherit", + background: "color-mix(in srgb, currentColor 6%, transparent)", + border: "1px solid color-mix(in srgb, currentColor 22%, transparent)", + borderRadius: "0.5rem", + padding: "0.35em 1.6em 0.35em 0.6em", + cursor: "pointer", + maxWidth: "16em", + }); + + if (!currentEntry) { + const placeholder = document.createElement("option"); + placeholder.textContent = "Choose version…"; + placeholder.value = ""; + placeholder.disabled = true; + placeholder.selected = true; + select.appendChild(placeholder); + } + + entries.forEach((entry, i) => { + const opt = document.createElement("option"); + opt.value = String(i); + opt.textContent = entryLabel(entry); + if (currentEntry && entry === currentEntry) opt.selected = true; + select.appendChild(opt); + }); + + select.addEventListener("change", () => { + const idx = Number(select.value); + if (Number.isInteger(idx) && entries[idx]) onPick(entries[idx], select); + }); + + wrap.appendChild(select); + return wrap; } function showError(el, message) { - const err = document.createElement('span'); - err.textContent = message; - Object.assign(err.style, { fontSize: '0.8rem', opacity: '0.7' }); - el.appendChild(err); + const err = document.createElement("span"); + err.textContent = message; + Object.assign(err.style, { fontSize: "0.8rem", opacity: "0.7" }); + el.appendChild(err); } /** AnyWidget runtime entry point (`default.render`). */ export async function render({ model, el }) { - const jsonUrl = model.get('json_url'); - const versionMatch = model.get('version_match'); // optional override - const preservePath = model.get('preserve_path') !== false; // default true - const probeTarget = model.get('probe_target') !== false; // default true - - if (!jsonUrl) { - showError(el, 'version-switcher: no json_url configured.'); - return () => { el.innerHTML = ''; }; - } - - try { - const resolved = new URL(jsonUrl, window.location.href).href; - const res = await fetch(resolved, { credentials: 'omit' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const raw = await res.json(); - - const detected = detectCurrent(raw, window.location.pathname, versionMatch); - // On localhost, fall back to a synthetic "local" version rooted at `/` so - // the switcher is usable in `myst start` against live version URLs. - const { entries, current } = withLocalFallback(raw, detected, window.location); - - const ui = buildSelect(entries, current, async (targetEntry, select) => { - if (current && targetEntry === current) return; - // Disable while probing so a slow HEAD can't be double-triggered. - if (select) select.disabled = true; - try { - const href = await resolveTargetUrl({ - targetEntry, - currentEntry: current, - location: { pathname: window.location.pathname, hash: window.location.hash }, - preservePath, - // When probing is off, claim "exists" so we keep the path unchanged. - pageExists: probeTarget ? pageExists : async () => true, - }); - window.location.assign(href); - } finally { - if (select) select.disabled = false; - } - }); - - el.appendChild(ui); - } catch (err) { - showError(el, 'Could not load version list.'); - // eslint-disable-next-line no-console - console.error('[version-switcher]', err); - } - - return () => { el.innerHTML = ''; }; + const jsonUrl = model.get("json_url"); + const versionMatch = model.get("version_match"); // optional override + const preservePath = model.get("preserve_path") !== false; // default true + const probeTarget = model.get("probe_target") !== false; // default true + + if (!jsonUrl) { + showError(el, "version-switcher: no json_url configured."); + return () => { + el.innerHTML = ""; + }; + } + + try { + const resolved = new URL(jsonUrl, window.location.href).href; + const res = await fetch(resolved, { credentials: "omit" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const raw = await res.json(); + + const detected = detectCurrent(raw, window.location.pathname, versionMatch); + // On localhost, fall back to a synthetic "local" version rooted at `/` so + // the switcher is usable in `myst start` against live version URLs. + const { entries, current } = withLocalFallback( + raw, + detected, + window.location, + ); + + const ui = buildSelect(entries, current, async (targetEntry, select) => { + if (current && targetEntry === current) return; + // Disable while probing so a slow HEAD can't be double-triggered. + if (select) select.disabled = true; + try { + const href = await resolveTargetUrl({ + targetEntry, + currentEntry: current, + location: { + pathname: window.location.pathname, + hash: window.location.hash, + }, + preservePath, + // When probing is off, claim "exists" so we keep the path unchanged. + pageExists: probeTarget ? pageExists : async () => true, + }); + window.location.assign(href); + } finally { + if (select) select.disabled = false; + } + }); + + el.appendChild(ui); + } catch (err) { + showError(el, "Could not load version list."); + // eslint-disable-next-line no-console + console.error("[version-switcher]", err); + } + + return () => { + el.innerHTML = ""; + }; } /* ----------------------- build-time MyST directive ---------------------- */ let counter = 0; function uid() { - counter += 1; - return `version-switcher-${counter}`; + counter += 1; + return `version-switcher-${counter}`; } const versionSwitcherDirective = { - name: 'version-switcher', - doc: 'A pydata-style documentation version switcher, rendered via anywidget.', - options: { - 'json-url': { - type: String, - required: true, - doc: 'URL (absolute or root-relative) to a pydata-format switcher.json.', - }, - 'version-match': { - type: String, - required: false, - doc: 'Force the "current" version instead of auto-detecting from the URL.', - }, - 'preserve-path': { - type: Boolean, - required: false, - doc: 'Carry the current page path across versions (default: true).', - }, - 'probe-target': { - type: Boolean, - required: false, - doc: 'Probe the target page and fall back to the version root if it 404s ' - + '(default: true). Set false for cross-origin switchers where the probe ' - + 'is CORS-blocked.', - }, - class: { - type: String, - required: false, - doc: 'Extra class names for the widget container.', - }, - }, - run(data, vfile) { - const opts = data.options ?? {}; - // Point the anywidget at this very file, relative to the document being built, - // unless a dev override is supplied. - const fromDir = String((vfile && vfile.path) || '').replace(/\/[^/]*$/, ''); - const esm = relativePath(fromDir, PLUGIN_PATH); - const model = { - json_url: opts['json-url'], - version_match: opts['version-match'], - // default true unless explicitly set to false - preserve_path: opts['preserve-path'] !== false, - probe_target: opts['probe-target'] !== false, - }; - return [ - { - type: 'anywidget', - esm, - id: uid(), - model, - class: opts.class, - }, - ]; - }, + name: "version-switcher", + doc: "A pydata-style documentation version switcher, rendered via anywidget.", + options: { + "json-url": { + type: String, + required: true, + doc: "URL (absolute or root-relative) to a pydata-format switcher.json.", + }, + "version-match": { + type: String, + required: false, + doc: 'Force the "current" version instead of auto-detecting from the URL.', + }, + "preserve-path": { + type: Boolean, + required: false, + doc: "Carry the current page path across versions (default: true).", + }, + "probe-target": { + type: Boolean, + required: false, + doc: + "Probe the target page and fall back to the version root if it 404s " + + "(default: true). Set false for cross-origin switchers where the probe " + + "is CORS-blocked.", + }, + class: { + type: String, + required: false, + doc: "Extra class names for the widget container.", + }, + }, + run(data, vfile) { + const opts = data.options ?? {}; + // Point the anywidget at this very file, relative to the document being built, + // unless a dev override is supplied. + const fromDir = String(vfile?.path || "").replace(/\/[^/]*$/, ""); + const esm = relativePath(fromDir, PLUGIN_PATH); + const model = { + json_url: opts["json-url"], + version_match: opts["version-match"], + // default true unless explicitly set to false + preserve_path: opts["preserve-path"] !== false, + probe_target: opts["probe-target"] !== false, + }; + return [ + { + type: "anywidget", + esm, + id: uid(), + model, + class: opts.class, + }, + ]; + }, }; const plugin = { - name: 'version-switcher', - directives: [versionSwitcherDirective], - // `render` lives on the default export so the same file works as the anywidget - // runtime module (AnyWidget reads `default.render`). - render, + name: "version-switcher", + directives: [versionSwitcherDirective], + // `render` lives on the default export so the same file works as the anywidget + // runtime module (AnyWidget reads `default.render`). + render, }; export default plugin; diff --git a/switcher/make-switcher.mjs b/switcher/make-switcher.mjs index 0071966..abcd5c3 100644 --- a/switcher/make-switcher.mjs +++ b/switcher/make-switcher.mjs @@ -12,30 +12,30 @@ * * node make-switcher.mjs --add */ -import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { parseArgs } from 'node:util'; +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; /** Run a git command and return its non-empty stdout lines. */ function gitLines(args) { - const out = execFileSync('git', args, { encoding: 'utf8' }); - return out.trim().split('\n').filter(Boolean); + const out = execFileSync("git", args, { encoding: "utf8" }); + return out.trim().split("\n").filter(Boolean); } /** Directory names on a branch (i.e. the deployed builds). */ export function getBranchContents(ref) { - try { - return gitLines(['ls-tree', '-d', '--name-only', ref]); - } catch { - // Branch may not exist yet (first deploy). - console.warn(`Cannot get ${ref} contents`); - return []; - } + try { + return gitLines(["ls-tree", "-d", "--name-only", ref]); + } catch { + // Branch may not exist yet (first deploy). + console.warn(`Cannot get ${ref} contents`); + return []; + } } /** Tags newest-first (semver-aware), matching `git tag -l --sort=-v:refname`. */ export function getSortedTags() { - return gitLines(['tag', '-l', '--sort=-v:refname']); + return gitLines(["tag", "-l", "--sort=-v:refname"]); } /** @@ -44,55 +44,57 @@ export function getSortedTags() { * `tags` must already be newest-first. */ export function orderVersions(builds, tags, add) { - const remaining = new Set(builds); - if (add) remaining.add(add); + const remaining = new Set(builds); + if (add) remaining.add(add); - const versions = []; - for (const version of ['master', 'main', ...tags]) { - if (remaining.has(version)) { - versions.push(version); - remaining.delete(version); - } - } - versions.push(...[...remaining].sort()); - return versions; + const versions = []; + for (const version of ["master", "main", ...tags]) { + if (remaining.has(version)) { + versions.push(version); + remaining.delete(version); + } + } + versions.push(...[...remaining].sort()); + return versions; } /** Build the pydata switcher array for `org/repo`. */ export function switcherStruct(repository, versions) { - const [org, repoName] = repository.split('/'); - return versions.map((version) => ({ - version, - url: `https://${org}.github.io/${repoName}/${version}/`, - })); + const [org, repoName] = repository.split("/"); + return versions.map((version) => ({ + version, + url: `https://${org}.github.io/${repoName}/${version}/`, + })); } /** Serialise the switcher exactly as the Python tool did (2-space JSON). */ export function renderSwitcher(repository, versions) { - return JSON.stringify(switcherStruct(repository, versions), null, 2); + return JSON.stringify(switcherStruct(repository, versions), null, 2); } export function main(argv = process.argv.slice(2)) { - const { values, positionals } = parseArgs({ - args: argv, - options: { add: { type: 'string' } }, - allowPositionals: true, - }); - const [repository, output] = positionals; - if (!repository || !output) { - throw new Error('usage: make-switcher.mjs --add '); - } + const { values, positionals } = parseArgs({ + args: argv, + options: { add: { type: "string" } }, + allowPositionals: true, + }); + const [repository, output] = positionals; + if (!repository || !output) { + throw new Error( + "usage: make-switcher.mjs --add ", + ); + } - const builds = getBranchContents('origin/gh-pages'); - const tags = getSortedTags(); - const versions = orderVersions(builds, tags, values.add); - console.log(`Sorted versions: ${JSON.stringify(versions)}`); + const builds = getBranchContents("origin/gh-pages"); + const tags = getSortedTags(); + const versions = orderVersions(builds, tags, values.add); + console.log(`Sorted versions: ${JSON.stringify(versions)}`); - const text = renderSwitcher(repository, versions); - console.log(`JSON switcher:\n${text}`); - writeFileSync(output, text, 'utf8'); + const text = renderSwitcher(repository, versions); + console.log(`JSON switcher:\n${text}`); + writeFileSync(output, text, "utf8"); } if (import.meta.url === `file://${process.argv[1]}`) { - main(); + main(); } diff --git a/test/test-make-switcher.mjs b/test/test-make-switcher.mjs index 3e72c9d..16f7e6f 100644 --- a/test/test-make-switcher.mjs +++ b/test/test-make-switcher.mjs @@ -3,59 +3,75 @@ * ordering (master/main first, tags newest-first, leftovers alphabetical), * `--add`, and the exact JSON shape/serialisation. */ -import assert from 'node:assert/strict'; +import assert from "node:assert/strict"; import { - orderVersions, - switcherStruct, - renderSwitcher, -} from '../switcher/make-switcher.mjs'; + orderVersions, + renderSwitcher, + switcherStruct, +} from "../switcher/make-switcher.mjs"; let passed = 0; -function ok(name) { passed += 1; console.log(' ok -', name); } +function ok(name) { + passed += 1; + console.log(" ok -", name); +} // tags come newest-first (as `git tag --sort=-v:refname` produces). -const tags = ['2.1', '2.0', '1.0']; +const tags = ["2.1", "2.0", "1.0"]; // main + a subset of tags deployed; --add folds in the build being published. -assert.deepEqual( - orderVersions(['main', '2.0'], tags, '2.1'), - ['main', '2.1', '2.0'], -); -ok('orders main first, then tags newest-first'); +assert.deepEqual(orderVersions(["main", "2.0"], tags, "2.1"), [ + "main", + "2.1", + "2.0", +]); +ok("orders main first, then tags newest-first"); // first deploy: no branch dirs, only the build being added. -assert.deepEqual(orderVersions([], [], 'main'), ['main']); -ok('handles an empty gh-pages branch (first deploy)'); +assert.deepEqual(orderVersions([], [], "main"), ["main"]); +ok("handles an empty gh-pages branch (first deploy)"); // master wins over main when both somehow present; leftovers sort alphabetically. -assert.deepEqual( - orderVersions(['main', 'master', 'zzz', 'aaa'], [], null), - ['master', 'main', 'aaa', 'zzz'], -); -ok('master before main; unknown dirs appended alphabetically'); +assert.deepEqual(orderVersions(["main", "master", "zzz", "aaa"], [], null), [ + "master", + "main", + "aaa", + "zzz", +]); +ok("master before main; unknown dirs appended alphabetically"); // a deployed tag not in --add still orders by the tag list. -assert.deepEqual( - orderVersions(['main', '2.1', '2.0'], tags, null), - ['main', '2.1', '2.0'], -); -ok('existing deployed tags ordered newest-first'); +assert.deepEqual(orderVersions(["main", "2.1", "2.0"], tags, null), [ + "main", + "2.1", + "2.0", +]); +ok("existing deployed tags ordered newest-first"); // --- switcherStruct shape --- assert.deepEqual( - switcherStruct('DiamondLightSource/myst-version-switcher-plugin', ['main', '2.1']), - [ - { version: 'main', url: 'https://DiamondLightSource.github.io/myst-version-switcher-plugin/main/' }, - { version: '2.1', url: 'https://DiamondLightSource.github.io/myst-version-switcher-plugin/2.1/' }, - ], + switcherStruct("DiamondLightSource/myst-version-switcher-plugin", [ + "main", + "2.1", + ]), + [ + { + version: "main", + url: "https://DiamondLightSource.github.io/myst-version-switcher-plugin/main/", + }, + { + version: "2.1", + url: "https://DiamondLightSource.github.io/myst-version-switcher-plugin/2.1/", + }, + ], ); -ok('switcherStruct builds the pydata {version,url} array'); +ok("switcherStruct builds the pydata {version,url} array"); // --- exact serialisation (2-space, no trailing newline), parity with json.dumps(indent=2) --- -const text = renderSwitcher('acme/widget', ['main', '2.0']); +const text = renderSwitcher("acme/widget", ["main", "2.0"]); assert.equal( - text, - `[ + text, + `[ { "version": "main", "url": "https://acme.github.io/widget/main/" @@ -66,6 +82,6 @@ assert.equal( } ]`, ); -ok('renderSwitcher matches make_switcher.py 2-space JSON output'); +ok("renderSwitcher matches make_switcher.py 2-space JSON output"); console.log(`\nAll ${passed} checks passed.`); diff --git a/test/test-url-logic.mjs b/test/test-url-logic.mjs index 3c4f6d3..b661d59 100644 --- a/test/test-url-logic.mjs +++ b/test/test-url-logic.mjs @@ -3,224 +3,282 @@ * Runs in plain Node (no DOM, no myst) — `node --test` or directly. * Live anywidget rendering is a browser job. */ -import assert from 'node:assert/strict'; +import assert from "node:assert/strict"; import { - detectCurrent, - computeTargetUrl, - resolveTargetUrl, - withTrailingSlash, - relativePath, - isLocalHost, - withLocalFallback, -} from '../plugins/version-switcher/version-switcher.mjs'; -import plugin from '../plugins/version-switcher/version-switcher.mjs'; + computeTargetUrl, + detectCurrent, + entryLabel, + isLocalHost, + relativePath, + resolveTargetUrl, + withLocalFallback, + withTrailingSlash, +} from "../plugins/version-switcher/version-switcher.mjs"; +import plugin from "../plugins/version-switcher/version-switcher.mjs"; const switcher = [ - { version: 'dev', name: 'dev (main)', url: 'https://acme.github.io/widget/main/' }, - { version: '2.1', name: '2.1 (stable)', url: 'https://acme.github.io/widget/2.1/', preferred: true }, - { version: '2.0', url: 'https://acme.github.io/widget/2.0/' }, + { + version: "dev", + name: "dev (main)", + url: "https://acme.github.io/widget/main/", + }, + { + version: "2.1", + name: "2.1 (stable)", + url: "https://acme.github.io/widget/2.1/", + preferred: true, + }, + { version: "2.0", url: "https://acme.github.io/widget/2.0/" }, ]; let passed = 0; -function ok(name) { passed += 1; console.log(' ok -', name); } +function ok(name) { + passed += 1; + console.log(" ok -", name); +} // --- withTrailingSlash --- -assert.equal(withTrailingSlash('/a/b'), '/a/b/'); -assert.equal(withTrailingSlash('/a/b/'), '/a/b/'); -assert.equal(withTrailingSlash(''), '/'); -ok('withTrailingSlash normalises'); +assert.equal(withTrailingSlash("/a/b"), "/a/b/"); +assert.equal(withTrailingSlash("/a/b/"), "/a/b/"); +assert.equal(withTrailingSlash(""), "/"); +ok("withTrailingSlash normalises"); + +// --- entryLabel --- +assert.equal(entryLabel({ name: "2.1 (stable)" }), "2.1 (stable)"); +assert.equal(entryLabel({ version: "2.0" }), "2.0"); +assert.equal(entryLabel({ url: "https://x/" }), "https://x/"); +assert.equal(entryLabel({ name: "2.1", preferred: true }), "2.1 ★"); +ok("entryLabel falls back name->version->url and stars the preferred entry"); // --- relativePath --- -assert.equal(relativePath('/a/b/c', '/a/b/d/plugin.mjs'), '../d/plugin.mjs'); -assert.equal(relativePath('/a/b', '/a/b/plugin.mjs'), 'plugin.mjs'); -assert.equal(relativePath('/x/y', '/a/b/plugin.mjs'), '../../a/b/plugin.mjs'); -ok('relativePath computes POSIX relative paths'); +assert.equal(relativePath("/a/b/c", "/a/b/d/plugin.mjs"), "../d/plugin.mjs"); +assert.equal(relativePath("/a/b", "/a/b/plugin.mjs"), "plugin.mjs"); +assert.equal(relativePath("/x/y", "/a/b/plugin.mjs"), "../../a/b/plugin.mjs"); +ok("relativePath computes POSIX relative paths"); // --- detectCurrent by URL prefix (gh-pages project path) --- -const cur = detectCurrent(switcher, '/widget/2.0/guide/install.html'); -assert.equal(cur.version, '2.0'); -ok('detectCurrent picks 2.0 from pathname'); +const cur = detectCurrent(switcher, "/widget/2.0/guide/install.html"); +assert.equal(cur.version, "2.0"); +ok("detectCurrent picks 2.0 from pathname"); // longest-prefix wins, not first match -const cur2 = detectCurrent(switcher, '/widget/2.1/'); -assert.equal(cur2.version, '2.1'); -ok('detectCurrent picks 2.1 root page'); +const cur2 = detectCurrent(switcher, "/widget/2.1/"); +assert.equal(cur2.version, "2.1"); +ok("detectCurrent picks 2.1 root page"); // no match -> null (e.g. local preview at /) -assert.equal(detectCurrent(switcher, '/'), null); -ok('detectCurrent returns null when nothing matches'); +assert.equal(detectCurrent(switcher, "/"), null); +ok("detectCurrent returns null when nothing matches"); // --- detectCurrent via explicit version-match, incl. loose semver --- -assert.equal(detectCurrent(switcher, '/', '2.1').version, '2.1'); -assert.equal(detectCurrent(switcher, '/', '2.1.3').version, '2.1'); // loose -ok('detectCurrent honours version_match (exact + loose)'); +assert.equal(detectCurrent(switcher, "/", "2.1").version, "2.1"); +assert.equal(detectCurrent(switcher, "/", "2.1.3").version, "2.1"); // loose +ok("detectCurrent honours version_match (exact + loose)"); // --- computeTargetUrl preserves page path across versions --- const t1 = computeTargetUrl( - switcher[0], // -> dev - cur, // from 2.0 - { pathname: '/widget/2.0/guide/install.html', hash: '#setup' }, - true, + switcher[0], // -> dev + cur, // from 2.0 + { pathname: "/widget/2.0/guide/install.html", hash: "#setup" }, + true, ); -assert.equal(t1, 'https://acme.github.io/widget/main/guide/install.html#setup'); -ok('computeTargetUrl carries page + hash to dev'); +assert.equal(t1, "https://acme.github.io/widget/main/guide/install.html#setup"); +ok("computeTargetUrl carries page + hash to dev"); // --- preserve_path = false goes to version root --- const t2 = computeTargetUrl( - switcher[1], // -> 2.1 - cur, - { pathname: '/widget/2.0/guide/install.html', hash: '' }, - false, + switcher[1], // -> 2.1 + cur, + { pathname: "/widget/2.0/guide/install.html", hash: "" }, + false, ); -assert.equal(t2, 'https://acme.github.io/widget/2.1/'); -ok('computeTargetUrl with preserve_path=false -> target root'); +assert.equal(t2, "https://acme.github.io/widget/2.1/"); +ok("computeTargetUrl with preserve_path=false -> target root"); // --- no current detected: still navigates to target root --- const t3 = computeTargetUrl( - switcher[2], - null, - { pathname: '/somewhere/else/', hash: '' }, - true, + switcher[2], + null, + { pathname: "/somewhere/else/", hash: "" }, + true, ); -assert.equal(t3, 'https://acme.github.io/widget/2.0/'); -ok('computeTargetUrl with no current -> target root'); +assert.equal(t3, "https://acme.github.io/widget/2.0/"); +ok("computeTargetUrl with no current -> target root"); // --- isLocalHost --- -assert.ok(isLocalHost('localhost')); -assert.ok(isLocalHost('127.0.0.1')); -assert.ok(isLocalHost('foo.localhost')); -assert.ok(!isLocalHost('pandablocks.github.io')); -ok('isLocalHost recognises local dev hosts'); +assert.ok(isLocalHost("localhost")); +assert.ok(isLocalHost("127.0.0.1")); +assert.ok(isLocalHost("foo.localhost")); +assert.ok(!isLocalHost("pandablocks.github.io")); +ok("isLocalHost recognises local dev hosts"); // --- withLocalFallback: synthesise a "local" current on localhost --- -const lf = withLocalFallback(switcher, null, { hostname: 'localhost', origin: 'http://localhost:3043' }); -assert.equal(lf.current.version, 'local'); -assert.equal(lf.current.url, 'http://localhost:3043/'); +const lf = withLocalFallback(switcher, null, { + hostname: "localhost", + origin: "http://localhost:3043", +}); +assert.equal(lf.current.version, "local"); +assert.equal(lf.current.url, "http://localhost:3043/"); assert.equal(lf.entries[0], lf.current); assert.equal(lf.entries.length, switcher.length + 1); -ok('withLocalFallback adds a local entry rooted at / when nothing matched'); +ok("withLocalFallback adds a local entry rooted at / when nothing matched"); const localFromCommands = computeTargetUrl( - switcher[1], lf.current, { pathname: '/commands', hash: '' }, true, + switcher[1], + lf.current, + { pathname: "/commands", hash: "" }, + true, ); -assert.equal(localFromCommands, 'https://acme.github.io/widget/2.1/commands'); -ok('local current (base /) carries the page path to the target version'); +assert.equal(localFromCommands, "https://acme.github.io/widget/2.1/commands"); +ok("local current (base /) carries the page path to the target version"); -const lf2 = withLocalFallback(switcher, switcher[2], { hostname: 'localhost', origin: 'http://localhost:3043' }); +const lf2 = withLocalFallback(switcher, switcher[2], { + hostname: "localhost", + origin: "http://localhost:3043", +}); assert.equal(lf2.current, switcher[2]); assert.equal(lf2.entries, switcher); -ok('withLocalFallback leaves a detected version untouched'); +ok("withLocalFallback leaves a detected version untouched"); -const lf3 = withLocalFallback(switcher, null, { hostname: 'pandablocks.github.io', origin: 'https://pandablocks.github.io' }); +const lf3 = withLocalFallback(switcher, null, { + hostname: "pandablocks.github.io", + origin: "https://pandablocks.github.io", +}); assert.equal(lf3.current, null); assert.equal(lf3.entries, switcher); -ok('withLocalFallback is a no-op in production'); +ok("withLocalFallback is a no-op in production"); // --- resolveTargetUrl: probe the target page, fall back to version root --- function mockExists(verdict) { - const calls = []; - const fn = async (url) => { calls.push(url); return verdict; }; - fn.calls = calls; - return fn; + const calls = []; + const fn = async (url) => { + calls.push(url); + return verdict; + }; + fn.calls = calls; + return fn; } -const fromDev = { pathname: '/widget/main/guide/install.html', hash: '#setup' }; +const fromDev = { pathname: "/widget/main/guide/install.html", hash: "#setup" }; const devCur = detectCurrent(switcher, fromDev.pathname); // dev entry const exists = mockExists(true); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[1], currentEntry: devCur, location: fromDev, - preservePath: true, pageExists: exists, - }), - 'https://acme.github.io/widget/2.1/guide/install.html#setup', + await resolveTargetUrl({ + targetEntry: switcher[1], + currentEntry: devCur, + location: fromDev, + preservePath: true, + pageExists: exists, + }), + "https://acme.github.io/widget/2.1/guide/install.html#setup", ); -assert.deepEqual(exists.calls, ['https://acme.github.io/widget/2.1/guide/install.html#setup']); -ok('resolveTargetUrl keeps path when target page exists'); +assert.deepEqual(exists.calls, [ + "https://acme.github.io/widget/2.1/guide/install.html#setup", +]); +ok("resolveTargetUrl keeps path when target page exists"); const missing = mockExists(false); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[1], currentEntry: devCur, location: fromDev, - preservePath: true, pageExists: missing, - }), - 'https://acme.github.io/widget/2.1/', + await resolveTargetUrl({ + targetEntry: switcher[1], + currentEntry: devCur, + location: fromDev, + preservePath: true, + pageExists: missing, + }), + "https://acme.github.io/widget/2.1/", ); -ok('resolveTargetUrl falls back to root when target page 404s'); +ok("resolveTargetUrl falls back to root when target page 404s"); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[1], currentEntry: devCur, location: fromDev, - preservePath: true, pageExists: mockExists(null), - }), - 'https://acme.github.io/widget/2.1/guide/install.html#setup', + await resolveTargetUrl({ + targetEntry: switcher[1], + currentEntry: devCur, + location: fromDev, + preservePath: true, + pageExists: mockExists(null), + }), + "https://acme.github.io/widget/2.1/guide/install.html#setup", ); -ok('resolveTargetUrl keeps path when probe is indeterminate'); +ok("resolveTargetUrl keeps path when probe is indeterminate"); const notCalled1 = mockExists(false); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[1], currentEntry: devCur, location: fromDev, - preservePath: false, pageExists: notCalled1, - }), - 'https://acme.github.io/widget/2.1/', + await resolveTargetUrl({ + targetEntry: switcher[1], + currentEntry: devCur, + location: fromDev, + preservePath: false, + pageExists: notCalled1, + }), + "https://acme.github.io/widget/2.1/", ); assert.equal(notCalled1.calls.length, 0); -ok('resolveTargetUrl skips probe when preserve_path is false'); +ok("resolveTargetUrl skips probe when preserve_path is false"); const notCalled2 = mockExists(false); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[2], currentEntry: null, location: fromDev, - preservePath: true, pageExists: notCalled2, - }), - 'https://acme.github.io/widget/2.0/', + await resolveTargetUrl({ + targetEntry: switcher[2], + currentEntry: null, + location: fromDev, + preservePath: true, + pageExists: notCalled2, + }), + "https://acme.github.io/widget/2.0/", ); assert.equal(notCalled2.calls.length, 0); -ok('resolveTargetUrl skips probe when no current version'); +ok("resolveTargetUrl skips probe when no current version"); const notCalled3 = mockExists(false); assert.equal( - await resolveTargetUrl({ - targetEntry: switcher[1], - currentEntry: detectCurrent(switcher, '/widget/main/'), - location: { pathname: '/widget/main/', hash: '' }, - preservePath: true, pageExists: notCalled3, - }), - 'https://acme.github.io/widget/2.1/', + await resolveTargetUrl({ + targetEntry: switcher[1], + currentEntry: detectCurrent(switcher, "/widget/main/"), + location: { pathname: "/widget/main/", hash: "" }, + preservePath: true, + pageExists: notCalled3, + }), + "https://acme.github.io/widget/2.1/", ); assert.equal(notCalled3.calls.length, 0); -ok('resolveTargetUrl skips probe when current page is the version root'); +ok("resolveTargetUrl skips probe when current page is the version root"); // --- plugin directive emits a correct anywidget node --- const dir = plugin.directives[0]; -assert.equal(dir.name, 'version-switcher'); -assert.equal(typeof plugin.render, 'function'); // anywidget runtime on default export +assert.equal(dir.name, "version-switcher"); +assert.equal(typeof plugin.render, "function"); // anywidget runtime on default export const nodes = dir.run({ - options: { 'json-url': 'https://acme.github.io/widget/switcher.json' }, + options: { "json-url": "https://acme.github.io/widget/switcher.json" }, }); assert.equal(nodes.length, 1); const node = nodes[0]; -assert.equal(node.type, 'anywidget'); -assert.equal(node.model.json_url, 'https://acme.github.io/widget/switcher.json'); +assert.equal(node.type, "anywidget"); +assert.equal( + node.model.json_url, + "https://acme.github.io/widget/switcher.json", +); assert.equal(node.model.preserve_path, true); assert.equal(node.model.probe_target, true); -assert.ok(node.esm && typeof node.esm === 'string'); // self-referential path -assert.ok(node.id && typeof node.id === 'string'); -ok('plugin directive emits a valid anywidget node with a self-referential esm'); +assert.ok(node.esm && typeof node.esm === "string"); // self-referential path +assert.ok(node.id && typeof node.id === "string"); +ok("plugin directive emits a valid anywidget node with a self-referential esm"); // boolean options flow through const nodes2 = dir.run({ - options: { - 'json-url': '/widget/switcher.json', - 'preserve-path': false, - 'probe-target': false, - 'version-match': 'dev', - }, + options: { + "json-url": "/widget/switcher.json", + "preserve-path": false, + "probe-target": false, + "version-match": "dev", + }, }); assert.equal(nodes2[0].model.preserve_path, false); assert.equal(nodes2[0].model.probe_target, false); -assert.equal(nodes2[0].model.version_match, 'dev'); -ok('plugin directive honours preserve-path / probe-target / version-match options'); +assert.equal(nodes2[0].model.version_match, "dev"); +ok( + "plugin directive honours preserve-path / probe-target / version-match options", +); console.log(`\nAll ${passed} checks passed.`); From 714be0964f3c2fe8aac211efc02929942d8a14a8 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 16 Jun 2026 15:24:25 +0000 Subject: [PATCH 2/2] Generate root redirect + flag preferred version; fix docs CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make-switcher.mjs: compute the preferred (newest deployed non-prerelease tag, else main/master) version, flag it preferred:true in switcher.json, and write a root index.html redirecting to it. Action takes an output-dir and always writes both files; prerelease test mirrors _release.yml - delete committed .github/pages/index.html — the redirect is now generated each deploy (first deploy with no tags still points at main) - _docs.yml: fix `cp` into a missing parent (mkdir -p), consolidate the two build copies into one Stage step, rename _staging -> pages - docs + CLAUDE.md: output-dir input, two-file output, redirect/preferred behaviour - tests: cover isPrerelease, preferredVersion, the preferred flag, and the redirect Co-Authored-By: Claude Opus 4.8 --- .github/pages/index.html | 11 ---- .github/workflows/_docs.yml | 22 ++++---- CLAUDE.md | 48 +++++++++-------- docs/index.md | 28 ++++++---- switcher/action.yml | 20 +++---- switcher/make-switcher.mjs | 101 +++++++++++++++++++++++++++++------- test/test-make-switcher.mjs | 53 ++++++++++++++++--- 7 files changed, 191 insertions(+), 92 deletions(-) delete mode 100644 .github/pages/index.html diff --git a/.github/pages/index.html b/.github/pages/index.html deleted file mode 100644 index c495f39..0000000 --- a/.github/pages/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Redirecting to main branch - - - - - - diff --git a/.github/workflows/_docs.yml b/.github/workflows/_docs.yml index 2a82bae..a5875ec 100644 --- a/.github/workflows/_docs.yml +++ b/.github/workflows/_docs.yml @@ -33,8 +33,15 @@ jobs: BASE_URL: /${{ github.event.repository.name }}/${{ env.DOCS_VERSION }} run: npm run docs - - name: Prepare html pages for artifact upload - run: cp -r docs/_build/html $RUNNER_TEMP/artifact/html + # Two layouts from the one build: `artifact/html` for the uploaded artifact + # (downstream relies on the `html` dir name), and `pages/` for + # the gh-pages publish tree, into which the action also writes switcher.json + # and the root redirect. + - name: Stage built docs + run: | + mkdir -p $RUNNER_TEMP/artifact $RUNNER_TEMP/pages + cp -r docs/_build/html $RUNNER_TEMP/artifact/html + cp -r docs/_build/html $RUNNER_TEMP/pages/${{ env.DOCS_VERSION }} - name: Upload built docs artifact uses: actions/upload-artifact@v4 @@ -42,17 +49,12 @@ jobs: name: docs path: ${{ runner.temp }}/artifact - - name: Copy committed pages into staging - run: | - cp -r .github/pages/. $RUNNER_TEMP/_staging/ - cp -r docs/_build/html $RUNNER_TEMP/_staging/${{ env.DOCS_VERSION }} - - - name: Write switcher.json + - name: Write switcher.json + redirect uses: ./switcher with: version: ${{ env.DOCS_VERSION }} repo: ${{ github.repository }} - output: ${{ runner.temp }}/_staging/switcher.json + output-dir: ${{ runner.temp }}/pages - name: Publish Docs to gh-pages if: github.ref_type == 'tag' || github.ref_name == 'main' @@ -61,5 +63,5 @@ jobs: uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ${{ runner.temp }}/_staging + publish_dir: ${{ runner.temp }}/pages keep_files: true diff --git a/CLAUDE.md b/CLAUDE.md index 967dd04..11db956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,19 +2,18 @@ A pydata-style version-switcher for [MyST](https://mystmd.org) docs, delivered as a single `anywidget` plugin **plus** a CI composite action that generates the -`switcher.json` the widget reads. +`switcher.json` the widget reads and a root `index.html` redirect to the newest +stable release. ## Repo layout ``` plugins/version-switcher/version-switcher.mjs # MyST directive + anywidget runtime (single file, no README — docs are in docs/) -switcher/action.yml # composite action: writes switcher.json ONLY -switcher/make-switcher.mjs # dependency-free Node switcher generator +switcher/action.yml # composite action: writes switcher.json + index.html +switcher/make-switcher.mjs # dependency-free Node switcher + redirect generator test/ # npm test suite (node, no framework) docs/ # this repo's own docs (dogfoods the plugin) .github/workflows/ci.yml # orchestrator → _test / _docs / _release -.github/pages/index.html # MUST stay committed — bootstraps .github/pages/ - # dir (so mv step works) + redirects root to main/ ``` ## Two halves, different lifecycles @@ -29,13 +28,15 @@ the action is consumed from the repo tree at the same tag. ## Key design decisions -### `switcher` action is write-only -The action writes `switcher.json` to the caller-supplied `output` path and nothing -else (the consuming docs use `.github/pages/switcher.json`; this repo's own CI -writes to `_staging/switcher.json`). It does NOT `mv` -the built docs, does NOT `git fetch`. Staging the versioned dir (`mv`) and -`fetch-depth: 0` (for tags + `origin/gh-pages`) are the caller's responsibility -(pattern lifted from `python-copier-template-example`). +### `switcher` action only writes the two derived files +The action writes `switcher.json` and a root `index.html` (a redirect to the +newest stable release) into the caller-supplied `output-dir` — the gh-pages +publish root — and nothing else. It does NOT `mv` the built docs, does NOT +`git fetch`. Staging the versioned dir (`mv`) and `fetch-depth: 0` (for tags + +`origin/gh-pages`) are the caller's responsibility (pattern lifted from +`python-copier-template-example`). Both files are derived purely from the git +version ordering, so regenerating them every deploy is intentional — with +`keep_files: true` each deploy refreshes the root redirect to the latest release. ### BASE_URL must be set before `myst build` ```yaml @@ -45,14 +46,13 @@ run: cd docs && myst build --html ``` Without this, assets and links break under the versioned GitHub Pages sub-path. -### `.github/pages/index.html` must stay committed -- Redirects the Pages root to `./main/index.html`. -- CI copies it into `_staging/` before publishing, so it always lands on gh-pages. -- `.github/pages/` is source-only; CI writes nothing there — versioned builds stage in `_staging/`. - ### `make-switcher.mjs` degrades gracefully on first deploy -When `origin/gh-pages` does not yet exist, it produces a single-entry `switcher.json` -for just the current version rather than failing. +When `origin/gh-pages` does not yet exist (no deployed builds, no tags), it +produces a single-entry `switcher.json` for just the current version and an +`index.html` redirecting to it, rather than failing. The "preferred" version (the +`index.html` target, flagged `preferred: true` in switcher.json) is the newest +non-prerelease tag with a deployed build, falling back to `main`/`master`. +Prerelease detection mirrors `_release.yml` (an `a`/`b`/`rc` marker). ## CI structure @@ -66,7 +66,7 @@ Mirrors `python-copier-template-example` as closely as possible: ### `_docs.yml` deviations from template 1. `npm install -g mystmd@1.10.1` instead of `uv run tox -e docs` (no Python here) 2. `BASE_URL` env var set before `myst build` -3. `uses: ./switcher` writes `switcher.json` (instead of `make_switcher.py`) +3. `uses: ./switcher` writes `switcher.json` + `index.html` (instead of `make_switcher.py`) `mystmd` is pinned at `1.10.1` (not `latest`). @@ -125,15 +125,17 @@ site: - run: cd docs && myst build --html env: BASE_URL: //${{ env.DOCS_VERSION }} -- run: mv docs/_build/html .github/pages/$DOCS_VERSION +- run: | + mkdir -p _site + mv docs/_build/html _site/$DOCS_VERSION - uses: DiamondLightSource/myst-version-switcher-plugin/switcher@ with: version: ${{ env.DOCS_VERSION }} repo: ${{ github.repository }} - output: .github/pages/switcher.json # required: where to write it + output-dir: _site # required: writes switcher.json + index.html into the publish root - uses: peaceiris/actions-gh-pages@v4 with: - publish_dir: .github/pages + publish_dir: _site keep_files: true ``` diff --git a/docs/index.md b/docs/index.md index c1fbc2c..e14c21a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,15 +63,18 @@ stranding users at the root. path, the widget synthesises a `local (dev)` entry rooted at `/` so the switcher is usable during `myst start`. -## Generating switcher.json +## Generating switcher.json + the root redirect The `switcher` composite action reads your repo's tags and `origin/gh-pages` to -produce a `switcher.json` in the standard pydata format: +produce two files in your publish root: a `switcher.json` in the standard pydata +format, and an `index.html` that redirects the site root to your newest stable +release. The newest non-prerelease tag is flagged `preferred` (rendered with a ★) +and is the redirect target; before any release exists it falls back to `main`. ```json [ - { "version": "main", "name": "main (dev)", "url": "https://ORG.github.io/REPO/main/" }, - { "version": "2.1", "name": "2.1 (stable)", "url": "https://ORG.github.io/REPO/2.1/", "preferred": true }, + { "version": "main", "url": "https://ORG.github.io/REPO/main/" }, + { "version": "2.1", "url": "https://ORG.github.io/REPO/2.1/", "preferred": true }, { "version": "2.0", "url": "https://ORG.github.io/REPO/2.0/" } ] ``` @@ -86,19 +89,22 @@ Wire it into your docs workflow after staging the built HTML and before publishi - run: cd docs && myst build --html env: BASE_URL: //${{ env.DOCS_VERSION }} # required for versioned sub-path -- run: mv docs/_build/html .github/pages/$DOCS_VERSION +- run: | + mkdir -p _site + mv docs/_build/html _site/$DOCS_VERSION - uses: DiamondLightSource/myst-version-switcher-plugin/switcher@ with: version: ${{ env.DOCS_VERSION }} repo: ${{ github.repository }} - output: .github/pages/switcher.json # where to write it (required) + output-dir: _site # writes switcher.json + index.html into the publish root - uses: peaceiris/actions-gh-pages@v4 with: - publish_dir: .github/pages + publish_dir: _site keep_files: true ``` -The action **only writes `switcher.json`** — staging (`mv`) and publishing stay in -the workflow. `fetch-depth: 0` is the consumer's responsibility. On the first -deploy, when `origin/gh-pages` does not yet exist, the action produces a -single-entry `switcher.json` for the current version rather than failing. +The action **only writes `switcher.json` and `index.html`** — staging (`mv`) and +publishing stay in the workflow. `fetch-depth: 0` is the consumer's +responsibility. On the first deploy, when `origin/gh-pages` does not yet exist, +the action produces a single-entry `switcher.json` for the current version and an +`index.html` redirecting to it, rather than failing. diff --git a/switcher/action.yml b/switcher/action.yml index 051b4b1..bd4f25c 100644 --- a/switcher/action.yml +++ b/switcher/action.yml @@ -1,11 +1,11 @@ -name: Write switcher.json +name: Write switcher.json + redirect description: >- - Generate the pydata switcher.json from the versions deployed on gh-pages plus - the repo's tags. Run it after staging the versioned build and before the - gh-pages publish step (it only writes the file — it does not move or fetch - anything). Requires the repository checked out with tags and the - origin/gh-pages tree available (e.g. actions/checkout with fetch-depth: 0) and - Node on PATH. + Generate the pydata switcher.json and a root index.html that redirects to the + newest stable release, from the versions deployed on gh-pages plus the repo's + tags. Run it after staging the versioned build and before the gh-pages publish + step (it only writes those two files — it does not move or fetch anything). + Requires the repository checked out with tags and the origin/gh-pages tree + available (e.g. actions/checkout with fetch-depth: 0) and Node on PATH. inputs: version: @@ -14,8 +14,8 @@ inputs: repo: description: "org/repo slug (e.g. DiamondLightSource/myst-version-switcher-plugin) — used to build version URLs. Pass github.repository from your workflow." required: true - output: - description: Path to write switcher.json to. + output-dir: + description: Directory (the gh-pages publish root) to write switcher.json and index.html into. required: true runs: @@ -26,4 +26,4 @@ runs: node "$GITHUB_ACTION_PATH/make-switcher.mjs" \ --add "${{ inputs.version }}" \ "${{ inputs.repo }}" \ - "${{ inputs.output }}" + "${{ inputs.output-dir }}" diff --git a/switcher/make-switcher.mjs b/switcher/make-switcher.mjs index abcd5c3..7d8d403 100644 --- a/switcher/make-switcher.mjs +++ b/switcher/make-switcher.mjs @@ -1,19 +1,24 @@ /** - * make-switcher — generate the pydata `switcher.json` for a versioned docs site. + * make-switcher — generate the pydata `switcher.json` AND the root `index.html` + * redirect for a versioned docs site. * * A dependency-free Node port of the DLS `make_switcher.py`. The version list is * derived from git: directories on the gh-pages branch (the deployed builds) plus - * the tag list (used only to order them). Ordering is `master`, `main`, then tags + * the tag list (used to order them). Ordering is `master`, `main`, then tags * newest-first, then any remaining dirs alphabetically. * - * The pure functions (`orderVersions`, `switcherStruct`, `renderSwitcher`) take - * plain arrays so they can be unit-tested without a git repo; only `main()` shells - * out to git and writes the file. + * The newest non-prerelease tag is the "preferred" (stable) version: it is + * flagged `preferred: true` in switcher.json and is where the site root + * redirects. When no stable tag is deployed yet, both fall back to `main`. * - * node make-switcher.mjs --add + * The pure functions take plain arrays so they can be unit-tested without a git + * repo; only `main()` shells out to git and writes the files. + * + * node make-switcher.mjs --add */ import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { parseArgs } from "node:util"; /** Run a git command and return its non-empty stdout lines. */ @@ -58,18 +63,64 @@ export function orderVersions(builds, tags, add) { return versions; } -/** Build the pydata switcher array for `org/repo`. */ -export function switcherStruct(repository, versions) { +/** + * Is `tag` a prerelease? Mirrors `_release.yml`'s test (an `a`, `b`, or `rc` + * marker in the name, PEP 440 style) so "stable" means the same thing repo-wide. + */ +export function isPrerelease(tag) { + return /a|b|rc/i.test(tag); +} + +/** + * The preferred (stable) version: the newest non-prerelease tag that is actually + * deployed, else `main`, else `master`, else the first version. `tags` must be + * newest-first; `versions` is the deployed set (output of `orderVersions`). + */ +export function preferredVersion(versions, tags) { + for (const tag of tags) { + if (!isPrerelease(tag) && versions.includes(tag)) return tag; + } + if (versions.includes("main")) return "main"; + if (versions.includes("master")) return "master"; + return versions[0] ?? null; +} + +/** Build the pydata switcher array for `org/repo`, flagging the stable entry. */ +export function switcherStruct(repository, versions, preferred) { const [org, repoName] = repository.split("/"); - return versions.map((version) => ({ - version, - url: `https://${org}.github.io/${repoName}/${version}/`, - })); + return versions.map((version) => { + const entry = { + version, + url: `https://${org}.github.io/${repoName}/${version}/`, + }; + if (version === preferred) entry.preferred = true; + return entry; + }); } /** Serialise the switcher exactly as the Python tool did (2-space JSON). */ -export function renderSwitcher(repository, versions) { - return JSON.stringify(switcherStruct(repository, versions), null, 2); +export function renderSwitcher(repository, versions, preferred) { + return JSON.stringify( + switcherStruct(repository, versions, preferred), + null, + 2, + ); +} + +/** Root redirect to `version` (relative, so it is host- and repo-agnostic). */ +export function renderRedirect(version) { + return ` + + + + Redirecting to ${version} + + + + + + +`; } export function main(argv = process.argv.slice(2)) { @@ -78,21 +129,31 @@ export function main(argv = process.argv.slice(2)) { options: { add: { type: "string" } }, allowPositionals: true, }); - const [repository, output] = positionals; - if (!repository || !output) { + const [repository, outputDir] = positionals; + if (!repository || !outputDir) { throw new Error( - "usage: make-switcher.mjs --add ", + "usage: make-switcher.mjs --add ", ); } const builds = getBranchContents("origin/gh-pages"); const tags = getSortedTags(); const versions = orderVersions(builds, tags, values.add); + const preferred = preferredVersion(versions, tags); console.log(`Sorted versions: ${JSON.stringify(versions)}`); + console.log(`Preferred version: ${preferred}`); + + const switcher = renderSwitcher(repository, versions, preferred); + console.log(`JSON switcher:\n${switcher}`); + writeFileSync(join(outputDir, "switcher.json"), switcher, "utf8"); - const text = renderSwitcher(repository, versions); - console.log(`JSON switcher:\n${text}`); - writeFileSync(output, text, "utf8"); + if (preferred) { + writeFileSync( + join(outputDir, "index.html"), + renderRedirect(preferred), + "utf8", + ); + } } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/test/test-make-switcher.mjs b/test/test-make-switcher.mjs index 16f7e6f..4399157 100644 --- a/test/test-make-switcher.mjs +++ b/test/test-make-switcher.mjs @@ -5,7 +5,10 @@ */ import assert from "node:assert/strict"; import { + isPrerelease, orderVersions, + preferredVersion, + renderRedirect, renderSwitcher, switcherStruct, } from "../switcher/make-switcher.mjs"; @@ -48,12 +51,40 @@ assert.deepEqual(orderVersions(["main", "2.1", "2.0"], tags, null), [ ]); ok("existing deployed tags ordered newest-first"); -// --- switcherStruct shape --- +// --- isPrerelease: rc/a/b markers (parity with _release.yml) --- +assert.equal(isPrerelease("2.1"), false); +assert.equal(isPrerelease("2.1.0"), false); +assert.ok(isPrerelease("2.1rc1")); +assert.ok(isPrerelease("3.0a2")); +assert.ok(isPrerelease("3.0b1")); +ok("isPrerelease flags rc/a/b tags only"); + +// --- preferredVersion: newest deployed stable tag, else main --- +assert.equal(preferredVersion(["main", "2.1", "2.0"], tags), "2.1"); +ok("preferredVersion picks the newest deployed stable tag"); + +// a newer prerelease is skipped in favour of the newest stable +assert.equal( + preferredVersion(["main", "3.0rc1", "2.1"], ["3.0rc1", "2.1"]), + "2.1", +); +ok("preferredVersion skips prereleases"); + +// no tags deployed yet -> fall back to main +assert.equal(preferredVersion(["main"], []), "main"); +ok("preferredVersion falls back to main when no stable tag is deployed"); + +// a tag that exists but was never deployed is not preferred +assert.equal(preferredVersion(["main", "2.0"], ["2.1", "2.0"]), "2.0"); +ok("preferredVersion ignores tags with no deployed build"); + +// --- switcherStruct shape, with the stable entry flagged --- assert.deepEqual( - switcherStruct("DiamondLightSource/myst-version-switcher-plugin", [ - "main", + switcherStruct( + "DiamondLightSource/myst-version-switcher-plugin", + ["main", "2.1"], "2.1", - ]), + ), [ { version: "main", @@ -62,13 +93,14 @@ assert.deepEqual( { version: "2.1", url: "https://DiamondLightSource.github.io/myst-version-switcher-plugin/2.1/", + preferred: true, }, ], ); -ok("switcherStruct builds the pydata {version,url} array"); +ok("switcherStruct builds the pydata array and flags the preferred entry"); // --- exact serialisation (2-space, no trailing newline), parity with json.dumps(indent=2) --- -const text = renderSwitcher("acme/widget", ["main", "2.0"]); +const text = renderSwitcher("acme/widget", ["main", "2.0"], "2.0"); assert.equal( text, `[ @@ -78,10 +110,17 @@ assert.equal( }, { "version": "2.0", - "url": "https://acme.github.io/widget/2.0/" + "url": "https://acme.github.io/widget/2.0/", + "preferred": true } ]`, ); ok("renderSwitcher matches make_switcher.py 2-space JSON output"); +// --- redirect points (relatively) at the preferred version --- +const redirect = renderRedirect("2.1"); +assert.match(redirect, /url=\.\/2\.1\/index\.html/); +assert.match(redirect, //); +ok("renderRedirect emits a relative refresh to the preferred version"); + console.log(`\nAll ${passed} checks passed.`);