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/.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 148db6c..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,11 +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 `.github/pages/switcher.json` 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`).
+### `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
@@ -43,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
@@ -64,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`).
@@ -123,14 +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-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/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..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 defaults to .github/pages/switcher.json
+ 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/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/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 0071966..7d8d403 100644
--- a/switcher/make-switcher.mjs
+++ b/switcher/make-switcher.mjs
@@ -1,41 +1,46 @@
/**
- * 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 { parseArgs } from 'node:util';
+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. */
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 +49,113 @@ 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 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 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;
}
-/** 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}/`,
- }));
+/**
+ * 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) => {
+ 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)) {
- 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 text = renderSwitcher(repository, versions);
- console.log(`JSON switcher:\n${text}`);
- writeFileSync(output, text, 'utf8');
+ const { values, positionals } = parseArgs({
+ args: argv,
+ options: { add: { type: "string" } },
+ allowPositionals: true,
+ });
+ const [repository, outputDir] = positionals;
+ if (!repository || !outputDir) {
+ throw new Error(
+ "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");
+
+ if (preferred) {
+ writeFileSync(
+ join(outputDir, "index.html"),
+ renderRedirect(preferred),
+ "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..4399157 100644
--- a/test/test-make-switcher.mjs
+++ b/test/test-make-switcher.mjs
@@ -3,69 +3,124 @@
* 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';
+ isPrerelease,
+ orderVersions,
+ preferredVersion,
+ renderRedirect,
+ 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'],
+assert.deepEqual(orderVersions(["main", "2.1", "2.0"], tags, null), [
+ "main",
+ "2.1",
+ "2.0",
+]);
+ok("existing deployed tags ordered newest-first");
+
+// --- 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('existing deployed tags ordered newest-first');
+ok("preferredVersion skips prereleases");
-// --- switcherStruct shape ---
+// 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', '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"],
+ "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/",
+ 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,
- `[
+ text,
+ `[
{
"version": "main",
"url": "https://acme.github.io/widget/main/"
},
{
"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');
+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.`);
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.`);