describe_version.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /**
  2. * Match a PEP 440 version string. The full regex given in PEP 440 is not used.
  3. * This subset covers what we expect to encounter in our projects.
  4. */
  5. const versionRe = new RegExp([
  6. "^",
  7. "(?:(?<epoch>[1-9][0-9]*)!)?",
  8. "(?<version>(?:0|[1-9][0-9]*)(?:\\.(?:0|[1-9][0-9]*))*)",
  9. "(?:(?<preL>a|b|rc)(?<preN>0|[1-9][0-9]*))?",
  10. "(?:\\.post(?<postN>0|[1-9][0-9]*))?",
  11. "(?:\\.dev(?<devN>0|[1-9][0-9]*))?",
  12. "$",
  13. ].join(""))
  14. /**
  15. * Parse a PEP 440 version string into an object.
  16. *
  17. * @param {string} value
  18. * @returns {Object} parsed version information
  19. */
  20. function parseVersion(value) {
  21. let {groups: {epoch, version, preL, preN, postN, devN}} = versionRe.exec(value)
  22. return {
  23. value: value,
  24. parts: [
  25. parseInt(epoch) || 0, ...version.split(".").map(p => parseInt(p))
  26. ],
  27. isPre: Boolean(preL),
  28. preL: preL || "",
  29. preN: parseInt(preN) || 0,
  30. isPost: Boolean(postN),
  31. postN: parseInt(postN) || 0,
  32. isDev: Boolean(devN),
  33. devN: parseInt(devN) || 0,
  34. }
  35. }
  36. /**
  37. * Compare two version objects.
  38. *
  39. * @param {Object} a left side of comparison
  40. * @param {Object} b right side of comparison
  41. * @returns {number} -1 less than, 0 equal to, 1 greater than
  42. */
  43. function compareVersions(a, b) {
  44. for (let [i, an] of a.parts.entries()) {
  45. let bn = i < b.parts.length ? b.parts[i] : 0
  46. if (an < bn) {
  47. return -1
  48. } else if (an > bn) {
  49. return 1
  50. }
  51. }
  52. if (a.parts.length < b.parts.length) {
  53. return -1
  54. }
  55. return 0
  56. }
  57. /**
  58. * Get the list of released versions for the project from PyPI. Prerelease and
  59. * development versions are discarded. The list is sorted in descending order,
  60. * highest version first.
  61. *
  62. * This will be called on every page load. To avoid making excessive requests to
  63. * PyPI, the result is cached for 1 day. PyPI also sends cache headers, so a
  64. * subsequent request may still be more efficient, but it only specifies caching
  65. * the full response for 5 minutes.
  66. *
  67. * @param {string} name The normalized PyPI project name to query.
  68. * @returns {Promise<Object[]>} A sorted list of version objects.
  69. */
  70. async function getReleasedVersions(name) {
  71. // The response from PyPI is only cached for 5 minutes. Extend that to 1 day.
  72. let cacheTime = localStorage.getItem("describeVersion-time")
  73. let cacheResult = localStorage.getItem("describeVersion-result")
  74. // if there is a cached value
  75. if (cacheTime && cacheResult) {
  76. // if the cache is younger than 1 day
  77. if (Number(cacheTime) >= Date.now() - 86400000) {
  78. // Use the cached value instead of making another request.
  79. return JSON.parse(cacheResult)
  80. }
  81. }
  82. let response = await fetch(
  83. `https://pypi.org/simple/${name}/`,
  84. {"headers": {"Accept": "application/vnd.pypi.simple.v1+json"}}
  85. )
  86. let data = await response.json()
  87. let result = data["versions"]
  88. .map(parseVersion)
  89. .filter(v => !(v.isPre || v.isDev))
  90. .sort(compareVersions)
  91. .reverse()
  92. localStorage.setItem("describeVersion-time", Date.now().toString())
  93. localStorage.setItem("describeVersion-result", JSON.stringify(result))
  94. return result
  95. }
  96. /**
  97. * Get the highest released version of the project from PyPI, and compare the
  98. * version being documented. Returns a list of two values, the comparison
  99. * result and the highest version.
  100. *
  101. * @param name The normalized PyPI project name.
  102. * @param value The version being documented.
  103. * @returns {Promise<[number, Object|null]>}
  104. */
  105. async function describeVersion(name, value) {
  106. if (value.endsWith(".x")) {
  107. value = value.slice(0, -2)
  108. }
  109. let currentVersion = parseVersion(value)
  110. let releasedVersions = await getReleasedVersions(name)
  111. if (releasedVersions.length === 0) {
  112. return [1, null]
  113. }
  114. let highestVersion = releasedVersions[0]
  115. let compared = compareVersions(currentVersion, highestVersion)
  116. if (compared === 1) {
  117. return [1, highestVersion]
  118. }
  119. // If the current version including trailing zeros is a prefix of the highest
  120. // version, then these are the stable docs. For example, 2.0.x becomes 2.0,
  121. // which is a prefix of 2.0.3. If we were just looking at the compare result,
  122. // it would incorrectly be marked as an old version.
  123. if (currentVersion.parts.every((n, i) => n === highestVersion.parts[i])) {
  124. return [0, highestVersion]
  125. }
  126. return [-1, highestVersion]
  127. }
  128. /**
  129. * Compare the version being documented to the highest released version, and
  130. * display a warning banner if it is not the highest version.
  131. *
  132. * @param project The normalized PyPI project name.
  133. * @param version The version being documented.
  134. * @returns {Promise<void>}
  135. */
  136. async function createBanner(project, version) {
  137. let [compared, stable] = await describeVersion(project, version)
  138. // No banner if this is the highest version or there are no other versions.
  139. if (compared === 0 || stable === null) {
  140. return
  141. }
  142. let banner = document.createElement("p")
  143. banner.className = "version-warning"
  144. if (compared === 1) {
  145. banner.textContent = "This is the development version. The stable version is "
  146. } else if (compared === -1) {
  147. banner.textContent = "This is an old version. The current version is "
  148. }
  149. let canonical = document.querySelector('link[rel="canonical"]')
  150. if (canonical !== null) {
  151. // If a canonical URL is available, the version is a link to it.
  152. let link = document.createElement("a")
  153. link.href = canonical.href
  154. link.textContent = stable.value
  155. banner.append(link, ".")
  156. } else {
  157. // Otherwise, the version is text only.
  158. banner.append(stable.value, ".")
  159. }
  160. document.getElementsByClassName("document")[0].prepend(banner)
  161. // Set scroll-padding-top to prevent the banner from overlapping anchors.
  162. // It's also set in CSS assuming the banner text is only 1 line.
  163. let bannerStyle = window.getComputedStyle(banner)
  164. let bannerMarginTop = parseFloat(bannerStyle["margin-top"])
  165. let bannerMarginBottom = parseFloat(bannerStyle["margin-bottom"])
  166. let height = banner.offsetHeight + bannerMarginTop + bannerMarginBottom
  167. document.documentElement.style["scroll-padding-top"] = `${height}px`
  168. }
  169. (() => {
  170. // currentScript is only available during init, not during callbacks.
  171. let {project, version} = document.currentScript.dataset
  172. document.addEventListener("DOMContentLoaded", async () => {
  173. await createBanner(project, version)
  174. })
  175. })()