Astro Docs Preview Links

Adds preview links of tracked files in GitHub pull requests to the Astro and Starlight documentation.

  1. // ==UserScript==
  2. // @name Astro Docs Preview Links
  3. // @version 0.1.2
  4. // @namespace https://hideoo.dev/
  5. // @description Adds preview links of tracked files in GitHub pull requests to the Astro and Starlight documentation.
  6. // @tag productivity
  7. // @license MIT
  8. // @author HiDeoo (https://github.com/hideoo)
  9. // @homepageURL https://github.com/HiDeoo/userscript-astro-docs-preview-links
  10. // @supportURL https://github.com/HiDeoo/userscript-astro-docs-preview-links/issues
  11. // @iconURL https://raw.githubusercontent.com/primer/octicons/refs/heads/main/icons/link-24.svg
  12. //
  13. // @match https://github.com/*
  14. // @run-at document-end
  15. // ==/UserScript==
  16.  
  17. ;(function () {
  18. 'use strict'
  19.  
  20. const docsPullRequestRegex = /^https:\/\/github\.com\/withastro\/(?:docs|starlight)\/pull\/\d+\/?$/
  21. const validExtensionsRegex = /\.mdx?$/
  22.  
  23. /**
  24. * @param {Element[]} comments
  25. * @param {string} author
  26. * @returns {Element[]}
  27. */
  28. function getCommentsFromAuthor(comments, author) {
  29. return comments.filter((comment) =>
  30. isElementTextEqual(comment.querySelector('.timeline-comment-header .author'), author),
  31. )
  32. }
  33.  
  34. /**
  35. * @param {Element | null} element
  36. * @param {string} text
  37. * @returns {boolean}
  38. */
  39. function isElementTextEqual(element, text) {
  40. return element instanceof HTMLElement && element.innerText === text
  41. }
  42.  
  43. /**
  44. * @param {string} path
  45. * @returns {string}
  46. */
  47. function stripExtension(path) {
  48. const periodIndex = path.lastIndexOf('.')
  49. return path.slice(0, periodIndex > -1 ? periodIndex : undefined)
  50. }
  51.  
  52. /**
  53. * @param {string} locale
  54. * @returns {boolean}
  55. */
  56. function isRootLocale(locale) {
  57. return location.href.startsWith('https://github.com/withastro/starlight/pull/') && locale === 'en'
  58. }
  59.  
  60. /**
  61. * @param {string} url
  62. * @returns {boolean}
  63. */
  64. function isDocsPullRequestPage(url) {
  65. return docsPullRequestRegex.test(url)
  66. }
  67.  
  68. /**
  69. * @returns {boolean}
  70. */
  71. function addLinks() {
  72. const comments = [...document.querySelectorAll('.pull-discussion-timeline .timeline-comment')]
  73.  
  74. const deployComment = getCommentsFromAuthor(comments, 'netlify').find((comment) => {
  75. const title = comment.querySelector('.comment-body > h3:first-child')
  76. return title instanceof HTMLElement && title.innerText.includes('Deploy Preview for')
  77. })
  78. if (!deployComment) return false
  79.  
  80. const deployPreviewRow = [...deployComment.querySelectorAll('.comment-body td')].find((cell) =>
  81. isElementTextEqual(cell, '😎 Deploy Preview'),
  82. )?.parentElement
  83. if (!deployPreviewRow) return false
  84.  
  85. const deployPreviewUrl = deployPreviewRow.querySelector('a')?.href
  86. if (!deployPreviewUrl) return false
  87.  
  88. const lunariaComment = getCommentsFromAuthor(comments, 'astrobot-houston').find((comment) =>
  89. isElementTextEqual(comment.querySelector('.comment-body > h2:first-child'), 'Lunaria Status Overview'),
  90. )
  91. if (!lunariaComment) return false
  92.  
  93. const trackedFileTable = lunariaComment.querySelector('.comment-body > h3 ~ markdown-accessiblity-table')
  94. if (!trackedFileTable) return true
  95. const trackedFilesRows = [...trackedFileTable.querySelectorAll('table > tbody > tr')]
  96.  
  97. /** @type {Set<string>} */
  98. const trackedFiles = new Set()
  99.  
  100. for (const row of trackedFilesRows) {
  101. const [locale, path] = [...row.querySelectorAll('td')].map((cell) => cell.innerText)
  102. if (!locale || !path || !validExtensionsRegex.test(path)) continue
  103. trackedFiles.add(`${isRootLocale(locale) ? '' : `${locale}/`}${stripExtension(path)}/`)
  104. }
  105.  
  106. if (trackedFiles.size === 0) return true
  107.  
  108. const linksRow = document.createElement('tr')
  109.  
  110. const linksTitleCell = document.createElement('td')
  111. linksTitleCell.setAttribute('align', 'center')
  112. linksTitleCell.setAttribute('style', 'vertical-align: top;')
  113. linksTitleCell.innerText = '⚡ Tracked links'
  114.  
  115. const linksContentCell = document.createElement('td')
  116. linksContentCell.append(
  117. ...[...trackedFiles].flatMap((pathname, index) => {
  118. if (pathname.endsWith('index/')) pathname = pathname.replace(/index\/$/, '')
  119.  
  120. const link = document.createElement('a')
  121. link.href = deployPreviewUrl + pathname
  122. link.innerText = `/${pathname}`
  123.  
  124. return index < trackedFiles.size - 1 ? [link, document.createElement('br')] : link
  125. }),
  126. )
  127.  
  128. linksRow.append(linksTitleCell, linksContentCell)
  129. deployPreviewRow.after(linksRow)
  130.  
  131. return true
  132. }
  133.  
  134. function handlePagination() {
  135. const paginationButton = /** @type {HTMLButtonElement?} */ (
  136. document.querySelector('.ajax-pagination-form button.ajax-pagination-btn')
  137. )
  138. if (!paginationButton) return
  139.  
  140. paginationButton.form?.addEventListener(
  141. 'click',
  142. () => paginationButton.form?.addEventListener('page:loaded', run, { once: true }),
  143. { once: true },
  144. )
  145. }
  146.  
  147. function run() {
  148. if (isDocsPullRequestPage(location.href)) {
  149. const didAddLinks = addLinks()
  150. if (!didAddLinks) handlePagination()
  151. }
  152. }
  153.  
  154. run()
  155.  
  156. document.addEventListener('turbo:render', run)
  157. })()