From 4a284c25bcf81c50dd7ab1fd9dc11b0bdde28c78 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Tue, 16 Jul 2024 03:26:21 +0100 Subject: [PATCH] Add language and version switchers --- pyproject.toml | 4 + python_docs_theme/__init__.py | 72 +++++++++++++- python_docs_theme/layout.html | 17 +++- python_docs_theme/static/switchers.js | 131 ++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 python_docs_theme/static/switchers.js diff --git a/pyproject.toml b/pyproject.toml index 4571a41..dcafccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ classifiers = [ "Topic :: Documentation", "Topic :: Software Development :: Documentation", ] +dependencies = [ + "httpx>=0.25", + 'tomli>=2; python_version < "3.11"', +] urls.Code = "https://github.com/python/python-docs-theme" urls.Download = "https://pypi.org/project/python-docs-theme/" urls.Homepage = "https://github.com/python/python-docs-theme/" diff --git a/python_docs_theme/__init__.py b/python_docs_theme/__init__.py index 7b9df30..fe7a030 100644 --- a/python_docs_theme/__init__.py +++ b/python_docs_theme/__init__.py @@ -2,16 +2,85 @@ import hashlib import os +import sys from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Literal +import httpx import sphinx.application from sphinx.builders.html import StandaloneHTMLBuilder +if sys.version_info[:2] >= (3, 11): + import tomllib +else: + import tomli as tomllib + THEME_PATH = Path(__file__).parent.resolve() +def _version_label( + version_name: str, + status: Literal["feature", "prerelease", "bugfix", "security", "end-of-life"], +) -> str: + if status == "feature": + return f"dev ({version_name})" + if status == "prerelease": + return f"pre ({version_name})" + if status in {"end-of-life", "security", "bugfix"}: + return version_name + msg = f"Unknown status: {status}" + raise ValueError(msg) + + +def _builder_inited(app): + html_context = app.config.html_context + language = app.config.language + release = app.config.release + if app.config.html_theme != "python_docs_theme": + return + + # Get the current branch statuses + releases = httpx.get( + "https://raw.githubusercontent.com/python/devguide/main/include/release-cycle.json", + timeout=30, + ).json() + # Get appropriate version labels + release_labels = { + name: _version_label(name, release["status"]) + for name, release in releases.items() + } + # Update the current version to be the full release string + if (short_version := ".".join(release.split(".", 2)[:2])) in release_labels: + release_labels[short_version] = release + + # Store the versions in the context as a sorted list of tuples + html_context["switchers_versions"] = sorted( + release_labels.items(), + key=lambda release_label: tuple(map(int, release_label[0].split("."))), + reverse=True, + ) + + # Get the languages from the docsbuild-scripts config + docsbuild_config = httpx.get( + "https://raw.githubusercontent.com/python/docsbuild-scripts/main/config.toml", + timeout=30, + ).text + # Convert language tags and extract language names + languages = [ + (iso639_tag.replace("_", "-").lower(), section["name"]) + for iso639_tag, section in tomllib.loads(docsbuild_config)["languages"].items() + if section.get("in_prod", True) + ] + + # If we are working on a language that is not in the list, add it + if language and language not in dict(languages): + languages.append((language, language)) + + # Store the versions in the context as a sorted list of tuples + html_context["switchers_languages"] = sorted(languages) + + @lru_cache(maxsize=None) def _asset_hash(path: str) -> str: """Append a `?digest=` to an url based on the file content.""" @@ -56,6 +125,7 @@ def setup(app): current_dir = os.path.abspath(os.path.dirname(__file__)) app.add_html_theme("python_docs_theme", current_dir) + app.connect("builder-inited", _builder_inited) app.connect("html-page-context", _html_page_context) return { diff --git a/python_docs_theme/layout.html b/python_docs_theme/layout.html index 9762b06..0de99c5 100644 --- a/python_docs_theme/layout.html +++ b/python_docs_theme/layout.html @@ -17,8 +17,20 @@

{{ _('Navigation') }}

  • {{ theme_root_icon_alt_text }}
  • {{theme_root_name}}{{ reldelim1 }}
  • -
    -
    +
    {% if switchers_languages %} + + {% endif -%}
    +
    {% if switchers_versions %} + + {% endif %}
  • {% if theme_root_include_title %} @@ -74,6 +86,7 @@

    {{ _('Navigation') }}

    {%- if builder != "htmlhelp" %} {%- if not embedded %} + diff --git a/python_docs_theme/static/switchers.js b/python_docs_theme/static/switchers.js new file mode 100644 index 0000000..0eafd02 --- /dev/null +++ b/python_docs_theme/static/switchers.js @@ -0,0 +1,131 @@ +'use strict'; + +const _is_file_uri = (uri) => uri.startsWith('file://'); + +const _IS_LOCAL = _is_file_uri(window.location.href); +const _CONTENT_ROOT = document.documentElement.dataset.content_root; +const _CURRENT_PREFIX = _IS_LOCAL + ? null + : new URL(_CONTENT_ROOT, window.location).pathname; +const _CURRENT_RELEASE = DOCUMENTATION_OPTIONS.VERSION || ''; +const _CURRENT_VERSION = _CURRENT_RELEASE.split('.').slice(0, 2).join('.'); +const _CURRENT_LANGUAGE = DOCUMENTATION_OPTIONS.LANGUAGE?.toLowerCase() || 'en'; + +/** + * Change the current page to the first existing URL in the list. + * @param {Array} urls + * @private + */ +const _navigate_to_first_existing = async (urls) => { + // Navigate to the first existing URL of urls. + for (const url of urls) { + try { + const response = await fetch(url, { method: 'GET' }) + if (response.ok) { + window.location.href = url; + return url; // Avoid race conditions with multiple redirects + } + } catch(err) { + console.error(`Error in: ${url}`); + console.error(err) + } + } + + // if all else fails, redirect to the d.p.o root + window.location.href = '/'; +}; + +/** + * Navigate to the selected version. + * @param {Event} event + * @returns {Promise} + */ +const on_version_switch = async (event) => { + if (_IS_LOCAL) return; + + const selected_version = event.target.value; + // Special 'default' case for English. + const new_prefix = + _CURRENT_LANGUAGE === 'en' + ? `/${selected_version}/` + : `/${_CURRENT_LANGUAGE}/${selected_version}/`; + const new_prefix_en = `/${selected_version}/`; + if (_CURRENT_PREFIX !== new_prefix) { + // Try the following pages in order: + // 1. The current page in the current language with the new version + // 2. The current page in English with the new version + // 3. The documentation home in the current language with the new version + // 4. The documentation home in English with the new version + await _navigate_to_first_existing([ + window.location.href.replace(_CURRENT_PREFIX, new_prefix), + window.location.href.replace(_CURRENT_PREFIX, new_prefix_en), + new_prefix, + new_prefix_en, + ]); + } +}; + +/** + * Navigate to the selected language. + * @param {Event} event + * @returns {Promise} + */ +const on_language_switch = async (event) => { + if (_IS_LOCAL) return; + + const selected_language = event.target.value; + // Special 'default' case for English. + const new_prefix = + selected_language === 'en' + ? `/${_CURRENT_VERSION}/` + : `/${selected_language}/${_CURRENT_VERSION}/`; + if (_CURRENT_PREFIX !== new_prefix) { + // Try the following pages in order: + // 1. The current page in the new language with the current version + // 2. The documentation home in the new language with the current version + await _navigate_to_first_existing([ + window.location.href.replace(_CURRENT_PREFIX, new_prefix), + new_prefix, + ]); + } +}; + +/** + * Set up the version and language switchers. + * @returns {Promise} + */ +const initialise_switchers = async () => { + try { + // Update the version select elements + document + .querySelectorAll('.version_switcher_placeholder select') + .forEach((select) => { + if (_IS_LOCAL) { + select.disabled = true; + select.title = 'Version switching is disabled in local builds'; + } + select.addEventListener('change', on_version_switch); + select.parentElement.classList.remove('version_switcher_placeholder'); + }); + + // Update the language select elements + document + .querySelectorAll('.language_switcher_placeholder select') + .forEach((select) => { + if (_IS_LOCAL) { + select.disabled = true; + select.title = 'Language switching is disabled in local builds'; + } + select.addEventListener('change', on_language_switch); + select.parentElement.classList.remove('language_switcher_placeholder'); + }); + } catch (error) { + console.error(error); + } +}; + +if (document.readyState !== 'loading') { + initialise_switchers(); +} else { + document.addEventListener('DOMContentLoaded', initialise_switchers); +}