diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index fe90af346..a40a5ff31 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -30,6 +30,12 @@ jobs: run: | mkdir _html cp -r index.html ufl basix ffcx dolfinx _html + - name: Add version switcher + run: | + cp -r _fenics _html/ + python3 make_versions.py _html + python3 inject_switcher.py _html + python3 inject_switcher.py _html --verify . - name: Upload build as artifact uses: actions/upload-artifact@v7 with: @@ -51,7 +57,7 @@ jobs: # Make index.html - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: 3.12 - name: Generate index.html @@ -65,6 +71,13 @@ jobs: run: | mkdir _html cp -r index.html ufl basix ffcx dolfinx _html + + - name: Add version switcher + run: | + cp -r _fenics _html/ + python3 make_versions.py _html + python3 inject_switcher.py _html + python3 inject_switcher.py _html --verify . # Deploy to GitHub Pages - name: Setup Pages diff --git a/_fenics/version-switcher.js b/_fenics/version-switcher.js new file mode 100644 index 000000000..12743f05f --- /dev/null +++ b/_fenics/version-switcher.js @@ -0,0 +1,183 @@ +// Version switcher for docs.fenicsproject.org. +// +// Injected into every page by inject_switcher.py. Reads the page's +// // from its URL, lists the versions in +// /versions.json (written by make_versions.py) and, on selection, +// opens the same page in the chosen version, falling back to the nearest +// existing parent folder. +(function () { + "use strict"; + + // Doxygen search results and other iframes get no switcher. + if (window.self !== window.top) return; + + // Site root, from this script's own URL, so previews served below a + // subpath work too. + const script = document.currentScript; + const root = new URL("..", script.src).pathname; + if (!location.pathname.startsWith(root)) return; + const parts = location.pathname.slice(root.length).split("/"); + if (parts.length < 3) return; + const project = decodeURIComponent(parts[0]); + const current = decodeURIComponent(parts[1]); + const rest = parts.slice(2); + + // dolfinx moved its Doxygen pages from cpp/ (v0.1-v0.3) to cpp/doxygen/ + // (v0.4 onwards, where cpp/ is Sphinx). On a Doxygen page, this is the + // page's path relative to the Doxygen root. + const doxygen = + rest[0] === "cpp" && + document.querySelector('meta[name="generator"][content^="Doxygen"]') !== null + ? rest.slice(rest[1] === "doxygen" ? 2 : 1).join("/") + : null; + + // Candidate URLs in the target version, and how many leading ones are the + // same page: the page itself (on a Doxygen page, at both Doxygen roots, + // falling back to the Doxygen main page), then each parent folder's + // index.html up to the version root, then the python/ and cpp/ landing + // pages (dolfinx and newer basix versions have no root index). + function candidates(version) { + const base = root + project + "/" + version + "/"; + const out = []; + let same; + if (doxygen !== null) { + const page = doxygen === "" || doxygen.endsWith("/") ? doxygen + "index.html" : doxygen; + out.push(base + "cpp/doxygen/" + page, base + "cpp/" + page); + same = out.length; + out.push(base + "cpp/doxygen/index.html"); + } else { + const page = rest.join("/"); + if (page !== "" && !page.endsWith("/")) out.push(base + page); + same = out.length; + } + const dirs = rest.slice(0, -1); + for (let n = dirs.length; n >= 0; n--) { + const dir = dirs.slice(0, n).join("/"); + out.push(base + (dir ? dir + "/" : "") + "index.html"); + } + out.push(base + "python/index.html", base + "cpp/index.html"); + return { urls: [...new Set(out)], same: same }; + } + + async function exists(url) { + try { + const r = await fetch(url, { method: "HEAD" }); + return r.ok; + } catch (e) { + return false; + } + } + + async function go(version) { + const { urls, same } = candidates(version); + // Check all candidates at once, then take the first that exists + const found = await Promise.all(urls.map(exists)); + const i = found.indexOf(true); + if (i === -1) { + location.href = root + project + "/" + version + "/"; + return; + } + const keep = i < same ? location.search + location.hash : ""; + location.href = urls[i].replace(/(^|\/)index\.html$/, "$1") + keep; + } + + const css = ` +.fenics-vs { position: fixed; right: 1rem; bottom: 1rem; z-index: 10000; + font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: #fcfcfc; background: #272525; border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, .35); max-width: 18rem; } +.fenics-vs summary { cursor: pointer; padding: .4rem .8rem; list-style: none; + user-select: none; white-space: nowrap; } +.fenics-vs summary::-webkit-details-marker { display: none; } +.fenics-vs summary::after { content: " \\25BE"; } +.fenics-vs[open] summary::after { content: " \\25B4"; } +.fenics-vs summary .fenics-vs-old { color: #f0b37e; } +.fenics-vs ul { margin: 0; padding: .3rem .8rem .6rem; list-style: none; + max-height: 50vh; overflow-y: auto; border-top: 1px solid #413d3d; } +.fenics-vs li { margin: .15rem 0; padding: 0; } +.fenics-vs a { color: #9cf; text-decoration: none; } +.fenics-vs a:hover { text-decoration: underline; } +.fenics-vs li.fenics-vs-current a { color: #fcfcfc; font-weight: 600; } +.fenics-vs-banner { position: relative; z-index: 9999; padding: .5rem 2.5rem; + text-align: center; background: #fff3cd; color: #533f03; + border-bottom: 1px solid #e6d28a; + font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +.fenics-vs-banner a { color: #0b5394; font-weight: 600; } +.fenics-vs-banner button { position: absolute; right: .6rem; top: .3rem; + border: 0; background: none; color: inherit; font-size: 1.2rem; + cursor: pointer; line-height: 1; } +@media print { .fenics-vs, .fenics-vs-banner { display: none; } }`; + + function el(tag, attrs, text) { + const e = document.createElement(tag); + for (const k in attrs) e.setAttribute(k, attrs[k]); + if (text !== undefined) e.textContent = text; + return e; + } + + // The href (used for middle-click and copy-link) is the same page in that + // version; a plain click resolves the nearest existing page first. + function link(version, text) { + const a = el("a", { href: candidates(version).urls[0] }, text); + a.addEventListener("click", function (ev) { + if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.button !== 0) return; + ev.preventDefault(); + go(version); + }); + return a; + } + + function banner(preferred) { + const key = "fenics-vs-dismissed:" + project + "/" + current; + try { + if (sessionStorage.getItem(key)) return; + } catch (e) {} + const div = el("div", { class: "fenics-vs-banner", role: "note" }); + div.append( + "You are viewing the documentation for an older release of " + + project + " (" + current + "). The latest release is " + ); + div.append(link(preferred.version, preferred.version)); + div.append("."); + const close = el("button", { type: "button", "aria-label": "Dismiss" }, "×"); + close.addEventListener("click", function () { + div.remove(); + try { + sessionStorage.setItem(key, "1"); + } catch (e) {} + }); + div.append(close); + document.body.prepend(div); + } + + function render(versions) { + if (!versions.some((v) => v.version === current)) return; + const preferred = versions.find((v) => v.preferred); + const old = preferred && current !== "main" && current !== preferred.version; + + const style = el("style"); + style.textContent = css; + document.head.append(style); + + const box = el("details", { class: "fenics-vs" }); + const summary = el("summary", { "aria-label": "Select documentation version" }); + summary.append(project + " "); + summary.append(el("span", old ? { class: "fenics-vs-old" } : {}, current)); + box.append(summary); + const list = el("ul"); + for (const v of versions) { + const li = el("li", v.version === current ? { class: "fenics-vs-current" } : {}); + li.append(link(v.version, v.name)); + list.append(li); + } + box.append(list); + document.body.append(box); + + if (old) banner(preferred); + } + + fetch(root + project + "/versions.json") + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then(render) + .catch(() => {}); +})(); diff --git a/inject_switcher.py b/inject_switcher.py new file mode 100644 index 000000000..2705ff72f --- /dev/null +++ b/inject_switcher.py @@ -0,0 +1,102 @@ +"""Add the version switcher script to every HTML page of the site. + +Inserts one marked line before the last ```` of each page and changes +nothing else, so every URL and every page's content stay as published. +Pages without ```` (template fragments) are left untouched. Running +it twice is a no-op. + +With ``--verify ORIGINAL``, instead checks that the site differs from +ORIGINAL only by the injected lines and the switcher's own new files. +""" + +import argparse +import filecmp +import os +import sys + +from make_versions import PROJECTS + +MARKER = b"" +TAG = MARKER + b'\n' +NEW_FILES = {"versions.json", "_fenics/version-switcher.js"} | { + f"{p}/versions.json" for p in PROJECTS +} + + +def inject(content): + """Return the page with the switcher tag, or None if not applicable.""" + if MARKER in content: + return None + i = content.lower().rfind(b"") + if i == -1: + return None + return content[:i] + TAG + content[i:] + + +def all_files(root): + """Return all file paths below root, relative to root.""" + out = set() + for dirpath, _, filenames in os.walk(root): + for name in filenames: + out.add(os.path.relpath(os.path.join(dirpath, name), root)) + return out + + +def project_files(root): + """Return the files of the projects, relative to root.""" + return { + os.path.join(project, p) + for project in PROJECTS + if os.path.isdir(os.path.join(root, project)) + for p in all_files(os.path.join(root, project)) + } + + +def verify(site, original): + """Return a list of differences that are not the injected switcher.""" + errors = [] + site_files = all_files(site) - NEW_FILES + original_files = project_files(original) + for path in sorted(original_files - site_files): + errors.append(f"missing: {path}") + for path in sorted(site_files): + a = os.path.join(site, path) + b = os.path.join(original, path) + if not os.path.isfile(b): + errors.append(f"unexpected new file: {path}") + elif path.endswith(".html"): + with open(a, "rb") as f: + content = f.read() + with open(b, "rb") as f: + expected = f.read() + if content.replace(TAG, b"", 1) != expected: + errors.append(f"changed beyond the injected tag: {path}") + elif not filecmp.cmp(a, b, shallow=False): + errors.append(f"changed: {path}") + return errors + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", help="Site root, e.g. _html") + parser.add_argument("--verify", metavar="ORIGINAL", help="Compare against this tree") + args = parser.parse_args() + + if args.verify: + errors = verify(args.site, args.verify) + print("\n".join(errors[:50])) + print(f"{len(errors)} unexpected differences") + sys.exit(1 if errors else 0) + + injected = skipped = 0 + for path in sorted(p for p in project_files(args.site) if p.endswith(".html")): + full = os.path.join(args.site, path) + with open(full, "rb") as f: + content = inject(f.read()) + if content is None: + skipped += 1 + continue + with open(full, "wb") as f: + f.write(content) + injected += 1 + print(f"Injected {injected} pages, skipped {skipped}") diff --git a/make_versions.py b/make_versions.py new file mode 100644 index 000000000..d3e91057f --- /dev/null +++ b/make_versions.py @@ -0,0 +1,98 @@ +"""Write the versions.json files that drive the version switcher. + +The versions of a project are the folders under ``/``. Upstream CI +deploys each build to ``/``, so a folder is +either ``main`` or a tag (``v0.11.0.post0``, ``2026.2.0``) with published +docs. + +Writes ``//versions.json`` in pydata-sphinx-theme's switcher +format, and ``/versions.json`` with all projects keyed by name. +""" + +import argparse +import json +import os + +from packaging.version import InvalidVersion, Version + +PROJECTS = ["dolfinx", "basix", "ffcx", "ufl"] +BASE_URL = "https://docs.fenicsproject.org" + + +def parse_version(name): + """Return the version of a tag folder, or None if it is not a tag.""" + try: + return Version(name.removeprefix("v")) + except InvalidVersion: + return None + + +def landing_page(site, project, name): + """Return the front page of a version folder, relative to the folder. + + Projects with Python and C++ documentation have no root index. + """ + for sub in ("", "python/", "cpp/"): + if os.path.isfile(os.path.join(site, project, name, sub, "index.html")): + return sub + return "" + + +def project_versions(site, project): + """Return the switcher entries of one project, newest first.""" + path = os.path.join(site, project) + tags = [] + has_main = False + for name in os.listdir(path): + if not os.path.isdir(os.path.join(path, name)): + continue + if name == "main": + has_main = True + elif (v := parse_version(name)) is not None: + tags.append((v, name)) + tags.sort(reverse=True) + + releases = [name for v, name in tags if not v.is_prerelease] + preferred = releases[0] if releases else None + + entries = [] + if has_main: + entries.append( + { + "name": "main (dev)", + "version": "main", + "url": f"{BASE_URL}/{project}/main/{landing_page(site, project, 'main')}", + } + ) + for _, name in tags: + url = f"{BASE_URL}/{project}/{name}/{landing_page(site, project, name)}" + entry = {"name": name, "version": name, "url": url} + if name == preferred: + entry["name"] = f"{name} (stable)" + entry["preferred"] = True + entries.append(entry) + return entries + + +def write_new(path, data): + """Write JSON to a path that must not already be published.""" + if os.path.exists(path): + raise FileExistsError(f"Refusing to overwrite existing file {path}") + with open(path, "w") as f: + json.dump(data, f, indent=1) + f.write("\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", help="Site root, e.g. _html") + args = parser.parse_args() + + combined = {} + for project in PROJECTS: + if not os.path.isdir(os.path.join(args.site, project)): + continue + combined[project] = project_versions(args.site, project) + write_new(os.path.join(args.site, project, "versions.json"), combined[project]) + print(f"{project}: {len(combined[project])} versions") + write_new(os.path.join(args.site, "versions.json"), combined) diff --git a/requirements.txt b/requirements.txt index 0918c9768..164adbe5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ markdown +packaging