Skip to content

Commit

Permalink
Version warningbar (#1354)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
drammock committed Jul 19, 2023
1 parent 1408fb0 commit 29fcd08
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 38 deletions.
8 changes: 4 additions & 4 deletions 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",
Expand Down
3 changes: 2 additions & 1 deletion docs/conf.py
Expand Up @@ -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"
Expand Down Expand Up @@ -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"],
Expand Down
47 changes: 46 additions & 1 deletion docs/user_guide/announcements.rst
Expand Up @@ -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 = {
...
Expand All @@ -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 <version-dropdowns>` 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 <https://www.npmjs.com/package/compare-versions>`__, 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

<div style="background-color: rgb(248, 215, 218); color: rgb(114, 28, 36); text-align: center;">
<div>
<div>This is documentation for <strong>an old version</strong>.
<a href="{{ URL_OF_STABLE_VERSION_OF_PROJECT }}" style="background-color: rgb(220, 53, 69); color: rgb(255, 255, 255); margin: 1rem; padding: 0.375rem 0.75rem; border-radius: 4px; display: inline-block; text-align: center;">Switch to stable version</a>
</div>
</div>
</div>
4 changes: 4 additions & 0 deletions docs/user_guide/version-dropdown.rst
@@ -1,3 +1,5 @@
.. _version-dropdowns:

Version switcher dropdowns
==========================

Expand Down Expand Up @@ -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:

Expand Down
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions src/pydata_sphinx_theme/__init__.py
Expand Up @@ -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)

Expand Down
153 changes: 126 additions & 27 deletions 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";

Expand Down Expand Up @@ -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");
Expand All @@ -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.
Expand All @@ -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
*/
Expand Down Expand Up @@ -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.
*/
Expand Down

0 comments on commit 29fcd08

Please sign in to comment.