Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add language and version switchers #193

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
72 changes: 71 additions & 1 deletion python_docs_theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions python_docs_theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@ <h3>{{ _('Navigation') }}</h3>
<li><img src="{{ pathto('_static/' ~ theme_root_icon, 1) }}" alt="{{ theme_root_icon_alt_text }}" style="vertical-align: middle; margin-top: -1px"/></li>
<li><a href="{{theme_root_url}}">{{theme_root_name}}</a>{{ reldelim1 }}</li>
<li class="switchers">
<div class="language_switcher_placeholder"></div>
<div class="version_switcher_placeholder"></div>
<div class="language_switcher_placeholder">{% if switchers_languages %}
<select class="language">
{% for lang_code, lang_name in switchers_languages -%}
<option value="{{ lang_code }}" {%- if lang_code == language %} selected="true" {%- endif %}>{{ lang_name }}</option>
{% endfor -%}
</select>
{% endif -%}</div>
<div class="version_switcher_placeholder">{% if switchers_versions %}
<select class="version-select">
{% for (version_name, version_title) in switchers_versions -%}
<option value="{{ version_name }}" {%- if version_title == release %} selected="true" {%- endif %}>{{ version_title }}</option>
{% endfor -%}
</select>
{% endif %}</div>
</li>
<li>
{% if theme_root_include_title %}
Expand Down Expand Up @@ -74,6 +86,7 @@ <h3>{{ _('Navigation') }}</h3>
<link rel="shortcut icon" type="image/png" href="{{ pathto('_static/' ~ theme_root_icon, 1) }}" />
{%- if builder != "htmlhelp" %}
{%- if not embedded %}
<script type="text/javascript" src="{{ pathto('_static/switchers.js', 1) }}"></script>
<script type="text/javascript" src="{{ pathto('_static/copybutton.js', 1) }}"></script>
<script type="text/javascript" src="{{ pathto('_static/menu.js', 1) }}"></script>
<script type="text/javascript" src="{{ pathto('_static/search-focus.js', 1) }}"></script>
Expand Down
131 changes: 131 additions & 0 deletions python_docs_theme/static/switchers.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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);
}
Loading