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') }}
+
+
+
+ {{ $t('package.links.changelog') }}
+
+
+
+ {{ $t('package.links.changelog') }}
+
+
{
@@ -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
+ }
}
/**