GithubMagicLink.vue 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. <script setup lang="ts">
  2. import { computedAsync } from '@vueuse/core'
  3. interface GitHubURLInfo {
  4. type: 'permalink' | 'issue' | 'pr' | 'repo' | 'wiki' | 'unknown' // Only declare 'type' once, including 'wiki'
  5. owner: string
  6. repo: string
  7. path?: string
  8. lines?: string
  9. number?: string
  10. title?: string
  11. page?: string // For wiki page name
  12. }
  13. const props = defineProps<{
  14. href: string
  15. target: string
  16. }>()
  17. // Parse GitHub URL to determine type and extract information
  18. const parseGitHubURL = (url: string): GitHubURLInfo => {
  19. try {
  20. const urlObj = new URL(url)
  21. if (urlObj.hostname !== 'github.com') {
  22. return { type: 'unknown', owner: '', repo: '' }
  23. }
  24. const pathParts = urlObj.pathname.split('/').filter(Boolean)
  25. // Repo root: https://github.com/owner/repo
  26. if (pathParts.length === 2) {
  27. return { type: 'repo', owner: pathParts[0] || '', repo: pathParts[1] || '' }
  28. }
  29. if (pathParts.length < 2) {
  30. return { type: 'unknown', owner: '', repo: '' }
  31. }
  32. const owner = pathParts[0] || ''
  33. const repo = pathParts[1] || ''
  34. // Check for issue URL
  35. if (pathParts[2] === 'issues' && pathParts[3]) {
  36. return {
  37. type: 'issue',
  38. owner,
  39. repo,
  40. number: pathParts[3]!,
  41. }
  42. }
  43. // Check for PR URL
  44. if (pathParts[2] === 'pull' && pathParts[3]) {
  45. return {
  46. type: 'pr',
  47. owner,
  48. repo,
  49. number: pathParts[3]!,
  50. }
  51. }
  52. // Check for wiki URL
  53. if (pathParts[2] === 'wiki' && pathParts[3]) {
  54. // Handles links like https://github.com/owner/repo/wiki/Page-Name
  55. return {
  56. type: 'wiki',
  57. owner,
  58. repo,
  59. page: pathParts[3],
  60. }
  61. }
  62. // Check for permalink URL (blob/commit/tree)
  63. if (pathParts[2] === 'blob' && pathParts.length > 4) {
  64. const filePath = pathParts.slice(4).join('/')
  65. const hashLines = urlObj.hash ? urlObj.hash.substring(1) : ''
  66. return {
  67. type: 'permalink',
  68. owner,
  69. repo,
  70. path: filePath,
  71. lines: hashLines || undefined,
  72. }
  73. }
  74. return { type: 'unknown', owner, repo }
  75. }
  76. catch {
  77. return { type: 'unknown', owner: '', repo: '' }
  78. }
  79. }
  80. // Fetch issue or PR title from GitHub API
  81. const fetchGitHubTitle = async (owner: string, repo: string, type: 'issue' | 'pr', number: string): Promise<string> => {
  82. try {
  83. const endpoint = type === 'issue' ? 'issues' : 'pulls'
  84. const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/${endpoint}/${number}`)
  85. if (!response.ok) {
  86. throw new Error(`GitHub API error: ${response.status}`)
  87. }
  88. const data = await response.json()
  89. return data.title || `${type === 'issue' ? 'Issue' : 'PR'} #${number}`
  90. }
  91. catch (error) {
  92. console.warn('Failed to fetch GitHub title:', error)
  93. return `${type === 'issue' ? 'Issue' : 'PR'} #${number}`
  94. }
  95. }
  96. // Parse URL and get display information
  97. const urlInfo = computed(() => parseGitHubURL(props.href))
  98. const displayTitle = computedAsync(async () => {
  99. if (urlInfo.value.type === 'permalink') {
  100. return `${urlInfo.value.repo}/${urlInfo.value.path}`
  101. }
  102. else if (urlInfo.value.type === 'repo') {
  103. return `${urlInfo.value.owner}/${urlInfo.value.repo}`
  104. }
  105. else if (urlInfo.value.type === 'issue' || urlInfo.value.type === 'pr') {
  106. return await fetchGitHubTitle(urlInfo.value.owner, urlInfo.value.repo, urlInfo.value.type, urlInfo.value.number!)
  107. }
  108. else if (urlInfo.value.type === 'wiki') {
  109. // For wiki, use the page slug (replace dashes with spaces for readability)
  110. // Optionally, you could fetch the actual page title from the HTML, but this is a simple fallback
  111. return urlInfo.value.page ? urlInfo.value.page.replace(/-/g, ' ') : 'Wiki Page'
  112. }
  113. else {
  114. return props.href
  115. }
  116. })
  117. const suffix = computed(() => {
  118. if (urlInfo.value.type === 'permalink') {
  119. return urlInfo.value.lines ? `#${urlInfo.value.lines}` : ''
  120. }
  121. else if (urlInfo.value.type === 'issue' || urlInfo.value.type === 'pr') {
  122. return `#${urlInfo.value.number}`
  123. }
  124. else if (urlInfo.value.type === 'wiki') {
  125. // For wiki links, always show 'wiki' as the suffix
  126. return 'wiki'
  127. }
  128. else {
  129. return ''
  130. }
  131. })
  132. const icon = computed(() => {
  133. if (urlInfo.value.type === 'issue') {
  134. return 'octicon:issue-opened-16'
  135. }
  136. else if (urlInfo.value.type === 'pr') {
  137. return 'octicon:git-pull-request-16'
  138. }
  139. else {
  140. return 'i-simple-icons-github'
  141. }
  142. })
  143. </script>
  144. <template>
  145. <a
  146. v-if="urlInfo.type !== 'unknown'"
  147. :href="props.href"
  148. :target="props.target"
  149. class="inline-flex translate-y-0.5 items-center gap-1 bg-gray-100 hover:bg-gray-200 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 rounded-md text-xs text-muted no-underline transition-colors"
  150. >
  151. <span class="flex items-center gap-1 px-1 py-0.5">
  152. <UIcon
  153. v-if="icon"
  154. :name="icon"
  155. />
  156. {{ displayTitle }}
  157. </span>
  158. <span
  159. v-if="suffix"
  160. class="bg-gray-300 dark:bg-gray-700 px-1 py-0.5 rounded-r-md"
  161. >
  162. {{ suffix }}
  163. </span>
  164. </a>
  165. <NuxtLink
  166. v-else
  167. class="text-primary border-b border-transparent hover:border-primary font-medium focus-visible:outline-primary [&>code]:border-dashed hover:[&>code]:border-primary hover:[&>code]:text-primary"
  168. :href="props.href"
  169. :target="props.target"
  170. >
  171. <slot></slot>
  172. </NuxtLink>
  173. </template>