From 29fcd08a4cba177236630396725b1f101ea2bddd Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 19 Jul 2023 15:28:50 -0500 Subject: [PATCH] Version warningbar (#1354) * update supporting files * improve code comments * do switcher loading as a function; add warning banner function * fix variable rename * cleanup after rebase * banner background color * docs * add TODO * be forgiving about version specs * update package-lock * more documentation * handle more prerelease indicators in version strings * Update docs/user_guide/announcements.rst * add HTML snippet to docs * unrelated formatting fix * disambiguate button message * use parsable version string for stable entry in JSON * update test for new color palette * handle Bokeh case elegantly --- docs/_static/switcher.json | 8 +- docs/conf.py | 3 +- docs/user_guide/announcements.rst | 47 +++++- docs/user_guide/version-dropdown.rst | 4 + package-lock.json | 13 +- package.json | 3 +- src/pydata_sphinx_theme/__init__.py | 1 + .../assets/scripts/pydata-sphinx-theme.js | 153 ++++++++++++++---- .../assets/styles/sections/_announcement.scss | 16 +- .../theme/pydata_sphinx_theme/theme.conf | 1 + tests/test_a11y.py | 4 +- 11 files changed, 215 insertions(+), 38 deletions(-) diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index a70e93c3d..6d4f0a912 100644 --- a/docs/_static/switcher.json +++ b/docs/_static/switcher.json @@ -1,13 +1,13 @@ [ { - "name": "dev", - "version": "latest", + "version": "dev", "url": "https://pydata-sphinx-theme.readthedocs.io/en/latest/" }, { "name": "0.13.3 (stable)", - "version": "stable", - "url": "https://pydata-sphinx-theme.readthedocs.io/en/stable/" + "version": "v0.13.3", + "url": "https://pydata-sphinx-theme.readthedocs.io/en/stable/", + "preferred": true }, { "name": "0.12.0", diff --git a/docs/conf.py b/docs/conf.py index b86bec3f5..54537a096 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -104,7 +104,7 @@ # For local development, infer the version to match from the package. release = pydata_sphinx_theme.__version__ if "dev" in release or "rc" in release: - version_match = "latest" + version_match = "dev" # We want to keep the relative reference if we are in dev mode # but we want the whole url if we are effectively in a released version json_url = "_static/switcher.json" @@ -164,6 +164,7 @@ "navbar_align": "left", # [left, content, right] For testing that the navbar items align properly "navbar_center": ["version-switcher", "navbar-nav"], "announcement": "https://raw.githubusercontent.com/pydata/pydata-sphinx-theme/main/docs/_templates/custom-template.html", + "show_version_warning_banner": True, # "show_nav_level": 2, # "navbar_start": ["navbar-logo"], # "navbar_end": ["theme-switcher", "navbar-icon-links"], diff --git a/docs/user_guide/announcements.rst b/docs/user_guide/announcements.rst index 2d71417d9..d86abb295 100644 --- a/docs/user_guide/announcements.rst +++ b/docs/user_guide/announcements.rst @@ -15,6 +15,7 @@ By default, the value of your ``html_theme_options["announcement"]`` will be ins For example, the following configuration adds a simple announcement. .. code-block:: python + :caption: conf.py html_theme_options = { ... @@ -28,13 +29,57 @@ You can specify an arbitrary URL that will be used as the HTML source for your a When the page is loaded, JavaScript will attempt to fetch this HTML and insert it as-is into the announcement banner. This allows you to define a single HTML announcement that you can pull into multiple documentation sites or versions. -If the value of ``html_theme_options["announcement"]`` begins with **``http``** it will be treated as a URL to remote HTML. +If the value of ``html_theme_options["announcement"]`` begins with ``http`` it will be treated as a URL to remote HTML. For example, the following configuration tells the theme to load the ``custom-template.html`` example from this documentation's GitHub repository: .. code-block:: python + :caption: conf.py html_theme_options = { ... "announcement": "https://github.com/pydata/pydata-sphinx-theme/raw/main/docs/_templates/custom-template.html", } + + +.. _version-warning-banners: + +Version warning banners +----------------------- + +In addition to the general-purpose announcement banner, the theme includes a built-in banner to warn users when they are viewing versions of your docs other than the latest stable version. To use this feature, add the following to your ``conf.py``: + +.. code-block:: python + :caption: conf.py + + html_theme_options = { + ... + "show_version_warning_banner": True, + } + +.. important:: + + This functionality relies on the :ref:`version switcher ` to determine the version number of the latest stable release. + *It will only work* if your version switcher ``.json`` has exactly one entry with property ``"preferred": true`` + and your entries have ``version`` properties that are parsable by the `compare-versions node module `__, for example: + + .. code-block:: json + + { + "name": "stable", + "version": "9.9.9", + "url": "https://anything", + "preferred": true + } + +If you want similar functionality for *older* versions of your docs (i.e. those built before the ``show_version_warning_banner`` configuration option was available), you can manually add a banner by prepending the following HTML to all pages (be sure to replace ``URL_OF_STABLE_VERSION_OF_PROJECT`` with a valid URL, and adjust styling as desired): + +.. code-block:: html + +
+
+
This is documentation for an old version. + Switch to stable version +
+
+
diff --git a/docs/user_guide/version-dropdown.rst b/docs/user_guide/version-dropdown.rst index 2502a99c4..299ab678e 100644 --- a/docs/user_guide/version-dropdown.rst +++ b/docs/user_guide/version-dropdown.rst @@ -1,3 +1,5 @@ +.. _version-dropdowns: + Version switcher dropdowns ========================== @@ -40,6 +42,8 @@ each can have the following fields: - ``url``: the URL for this version. - ``name``: an optional name to display in the switcher dropdown instead of the version string (e.g., "latest", "stable", "dev", etc.). +- ``preferred``: an optional field that *should occur on at most one entry* in the JSON file. + It specifies which version is considered "latest stable", and is used to customize the message used on :ref:`version-warning-banners` (if they are enabled). Here is an example JSON file: diff --git a/package-lock.json b/package-lock.json index 89c5c3906..474c42cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "dependencies": { "@fortawesome/fontawesome-free": "6.1.2", "@popperjs/core": "^2.11.6", - "bootstrap": "^5.2.2" + "bootstrap": "^5.2.2", + "compare-versions": "^5.0.3" }, "devDependencies": { "axe-core": "^4.6.3", @@ -993,6 +994,11 @@ "node": ">= 6" } }, + "node_modules/compare-versions": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.3.tgz", + "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==" + }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -5293,6 +5299,11 @@ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true }, + "compare-versions": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.3.tgz", + "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==" + }, "copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", diff --git a/package.json b/package.json index f615c4a97..f992c9a7a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "6.1.2", "@popperjs/core": "^2.11.6", - "bootstrap": "^5.2.2" + "bootstrap": "^5.2.2", + "compare-versions": "^5.0.3" } } diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index 859e5239f..db7468814 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -255,6 +255,7 @@ def _remove_empty_templates(tname): js = f""" DOCUMENTATION_OPTIONS.theme_switcher_json_url = '{json_url}'; DOCUMENTATION_OPTIONS.theme_switcher_version_match = '{version_match}'; + DOCUMENTATION_OPTIONS.show_version_warning_banner = {str(context["theme_show_version_warning_banner"]).lower()}; """ app.add_js_file(None, body=js) diff --git a/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js b/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js index 463ab9fc2..c29b8a04d 100644 --- a/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js +++ b/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js @@ -1,5 +1,6 @@ // Define the custom behavior of the page import { documentReady } from "./mixin"; +import { compare, validate } from "compare-versions"; import "../styles/pydata-sphinx-theme.scss"; @@ -299,60 +300,67 @@ function checkPageExistsAndRedirect(event) { location.href = otherDocsHomepage; }); - // ensure we don't follow the initial link + // ↓ this prevents the browser from following the href of the clicked node + // ↓ (which is fine because this function takes care of redirecting) event.preventDefault(); } /** - * Check if the corresponding url is absolute and make a absolute path from root if necessary + * Load and parse the version switcher JSON file from an absolute or relative URL. * - * @param {string} url the url to check + * @param {string} url The URL to load version switcher entries from. */ async function fetchVersionSwitcherJSON(url) { // first check if it's a valid URL try { var result = new URL(url); } catch (err) { - // if not, assume relative path and fix accordingly if (err instanceof TypeError) { - // workaround for redirects like https://pydata-sphinx-theme.readthedocs.io - // fetch() automatically follows redirects so it should work in every builder - // (RDT, GitHub actions, etc) - const origin = await fetch(window.location.origin, { - method: "HEAD", - }); + // assume we got a relative path, and fix accordingly. But first, we need to + // use `fetch()` to follow redirects so we get the correct final base URL + const origin = await fetch(window.location.origin, { method: "HEAD" }); result = new URL(url, origin.url); } else { + // something unexpected happened throw err; } } - + // load and return the JSON const response = await fetch(result); const data = await response.json(); return data; } -// Populate the version switcher from the JSON config file -var versionSwitcherBtns = document.querySelectorAll( - ".version-switcher__button" -); - -if (versionSwitcherBtns.length) { - const data = await fetchVersionSwitcherJSON( - DOCUMENTATION_OPTIONS.theme_switcher_json_url - ); +// Populate the version switcher from the JSON data +function populateVersionSwitcher(data, versionSwitcherBtns) { const currentFilePath = `${DOCUMENTATION_OPTIONS.pagename}.html`; versionSwitcherBtns.forEach((btn) => { // Set empty strings by default so that these attributes exist and can be used in CSS selectors btn.dataset["activeVersionName"] = ""; btn.dataset["activeVersion"] = ""; }); - // create links to the corresponding page in the other docs versions - data.forEach((entry) => { + // in case there are multiple entries with the same version string, this helps us + // decide which entry's `name` to put on the button itself. Without this, it would + // always be the *last* version-matching entry; now it will be either the + // version-matching entry that is also marked as `"preferred": true`, or if that + // doesn't exist: the *first* version-matching entry. + data = data.map((entry) => { + // does this entry match the version that we're currently building/viewing? + entry.match = + entry.version == DOCUMENTATION_OPTIONS.theme_switcher_version_match; + entry.preferred = entry.preferred || false; // if no custom name specified (e.g., "latest"), use version string if (!("name" in entry)) { entry.name = entry.version; } + return entry; + }); + const hasMatchingPreferredEntry = data + .map((entry) => entry.preferred && entry.match) + .some(Boolean); + var foundMatch = false; + // create links to the corresponding page in the other docs versions + data.forEach((entry) => { // create the node const anchor = document.createElement("a"); anchor.setAttribute("class", "list-group-item list-group-item-action py-1"); @@ -365,17 +373,20 @@ if (versionSwitcherBtns.length) { // to apply CSS styling based on this information. anchor.dataset["versionName"] = entry.name; anchor.dataset["version"] = entry.version; - // replace dropdown button text with the preferred display name of - // this version, rather than using sphinx's {{ version }} variable. - // also highlight the dropdown entry for the currently-viewed - // version's entry - if (entry.version == DOCUMENTATION_OPTIONS.theme_switcher_version_match) { + // replace dropdown button text with the preferred display name of the + // currently-viewed version, rather than using sphinx's {{ version }} variable. + // also highlight the dropdown entry for the currently-viewed version's entry + let matchesAndIsPreferred = hasMatchingPreferredEntry && entry.preferred; + let matchesAndIsFirst = + !hasMatchingPreferredEntry && !foundMatch && entry.match; + if (matchesAndIsPreferred || matchesAndIsFirst) { anchor.classList.add("active"); versionSwitcherBtns.forEach((btn) => { btn.innerText = entry.name; btn.dataset["activeVersionName"] = entry.name; btn.dataset["activeVersion"] = entry.version; }); + foundMatch = true; } // There may be multiple version-switcher elements, e.g. one // in a slide-over panel displayed on smaller screens. @@ -392,6 +403,73 @@ if (versionSwitcherBtns.length) { }); } +/******************************************************************************* + * Warning banner when viewing non-stable version of the docs. + */ + +/** + * Show a warning banner when viewing a non-stable version of the docs. + * + * adapted 2023-06 from https://mne.tools/versionwarning.js, which was + * originally adapted 2020-05 from https://scikit-learn.org/versionwarning.js + * + * @param {Array} data The version data used to populate the switcher menu. + */ +function showVersionWarningBanner(data) { + const version = DOCUMENTATION_OPTIONS.VERSION; + // figure out what latest stable version is + var preferredEntries = data.filter((entry) => entry.preferred); + if (preferredEntries.length !== 1) { + const howMany = preferredEntries.length == 0 ? "No" : "Multiple"; + throw new Error( + `[PST] ${howMany} versions marked "preferred" found in versions JSON` + ); + } + const preferredVersion = preferredEntries[0].version; + const preferredURL = preferredEntries[0].url; + // if already on preferred version, nothing to do + const versionsAreComparable = validate(version) && validate(preferredVersion); + if (versionsAreComparable && compare(version, preferredVersion, "=")) { + return; + } + // now construct the warning banner + var outer = document.createElement("div"); + const middle = document.createElement("div"); + const inner = document.createElement("div"); + const bold = document.createElement("strong"); + const button = document.createElement("a"); + // these classes exist since pydata-sphinx-theme v0.10.0 + outer.classList = "bd-header-version-warning container-fluid"; + middle.classList = "bd-header-announcement__content"; + inner.classList = "sidebar-message"; + button.classList = + "sd-btn sd-btn-danger sd-shadow-sm sd-text-wrap font-weight-bold ms-3 my-1 align-baseline"; + button.href = `${preferredURL}${DOCUMENTATION_OPTIONS.pagename}.html`; + button.innerText = "Switch to stable version"; + button.onclick = checkPageExistsAndRedirect; + // add the version-dependent text + inner.innerText = "This is documentation for an "; + const isDev = + version.includes("dev") || + version.includes("rc") || + version.includes("pre"); + const newerThanPreferred = + versionsAreComparable && compare(version, preferredVersion, ">"); + if (isDev || newerThanPreferred) { + bold.innerText = "unstable development version"; + } else if (versionsAreComparable && compare(version, preferredVersion, "<")) { + bold.innerText = `old version (${version})`; + } else { + bold.innerText = `version ${version}`; + } + outer.appendChild(middle); + middle.appendChild(inner); + inner.appendChild(bold); + inner.appendChild(document.createTextNode(".")); + inner.appendChild(button); + document.body.prepend(outer); +} + /******************************************************************************* * MutationObserver to move the ReadTheDocs button */ @@ -423,6 +501,27 @@ function initRTDObserver() { observer.observe(document.body, config); } +// fetch the JSON version data (only once), then use it to populate the version +// switcher and maybe show the version warning bar +var versionSwitcherBtns = document.querySelectorAll( + ".version-switcher__button" +); +const hasSwitcherMenu = versionSwitcherBtns.length > 0; +const hasVersionsJSON = DOCUMENTATION_OPTIONS.hasOwnProperty( + "theme_switcher_json_url" +); +const wantsWarningBanner = DOCUMENTATION_OPTIONS.show_version_warning_banner; + +if (hasVersionsJSON && (hasSwitcherMenu || wantsWarningBanner)) { + const data = await fetchVersionSwitcherJSON( + DOCUMENTATION_OPTIONS.theme_switcher_json_url + ); + populateVersionSwitcher(data, versionSwitcherBtns); + if (wantsWarningBanner) { + showVersionWarningBanner(data); + } +} + /******************************************************************************* * Call functions after document loading. */ diff --git a/src/pydata_sphinx_theme/assets/styles/sections/_announcement.scss b/src/pydata_sphinx_theme/assets/styles/sections/_announcement.scss index 524f2ec91..843c4be7f 100644 --- a/src/pydata_sphinx_theme/assets/styles/sections/_announcement.scss +++ b/src/pydata_sphinx_theme/assets/styles/sections/_announcement.scss @@ -1,3 +1,4 @@ +.bd-header-version-warning, .bd-header-announcement { min-height: 3rem; width: 100%; @@ -18,14 +19,12 @@ margin: 0; } - // Bg color is now defined in the theme color palette - using our secondary color &:after { position: absolute; width: 100%; height: 100%; left: 0; top: 0; - background-color: var(--pst-color-secondary-bg); content: ""; z-index: -1; // So it doesn't hover over the content } @@ -39,3 +38,16 @@ color: var(--pst-color-inline-code-links); } } + +// Bg color is now defined in the theme color palette - using our secondary color +.bd-header-announcement { + &:after { + background-color: var(--pst-color-secondary-bg); + } +} + +.bd-header-version-warning { + &:after { + background-color: var(--pst-color-danger-bg); + } +} diff --git a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf index eb750f318..0b2685086 100644 --- a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf +++ b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/theme.conf @@ -49,6 +49,7 @@ primary_sidebar_end = sidebar-ethical-ads.html footer_start = copyright.html, sphinx-version.html footer_end = theme-version.html secondary_sidebar_items = page-toc.html, edit-this-page.html, sourcelink.html +show_version_warning_banner = False announcement = # DEPRECATE after 0.14 diff --git a/tests/test_a11y.py b/tests/test_a11y.py index 7259056bd..71b09f345 100644 --- a/tests/test_a11y.py +++ b/tests/test_a11y.py @@ -127,4 +127,6 @@ def test_version_switcher_highlighting(page: Page, url_base: str) -> None: assert entries.count() == 2 # make sure they're highlighted for entry in entries.all(): - expect(entry).to_have_css("color", "rgb(10, 125, 145)") + light_mode = "rgb(39, 107, 233)" + # dark_mode = "rgb(121, 163, 142)" + expect(entry).to_have_css("color", light_mode)