diff --git a/app/composables/useInstallCommand.ts b/app/composables/useInstallCommand.ts new file mode 100644 index 0000000000..666c7cd7cd --- /dev/null +++ b/app/composables/useInstallCommand.ts @@ -0,0 +1,98 @@ +import type { JsrPackageInfo } from '#shared/types/jsr' + +/** + * Composable for generating install commands with support for + * multiple package managers, @types packages, and JSR. + */ +export function useInstallCommand( + packageName: MaybeRefOrGetter, + requestedVersion: MaybeRefOrGetter, + jsrInfo: MaybeRefOrGetter, + typesPackageName: MaybeRefOrGetter, +) { + const selectedPM = useSelectedPackageManager() + const { settings } = useSettings() + + // Check if we should show @types in install command + const showTypesInInstall = computed(() => { + return settings.value.includeTypesInInstall && !!toValue(typesPackageName) + }) + + const installCommandParts = computed(() => { + const name = toValue(packageName) + if (!name) return [] + return getInstallCommandParts({ + packageName: name, + packageManager: selectedPM.value, + version: toValue(requestedVersion), + jsrInfo: toValue(jsrInfo), + }) + }) + + const installCommand = computed(() => { + const name = toValue(packageName) + if (!name) return '' + return getInstallCommand({ + packageName: name, + packageManager: selectedPM.value, + version: toValue(requestedVersion), + jsrInfo: toValue(jsrInfo), + }) + }) + + // Get the dev dependency flag for the selected package manager + const devFlag = computed(() => { + // bun uses lowercase -d, all others use -D + return selectedPM.value === 'bun' ? '-d' : '-D' + }) + + // @types install command parts (for display) + const typesInstallCommandParts = computed(() => { + const types = toValue(typesPackageName) + if (!types) return [] + const pm = packageManagers.find(p => p.id === selectedPM.value) + if (!pm) return [] + + const pkgSpec = selectedPM.value === 'deno' ? `npm:${types}` : types + + return [pm.label, pm.action, devFlag.value, pkgSpec] + }) + + // Full install command including @types (for copying) + const fullInstallCommand = computed(() => { + if (!installCommand.value) return '' + const types = toValue(typesPackageName) + if (!showTypesInInstall.value || !types) { + return installCommand.value + } + + const pm = packageManagers.find(p => p.id === selectedPM.value) + if (!pm) return installCommand.value + + const pkgSpec = selectedPM.value === 'deno' ? `npm:${types}` : types + + // Use semicolon to separate commands + return `${installCommand.value}; ${pm.label} ${pm.action} ${devFlag.value} ${pkgSpec}` + }) + + // Copy state + const copied = ref(false) + + async function copyInstallCommand() { + if (!fullInstallCommand.value) return + await navigator.clipboard.writeText(fullInstallCommand.value) + copied.value = true + setTimeout(() => (copied.value = false), 2000) + } + + return { + selectedPM, + installCommandParts, + installCommand, + typesInstallCommandParts, + fullInstallCommand, + showTypesInInstall, + copied, + copyInstallCommand, + } +} diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index fa8e639bd2..b0053056cd 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -114,43 +114,37 @@ export function usePackage( ) { const cachedFetch = useCachedFetch() - const asyncData = useLazyAsyncData( + return useLazyAsyncData( () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, async () => { const encodedName = encodePackageName(toValue(name)) - const pkg = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`) - return transformPackument(pkg, toValue(requestedVersion)) + const r = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`) + const reqVer = toValue(requestedVersion) + const pkg = transformPackument(r, reqVer) + const resolvedVersion = getResolvedVersion(pkg, reqVer) + return { ...pkg, resolvedVersion } }, ) +} - // Resolve requestedVersion to an exact version - // Handles: exact versions, dist-tags (latest, next), and semver ranges (^4.2, >=1.0.0) - const resolvedVersion = computed(() => { - const pkg = asyncData.data.value - const reqVer = toValue(requestedVersion) - if (!pkg || !reqVer) return null +function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null { + if (!pkg || !reqVer) return null - // 1. Check if it's already an exact version in pkg.versions - if (isExactVersion(reqVer) && pkg.versions[reqVer]) { - return reqVer - } - - // 2. Check if it's a dist-tag (latest, next, beta, etc.) - const tagVersion = pkg['dist-tags']?.[reqVer] - if (tagVersion) { - return tagVersion - } - - // 3. Try to resolve as a semver range - const versions = Object.keys(pkg.versions) - const resolved = maxSatisfying(versions, reqVer) - return resolved - }) + // 1. Check if it's already an exact version in pkg.versions + if (isExactVersion(reqVer) && pkg.versions[reqVer]) { + return reqVer + } - return { - ...asyncData, - resolvedVersion, + // 2. Check if it's a dist-tag (latest, next, beta, etc.) + const tagVersion = pkg['dist-tags']?.[reqVer] + if (tagVersion) { + return tagVersion } + + // 3. Try to resolve as a semver range + const versions = Object.keys(pkg.versions) + const resolved = maxSatisfying(versions, reqVer) + return resolved } export function usePackageDownloads( diff --git a/app/composables/usePackageRoute.ts b/app/composables/usePackageRoute.ts new file mode 100644 index 0000000000..aa824d3bec --- /dev/null +++ b/app/composables/usePackageRoute.ts @@ -0,0 +1,60 @@ +/** + * Parse package name and optional version from the route URL. + * + * Supported patterns: + * /nuxt → packageName: "nuxt", requestedVersion: null + * /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" + * /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null + * /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" + * /axios@1.13.3 → packageName: "axios", requestedVersion: "1.13.3" + * /@nuxt/kit@1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" + */ +export function usePackageRoute() { + const route = useRoute('package') + + const parsedRoute = computed(() => { + const segments = route.params.package || [] + + // Find the /v/ separator for version + const vIndex = segments.indexOf('v') + if (vIndex !== -1 && vIndex < segments.length - 1) { + return { + packageName: segments.slice(0, vIndex).join('/'), + requestedVersion: segments.slice(vIndex + 1).join('/'), + } + } + + // Parse @ versioned package + const fullPath = segments.join('/') + const versionMatch = fullPath.match(/^(@[^/]+\/[^/]+|[^/]+)@([^/]+)$/) + if (versionMatch) { + const [, packageName, requestedVersion] = versionMatch as [string, string, string] + return { + packageName, + requestedVersion, + } + } + + return { + packageName: fullPath, + requestedVersion: null as string | null, + } + }) + + const packageName = computed(() => parsedRoute.value.packageName) + const requestedVersion = computed(() => parsedRoute.value.requestedVersion) + + // Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") + const orgName = computed(() => { + const name = packageName.value + if (!name.startsWith('@')) return null + const match = name.match(/^@([^/]+)\//) + return match ? match[1] : null + }) + + return { + packageName, + requestedVersion, + orgName, + } +} diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 311636fc04..86dfe14163 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -73,6 +73,19 @@ type GiteeRepoResponse = { watchers_count?: number } +/** Radicle API response for project details */ +type RadicleProjectResponse = { + id: string + name: string + description?: string + defaultBranch?: string + head?: string + seeding?: number + delegates?: Array<{ id: string; alias?: string }> + patches?: { open: number; draft: number; archived: number; merged: number } + issues?: { open: number; closed: number } +} + type ProviderAdapter = { id: ProviderId parse(url: URL): RepoRef | null @@ -538,7 +551,7 @@ const tangledAdapter: ProviderAdapter = { }, links(ref) { - const base = `https://tangled.sh/${ref.owner}/${ref.repo}` + const base = `https://tangled.org/${ref.owner}/${ref.repo}` return { repo: base, stars: base, // Tangled shows stars on the repo page @@ -546,14 +559,152 @@ const tangledAdapter: ProviderAdapter = { } }, - async fetchMeta(_cachedFetch, _ref, links) { - // Tangled doesn't have a public API for repo stats yet - // Just return basic info without fetching + async fetchMeta(cachedFetch, ref, links) { + // Tangled doesn't have a public JSON API, but we can scrape the star count + // from the HTML page (it's in the hx-post URL as countHint=N) + try { + const html = await cachedFetch( + `https://tangled.org/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' } }, + REPO_META_TTL, + ) + // Extract star count from: hx-post="/star?subject=...&countHint=23" + const starMatch = html.match(/countHint=(\d+)/) + const stars = starMatch?.[1] ? parseInt(starMatch[1], 10) : 0 + + return { + provider: 'tangled', + url: links.repo, + stars, + forks: 0, // Tangled doesn't expose fork count + links, + } + } catch { + return { + provider: 'tangled', + url: links.repo, + stars: 0, + forks: 0, + links, + } + } + }, +} + +const radicleAdapter: ProviderAdapter = { + id: 'radicle', + + parse(url) { + const host = url.hostname.toLowerCase() + if (host !== 'radicle.at' && host !== 'app.radicle.at' && host !== 'seed.radicle.at') { + return null + } + + // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT + const path = url.pathname + const radMatch = path.match(/rad:[a-zA-Z0-9]+/) + if (!radMatch?.[0]) return null + + // Use empty owner, store full rad: ID as repo + return { provider: 'radicle', owner: '', repo: radMatch[0], host } + }, + + links(ref) { + const base = `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}` return { - provider: 'tangled', + repo: base, + stars: base, // Radicle doesn't have stars, shows seeding count + forks: base, + } + }, + + async fetchMeta(cachedFetch, ref, links) { + let res: RadicleProjectResponse | null = null + try { + res = await cachedFetch( + `https://seed.radicle.at/api/v1/projects/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } + + if (!res) return null + + return { + provider: 'radicle', url: links.repo, - stars: 0, - forks: 0, + // Use seeding count as a proxy for "stars" (number of nodes hosting this repo) + stars: res.seeding ?? 0, + forks: 0, // Radicle doesn't have forks in the traditional sense + description: res.description ?? null, + defaultBranch: res.defaultBranch, + links, + } + }, +} + +const forgejoAdapter: ProviderAdapter = { + id: 'forgejo', + + parse(url) { + const host = url.hostname.toLowerCase() + + // Match explicit Forgejo instances + const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i] + const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org'] + + const isMatch = knownInstances.some(h => host === h) || forgejoPatterns.some(p => p.test(host)) + if (!isMatch) return null + + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length < 2) return null + + const owner = decodeURIComponent(parts[0] ?? '').trim() + const repo = decodeURIComponent(parts[1] ?? '') + .trim() + .replace(/\.git$/i, '') + + if (!owner || !repo) return null + + return { provider: 'forgejo', owner, repo, host } + }, + + links(ref) { + const base = `https://${ref.host}/${ref.owner}/${ref.repo}` + return { + repo: base, + stars: base, + forks: `${base}/forks`, + watchers: base, + } + }, + + async fetchMeta(cachedFetch, ref, links) { + if (!ref.host) return null + + let res: GiteaRepoResponse | null = null + try { + res = await cachedFetch( + `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } + + if (!res) return null + + return { + provider: 'forgejo', + url: links.repo, + stars: res.stars_count ?? 0, + forks: res.forks_count ?? 0, + watchers: res.watchers_count ?? 0, + description: res.description ?? null, + defaultBranch: res.default_branch, links, } }, @@ -568,6 +719,8 @@ const providers: readonly ProviderAdapter[] = [ giteeAdapter, sourcehutAdapter, tangledAdapter, + radicleAdapter, + forgejoAdapter, giteaAdapter, // Generic Gitea adapter last as fallback for self-hosted instances ] as const diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index af6e825a97..e03ab644c5 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -11,64 +11,14 @@ definePageMeta({ alias: ['/package/:package(.*)*'], }) -const route = useRoute('package') - const router = useRouter() -// Parse package name and optional version from URL -// Patterns: -// /nuxt → packageName: "nuxt", requestedVersion: null -// /nuxt/v/4.2.0 → packageName: "nuxt", requestedVersion: "4.2.0" -// /@nuxt/kit → packageName: "@nuxt/kit", requestedVersion: null -// /@nuxt/kit/v/1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" -// /axios@1.13.3 → packageName: "axios", requestedVersion: "1.13.3" -// /@nuxt/kit@1.0.0 → packageName: "@nuxt/kit", requestedVersion: "1.0.0" -const parsedRoute = computed(() => { - const segments = route.params.package || [] - - // Find the /v/ separator for version - const vIndex = segments.indexOf('v') - if (vIndex !== -1 && vIndex < segments.length - 1) { - return { - packageName: segments.slice(0, vIndex).join('/'), - requestedVersion: segments.slice(vIndex + 1).join('/'), - } - } - - // Parse @ versioned package - const fullPath = segments.join('/') - const versionMatch = fullPath.match(/^(@[^/]+\/[^/]+|[^/]+)@([^/]+)$/) - if (versionMatch) { - const [, packageName, requestedVersion] = versionMatch as [string, string, string] - return { - packageName, - requestedVersion, - } - } - - return { - packageName: fullPath, - requestedVersion: null as string | null, - } -}) - -const packageName = computed(() => parsedRoute.value.packageName) -const requestedVersion = computed(() => parsedRoute.value.requestedVersion) +const { packageName, requestedVersion, orgName } = usePackageRoute() if (import.meta.server) { assertValidPackageName(packageName.value) } -// Extract org name from scoped package (e.g., "@nuxt/kit" -> "nuxt") -const orgName = computed(() => { - const name = packageName.value - if (!name.startsWith('@')) return null - const match = name.match(/^@([^/]+)\//) - return match ? match[1] : null -}) - -const { data: pkg, status, error, resolvedVersion } = usePackage(packageName, requestedVersion) - const { data: downloads } = usePackageDownloads(packageName, 'last-week') // Fetch README for specific version if requested, otherwise latest @@ -113,17 +63,10 @@ const { ) onMounted(() => fetchInstallSize()) -const sizeTooltip = computed(() => { - const chunks = [ - displayVersion.value && - displayVersion.value.dist.unpackedSize && - `${formatBytes(displayVersion.value.dist.unpackedSize)} unpacked size (this package)`, - installSize.value && - installSize.value.dependencyCount && - `${formatBytes(installSize.value.totalSize)} total unpacked size (including all ${installSize.value.dependencyCount} dependencies for linux-x64)`, - ] - return chunks.filter(Boolean).join('\n') -}) +const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) + +const { data: pkg, status, error } = await usePackage(packageName, requestedVersion) +const resolvedVersion = computed(() => pkg.value?.resolvedVersion ?? null) // Get the version to display (resolved version or latest) const displayVersion = computed(() => { @@ -162,6 +105,18 @@ const deprecationNotice = computed(() => { return { type: 'version' as const, message: displayVersion.value.deprecated } }) +const sizeTooltip = computed(() => { + const chunks = [ + displayVersion.value && + displayVersion.value.dist.unpackedSize && + `${formatBytes(displayVersion.value.dist.unpackedSize)} unpacked size (this package)`, + installSize.value && + installSize.value.dependencyCount && + `${formatBytes(installSize.value.totalSize)} total unpacked size (including all ${installSize.value.dependencyCount} dependencies for linux-x64)`, + ] + return chunks.filter(Boolean).join('\n') +}) + const hasDependencies = computed(() => { if (!displayVersion.value) return false const deps = displayVersion.value.dependencies @@ -193,9 +148,11 @@ const PROVIDER_ICONS: Record = { bitbucket: 'i-simple-icons-bitbucket', codeberg: 'i-simple-icons-codeberg', gitea: 'i-simple-icons-gitea', + forgejo: 'i-simple-icons-forgejo', gitee: 'i-simple-icons-gitee', sourcehut: 'i-simple-icons-sourcehut', tangled: 'i-custom-tangled', + radicle: 'i-carbon-network-3', // Radicle is a P2P network, using network icon } const repoProviderIcon = computed(() => { @@ -258,12 +215,6 @@ function hasProvenance(version: PackumentVersion | null): boolean { return !!dist.attestations } -const selectedPM = useSelectedPackageManager() -const { settings } = useSettings() - -// Fetch package analysis for @types info -const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) - // Get @types package name if available (non-deprecated) const typesPackageName = computed(() => { if (!packageAnalysis.value) return null @@ -272,76 +223,14 @@ const typesPackageName = computed(() => { return packageAnalysis.value.types.packageName }) -// Check if we should show @types in install command -const showTypesInInstall = computed(() => { - return settings.value.includeTypesInInstall && typesPackageName.value -}) - -const installCommandParts = computed(() => { - if (!pkg.value) return [] - return getInstallCommandParts({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - version: requestedVersion.value, - jsrInfo: jsrInfo.value, - }) -}) - -const installCommand = computed(() => { - if (!pkg.value) return '' - return getInstallCommand({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - version: requestedVersion.value, - jsrInfo: jsrInfo.value, - }) -}) - -// Get the dev dependency flag for the selected package manager -function getDevFlag(pmId: string): string { - // bun uses lowercase -d, all others use -D - return pmId === 'bun' ? '-d' : '-D' -} - -// @types install command parts (for display) -const typesInstallCommandParts = computed(() => { - if (!typesPackageName.value) return [] - const pm = packageManagers.find(p => p.id === selectedPM.value) - if (!pm) return [] - - const devFlag = getDevFlag(selectedPM.value) - const pkgSpec = - selectedPM.value === 'deno' ? `npm:${typesPackageName.value}` : typesPackageName.value - - return [pm.label, pm.action, devFlag, pkgSpec] -}) - -// Full install command including @types (for copying) -const fullInstallCommand = computed(() => { - if (!installCommand.value) return '' - if (!showTypesInInstall.value || !typesPackageName.value) { - return installCommand.value - } - - const pm = packageManagers.find(p => p.id === selectedPM.value) - if (!pm) return installCommand.value - - const devFlag = getDevFlag(selectedPM.value) - const pkgSpec = - selectedPM.value === 'deno' ? `npm:${typesPackageName.value}` : typesPackageName.value - - // Use semicolon to separate commands - return `${installCommand.value}; ${pm.label} ${pm.action} ${devFlag} ${pkgSpec}` -}) - -// Copy install command -const copied = ref(false) -async function copyInstallCommand() { - if (!fullInstallCommand.value) return - await navigator.clipboard.writeText(fullInstallCommand.value) - copied.value = true - setTimeout(() => (copied.value = false), 2000) -} +const { + selectedPM, + installCommandParts, + typesInstallCommandParts, + showTypesInInstall, + copied, + copyInstallCommand, +} = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) // Expandable description const descriptionExpanded = ref(false) diff --git a/shared/utils/git-providers.ts b/shared/utils/git-providers.ts index 677a29bb5b..e8b36bd307 100644 --- a/shared/utils/git-providers.ts +++ b/shared/utils/git-providers.ts @@ -5,10 +5,12 @@ export type ProviderId = | 'gitlab' | 'bitbucket' | 'gitea' + | 'forgejo' | 'codeberg' | 'sourcehut' | 'gitee' | 'tangled' + | 'radicle' export interface RepoRef { provider: ProviderId @@ -170,19 +172,53 @@ const providers: ProviderConfig[] = [ `https://tangled.sh/${ref.owner}/${ref.repo}/raw/branch/${branch}`, blobToRaw: url => url.replace('/blob/', '/raw/branch/'), }, + { + id: 'radicle', + matchHost: host => + host === 'radicle.at' || host === 'app.radicle.at' || host === 'seed.radicle.at', + parsePath: parts => { + // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT + // We extract the rad:... identifier as the "repo" with no owner + const path = parts.join('/') + const radMatch = path.match(/rad:[a-zA-Z0-9]+/) + if (!radMatch?.[0]) return null + // Use empty owner, store full rad: ID as repo + return { owner: '', repo: radMatch[0] } + }, + getRawBaseUrl: (ref, branch = 'HEAD') => + `https://seed.radicle.at/api/v1/projects/${ref.repo}/blob/${branch}`, + }, + { + id: 'forgejo', + matchHost: host => { + // Match explicit Forgejo instances + const forgejoPatterns = [/^forgejo\./i, /\.forgejo\./i] + // Known Forgejo instances + const knownInstances = ['next.forgejo.org', 'try.next.forgejo.org'] + if (knownInstances.some(h => host === h)) return true + return forgejoPatterns.some(p => p.test(host)) + }, + parsePath: parts => { + if (parts.length < 2) return null + const owner = decodeURIComponent(parts[0] ?? '').trim() + const repo = decodeURIComponent(parts[1] ?? '') + .trim() + .replace(/\.git$/i, '') + if (!owner || !repo) return null + return { owner, repo } + }, + getRawBaseUrl: (ref, branch = 'HEAD') => { + const host = ref.host ?? 'codeberg.org' + return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` + }, + blobToRaw: url => url.replace('/src/', '/raw/'), + }, { id: 'gitea', matchHost: host => { - // Match common Gitea/Forgejo hosting patterns - const giteaPatterns = [ - /^git\./i, - /^gitea\./i, - /^forgejo\./i, - /^code\./i, - /^src\./i, - /gitea\.io$/i, - ] - // Skip known providers + // Match common Gitea hosting patterns (Forgejo has its own adapter) + const giteaPatterns = [/^git\./i, /^gitea\./i, /^code\./i, /^src\./i, /gitea\.io$/i] + // Skip known providers (including Forgejo patterns) const skipHosts = [ 'github.com', 'gitlab.com', @@ -193,9 +229,13 @@ const providers: ProviderConfig[] = [ 'git.sr.ht', 'tangled.sh', 'tangled.org', + 'next.forgejo.org', + 'try.next.forgejo.org', ...GITLAB_HOSTS, ] if (skipHosts.some(h => host === h || host.endsWith(`.${h}`))) return false + // Skip Forgejo patterns + if (/^forgejo\./i.test(host) || /\.forgejo\./i.test(host)) return false return giteaPatterns.some(p => p.test(host)) }, parsePath: parts => { @@ -225,7 +265,19 @@ export function normalizeGitUrl(input: string): string | null { const normalized = raw.replace(/^git\+/, '') + // Handle ssh:// URLs by converting to https:// + if (/^ssh:\/\//i.test(normalized)) { + try { + const url = new URL(normalized) + const path = url.pathname.replace(/^\/*/, '') + return `https://${url.hostname}/${path}` + } catch { + // Fall through to SCP handling + } + } + if (!/^https?:\/\//i.test(normalized)) { + // Handle SCP-style URLs: git@host:path const scp = normalized.match(/^(?:git@)?([^:/]+):(.+)$/i) if (scp?.[1] && scp?.[2]) { const host = scp[1] @@ -250,11 +302,12 @@ export function parseRepoUrl(input: string): RepoRef | null { if (!provider.matchHost(host)) continue const parsed = provider.parsePath(parts) if (parsed) { + const needsHost = ['gitlab', 'gitea', 'forgejo', 'radicle'].includes(provider.id) return { provider: provider.id, owner: parsed.owner, repo: parsed.repo, - host: provider.id === 'gitlab' || provider.id === 'gitea' ? host : undefined, + host: needsHost ? host : undefined, } } } diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts new file mode 100644 index 0000000000..bb091db6e7 --- /dev/null +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { JsrPackageInfo } from '#shared/types/jsr' + +describe('useInstallCommand', () => { + beforeEach(() => { + // Reset localStorage before each test + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('basic install commands', () => { + it('should generate npm install command by default', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + expect(selectedPM.value).toBe('npm') + expect(installCommand.value).toBe('npm install vue') + expect(installCommandParts.value).toEqual(['npm', 'install', 'vue']) + }) + + it('should include version when specified', () => { + const { installCommand, installCommandParts } = useInstallCommand('vue', '3.5.0', null, null) + + expect(installCommand.value).toBe('npm install vue@3.5.0') + expect(installCommandParts.value).toEqual(['npm', 'install', 'vue@3.5.0']) + }) + + it('should handle scoped packages', () => { + const { installCommand, installCommandParts } = useInstallCommand( + '@nuxt/kit', + null, + null, + null, + ) + + expect(installCommand.value).toBe('npm install @nuxt/kit') + expect(installCommandParts.value).toEqual(['npm', 'install', '@nuxt/kit']) + }) + + it('should handle null packageName', () => { + const { installCommand, installCommandParts } = useInstallCommand(null, null, null, null) + + expect(installCommand.value).toBe('') + expect(installCommandParts.value).toEqual([]) + }) + }) + + describe('package manager selection', () => { + it('should use pnpm when selected', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + selectedPM.value = 'pnpm' + expect(installCommand.value).toBe('pnpm add vue') + expect(installCommandParts.value).toEqual(['pnpm', 'add', 'vue']) + }) + + it('should use yarn when selected', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + selectedPM.value = 'yarn' + expect(installCommand.value).toBe('yarn add vue') + expect(installCommandParts.value).toEqual(['yarn', 'add', 'vue']) + }) + + it('should use bun when selected', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + selectedPM.value = 'bun' + expect(installCommand.value).toBe('bun add vue') + expect(installCommandParts.value).toEqual(['bun', 'add', 'vue']) + }) + + it('should use deno with npm: prefix when selected', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + selectedPM.value = 'deno' + expect(installCommand.value).toBe('deno add npm:vue') + expect(installCommandParts.value).toEqual(['deno', 'add', 'npm:vue']) + }) + + it('should use vlt when selected', () => { + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + selectedPM.value = 'vlt' + expect(installCommand.value).toBe('vlt install vue') + expect(installCommandParts.value).toEqual(['vlt', 'install', 'vue']) + }) + }) + + describe('deno with JSR', () => { + it('should use jsr: prefix when package exists on JSR', () => { + const jsrInfo: JsrPackageInfo = { + exists: true, + scope: 'std', + name: 'path', + url: 'https://jsr.io/@std/path', + } + + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + '@std/path', + null, + jsrInfo, + null, + ) + + selectedPM.value = 'deno' + expect(installCommand.value).toBe('deno add jsr:@std/path') + expect(installCommandParts.value).toEqual(['deno', 'add', 'jsr:@std/path']) + }) + + it('should use npm: prefix for deno when package is not on JSR', () => { + const jsrInfo: JsrPackageInfo = { exists: false } + + const { installCommand, installCommandParts, selectedPM } = useInstallCommand( + 'lodash', + null, + jsrInfo, + null, + ) + + selectedPM.value = 'deno' + expect(installCommand.value).toBe('deno add npm:lodash') + expect(installCommandParts.value).toEqual(['deno', 'add', 'npm:lodash']) + }) + }) + + describe('@types packages', () => { + it('should generate @types install command parts', () => { + const { typesInstallCommandParts, showTypesInInstall } = useInstallCommand( + 'express', + null, + null, + '@types/express', + ) + + expect(showTypesInInstall.value).toBe(true) + expect(typesInstallCommandParts.value).toEqual(['npm', 'install', '-D', '@types/express']) + }) + + it('should use -d flag for bun', () => { + const { typesInstallCommandParts, selectedPM } = useInstallCommand( + 'express', + null, + null, + '@types/express', + ) + + selectedPM.value = 'bun' + expect(typesInstallCommandParts.value).toEqual(['bun', 'add', '-d', '@types/express']) + }) + + it('should use npm: prefix for deno @types', () => { + const { typesInstallCommandParts, selectedPM } = useInstallCommand( + 'express', + null, + null, + '@types/express', + ) + + selectedPM.value = 'deno' + expect(typesInstallCommandParts.value).toEqual(['deno', 'add', '-D', 'npm:@types/express']) + }) + + it('should not show @types when typesPackageName is null', () => { + const { showTypesInInstall, typesInstallCommandParts } = useInstallCommand( + 'express', + null, + null, + null, + ) + + expect(showTypesInInstall.value).toBe(false) + expect(typesInstallCommandParts.value).toEqual([]) + }) + }) + + describe('fullInstallCommand with @types', () => { + it('should include both commands when @types enabled', () => { + const { fullInstallCommand } = useInstallCommand('express', null, null, '@types/express') + + expect(fullInstallCommand.value).toBe('npm install express; npm install -D @types/express') + }) + + it('should only include main command when @types disabled via settings', () => { + // Get settings and disable includeTypesInInstall directly + const { settings } = useSettings() + settings.value.includeTypesInInstall = false + + const { fullInstallCommand, showTypesInInstall } = useInstallCommand( + 'express', + null, + null, + '@types/express', + ) + + expect(showTypesInInstall.value).toBe(false) + expect(fullInstallCommand.value).toBe('npm install express') + }) + }) + + describe('reactive updates', () => { + it('should update command when package manager changes', () => { + const { installCommand, selectedPM } = useInstallCommand('vue', null, null, null) + + expect(installCommand.value).toBe('npm install vue') + + selectedPM.value = 'pnpm' + expect(installCommand.value).toBe('pnpm add vue') + + selectedPM.value = 'yarn' + expect(installCommand.value).toBe('yarn add vue') + }) + + it('should update when using ref values', () => { + const packageName = ref('vue') + const version = ref(null) + + const { installCommand } = useInstallCommand(packageName, version, null, null) + + expect(installCommand.value).toBe('npm install vue') + + packageName.value = 'react' + expect(installCommand.value).toBe('npm install react') + + version.value = '18.2.0' + expect(installCommand.value).toBe('npm install react@18.2.0') + }) + }) + + describe('copyInstallCommand', () => { + it('should copy command to clipboard and set copied state', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + const { copyInstallCommand, copied, fullInstallCommand } = useInstallCommand( + 'vue', + null, + null, + null, + ) + + expect(copied.value).toBe(false) + await copyInstallCommand() + + expect(writeText).toHaveBeenCalledWith(fullInstallCommand.value) + expect(copied.value).toBe(true) + + // Wait for the timeout to reset copied + await new Promise(resolve => setTimeout(resolve, 2100)) + expect(copied.value).toBe(false) + }) + + it('should not copy when command is empty', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + const { copyInstallCommand, copied } = useInstallCommand(null, null, null, null) + + await copyInstallCommand() + + expect(writeText).not.toHaveBeenCalled() + expect(copied.value).toBe(false) + }) + }) +}) diff --git a/test/nuxt/composables/use-repo-meta.spec.ts b/test/nuxt/composables/use-repo-meta.spec.ts new file mode 100644 index 0000000000..a1e9a91a36 --- /dev/null +++ b/test/nuxt/composables/use-repo-meta.spec.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest' +import { parseRepoUrl } from '#shared/utils/git-providers' + +/** + * Tests for useRepoMeta composable. + * + * Since the composable uses useLazyAsyncData for fetching, we focus on testing + * the synchronous URL parsing logic which is the core of the composable. + * The actual API fetching is covered by the parseRepoUrl utility tests. + */ +describe('useRepoMeta - URL parsing via repoRef', () => { + describe('GitHub URLs', () => { + it('should parse standard GitHub URL', () => { + const result = parseRepoUrl('https://github.com/vuejs/core') + + expect(result).toEqual({ + provider: 'github', + owner: 'vuejs', + repo: 'core', + }) + }) + + it('should parse GitHub URL with .git suffix', () => { + const result = parseRepoUrl('https://github.com/vuejs/core.git') + + expect(result).toEqual({ + provider: 'github', + owner: 'vuejs', + repo: 'core', + }) + }) + + it('should parse GitHub URL with www prefix', () => { + const result = parseRepoUrl('https://www.github.com/nuxt/nuxt') + + expect(result).toEqual({ + provider: 'github', + owner: 'nuxt', + repo: 'nuxt', + }) + }) + + it('should parse GitHub URL with extra path segments', () => { + const result = parseRepoUrl('https://github.com/vuejs/core/tree/main/packages') + + expect(result).toEqual({ + provider: 'github', + owner: 'vuejs', + repo: 'core', + }) + }) + + it('should handle URL-encoded characters in owner/repo', () => { + const result = parseRepoUrl('https://github.com/some-org/some-repo') + + expect(result).toEqual({ + provider: 'github', + owner: 'some-org', + repo: 'some-repo', + }) + }) + }) + + describe('GitLab URLs', () => { + it('should parse standard GitLab URL', () => { + const result = parseRepoUrl('https://gitlab.com/gitlab-org/gitlab') + + expect(result).toEqual({ + provider: 'gitlab', + owner: 'gitlab-org', + repo: 'gitlab', + host: 'gitlab.com', + }) + }) + + it('should parse GitLab URL with nested groups', () => { + const result = parseRepoUrl('https://gitlab.com/group/subgroup/project') + + expect(result).toEqual({ + provider: 'gitlab', + owner: 'group/subgroup', + repo: 'project', + host: 'gitlab.com', + }) + }) + + it('should parse self-hosted GitLab instance', () => { + const result = parseRepoUrl('https://gitlab.freedesktop.org/mesa/mesa') + + expect(result).toEqual({ + provider: 'gitlab', + owner: 'mesa', + repo: 'mesa', + host: 'gitlab.freedesktop.org', + }) + }) + }) + + describe('Bitbucket URLs', () => { + it('should parse standard Bitbucket URL', () => { + const result = parseRepoUrl('https://bitbucket.org/atlassian/aui') + + expect(result).toEqual({ + provider: 'bitbucket', + owner: 'atlassian', + repo: 'aui', + }) + }) + + it('should parse Bitbucket URL with www', () => { + const result = parseRepoUrl('https://www.bitbucket.org/atlassian/aui') + + expect(result).toEqual({ + provider: 'bitbucket', + owner: 'atlassian', + repo: 'aui', + }) + }) + }) + + describe('Codeberg URLs', () => { + it('should parse Codeberg URL', () => { + const result = parseRepoUrl('https://codeberg.org/forgejo/forgejo') + + expect(result).toMatchObject({ + provider: 'codeberg', + owner: 'forgejo', + repo: 'forgejo', + }) + }) + }) + + describe('Gitee URLs', () => { + it('should parse Gitee URL', () => { + const result = parseRepoUrl('https://gitee.com/oschina/gitee') + + expect(result).toEqual({ + provider: 'gitee', + owner: 'oschina', + repo: 'gitee', + }) + }) + }) + + describe('Sourcehut URLs', () => { + it('should parse Sourcehut URL with git.sr.ht', () => { + const result = parseRepoUrl('https://git.sr.ht/~sircmpwn/sourcehut') + + expect(result).toEqual({ + provider: 'sourcehut', + owner: '~sircmpwn', + repo: 'sourcehut', + }) + }) + + it('should parse Sourcehut URL with sr.ht', () => { + const result = parseRepoUrl('https://sr.ht/~user/repo') + + expect(result).toEqual({ + provider: 'sourcehut', + owner: '~user', + repo: 'repo', + }) + }) + }) + + describe('Tangled URLs', () => { + it('should parse Tangled URL', () => { + const result = parseRepoUrl('https://tangled.sh/did:plc:abc123/repo') + + expect(result).toEqual({ + provider: 'tangled', + owner: 'did:plc:abc123', + repo: 'repo', + }) + }) + }) + + describe('Radicle URLs', () => { + it('should parse Radicle URL', () => { + const result = parseRepoUrl( + 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + ) + + expect(result).toEqual({ + provider: 'radicle', + owner: '', + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + host: 'app.radicle.at', + }) + }) + }) + + describe('Generic Gitea URLs', () => { + it('should parse git.* subdomain as Gitea', () => { + const result = parseRepoUrl('https://git.example.com/user/repo') + + expect(result).toEqual({ + provider: 'gitea', + owner: 'user', + repo: 'repo', + host: 'git.example.com', + }) + }) + + it('should parse gitea.* subdomain', () => { + const result = parseRepoUrl('https://gitea.example.org/org/project') + + expect(result).toEqual({ + provider: 'gitea', + owner: 'org', + repo: 'project', + host: 'gitea.example.org', + }) + }) + }) + + describe('Forgejo URLs', () => { + it('should parse Forgejo instance URL', () => { + const result = parseRepoUrl('https://next.forgejo.org/forgejo/forgejo') + + expect(result).toEqual({ + provider: 'forgejo', + owner: 'forgejo', + repo: 'forgejo', + host: 'next.forgejo.org', + }) + }) + }) + + describe('Invalid URLs', () => { + it('should return null for invalid URL', () => { + const result = parseRepoUrl('not-a-url') + expect(result).toBeNull() + }) + + it('should return null for empty string', () => { + const result = parseRepoUrl('') + expect(result).toBeNull() + }) + + it('should return null for URL with insufficient path', () => { + const result = parseRepoUrl('https://github.com/vuejs') + expect(result).toBeNull() + }) + + it('should return null for unknown provider', () => { + const result = parseRepoUrl('https://example.com/user/repo') + expect(result).toBeNull() + }) + }) +}) diff --git a/test/unit/readme-url-resolution.spec.ts b/test/unit/readme-url-resolution.spec.ts index 2153b83dd6..7ee3aaf3ae 100644 --- a/test/unit/readme-url-resolution.spec.ts +++ b/test/unit/readme-url-resolution.spec.ts @@ -216,4 +216,68 @@ describe('parseRepositoryInfo', () => { }) }) }) + + describe('Radicle support', () => { + it('parses Radicle URL from app.radicle.at', () => { + const result = parseRepositoryInfo({ + url: 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + }) + expect(result).toMatchObject({ + provider: 'radicle', + owner: '', + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + host: 'app.radicle.at', + }) + }) + + it('parses Radicle URL from seed.radicle.at', () => { + const result = parseRepositoryInfo({ + url: 'https://seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + }) + expect(result).toMatchObject({ + provider: 'radicle', + owner: '', + repo: 'rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', + host: 'seed.radicle.at', + }) + }) + }) + + describe('Forgejo support', () => { + it('parses Forgejo URL from forgejo subdomain', () => { + const result = parseRepositoryInfo({ + url: 'https://forgejo.example.com/owner/repo', + }) + expect(result).toMatchObject({ + provider: 'forgejo', + owner: 'owner', + repo: 'repo', + host: 'forgejo.example.com', + }) + }) + + it('parses Forgejo URL from next.forgejo.org', () => { + const result = parseRepositoryInfo({ + url: 'https://next.forgejo.org/forgejo/forgejo', + }) + expect(result).toMatchObject({ + provider: 'forgejo', + owner: 'forgejo', + repo: 'forgejo', + host: 'next.forgejo.org', + }) + }) + + it('parses Forgejo URL with .git suffix', () => { + const result = parseRepositoryInfo({ + url: 'git+ssh://git@forgejo.myserver.com/user/project.git', + }) + expect(result).toMatchObject({ + provider: 'forgejo', + owner: 'user', + repo: 'project', + host: 'forgejo.myserver.com', + }) + }) + }) })