From 87a2f2aabfbc7f3ff70edd7de4e6a4396d448796 Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Mon, 2 Feb 2026 12:29:51 -0500 Subject: [PATCH 1/3] feat: add changelog button --- app/composables/usePackageAnalysis.ts | 2 + app/pages/package/[...package].vue | 20 +++ i18n/locales/en.json | 1 + lunaria/files/ar-EG.json | 1 + lunaria/files/az.json | 1 + lunaria/files/cs-CZ.json | 1 + lunaria/files/de-DE.json | 1 + lunaria/files/en-US.json | 1 + lunaria/files/es-419.json | 1 + lunaria/files/es-ES.json | 1 + lunaria/files/fr-FR.json | 1 + lunaria/files/hi-IN.json | 1 + lunaria/files/hu-HU.json | 1 + lunaria/files/id-ID.json | 1 + lunaria/files/it-IT.json | 1 + lunaria/files/ja-JP.json | 1 + lunaria/files/ne-NP.json | 1 + lunaria/files/pl-PL.json | 1 + lunaria/files/pt-BR.json | 1 + lunaria/files/ru-RU.json | 1 + lunaria/files/uk-UA.json | 1 + lunaria/files/zh-CN.json | 1 + server/api/registry/analysis/[...pkg].get.ts | 59 ++++++- server/utils/release-tag.ts | 156 +++++++++++++++++++ shared/types/npm-registry.ts | 12 ++ shared/utils/changelog.ts | 48 ++++++ shared/utils/package-analysis.ts | 6 + 27 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 server/utils/release-tag.ts create mode 100644 shared/utils/changelog.ts 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 7b0c1c838..549eed65a 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -609,6 +609,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..be4c5481d --- /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: ProviderId[] = 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 + } } /** From 900272d31738d2dd358e010920030f0e021852b2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:01:34 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- lunaria/files/ar-EG.json | 1 - lunaria/files/az.json | 1 - lunaria/files/cs-CZ.json | 1 - lunaria/files/de-DE.json | 1 - lunaria/files/es-419.json | 1 - lunaria/files/es-ES.json | 1 - lunaria/files/fr-FR.json | 1 - lunaria/files/hi-IN.json | 1 - lunaria/files/hu-HU.json | 1 - lunaria/files/id-ID.json | 1 - lunaria/files/it-IT.json | 1 - lunaria/files/ja-JP.json | 1 - lunaria/files/ne-NP.json | 1 - lunaria/files/pl-PL.json | 1 - lunaria/files/pt-BR.json | 1 - lunaria/files/ru-RU.json | 1 - lunaria/files/uk-UA.json | 1 - lunaria/files/zh-CN.json | 1 - 18 files changed, 18 deletions(-) diff --git a/lunaria/files/ar-EG.json b/lunaria/files/ar-EG.json index 31ece8ee3..e7ed0a3ab 100644 --- a/lunaria/files/ar-EG.json +++ b/lunaria/files/ar-EG.json @@ -175,7 +175,6 @@ "repo": "المستودع", "homepage": "الصفحة الرئيسية", "issues": "المشكلات", - "changelog": "سجل التغييرات", "jsr": "jsr", "code": "الكود", "docs": "التوثيق", diff --git a/lunaria/files/az.json b/lunaria/files/az.json index a8b8e9558..1c110488b 100644 --- a/lunaria/files/az.json +++ b/lunaria/files/az.json @@ -143,7 +143,6 @@ "repo": "repo", "homepage": "ana səhifə", "issues": "xətalar", - "changelog": "changelog", "jsr": "jsr", "code": "kod", "docs": "sənədlər", diff --git a/lunaria/files/cs-CZ.json b/lunaria/files/cs-CZ.json index 2afc49be7..d2d52a264 100644 --- a/lunaria/files/cs-CZ.json +++ b/lunaria/files/cs-CZ.json @@ -171,7 +171,6 @@ "repo": "repozitář", "homepage": "domovská stránka", "issues": "úkoly", - "changelog": "changelog", "jsr": "jsr", "code": "kód", "docs": "dokumentace", diff --git a/lunaria/files/de-DE.json b/lunaria/files/de-DE.json index fc2445e30..de697d4e8 100644 --- a/lunaria/files/de-DE.json +++ b/lunaria/files/de-DE.json @@ -172,7 +172,6 @@ "repo": "Repo", "homepage": "Homepage", "issues": "Issues", - "changelog": "Changelog", "jsr": "JSR", "code": "Code", "docs": "Doku", diff --git a/lunaria/files/es-419.json b/lunaria/files/es-419.json index 0d9f1b644..b3af969e2 100644 --- a/lunaria/files/es-419.json +++ b/lunaria/files/es-419.json @@ -144,7 +144,6 @@ "repo": "repo", "homepage": "página de inicio", "issues": "problemas", - "changelog": "historial de cambios", "jsr": "jsr", "code": "código", "docs": "documentación", diff --git a/lunaria/files/es-ES.json b/lunaria/files/es-ES.json index 0d813544e..2caa8ff05 100644 --- a/lunaria/files/es-ES.json +++ b/lunaria/files/es-ES.json @@ -144,7 +144,6 @@ "repo": "repo", "homepage": "página de inicio", "issues": "problemas", - "changelog": "historial de cambios", "jsr": "jsr", "code": "código", "docs": "documentación", diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json index 62b4c2585..6af79f26e 100644 --- a/lunaria/files/fr-FR.json +++ b/lunaria/files/fr-FR.json @@ -151,7 +151,6 @@ "repo": "dépôt", "homepage": "site web", "issues": "issues", - "changelog": "changelog", "jsr": "jsr", "code": "code", "docs": "docs", diff --git a/lunaria/files/hi-IN.json b/lunaria/files/hi-IN.json index a8a5e687e..579465b8f 100644 --- a/lunaria/files/hi-IN.json +++ b/lunaria/files/hi-IN.json @@ -171,7 +171,6 @@ "repo": "रिपो", "homepage": "मुखपृष्ठ", "issues": "मसले", - "changelog": "परिवर्तन लॉग", "jsr": "jsr", "code": "कोड", "docs": "दस्तावेज़", diff --git a/lunaria/files/hu-HU.json b/lunaria/files/hu-HU.json index 9ecc7a534..3ab82e418 100644 --- a/lunaria/files/hu-HU.json +++ b/lunaria/files/hu-HU.json @@ -143,7 +143,6 @@ "repo": "repo", "homepage": "honlap", "issues": "hibák", - "changelog": "changelog", "jsr": "jsr", "code": "kód", "docs": "dokumentáció", diff --git a/lunaria/files/id-ID.json b/lunaria/files/id-ID.json index fa7193cd9..15493c369 100644 --- a/lunaria/files/id-ID.json +++ b/lunaria/files/id-ID.json @@ -155,7 +155,6 @@ "repo": "repo", "homepage": "beranda", "issues": "kendala", - "changelog": "changelog", "jsr": "jsr", "code": "kode", "docs": "dokumen", diff --git a/lunaria/files/it-IT.json b/lunaria/files/it-IT.json index 47a493d15..2a79d9c45 100644 --- a/lunaria/files/it-IT.json +++ b/lunaria/files/it-IT.json @@ -128,7 +128,6 @@ "repo": "repo", "homepage": "homepage", "issues": "issues", - "changelog": "changelog", "jsr": "jsr", "code": "codice", "docs": "documenti", diff --git a/lunaria/files/ja-JP.json b/lunaria/files/ja-JP.json index 0f83b7aca..451ec8e76 100644 --- a/lunaria/files/ja-JP.json +++ b/lunaria/files/ja-JP.json @@ -132,7 +132,6 @@ "repo": "リポジトリ", "homepage": "ホームページ", "issues": "issues", - "changelog": "変更履歴", "jsr": "jsr", "code": "コード", "docs": "ドキュメント", diff --git a/lunaria/files/ne-NP.json b/lunaria/files/ne-NP.json index 08d92afeb..23c849342 100644 --- a/lunaria/files/ne-NP.json +++ b/lunaria/files/ne-NP.json @@ -155,7 +155,6 @@ "repo": "रिपो", "homepage": "होमपेज", "issues": "इश्यूहरू", - "changelog": "changelog", "jsr": "jsr", "code": "कोड", "docs": "डकुमेन्टेसन", diff --git a/lunaria/files/pl-PL.json b/lunaria/files/pl-PL.json index 907043350..9f838945a 100644 --- a/lunaria/files/pl-PL.json +++ b/lunaria/files/pl-PL.json @@ -171,7 +171,6 @@ "repo": "repozytorium", "homepage": "strona", "issues": "zgłoszenia", - "changelog": "changelog", "jsr": "jsr", "code": "kod źródłowy", "docs": "dokumentacja", diff --git a/lunaria/files/pt-BR.json b/lunaria/files/pt-BR.json index ffa0bf933..0f3e84395 100644 --- a/lunaria/files/pt-BR.json +++ b/lunaria/files/pt-BR.json @@ -172,7 +172,6 @@ "repo": "repositório", "homepage": "página inicial", "issues": "problemas", - "changelog": "changelog", "jsr": "jsr", "code": "código", "docs": "documentação", diff --git a/lunaria/files/ru-RU.json b/lunaria/files/ru-RU.json index c8dab742c..e5f479aac 100644 --- a/lunaria/files/ru-RU.json +++ b/lunaria/files/ru-RU.json @@ -140,7 +140,6 @@ "repo": "репозиторий", "homepage": "сайт", "issues": "issues", - "changelog": "changelog", "jsr": "jsr", "code": "код", "docs": "доки", diff --git a/lunaria/files/uk-UA.json b/lunaria/files/uk-UA.json index f71e20617..0ab818a58 100644 --- a/lunaria/files/uk-UA.json +++ b/lunaria/files/uk-UA.json @@ -143,7 +143,6 @@ "repo": "репозиторій", "homepage": "головна сторінка", "issues": "проблеми", - "changelog": "changelog", "jsr": "jsr", "code": "код", "docs": "документація", diff --git a/lunaria/files/zh-CN.json b/lunaria/files/zh-CN.json index e1a1b88a3..dc13c1a5b 100644 --- a/lunaria/files/zh-CN.json +++ b/lunaria/files/zh-CN.json @@ -175,7 +175,6 @@ "repo": "仓库", "homepage": "主页", "issues": "议题", - "changelog": "更新日志", "jsr": "jsr", "code": "代码", "docs": "文档", From 6c7c73ee2e62c8543543aee94e1b1aab182f562c Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Mon, 2 Feb 2026 13:06:27 -0500 Subject: [PATCH 3/3] fix: release tag types --- server/utils/release-tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/release-tag.ts b/server/utils/release-tag.ts index be4c5481d..f35e26ed9 100644 --- a/server/utils/release-tag.ts +++ b/server/utils/release-tag.ts @@ -92,7 +92,7 @@ function generateTagCandidates(version: string, packageName: string): string[] { /** * Providers that support release tag checking */ -const SUPPORTED_PROVIDERS: ProviderId[] = new Set([ +const SUPPORTED_PROVIDERS: Set = new Set([ 'github', 'gitlab', 'codeberg',