summaryrefslogtreecommitdiff
path: root/_static/describe_version.js
diff options
context:
space:
mode:
Diffstat (limited to '_static/describe_version.js')
-rw-r--r--_static/describe_version.js198
1 files changed, 198 insertions, 0 deletions
diff --git a/_static/describe_version.js b/_static/describe_version.js
new file mode 100644
index 000000000..19cd8d41e
--- /dev/null
+++ b/_static/describe_version.js
@@ -0,0 +1,198 @@
+/**
+ * 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,
+ * highest 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 highest released version of the project from PyPI, and compare the
+ * version being documented. Returns a list of two values, the comparison
+ * result and the highest version.
+ *
+ * @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 highestVersion = releasedVersions[0]
+ let compared = compareVersions(currentVersion, highestVersion)
+
+ if (compared === 1) {
+ return [1, highestVersion]
+ }
+
+ // If the current version including trailing zeros is a prefix of the highest
+ // version, then these are the stable 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 === highestVersion.parts[i])) {
+ return [0, highestVersion]
+ }
+
+ return [-1, highestVersion]
+}
+
+/**
+ * Compare the version being documented to the highest released version, and
+ * display a warning banner if it is not the highest version.
+ *
+ * @param project The normalized PyPI project name.
+ * @param version The version being documented.
+ * @returns {Promise<void>}
+ */
+async function createBanner(project, version) {
+ let [compared, stable] = await describeVersion(project, version)
+
+ // No banner if this is the highest version or there are no other versions.
+ if (compared === 0 || stable === null) {
+ return
+ }
+
+ let banner = document.createElement("p")
+ banner.className = "version-warning"
+
+ if (compared === 1) {
+ banner.textContent = "This is the development version. The stable version is "
+ } else if (compared === -1) {
+ banner.textContent = "This is an old version. The current version is "
+ }
+
+ let canonical = document.querySelector('link[rel="canonical"]')
+
+ if (canonical !== null) {
+ // If a canonical URL is available, the version is a link to it.
+ let link = document.createElement("a")
+ link.href = canonical.href
+ link.textContent = stable.value
+ banner.append(link, ".")
+ } else {
+ // Otherwise, the version is text only.
+ banner.append(stable.value, ".")
+ }
+
+ 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)
+ })
+})()