diff --git a/app/composables/usePackageAnalysis.ts b/app/composables/usePackageAnalysis.ts index 334e38446..c59ae1080 100644 --- a/app/composables/usePackageAnalysis.ts +++ b/app/composables/usePackageAnalysis.ts @@ -1,4 +1,5 @@ import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis' +import type { ChangelogInfo } from '#shared/types' export interface PackageAnalysisResponse { package: string @@ -10,6 +11,7 @@ export interface PackageAnalysisResponse { npm?: string } createPackage?: CreatePackageInfo + changelog?: ChangelogInfo } /** diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 734a7e060..fd76266ec 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -627,6 +627,26 @@ defineOgImageComponent('Package', { {{ $t('package.links.issues') }} +
  • + + + + +
  • { @@ -52,12 +56,17 @@ export default defineCachedEventHandler( // Only show if the packages are actually associated (same maintainers or same org) const createPackage = await findAssociatedCreatePackage(packageName, pkg) + // Detect changelog: check release tag first (faster), then fall back to file tree + const resolvedVersion = pkg.version ?? version ?? 'latest' + const changelog = await detectChangelog(pkg, packageName, resolvedVersion) + const analysis = analyzePackage(pkg, { typesPackage, createPackage }) return { package: packageName, - version: pkg.version ?? version ?? 'latest', + version: resolvedVersion, ...analysis, + changelog, } satisfies PackageAnalysisResponse } catch (error: unknown) { handleApiError(error, { @@ -71,7 +80,7 @@ export default defineCachedEventHandler( swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` + return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}` }, }, ) @@ -216,3 +225,49 @@ export interface PackageAnalysisResponse extends PackageAnalysis { package: string version: string } + +/** + * Detect changelog for a package version. + * Priority: release tag (faster) > changelog file in tarball + */ +async function detectChangelog( + pkg: ExtendedPackageJson, + packageName: string, + version: string, +): Promise { + // Step 1: Check for release tag (faster - single API call per tag format) + if (pkg.repository?.url) { + const repoRef = parseRepoUrl(pkg.repository.url) + if (repoRef) { + try { + const releaseInfo = await checkReleaseTag(repoRef, version, packageName) + if (releaseInfo.exists && releaseInfo.url) { + return { + source: 'releases', + url: releaseInfo.url, + } + } + } catch { + // Release tag check failed, continue to file tree check + } + } + } + + // Step 2: Check for changelog file in tarball (fallback) + try { + const fileTreeResult = await fetchFileTree(packageName, version) + const changelogFile = findChangelogFile(fileTreeResult.files) + if (changelogFile) { + return { + source: 'file', + url: `/package-code/${packageName}/v/${version}/${changelogFile}`, + filename: changelogFile, + } + } + } catch { + // File tree fetch failed, no changelog available + } + + // Neither release tag nor changelog file found + return undefined +} diff --git a/server/utils/release-tag.ts b/server/utils/release-tag.ts new file mode 100644 index 000000000..f35e26ed9 --- /dev/null +++ b/server/utils/release-tag.ts @@ -0,0 +1,156 @@ +import type { ProviderId, RepoRef } from '#shared/utils/git-providers' + +/** + * Result of checking for a release tag on a git provider + */ +export interface ReleaseTagResult { + /** Whether the release tag exists */ + exists: boolean + /** URL to the releases page if tag exists */ + url: string | null + /** The tag that was found (if any) */ + tag: string | null +} + +/** + * Tag formats to try, in priority order (most common first) + * Placeholders: {version} for semver, {name} for package name + */ +const TAG_FORMATS = [ + 'v{version}', // v1.2.3 (most common) + '{version}', // 1.2.3 + '{name}@{version}', // package@1.2.3 (monorepos like changesets) + '{name}@v{version}', // package@v1.2.3 +] as const + +/** + * Get the release URL for a tag on a given provider + */ +function getReleaseUrl(ref: RepoRef, tag: string): string { + const encodedTag = encodeURIComponent(tag) + + switch (ref.provider) { + case 'github': + return `https://github.com/${ref.owner}/${ref.repo}/releases/tag/${encodedTag}` + case 'gitlab': { + const host = ref.host ?? 'gitlab.com' + return `https://${host}/${ref.owner}/${ref.repo}/-/releases/${encodedTag}` + } + case 'codeberg': + return `https://codeberg.org/${ref.owner}/${ref.repo}/releases/tag/${encodedTag}` + case 'gitea': + case 'forgejo': { + const host = ref.host ?? 'codeberg.org' + return `https://${host}/${ref.owner}/${ref.repo}/releases/tag/${encodedTag}` + } + default: + return '' + } +} + +/** + * Get the API URL to check for a release tag + */ +function getTagCheckUrl(ref: RepoRef, tag: string): string | null { + const encodedTag = encodeURIComponent(tag) + + switch (ref.provider) { + case 'github': + return `https://api.github.com/repos/${ref.owner}/${ref.repo}/releases/tags/${encodedTag}` + case 'gitlab': { + const host = ref.host ?? 'gitlab.com' + const encodedProject = encodeURIComponent(`${ref.owner}/${ref.repo}`) + return `https://${host}/api/v4/projects/${encodedProject}/releases/${encodedTag}` + } + case 'codeberg': + return `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}/releases/tags/${encodedTag}` + case 'gitea': + case 'forgejo': { + const host = ref.host ?? 'codeberg.org' + return `https://${host}/api/v1/repos/${ref.owner}/${ref.repo}/releases/tags/${encodedTag}` + } + // Bitbucket, Sourcehut, Gitee, Tangled, Radicle don't have easy release tag APIs + default: + return null + } +} + +/** + * Generate tag candidates for a given version and package name + */ +function generateTagCandidates(version: string, packageName: string): string[] { + // Extract the short name for scoped packages: @scope/pkg -> pkg + const shortName = packageName.startsWith('@') + ? packageName.slice(packageName.indexOf('/') + 1) + : packageName + + return TAG_FORMATS.map(format => + format.replace('{version}', version).replace('{name}', shortName), + ) +} + +/** + * Providers that support release tag checking + */ +const SUPPORTED_PROVIDERS: Set = new Set([ + 'github', + 'gitlab', + 'codeberg', + 'gitea', + 'forgejo', +]) + +/** + * Check if a release tag exists for a package version on the given git provider. + * Tries multiple tag formats and returns the first successful match. + * + * @param ref - Repository reference from parseRepoUrl + * @param version - Package version to check (e.g., "1.2.3") + * @param packageName - Full package name (e.g., "@scope/pkg" or "pkg") + * @returns Result indicating if a release tag exists and its URL + */ +export async function checkReleaseTag( + ref: RepoRef, + version: string, + packageName: string, +): Promise { + // Skip unsupported providers + if (!SUPPORTED_PROVIDERS.has(ref.provider)) { + return { exists: false, url: null, tag: null } + } + + const tagCandidates = generateTagCandidates(version, packageName) + + // Try each tag format sequentially, stop on first success + for (const tag of tagCandidates) { + const checkUrl = getTagCheckUrl(ref, tag) + if (!checkUrl) continue + + try { + const response = await fetch(checkUrl, { + headers: { + 'Accept': 'application/json', + // GitHub API requires User-Agent + 'User-Agent': 'npmx.dev', + }, + }) + + if (response.ok) { + // Found a release with this tag + return { + exists: true, + url: getReleaseUrl(ref, tag), + tag, + } + } + // 404 means tag doesn't exist, continue to next candidate + // Other errors we also skip and try next + } catch { + // Network error, skip this tag and try next + continue + } + } + + // No matching tag found + return { exists: false, url: null, tag: null } +} diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index 4bdd41dd9..c155c2c77 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -342,3 +342,15 @@ export interface PackageFileContentResponse { lines: number markdownHtml?: ReadmeResponse } + +/** + * Changelog info for a package version + */ +export interface ChangelogInfo { + /** Source type: 'file' (internal view) or 'releases' (external link) */ + source: 'file' | 'releases' + /** URL to the changelog (internal path for file, external URL for releases) */ + url: string + /** Filename if source is 'file' (e.g., "CHANGELOG.md") */ + filename?: string +} diff --git a/shared/utils/changelog.ts b/shared/utils/changelog.ts new file mode 100644 index 000000000..47305a496 --- /dev/null +++ b/shared/utils/changelog.ts @@ -0,0 +1,48 @@ +import type { JsDelivrFileNode } from '#shared/types' + +/** + * Changelog filenames to check (case-insensitive match, priority order) + * Based on common conventions in npm packages + */ +export const CHANGELOG_FILENAMES = [ + 'CHANGELOG.md', + 'CHANGELOG', + 'CHANGELOG.txt', + 'changelog.md', + 'changelog', + 'HISTORY.md', + 'HISTORY', + 'history.md', + 'CHANGES.md', + 'CHANGES', + 'changes.md', + 'NEWS.md', + 'RELEASES.md', +] as const + +/** + * Find a changelog file in the package's root-level files. + * Returns the actual filename if found, null otherwise. + * + * @param files - The file tree from jsDelivr API (root-level files only) + * @returns The changelog filename if found, null otherwise + */ +export function findChangelogFile(files: JsDelivrFileNode[]): string | null { + // Create a map for case-insensitive lookup of actual filenames + const fileMap = new Map() + for (const file of files) { + if (file.type === 'file') { + fileMap.set(file.name.toLowerCase(), file.name) + } + } + + // Check for changelog files in priority order + for (const changelogName of CHANGELOG_FILENAMES) { + const actualName = fileMap.get(changelogName.toLowerCase()) + if (actualName) { + return actualName + } + } + + return null +} diff --git a/shared/utils/package-analysis.ts b/shared/utils/package-analysis.ts index f4a767220..d1cdd1439 100644 --- a/shared/utils/package-analysis.ts +++ b/shared/utils/package-analysis.ts @@ -18,6 +18,12 @@ export interface PackageAnalysis { } /** Associated create-* package if it exists */ createPackage?: CreatePackageInfo + /** Changelog info if available (file in tarball or release page) */ + changelog?: { + source: 'file' | 'releases' + url: string + filename?: string + } } /**