[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add radicle + forgejo, fix tangled meta, ensure meta is fetched on ssr (#154)

authored by

Daniel Roe and committed by
GitHub
465ce2c7 f09077a3

+1044 -184
+98
app/composables/useInstallCommand.ts
··· 1 + import type { JsrPackageInfo } from '#shared/types/jsr' 2 + 3 + /** 4 + * Composable for generating install commands with support for 5 + * multiple package managers, @types packages, and JSR. 6 + */ 7 + export function useInstallCommand( 8 + packageName: MaybeRefOrGetter<string | null>, 9 + requestedVersion: MaybeRefOrGetter<string | null>, 10 + jsrInfo: MaybeRefOrGetter<JsrPackageInfo | null>, 11 + typesPackageName: MaybeRefOrGetter<string | null>, 12 + ) { 13 + const selectedPM = useSelectedPackageManager() 14 + const { settings } = useSettings() 15 + 16 + // Check if we should show @types in install command 17 + const showTypesInInstall = computed(() => { 18 + return settings.value.includeTypesInInstall && !!toValue(typesPackageName) 19 + }) 20 + 21 + const installCommandParts = computed(() => { 22 + const name = toValue(packageName) 23 + if (!name) return [] 24 + return getInstallCommandParts({ 25 + packageName: name, 26 + packageManager: selectedPM.value, 27 + version: toValue(requestedVersion), 28 + jsrInfo: toValue(jsrInfo), 29 + }) 30 + }) 31 + 32 + const installCommand = computed(() => { 33 + const name = toValue(packageName) 34 + if (!name) return '' 35 + return getInstallCommand({ 36 + packageName: name, 37 + packageManager: selectedPM.value, 38 + version: toValue(requestedVersion), 39 + jsrInfo: toValue(jsrInfo), 40 + }) 41 + }) 42 + 43 + // Get the dev dependency flag for the selected package manager 44 + const devFlag = computed(() => { 45 + // bun uses lowercase -d, all others use -D 46 + return selectedPM.value === 'bun' ? '-d' : '-D' 47 + }) 48 + 49 + // @types install command parts (for display) 50 + const typesInstallCommandParts = computed(() => { 51 + const types = toValue(typesPackageName) 52 + if (!types) return [] 53 + const pm = packageManagers.find(p => p.id === selectedPM.value) 54 + if (!pm) return [] 55 + 56 + const pkgSpec = selectedPM.value === 'deno' ? `npm:${types}` : types 57 + 58 + return [pm.label, pm.action, devFlag.value, pkgSpec] 59 + }) 60 + 61 + // Full install command including @types (for copying) 62 + const fullInstallCommand = computed(() => { 63 + if (!installCommand.value) return '' 64 + const types = toValue(typesPackageName) 65 + if (!showTypesInInstall.value || !types) { 66 + return installCommand.value 67 + } 68 + 69 + const pm = packageManagers.find(p => p.id === selectedPM.value) 70 + if (!pm) return installCommand.value 71 + 72 + const pkgSpec = selectedPM.value === 'deno' ? `npm:${types}` : types 73 + 74 + // Use semicolon to separate commands 75 + return `${installCommand.value}; ${pm.label} ${pm.action} ${devFlag.value} ${pkgSpec}` 76 + }) 77 + 78 + // Copy state 79 + const copied = ref(false) 80 + 81 + async function copyInstallCommand() { 82 + if (!fullInstallCommand.value) return 83 + await navigator.clipboard.writeText(fullInstallCommand.value) 84 + copied.value = true 85 + setTimeout(() => (copied.value = false), 2000) 86 + } 87 + 88 + return { 89 + selectedPM, 90 + installCommandParts, 91 + installCommand, 92 + typesInstallCommandParts, 93 + fullInstallCommand, 94 + showTypesInInstall, 95 + copied, 96 + copyInstallCommand, 97 + } 98 + }
+22 -28
app/composables/useNpmRegistry.ts
··· 114 114 ) { 115 115 const cachedFetch = useCachedFetch() 116 116 117 - const asyncData = useLazyAsyncData( 117 + return useLazyAsyncData( 118 118 () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 119 119 async () => { 120 120 const encodedName = encodePackageName(toValue(name)) 121 - const pkg = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 122 - return transformPackument(pkg, toValue(requestedVersion)) 121 + const r = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 122 + const reqVer = toValue(requestedVersion) 123 + const pkg = transformPackument(r, reqVer) 124 + const resolvedVersion = getResolvedVersion(pkg, reqVer) 125 + return { ...pkg, resolvedVersion } 123 126 }, 124 127 ) 128 + } 125 129 126 - // Resolve requestedVersion to an exact version 127 - // Handles: exact versions, dist-tags (latest, next), and semver ranges (^4.2, >=1.0.0) 128 - const resolvedVersion = computed(() => { 129 - const pkg = asyncData.data.value 130 - const reqVer = toValue(requestedVersion) 131 - if (!pkg || !reqVer) return null 132 - 133 - // 1. Check if it's already an exact version in pkg.versions 134 - if (isExactVersion(reqVer) && pkg.versions[reqVer]) { 135 - return reqVer 136 - } 137 - 138 - // 2. Check if it's a dist-tag (latest, next, beta, etc.) 139 - const tagVersion = pkg['dist-tags']?.[reqVer] 140 - if (tagVersion) { 141 - return tagVersion 142 - } 130 + function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null { 131 + if (!pkg || !reqVer) return null 143 132 144 - // 3. Try to resolve as a semver range 145 - const versions = Object.keys(pkg.versions) 146 - const resolved = maxSatisfying(versions, reqVer) 147 - return resolved 148 - }) 133 + // 1. Check if it's already an exact version in pkg.versions 134 + if (isExactVersion(reqVer) && pkg.versions[reqVer]) { 135 + return reqVer 136 + } 149 137 150 - return { 151 - ...asyncData, 152 - resolvedVersion, 138 + // 2. Check if it's a dist-tag (latest, next, beta, etc.) 139 + const tagVersion = pkg['dist-tags']?.[reqVer] 140 + if (tagVersion) { 141 + return tagVersion 153 142 } 143 + 144 + // 3. Try to resolve as a semver range 145 + const versions = Object.keys(pkg.versions) 146 + const resolved = maxSatisfying(versions, reqVer) 147 + return resolved 154 148 } 155 149 156 150 export function usePackageDownloads(
+60
app/composables/usePackageRoute.ts
··· 1 + /** 2 + * Parse package name and optional version from the route URL. 3 + * 4 + * Supported patterns: 5 + * /nuxt → packageName: "nuxt", requestedVersion: null 6 + * /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" 7 + * /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null 8 + * /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 9 + * /axios@1.13.3 → packageName: "axios", requestedVersion: "1.13.3" 10 + * /@nuxt/kit@1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 11 + */ 12 + export function usePackageRoute() { 13 + const route = useRoute('package') 14 + 15 + const parsedRoute = computed(() => { 16 + const segments = route.params.package || [] 17 + 18 + // Find the /v/ separator for version 19 + const vIndex = segments.indexOf('v') 20 + if (vIndex !== -1 && vIndex < segments.length - 1) { 21 + return { 22 + packageName: segments.slice(0, vIndex).join('/'), 23 + requestedVersion: segments.slice(vIndex + 1).join('/'), 24 + } 25 + } 26 + 27 + // Parse @ versioned package 28 + const fullPath = segments.join('/') 29 + const versionMatch = fullPath.match(/^(@[^/]+\/[^/]+|[^/]+)@([^/]+)$/) 30 + if (versionMatch) { 31 + const [, packageName, requestedVersion] = versionMatch as [string, string, string] 32 + return { 33 + packageName, 34 + requestedVersion, 35 + } 36 + } 37 + 38 + return { 39 + packageName: fullPath, 40 + requestedVersion: null as string | null, 41 + } 42 + }) 43 + 44 + const packageName = computed(() => parsedRoute.value.packageName) 45 + const requestedVersion = computed(() => parsedRoute.value.requestedVersion) 46 + 47 + // Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") 48 + const orgName = computed(() => { 49 + const name = packageName.value 50 + if (!name.startsWith('@')) return null 51 + const match = name.match(/^@([^/]+)\//) 52 + return match ? match[1] : null 53 + }) 54 + 55 + return { 56 + packageName, 57 + requestedVersion, 58 + orgName, 59 + } 60 + }
+160 -7
app/composables/useRepoMeta.ts
··· 73 73 watchers_count?: number 74 74 } 75 75 76 + /** Radicle API response for project details */ 77 + type RadicleProjectResponse = { 78 + id: string 79 + name: string 80 + description?: string 81 + defaultBranch?: string 82 + head?: string 83 + seeding?: number 84 + delegates?: Array<{ id: string; alias?: string }> 85 + patches?: { open: number; draft: number; archived: number; merged: number } 86 + issues?: { open: number; closed: number } 87 + } 88 + 76 89 type ProviderAdapter = { 77 90 id: ProviderId 78 91 parse(url: URL): RepoRef | null ··· 538 551 }, 539 552 540 553 links(ref) { 541 - const base = `https://tangled.sh/${ref.owner}/${ref.repo}` 554 + const base = `https://tangled.org/${ref.owner}/${ref.repo}` 542 555 return { 543 556 repo: base, 544 557 stars: base, // Tangled shows stars on the repo page ··· 546 559 } 547 560 }, 548 561 549 - async fetchMeta(_cachedFetch, _ref, links) { 550 - // Tangled doesn't have a public API for repo stats yet 551 - // Just return basic info without fetching 562 + async fetchMeta(cachedFetch, ref, links) { 563 + // Tangled doesn't have a public JSON API, but we can scrape the star count 564 + // from the HTML page (it's in the hx-post URL as countHint=N) 565 + try { 566 + const html = await cachedFetch<string>( 567 + `https://tangled.org/${ref.owner}/${ref.repo}`, 568 + { headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' } }, 569 + REPO_META_TTL, 570 + ) 571 + // Extract star count from: hx-post="/star?subject=...&countHint=23" 572 + const starMatch = html.match(/countHint=(\d+)/) 573 + const stars = starMatch?.[1] ? parseInt(starMatch[1], 10) : 0 574 + 575 + return { 576 + provider: 'tangled', 577 + url: links.repo, 578 + stars, 579 + forks: 0, // Tangled doesn't expose fork count 580 + links, 581 + } 582 + } catch { 583 + return { 584 + provider: 'tangled', 585 + url: links.repo, 586 + stars: 0, 587 + forks: 0, 588 + links, 589 + } 590 + } 591 + }, 592 + } 593 + 594 + const radicleAdapter: ProviderAdapter = { 595 + id: 'radicle', 596 + 597 + parse(url) { 598 + const host = url.hostname.toLowerCase() 599 + if (host !== 'radicle.at' && host !== 'app.radicle.at' && host !== 'seed.radicle.at') { 600 + return null 601 + } 602 + 603 + // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT 604 + const path = url.pathname 605 + const radMatch = path.match(/rad:[a-zA-Z0-9]+/) 606 + if (!radMatch?.[0]) return null 607 + 608 + // Use empty owner, store full rad: ID as repo 609 + return { provider: 'radicle', owner: '', repo: radMatch[0], host } 610 + }, 611 + 612 + links(ref) { 613 + const base = `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}` 552 614 return { 553 - provider: 'tangled', 615 + repo: base, 616 + stars: base, // Radicle doesn't have stars, shows seeding count 617 + forks: base, 618 + } 619 + }, 620 + 621 + async fetchMeta(cachedFetch, ref, links) { 622 + let res: RadicleProjectResponse | null = null 623 + try { 624 + res = await cachedFetch<RadicleProjectResponse>( 625 + `https://seed.radicle.at/api/v1/projects/${ref.repo}`, 626 + { headers: { 'User-Agent': 'npmx' } }, 627 + REPO_META_TTL, 628 + ) 629 + } catch { 630 + return null 631 + } 632 + 633 + if (!res) return null 634 + 635 + return { 636 + provider: 'radicle', 554 637 url: links.repo, 555 - stars: 0, 556 - forks: 0, 638 + // Use seeding count as a proxy for "stars" (number of nodes hosting this repo) 639 + stars: res.seeding ?? 0, 640 + forks: 0, // Radicle doesn't have forks in the traditional sense 641 + description: res.description ?? null, 642 + defaultBranch: res.defaultBranch, 643 + links, 644 + } 645 + }, 646 + } 647 + 648 + const forgejoAdapter: ProviderAdapter = { 649 + id: 'forgejo', 650 + 651 + parse(url) { 652 + const host = url.hostname.toLowerCase() 653 + 654 + // Match explicit Forgejo instances 655 + const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i] 656 + const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org'] 657 + 658 + const isMatch = knownInstances.some(h => host === h) || forgejoPatterns.some(p => p.test(host)) 659 + if (!isMatch) return null 660 + 661 + const parts = url.pathname.split('/').filter(Boolean) 662 + if (parts.length < 2) return null 663 + 664 + const owner = decodeURIComponent(parts[0] ?? '').trim() 665 + const repo = decodeURIComponent(parts[1] ?? '') 666 + .trim() 667 + .replace(/\.git$/i, '') 668 + 669 + if (!owner || !repo) return null 670 + 671 + return { provider: 'forgejo', owner, repo, host } 672 + }, 673 + 674 + links(ref) { 675 + const base = `https://${ref.host}/${ref.owner}/${ref.repo}` 676 + return { 677 + repo: base, 678 + stars: base, 679 + forks: `${base}/forks`, 680 + watchers: base, 681 + } 682 + }, 683 + 684 + async fetchMeta(cachedFetch, ref, links) { 685 + if (!ref.host) return null 686 + 687 + let res: GiteaRepoResponse | null = null 688 + try { 689 + res = await cachedFetch<GiteaRepoResponse>( 690 + `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, 691 + { headers: { 'User-Agent': 'npmx' } }, 692 + REPO_META_TTL, 693 + ) 694 + } catch { 695 + return null 696 + } 697 + 698 + if (!res) return null 699 + 700 + return { 701 + provider: 'forgejo', 702 + url: links.repo, 703 + stars: res.stars_count ?? 0, 704 + forks: res.forks_count ?? 0, 705 + watchers: res.watchers_count ?? 0, 706 + description: res.description ?? null, 707 + defaultBranch: res.default_branch, 557 708 links, 558 709 } 559 710 }, ··· 568 719 giteeAdapter, 569 720 sourcehutAdapter, 570 721 tangledAdapter, 722 + radicleAdapter, 723 + forgejoAdapter, 571 724 giteaAdapter, // Generic Gitea adapter last as fallback for self-hosted instances 572 725 ] as const 573 726
+27 -138
app/pages/[...package].vue
··· 11 11 alias: ['/package/:package(.*)*'], 12 12 }) 13 13 14 - const route = useRoute('package') 15 - 16 14 const router = useRouter() 17 15 18 - // Parse package name and optional version from URL 19 - // Patterns: 20 - // /nuxt → packageName: "nuxt", requestedVersion: null 21 - // /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" 22 - // /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null 23 - // /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 24 - // /axios@1.13.3 → packageName: "axios", requestedVersion: "1.13.3" 25 - // /@nuxt/kit@1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" 26 - const parsedRoute = computed(() => { 27 - const segments = route.params.package || [] 28 - 29 - // Find the /v/ separator for version 30 - const vIndex = segments.indexOf('v') 31 - if (vIndex !== -1 && vIndex < segments.length - 1) { 32 - return { 33 - packageName: segments.slice(0, vIndex).join('/'), 34 - requestedVersion: segments.slice(vIndex + 1).join('/'), 35 - } 36 - } 37 - 38 - // Parse @ versioned package 39 - const fullPath = segments.join('/') 40 - const versionMatch = fullPath.match(/^(@[^/]+\/[^/]+|[^/]+)@([^/]+)$/) 41 - if (versionMatch) { 42 - const [, packageName, requestedVersion] = versionMatch as [string, string, string] 43 - return { 44 - packageName, 45 - requestedVersion, 46 - } 47 - } 48 - 49 - return { 50 - packageName: fullPath, 51 - requestedVersion: null as string | null, 52 - } 53 - }) 54 - 55 - const packageName = computed(() => parsedRoute.value.packageName) 56 - const requestedVersion = computed(() => parsedRoute.value.requestedVersion) 16 + const { packageName, requestedVersion, orgName } = usePackageRoute() 57 17 58 18 if (import.meta.server) { 59 19 assertValidPackageName(packageName.value) 60 20 } 61 21 62 - // Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") 63 - const orgName = computed(() => { 64 - const name = packageName.value 65 - if (!name.startsWith('@')) return null 66 - const match = name.match(/^@([^/]+)\//) 67 - return match ? match[1] : null 68 - }) 69 - 70 - const { data: pkg, status, error, resolvedVersion } = usePackage(packageName, requestedVersion) 71 - 72 22 const { data: downloads } = usePackageDownloads(packageName, 'last-week') 73 23 74 24 // Fetch README for specific version if requested, otherwise latest ··· 113 63 ) 114 64 onMounted(() => fetchInstallSize()) 115 65 116 - const sizeTooltip = computed(() => { 117 - const chunks = [ 118 - displayVersion.value && 119 - displayVersion.value.dist.unpackedSize && 120 - `${formatBytes(displayVersion.value.dist.unpackedSize)} unpacked size (this package)`, 121 - installSize.value && 122 - installSize.value.dependencyCount && 123 - `${formatBytes(installSize.value.totalSize)} total unpacked size (including all ${installSize.value.dependencyCount} dependencies for linux-x64)`, 124 - ] 125 - return chunks.filter(Boolean).join('\n') 126 - }) 66 + const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) 67 + 68 + const { data: pkg, status, error } = await usePackage(packageName, requestedVersion) 69 + const resolvedVersion = computed(() => pkg.value?.resolvedVersion ?? null) 127 70 128 71 // Get the version to display (resolved version or latest) 129 72 const displayVersion = computed(() => { ··· 162 105 return { type: 'version' as const, message: displayVersion.value.deprecated } 163 106 }) 164 107 108 + const sizeTooltip = computed(() => { 109 + const chunks = [ 110 + displayVersion.value && 111 + displayVersion.value.dist.unpackedSize && 112 + `${formatBytes(displayVersion.value.dist.unpackedSize)} unpacked size (this package)`, 113 + installSize.value && 114 + installSize.value.dependencyCount && 115 + `${formatBytes(installSize.value.totalSize)} total unpacked size (including all ${installSize.value.dependencyCount} dependencies for linux-x64)`, 116 + ] 117 + return chunks.filter(Boolean).join('\n') 118 + }) 119 + 165 120 const hasDependencies = computed(() => { 166 121 if (!displayVersion.value) return false 167 122 const deps = displayVersion.value.dependencies ··· 193 148 bitbucket: 'i-simple-icons-bitbucket', 194 149 codeberg: 'i-simple-icons-codeberg', 195 150 gitea: 'i-simple-icons-gitea', 151 + forgejo: 'i-simple-icons-forgejo', 196 152 gitee: 'i-simple-icons-gitee', 197 153 sourcehut: 'i-simple-icons-sourcehut', 198 154 tangled: 'i-custom-tangled', 155 + radicle: 'i-carbon-network-3', // Radicle is a P2P network, using network icon 199 156 } 200 157 201 158 const repoProviderIcon = computed(() => { ··· 258 215 return !!dist.attestations 259 216 } 260 217 261 - const selectedPM = useSelectedPackageManager() 262 - const { settings } = useSettings() 263 - 264 - // Fetch package analysis for @types info 265 - const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) 266 - 267 218 // Get @types package name if available (non-deprecated) 268 219 const typesPackageName = computed(() => { 269 220 if (!packageAnalysis.value) return null ··· 272 223 return packageAnalysis.value.types.packageName 273 224 }) 274 225 275 - // Check if we should show @types in install command 276 - const showTypesInInstall = computed(() => { 277 - return settings.value.includeTypesInInstall && typesPackageName.value 278 - }) 279 - 280 - const installCommandParts = computed(() => { 281 - if (!pkg.value) return [] 282 - return getInstallCommandParts({ 283 - packageName: pkg.value.name, 284 - packageManager: selectedPM.value, 285 - version: requestedVersion.value, 286 - jsrInfo: jsrInfo.value, 287 - }) 288 - }) 289 - 290 - const installCommand = computed(() => { 291 - if (!pkg.value) return '' 292 - return getInstallCommand({ 293 - packageName: pkg.value.name, 294 - packageManager: selectedPM.value, 295 - version: requestedVersion.value, 296 - jsrInfo: jsrInfo.value, 297 - }) 298 - }) 299 - 300 - // Get the dev dependency flag for the selected package manager 301 - function getDevFlag(pmId: string): string { 302 - // bun uses lowercase -d, all others use -D 303 - return pmId === 'bun' ? '-d' : '-D' 304 - } 305 - 306 - // @types install command parts (for display) 307 - const typesInstallCommandParts = computed(() => { 308 - if (!typesPackageName.value) return [] 309 - const pm = packageManagers.find(p => p.id === selectedPM.value) 310 - if (!pm) return [] 311 - 312 - const devFlag = getDevFlag(selectedPM.value) 313 - const pkgSpec = 314 - selectedPM.value === 'deno' ? `npm:${typesPackageName.value}` : typesPackageName.value 315 - 316 - return [pm.label, pm.action, devFlag, pkgSpec] 317 - }) 318 - 319 - // Full install command including @types (for copying) 320 - const fullInstallCommand = computed(() => { 321 - if (!installCommand.value) return '' 322 - if (!showTypesInInstall.value || !typesPackageName.value) { 323 - return installCommand.value 324 - } 325 - 326 - const pm = packageManagers.find(p => p.id === selectedPM.value) 327 - if (!pm) return installCommand.value 328 - 329 - const devFlag = getDevFlag(selectedPM.value) 330 - const pkgSpec = 331 - selectedPM.value === 'deno' ? `npm:${typesPackageName.value}` : typesPackageName.value 332 - 333 - // Use semicolon to separate commands 334 - return `${installCommand.value}; ${pm.label} ${pm.action} ${devFlag} ${pkgSpec}` 335 - }) 336 - 337 - // Copy install command 338 - const copied = ref(false) 339 - async function copyInstallCommand() { 340 - if (!fullInstallCommand.value) return 341 - await navigator.clipboard.writeText(fullInstallCommand.value) 342 - copied.value = true 343 - setTimeout(() => (copied.value = false), 2000) 344 - } 226 + const { 227 + selectedPM, 228 + installCommandParts, 229 + typesInstallCommandParts, 230 + showTypesInInstall, 231 + copied, 232 + copyInstallCommand, 233 + } = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) 345 234 346 235 // Expandable description 347 236 const descriptionExpanded = ref(false)
+64 -11
shared/utils/git-providers.ts
··· 5 5 | 'gitlab' 6 6 | 'bitbucket' 7 7 | 'gitea' 8 + | 'forgejo' 8 9 | 'codeberg' 9 10 | 'sourcehut' 10 11 | 'gitee' 11 12 | 'tangled' 13 + | 'radicle' 12 14 13 15 export interface RepoRef { 14 16 provider: ProviderId ··· 171 173 blobToRaw: url => url.replace('/blob/', '/raw/branch/'), 172 174 }, 173 175 { 176 + id: 'radicle', 177 + matchHost: host => 178 + host === 'radicle.at' || host === 'app.radicle.at' || host === 'seed.radicle.at', 179 + parsePath: parts => { 180 + // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT 181 + // We extract the rad:... identifier as the "repo" with no owner 182 + const path = parts.join('/') 183 + const radMatch = path.match(/rad:[a-zA-Z0-9]+/) 184 + if (!radMatch?.[0]) return null 185 + // Use empty owner, store full rad: ID as repo 186 + return { owner: '', repo: radMatch[0] } 187 + }, 188 + getRawBaseUrl: (ref, branch = 'HEAD') => 189 + `https://seed.radicle.at/api/v1/projects/${ref.repo}/blob/${branch}`, 190 + }, 191 + { 192 + id: 'forgejo', 193 + matchHost: host => { 194 + // Match explicit Forgejo instances 195 + const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i] 196 + // Known Forgejo instances 197 + const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org'] 198 + if (knownInstances.some(h => host === h)) return true 199 + return forgejoPatterns.some(p => p.test(host)) 200 + }, 201 + parsePath: parts => { 202 + if (parts.length < 2) return null 203 + const owner = decodeURIComponent(parts[0] ?? '').trim() 204 + const repo = decodeURIComponent(parts[1] ?? '') 205 + .trim() 206 + .replace(/\.git$/i, '') 207 + if (!owner || !repo) return null 208 + return { owner, repo } 209 + }, 210 + getRawBaseUrl: (ref, branch = 'HEAD') => { 211 + const host = ref.host ?? 'codeberg.org' 212 + return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` 213 + }, 214 + blobToRaw: url => url.replace('/src/', '/raw/'), 215 + }, 216 + { 174 217 id: 'gitea', 175 218 matchHost: host => { 176 - // Match common Gitea/Forgejo hosting patterns 177 - const giteaPatterns = [ 178 - /^git\./i, 179 - /^gitea\./i, 180 - /^forgejo\./i, 181 - /^code\./i, 182 - /^src\./i, 183 - /gitea\.io$/i, 184 - ] 185 - // Skip known providers 219 + // Match common Gitea hosting patterns (Forgejo has its own adapter) 220 + const giteaPatterns = [/^git\./i, /^gitea\./i, /^code\./i, /^src\./i, /gitea\.io$/i] 221 + // Skip known providers (including Forgejo patterns) 186 222 const skipHosts = [ 187 223 'github.com', 188 224 'gitlab.com', ··· 193 229 'git.sr.ht', 194 230 'tangled.sh', 195 231 'tangled.org', 232 + 'next.forgejo.org', 233 + 'try.next.forgejo.org', 196 234 ...GITLAB_HOSTS, 197 235 ] 198 236 if (skipHosts.some(h => host === h || host.endsWith(`.${h}`))) return false 237 + // Skip Forgejo patterns 238 + if (/^forgejo\./i.test(host) || /\.forgejo\./i.test(host)) return false 199 239 return giteaPatterns.some(p => p.test(host)) 200 240 }, 201 241 parsePath: parts => { ··· 225 265 226 266 const normalized = raw.replace(/^git\+/, '') 227 267 268 + // Handle ssh:// URLs by converting to https:// 269 + if (/^ssh:\/\//i.test(normalized)) { 270 + try { 271 + const url = new URL(normalized) 272 + const path = url.pathname.replace(/^\/*/, '') 273 + return `https://${url.hostname}/${path}` 274 + } catch { 275 + // Fall through to SCP handling 276 + } 277 + } 278 + 228 279 if (!/^https?:\/\//i.test(normalized)) { 280 + // Handle SCP-style URLs: git@host:path 229 281 const scp = normalized.match(/^(?:git@)?([^:/]+):(.+)$/i) 230 282 if (scp?.[1] && scp?.[2]) { 231 283 const host = scp[1] ··· 250 302 if (!provider.matchHost(host)) continue 251 303 const parsed = provider.parsePath(parts) 252 304 if (parsed) { 305 + const needsHost = ['gitlab', 'gitea', 'forgejo', 'radicle'].includes(provider.id) 253 306 return { 254 307 provider: provider.id, 255 308 owner: parsed.owner, 256 309 repo: parsed.repo, 257 - host: provider.id === 'gitlab' || provider.id === 'gitea' ? host : undefined, 310 + host: needsHost ? host : undefined, 258 311 } 259 312 } 260 313 }
+297
test/nuxt/composables/use-install-command.spec.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 + import type { JsrPackageInfo } from '#shared/types/jsr' 3 + 4 + describe('useInstallCommand', () => { 5 + beforeEach(() => { 6 + // Reset localStorage before each test 7 + localStorage.clear() 8 + }) 9 + 10 + afterEach(() => { 11 + vi.unstubAllGlobals() 12 + }) 13 + 14 + describe('basic install commands', () => { 15 + it('should generate npm install command by default', () => { 16 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 17 + 'vue', 18 + null, 19 + null, 20 + null, 21 + ) 22 + 23 + expect(selectedPM.value).toBe('npm') 24 + expect(installCommand.value).toBe('npm install vue') 25 + expect(installCommandParts.value).toEqual(['npm', 'install', 'vue']) 26 + }) 27 + 28 + it('should include version when specified', () => { 29 + const { installCommand, installCommandParts } = useInstallCommand('vue', '3.5.0', null, null) 30 + 31 + expect(installCommand.value).toBe('npm install vue@3.5.0') 32 + expect(installCommandParts.value).toEqual(['npm', 'install', 'vue@3.5.0']) 33 + }) 34 + 35 + it('should handle scoped packages', () => { 36 + const { installCommand, installCommandParts } = useInstallCommand( 37 + '@nuxt/kit', 38 + null, 39 + null, 40 + null, 41 + ) 42 + 43 + expect(installCommand.value).toBe('npm install @nuxt/kit') 44 + expect(installCommandParts.value).toEqual(['npm', 'install', '@nuxt/kit']) 45 + }) 46 + 47 + it('should handle null packageName', () => { 48 + const { installCommand, installCommandParts } = useInstallCommand(null, null, null, null) 49 + 50 + expect(installCommand.value).toBe('') 51 + expect(installCommandParts.value).toEqual([]) 52 + }) 53 + }) 54 + 55 + describe('package manager selection', () => { 56 + it('should use pnpm when selected', () => { 57 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 58 + 'vue', 59 + null, 60 + null, 61 + null, 62 + ) 63 + 64 + selectedPM.value = 'pnpm' 65 + expect(installCommand.value).toBe('pnpm add vue') 66 + expect(installCommandParts.value).toEqual(['pnpm', 'add', 'vue']) 67 + }) 68 + 69 + it('should use yarn when selected', () => { 70 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 71 + 'vue', 72 + null, 73 + null, 74 + null, 75 + ) 76 + 77 + selectedPM.value = 'yarn' 78 + expect(installCommand.value).toBe('yarn add vue') 79 + expect(installCommandParts.value).toEqual(['yarn', 'add', 'vue']) 80 + }) 81 + 82 + it('should use bun when selected', () => { 83 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 84 + 'vue', 85 + null, 86 + null, 87 + null, 88 + ) 89 + 90 + selectedPM.value = 'bun' 91 + expect(installCommand.value).toBe('bun add vue') 92 + expect(installCommandParts.value).toEqual(['bun', 'add', 'vue']) 93 + }) 94 + 95 + it('should use deno with npm: prefix when selected', () => { 96 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 97 + 'vue', 98 + null, 99 + null, 100 + null, 101 + ) 102 + 103 + selectedPM.value = 'deno' 104 + expect(installCommand.value).toBe('deno add npm:vue') 105 + expect(installCommandParts.value).toEqual(['deno', 'add', 'npm:vue']) 106 + }) 107 + 108 + it('should use vlt when selected', () => { 109 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 110 + 'vue', 111 + null, 112 + null, 113 + null, 114 + ) 115 + 116 + selectedPM.value = 'vlt' 117 + expect(installCommand.value).toBe('vlt install vue') 118 + expect(installCommandParts.value).toEqual(['vlt', 'install', 'vue']) 119 + }) 120 + }) 121 + 122 + describe('deno with JSR', () => { 123 + it('should use jsr: prefix when package exists on JSR', () => { 124 + const jsrInfo: JsrPackageInfo = { 125 + exists: true, 126 + scope: 'std', 127 + name: 'path', 128 + url: 'https://jsr.io/@std/path', 129 + } 130 + 131 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 132 + '@std/path', 133 + null, 134 + jsrInfo, 135 + null, 136 + ) 137 + 138 + selectedPM.value = 'deno' 139 + expect(installCommand.value).toBe('deno add jsr:@std/path') 140 + expect(installCommandParts.value).toEqual(['deno', 'add', 'jsr:@std/path']) 141 + }) 142 + 143 + it('should use npm: prefix for deno when package is not on JSR', () => { 144 + const jsrInfo: JsrPackageInfo = { exists: false } 145 + 146 + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( 147 + 'lodash', 148 + null, 149 + jsrInfo, 150 + null, 151 + ) 152 + 153 + selectedPM.value = 'deno' 154 + expect(installCommand.value).toBe('deno add npm:lodash') 155 + expect(installCommandParts.value).toEqual(['deno', 'add', 'npm:lodash']) 156 + }) 157 + }) 158 + 159 + describe('@types packages', () => { 160 + it('should generate @types install command parts', () => { 161 + const { typesInstallCommandParts, showTypesInInstall } = useInstallCommand( 162 + 'express', 163 + null, 164 + null, 165 + '@types/express', 166 + ) 167 + 168 + expect(showTypesInInstall.value).toBe(true) 169 + expect(typesInstallCommandParts.value).toEqual(['npm', 'install', '-D', '@types/express']) 170 + }) 171 + 172 + it('should use -d flag for bun', () => { 173 + const { typesInstallCommandParts, selectedPM } = useInstallCommand( 174 + 'express', 175 + null, 176 + null, 177 + '@types/express', 178 + ) 179 + 180 + selectedPM.value = 'bun' 181 + expect(typesInstallCommandParts.value).toEqual(['bun', 'add', '-d', '@types/express']) 182 + }) 183 + 184 + it('should use npm: prefix for deno @types', () => { 185 + const { typesInstallCommandParts, selectedPM } = useInstallCommand( 186 + 'express', 187 + null, 188 + null, 189 + '@types/express', 190 + ) 191 + 192 + selectedPM.value = 'deno' 193 + expect(typesInstallCommandParts.value).toEqual(['deno', 'add', '-D', 'npm:@types/express']) 194 + }) 195 + 196 + it('should not show @types when typesPackageName is null', () => { 197 + const { showTypesInInstall, typesInstallCommandParts } = useInstallCommand( 198 + 'express', 199 + null, 200 + null, 201 + null, 202 + ) 203 + 204 + expect(showTypesInInstall.value).toBe(false) 205 + expect(typesInstallCommandParts.value).toEqual([]) 206 + }) 207 + }) 208 + 209 + describe('fullInstallCommand with @types', () => { 210 + it('should include both commands when @types enabled', () => { 211 + const { fullInstallCommand } = useInstallCommand('express', null, null, '@types/express') 212 + 213 + expect(fullInstallCommand.value).toBe('npm install express; npm install -D @types/express') 214 + }) 215 + 216 + it('should only include main command when @types disabled via settings', () => { 217 + // Get settings and disable includeTypesInInstall directly 218 + const { settings } = useSettings() 219 + settings.value.includeTypesInInstall = false 220 + 221 + const { fullInstallCommand, showTypesInInstall } = useInstallCommand( 222 + 'express', 223 + null, 224 + null, 225 + '@types/express', 226 + ) 227 + 228 + expect(showTypesInInstall.value).toBe(false) 229 + expect(fullInstallCommand.value).toBe('npm install express') 230 + }) 231 + }) 232 + 233 + describe('reactive updates', () => { 234 + it('should update command when package manager changes', () => { 235 + const { installCommand, selectedPM } = useInstallCommand('vue', null, null, null) 236 + 237 + expect(installCommand.value).toBe('npm install vue') 238 + 239 + selectedPM.value = 'pnpm' 240 + expect(installCommand.value).toBe('pnpm add vue') 241 + 242 + selectedPM.value = 'yarn' 243 + expect(installCommand.value).toBe('yarn add vue') 244 + }) 245 + 246 + it('should update when using ref values', () => { 247 + const packageName = ref<string | null>('vue') 248 + const version = ref<string | null>(null) 249 + 250 + const { installCommand } = useInstallCommand(packageName, version, null, null) 251 + 252 + expect(installCommand.value).toBe('npm install vue') 253 + 254 + packageName.value = 'react' 255 + expect(installCommand.value).toBe('npm install react') 256 + 257 + version.value = '18.2.0' 258 + expect(installCommand.value).toBe('npm install react@18.2.0') 259 + }) 260 + }) 261 + 262 + describe('copyInstallCommand', () => { 263 + it('should copy command to clipboard and set copied state', async () => { 264 + const writeText = vi.fn().mockResolvedValue(undefined) 265 + vi.stubGlobal('navigator', { clipboard: { writeText } }) 266 + 267 + const { copyInstallCommand, copied, fullInstallCommand } = useInstallCommand( 268 + 'vue', 269 + null, 270 + null, 271 + null, 272 + ) 273 + 274 + expect(copied.value).toBe(false) 275 + await copyInstallCommand() 276 + 277 + expect(writeText).toHaveBeenCalledWith(fullInstallCommand.value) 278 + expect(copied.value).toBe(true) 279 + 280 + // Wait for the timeout to reset copied 281 + await new Promise(resolve => setTimeout(resolve, 2100)) 282 + expect(copied.value).toBe(false) 283 + }) 284 + 285 + it('should not copy when command is empty', async () => { 286 + const writeText = vi.fn().mockResolvedValue(undefined) 287 + vi.stubGlobal('navigator', { clipboard: { writeText } }) 288 + 289 + const { copyInstallCommand, copied } = useInstallCommand(null, null, null, null) 290 + 291 + await copyInstallCommand() 292 + 293 + expect(writeText).not.toHaveBeenCalled() 294 + expect(copied.value).toBe(false) 295 + }) 296 + }) 297 + })
+252
test/nuxt/composables/use-repo-meta.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { parseRepoUrl } from '#shared/utils/git-providers' 3 + 4 + /** 5 + * Tests for useRepoMeta composable. 6 + * 7 + * Since the composable uses useLazyAsyncData for fetching, we focus on testing 8 + * the synchronous URL parsing logic which is the core of the composable. 9 + * The actual API fetching is covered by the parseRepoUrl utility tests. 10 + */ 11 + describe('useRepoMeta - URL parsing via repoRef', () => { 12 + describe('GitHub URLs', () => { 13 + it('should parse standard GitHub URL', () => { 14 + const result = parseRepoUrl('https://github.com/vuejs/core') 15 + 16 + expect(result).toEqual({ 17 + provider: 'github', 18 + owner: 'vuejs', 19 + repo: 'core', 20 + }) 21 + }) 22 + 23 + it('should parse GitHub URL with .git suffix', () => { 24 + const result = parseRepoUrl('https://github.com/vuejs/core.git') 25 + 26 + expect(result).toEqual({ 27 + provider: 'github', 28 + owner: 'vuejs', 29 + repo: 'core', 30 + }) 31 + }) 32 + 33 + it('should parse GitHub URL with www prefix', () => { 34 + const result = parseRepoUrl('https://www.github.com/nuxt/nuxt') 35 + 36 + expect(result).toEqual({ 37 + provider: 'github', 38 + owner: 'nuxt', 39 + repo: 'nuxt', 40 + }) 41 + }) 42 + 43 + it('should parse GitHub URL with extra path segments', () => { 44 + const result = parseRepoUrl('https://github.com/vuejs/core/tree/main/packages') 45 + 46 + expect(result).toEqual({ 47 + provider: 'github', 48 + owner: 'vuejs', 49 + repo: 'core', 50 + }) 51 + }) 52 + 53 + it('should handle URL-encoded characters in owner/repo', () => { 54 + const result = parseRepoUrl('https://github.com/some-org/some-repo') 55 + 56 + expect(result).toEqual({ 57 + provider: 'github', 58 + owner: 'some-org', 59 + repo: 'some-repo', 60 + }) 61 + }) 62 + }) 63 + 64 + describe('GitLab URLs', () => { 65 + it('should parse standard GitLab URL', () => { 66 + const result = parseRepoUrl('https://gitlab.com/gitlab-org/gitlab') 67 + 68 + expect(result).toEqual({ 69 + provider: 'gitlab', 70 + owner: 'gitlab-org', 71 + repo: 'gitlab', 72 + host: 'gitlab.com', 73 + }) 74 + }) 75 + 76 + it('should parse GitLab URL with nested groups', () => { 77 + const result = parseRepoUrl('https://gitlab.com/group/subgroup/project') 78 + 79 + expect(result).toEqual({ 80 + provider: 'gitlab', 81 + owner: 'group/subgroup', 82 + repo: 'project', 83 + host: 'gitlab.com', 84 + }) 85 + }) 86 + 87 + it('should parse self-hosted GitLab instance', () => { 88 + const result = parseRepoUrl('https://gitlab.freedesktop.org/mesa/mesa') 89 + 90 + expect(result).toEqual({ 91 + provider: 'gitlab', 92 + owner: 'mesa', 93 + repo: 'mesa', 94 + host: 'gitlab.freedesktop.org', 95 + }) 96 + }) 97 + }) 98 + 99 + describe('Bitbucket URLs', () => { 100 + it('should parse standard Bitbucket URL', () => { 101 + const result = parseRepoUrl('https://bitbucket.org/atlassian/aui') 102 + 103 + expect(result).toEqual({ 104 + provider: 'bitbucket', 105 + owner: 'atlassian', 106 + repo: 'aui', 107 + }) 108 + }) 109 + 110 + it('should parse Bitbucket URL with www', () => { 111 + const result = parseRepoUrl('https://www.bitbucket.org/atlassian/aui') 112 + 113 + expect(result).toEqual({ 114 + provider: 'bitbucket', 115 + owner: 'atlassian', 116 + repo: 'aui', 117 + }) 118 + }) 119 + }) 120 + 121 + describe('Codeberg URLs', () => { 122 + it('should parse Codeberg URL', () => { 123 + const result = parseRepoUrl('https://codeberg.org/forgejo/forgejo') 124 + 125 + expect(result).toMatchObject({ 126 + provider: 'codeberg', 127 + owner: 'forgejo', 128 + repo: 'forgejo', 129 + }) 130 + }) 131 + }) 132 + 133 + describe('Gitee URLs', () => { 134 + it('should parse Gitee URL', () => { 135 + const result = parseRepoUrl('https://gitee.com/oschina/gitee') 136 + 137 + expect(result).toEqual({ 138 + provider: 'gitee', 139 + owner: 'oschina', 140 + repo: 'gitee', 141 + }) 142 + }) 143 + }) 144 + 145 + describe('Sourcehut URLs', () => { 146 + it('should parse Sourcehut URL with git.sr.ht', () => { 147 + const result = parseRepoUrl('https://git.sr.ht/~sircmpwn/sourcehut') 148 + 149 + expect(result).toEqual({ 150 + provider: 'sourcehut', 151 + owner: '~sircmpwn', 152 + repo: 'sourcehut', 153 + }) 154 + }) 155 + 156 + it('should parse Sourcehut URL with sr.ht', () => { 157 + const result = parseRepoUrl('https://sr.ht/~user/repo') 158 + 159 + expect(result).toEqual({ 160 + provider: 'sourcehut', 161 + owner: '~user', 162 + repo: 'repo', 163 + }) 164 + }) 165 + }) 166 + 167 + describe('Tangled URLs', () => { 168 + it('should parse Tangled URL', () => { 169 + const result = parseRepoUrl('https://tangled.sh/did:plc:abc123/repo') 170 + 171 + expect(result).toEqual({ 172 + provider: 'tangled', 173 + owner: 'did:plc:abc123', 174 + repo: 'repo', 175 + }) 176 + }) 177 + }) 178 + 179 + describe('Radicle URLs', () => { 180 + it('should parse Radicle URL', () => { 181 + const result = parseRepoUrl( 182 + 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 183 + ) 184 + 185 + expect(result).toEqual({ 186 + provider: 'radicle', 187 + owner: '', 188 + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 189 + host: 'app.radicle.at', 190 + }) 191 + }) 192 + }) 193 + 194 + describe('Generic Gitea URLs', () => { 195 + it('should parse git.* subdomain as Gitea', () => { 196 + const result = parseRepoUrl('https://git.example.com/user/repo') 197 + 198 + expect(result).toEqual({ 199 + provider: 'gitea', 200 + owner: 'user', 201 + repo: 'repo', 202 + host: 'git.example.com', 203 + }) 204 + }) 205 + 206 + it('should parse gitea.* subdomain', () => { 207 + const result = parseRepoUrl('https://gitea.example.org/org/project') 208 + 209 + expect(result).toEqual({ 210 + provider: 'gitea', 211 + owner: 'org', 212 + repo: 'project', 213 + host: 'gitea.example.org', 214 + }) 215 + }) 216 + }) 217 + 218 + describe('Forgejo URLs', () => { 219 + it('should parse Forgejo instance URL', () => { 220 + const result = parseRepoUrl('https://next.forgejo.org/forgejo/forgejo') 221 + 222 + expect(result).toEqual({ 223 + provider: 'forgejo', 224 + owner: 'forgejo', 225 + repo: 'forgejo', 226 + host: 'next.forgejo.org', 227 + }) 228 + }) 229 + }) 230 + 231 + describe('Invalid URLs', () => { 232 + it('should return null for invalid URL', () => { 233 + const result = parseRepoUrl('not-a-url') 234 + expect(result).toBeNull() 235 + }) 236 + 237 + it('should return null for empty string', () => { 238 + const result = parseRepoUrl('') 239 + expect(result).toBeNull() 240 + }) 241 + 242 + it('should return null for URL with insufficient path', () => { 243 + const result = parseRepoUrl('https://github.com/vuejs') 244 + expect(result).toBeNull() 245 + }) 246 + 247 + it('should return null for unknown provider', () => { 248 + const result = parseRepoUrl('https://example.com/user/repo') 249 + expect(result).toBeNull() 250 + }) 251 + }) 252 + })
+64
test/unit/readme-url-resolution.spec.ts
··· 216 216 }) 217 217 }) 218 218 }) 219 + 220 + describe('Radicle support', () => { 221 + it('parses Radicle URL from app.radicle.at', () => { 222 + const result = parseRepositoryInfo({ 223 + url: 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 224 + }) 225 + expect(result).toMatchObject({ 226 + provider: 'radicle', 227 + owner: '', 228 + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 229 + host: 'app.radicle.at', 230 + }) 231 + }) 232 + 233 + it('parses Radicle URL from seed.radicle.at', () => { 234 + const result = parseRepositoryInfo({ 235 + url: 'https://seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 236 + }) 237 + expect(result).toMatchObject({ 238 + provider: 'radicle', 239 + owner: '', 240 + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', 241 + host: 'seed.radicle.at', 242 + }) 243 + }) 244 + }) 245 + 246 + describe('Forgejo support', () => { 247 + it('parses Forgejo URL from forgejo subdomain', () => { 248 + const result = parseRepositoryInfo({ 249 + url: 'https://forgejo.example.com/owner/repo', 250 + }) 251 + expect(result).toMatchObject({ 252 + provider: 'forgejo', 253 + owner: 'owner', 254 + repo: 'repo', 255 + host: 'forgejo.example.com', 256 + }) 257 + }) 258 + 259 + it('parses Forgejo URL from next.forgejo.org', () => { 260 + const result = parseRepositoryInfo({ 261 + url: 'https://next.forgejo.org/forgejo/forgejo', 262 + }) 263 + expect(result).toMatchObject({ 264 + provider: 'forgejo', 265 + owner: 'forgejo', 266 + repo: 'forgejo', 267 + host: 'next.forgejo.org', 268 + }) 269 + }) 270 + 271 + it('parses Forgejo URL with .git suffix', () => { 272 + const result = parseRepositoryInfo({ 273 + url: 'git+ssh://git@forgejo.myserver.com/user/project.git', 274 + }) 275 + expect(result).toMatchObject({ 276 + provider: 'forgejo', 277 + owner: 'user', 278 + repo: 'project', 279 + host: 'forgejo.myserver.com', 280 + }) 281 + }) 282 + }) 219 283 })