Skip to content

Commit

Permalink
address Read the Docs changes (#117)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism authored Oct 15, 2024
2 parents c751949 + fa2ccb2 commit a8338e5
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 234 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ Version 2.2.0

Unreleased

- Get canonical URL from environment variable when building on Read the Docs.
:pr:`117`
- New version warning banner. Use JavaScript to query PyPI when viewing a
page, rather than baking the warning into the build. New builds of old
versions are no longer required for the banner to be correct. :pr:`117`


Version 2.1.3
-------------
Expand Down
34 changes: 32 additions & 2 deletions src/pallets_sphinx_themes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import textwrap
from collections import namedtuple
from importlib import metadata as importlib_metadata
from urllib.parse import urlsplit

from sphinx.application import Sphinx
from sphinx.builders._epub_base import EpubBuilder
Expand All @@ -14,7 +15,6 @@

from .theme_check import only_pallets_theme
from .theme_check import set_is_pallets_theme
from .versions import load_versions


def setup(app):
Expand All @@ -29,7 +29,8 @@ def setup(app):
app.add_config_value("is_pallets_theme", None, "html")

app.connect("builder-inited", set_is_pallets_theme)
app.connect("builder-inited", load_versions)
app.connect("builder-inited", find_base_canonical_url)
app.connect("builder-inited", add_theme_files)
app.connect("html-collect-pages", add_404_page)
app.connect("html-page-context", canonical_url)

Expand Down Expand Up @@ -68,6 +69,35 @@ def add_404_page(app):
yield ("404", {}, "404.html")


@only_pallets_theme()
def find_base_canonical_url(app: Sphinx) -> None:
"""When building on Read the Docs, build the base canonical URL from the
environment variable if it's not given in the config. Read the Docs has a
special `/page/<path>` rule that redirects any path to the current version
of the docs, so that's used as the canonical link.
"""
if app.config.html_baseurl:
return

if "READTHEDOCS_CANONICAL_URL" in os.environ:
parts = urlsplit(os.environ["READTHEDOCS_CANONICAL_URL"])
app.config.html_baseurl = f"{parts.scheme}://{parts.netloc}/page/"


@only_pallets_theme()
def add_theme_files(app: Sphinx) -> None:
# Add the JavaScript for the version warning banner. Include the project and
# version as data attributes that the script will access. The project name
# is assumed to be the PyPI name, and is normalized to avoid a redirect.
app.add_js_file(
"describe_version.js",
**{
"data-project": re.sub(r"[-_.]+", "-", app.config.project).lower(),
"data-version": app.config.version,
},
)


@only_pallets_theme()
def canonical_url(app: Sphinx, pagename, templatename, context, doctree):
"""Sphinx 1.8 builds a canonical URL if ``html_baseurl`` config is
Expand Down
14 changes: 0 additions & 14 deletions src/pallets_sphinx_themes/themes/pocoo/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,9 @@
{% endif %}
{% endblock %}

{% set version_warning = current_version.banner() if current_version %}

{% block document %}
{%- if version_warning %}
<p class="version-warning"><strong>Warning:</strong> {{ version_warning }}</p>
{%- endif %}
{{- super() }}
{%- endblock %}

{% block relbar2 %}{% endblock %}

{% block sidebar2 %}
<span id="sidebar-top"></span>
{{- super() }}
{%- endblock %}

{% block footer %}
{{ super() }}
{{ js_tag("_static/version_warning_offset.js") }}
{% endblock %}
189 changes: 189 additions & 0 deletions src/pallets_sphinx_themes/themes/pocoo/static/describe_version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Match a PEP 440 version string. The full regex given in PEP 440 is not used.
* This subset covers what we expect to encounter in our projects.
*/
const versionRe = new RegExp([
"^",
"(?:(?<epoch>[1-9][0-9]*)!)?",
"(?<version>(?:0|[1-9][0-9]*)(?:\\.(?:0|[1-9][0-9]*))*)",
"(?:(?<preL>a|b|rc)(?<preN>0|[1-9][0-9]*))?",
"(?:\\.post(?<postN>0|[1-9][0-9]*))?",
"(?:\\.dev(?<devN>0|[1-9][0-9]*))?",
"$",
].join(""))

/**
* Parse a PEP 440 version string into an object.
*
* @param {string} value
* @returns {Object} parsed version information
*/
function parseVersion(value) {
let {groups: {epoch, version, preL, preN, postN, devN}} = versionRe.exec(value)
return {
value: value,
parts: [
parseInt(epoch) || 0, ...version.split(".").map(p => parseInt(p))
],
isPre: Boolean(preL),
preL: preL || "",
preN: parseInt(preN) || 0,
isPost: Boolean(postN),
postN: parseInt(postN) || 0,
isDev: Boolean(devN),
devN: parseInt(devN) || 0,
}
}

/**
* Compare two version objects.
*
* @param {Object} a left side of comparison
* @param {Object} b right side of comparison
* @returns {number} -1 less than, 0 equal to, 1 greater than
*/
function compareVersions(a, b) {
for (let [i, an] of a.parts.entries()) {
let bn = i < b.parts.length ? b.parts[i] : 0

if (an < bn) {
return -1
} else if (an > bn) {
return 1
}
}

if (a.parts.length < b.parts.length) {
return -1
}

return 0
}

/**
* Get the list of released versions for the project from PyPI. Prerelease and
* development versions are discarded. The list is sorted in descending order,
* latest version first.
*
* This will be called on every page load. To avoid making excessive requests to
* PyPI, the result is cached for 1 day. PyPI also sends cache headers, so a
* subsequent request may still be more efficient, but it only specifies caching
* the full response for 5 minutes.
*
* @param {string} name The normalized PyPI project name to query.
* @returns {Promise<Object[]>} A sorted list of version objects.
*/
async function getReleasedVersions(name) {
// The response from PyPI is only cached for 5 minutes. Extend that to 1 day.
let cacheTime = localStorage.getItem("describeVersion-time")
let cacheResult = localStorage.getItem("describeVersion-result")

// if there is a cached value
if (cacheTime && cacheResult) {
// if the cache is younger than 1 day
if (Number(cacheTime) >= Date.now() - 86400000) {
// Use the cached value instead of making another request.
return JSON.parse(cacheResult)
}
}

let response = await fetch(
`https://pypi.org/simple/${name}/`,
{"headers": {"Accept": "application/vnd.pypi.simple.v1+json"}}
)
let data = await response.json()
let result = data["versions"]
.map(parseVersion)
.filter(v => !(v.isPre || v.isDev))
.sort(compareVersions)
.reverse()
localStorage.setItem("describeVersion-time", Date.now().toString())
localStorage.setItem("describeVersion-result", JSON.stringify(result))
return result
}

/**
* Get the latest released version of the project from PyPI, and compare the
* version being documented. Return the
*
* @param name The normalized PyPI project name.
* @param value The version being documented.
* @returns {Promise<[number, Object|null]>}
*/
async function describeVersion(name, value) {
if (value.endsWith(".x")) {
value = value.slice(0, -2)
}

let currentVersion = parseVersion(value)
let releasedVersions = await getReleasedVersions(name)

if (releasedVersions.length === 0) {
return [1, null]
}

let latestVersion = releasedVersions[0]
let compared = compareVersions(currentVersion, latestVersion)

if (compared === 1) {
return [1, latestVersion]
}

// If the current version including trailing zeros is a prefix of the latest
// version, then these are the latest docs. For example, 2.0.x becomes 2.0,
// which is a prefix of 2.0.3. If we were just looking at the compare result,
// it would incorrectly be marked as an old version.
if (currentVersion.parts.every((n, i) => n === latestVersion.parts[i])) {
return [0, latestVersion]
}

return [-1, latestVersion]
}

/**
* Compare the version being documented to the latest version, and display a
* warning banner if it is not the latest version.
*
* @param project The normalized PyPI project name.
* @param version The version being documented.
* @returns {Promise<void>}
*/
async function createBanner(project, version) {
let [desc, latest] = await describeVersion(project, version)

// No banner if this is the latest version or there are no other versions.
if (desc === 0 || latest === null) {
return
}

let banner = document.createElement("p")
banner.className = "version-warning"

if (desc === 1) {
banner.textContent = "This is the development version. The stable version is "
} else if (desc === -1) {
banner.textContent = "This is an old version. The current version is "
}

let link = document.createElement("a")
link.href = document.querySelector('link[rel="canonical"]').href
link.textContent = latest.value
banner.append(link, ".")
document.getElementsByClassName("document")[0].prepend(banner)

// Set scroll-padding-top to prevent the banner from overlapping anchors.
// It's also set in CSS assuming the banner text is only 1 line.
let bannerStyle = window.getComputedStyle(banner)
let bannerMarginTop = parseFloat(bannerStyle["margin-top"])
let bannerMarginBottom = parseFloat(bannerStyle["margin-bottom"])
let height = banner.offsetHeight + bannerMarginTop + bannerMarginBottom
document.documentElement.style["scroll-padding-top"] = `${height}px`
}

(() => {
// currentScript is only available during init, not during callbacks.
let {project, version} = document.currentScript.dataset
document.addEventListener("DOMContentLoaded", async () => {
await createBanner(project, version)
})
})()
24 changes: 7 additions & 17 deletions src/pallets_sphinx_themes/themes/pocoo/static/pocoo.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

/* -- page layout --------------------------------------------------- */

html {
scroll-padding-top: calc(30px + 1em);
/* see .version-warning selector for banner positioning */
}

body {
font-family: 'Garamond', 'Georgia', serif;
font-size: 17px;
Expand Down Expand Up @@ -136,20 +141,10 @@ div.sphinxsidebar #searchbox input[type=submit] {
border-left-width: 0;
}

/* -- versions ------------------------------------------------------ */

div.sphinxsidebar ul.versions a.current {
font-style: italic;
border-bottom: 1px solid #000;
color: #000;
}

div.sphinxsidebar ul.versions span.note {
color: #999;
}

/* -- version warning ----------------------------------------------- */

/* see html selector for scroll offset */

p.version-warning {
top: 10px;
position: sticky;
Expand Down Expand Up @@ -469,11 +464,6 @@ table.footnote td {
display: none;
}

div.sphinxsidebar ul.versions a.current {
border-bottom-color: #fff;
color: #fff;
}

div.footer {
text-align: left;
margin: 0 -20px;
Expand Down

This file was deleted.

8 changes: 0 additions & 8 deletions src/pallets_sphinx_themes/themes/pocoo/versions.html

This file was deleted.

Loading

0 comments on commit a8338e5

Please sign in to comment.