Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/composables/usePackageAnalysis.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,7 @@ export interface PackageAnalysisResponse {
npm?: string
}
createPackage?: CreatePackageInfo
changelog?: ChangelogInfo
}

/**
Expand Down
20 changes: 20 additions & 0 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,26 @@ defineOgImageComponent('Package', {
{{ $t('package.links.issues') }}
</a>
</li>
<li v-if="packageAnalysis?.changelog">
<NuxtLink
v-if="packageAnalysis.changelog.source === 'file'"
:to="packageAnalysis.changelog.url"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon:catalog w-4 h-4" aria-hidden="true" />
{{ $t('package.links.changelog') }}
</NuxtLink>
<a
v-else
:href="packageAnalysis.changelog.url"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon:catalog w-4 h-4" aria-hidden="true" />
{{ $t('package.links.changelog') }}
</a>
</li>
<li>
<a
:href="`https://www.npmjs.com/package/${pkg.name}`"
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"repo": "repo",
"homepage": "homepage",
"issues": "issues",
"changelog": "changelog",
"jsr": "jsr",
"code": "code",
"docs": "docs",
Expand Down
1 change: 1 addition & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"repo": "repo",
"homepage": "homepage",
"issues": "issues",
"changelog": "changelog",
"jsr": "jsr",
"code": "code",
"docs": "docs",
Expand Down
59 changes: 57 additions & 2 deletions server/api/registry/analysis/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
} from '#shared/utils/constants'
import { parseRepoUrl } from '#shared/utils/git-providers'
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'
import type { ChangelogInfo } from '#shared/types'
import { findChangelogFile } from '#shared/utils/changelog'
import { fetchFileTree } from '../../../utils/file-tree'
import { checkReleaseTag } from '../../../utils/release-tag'

export default defineCachedEventHandler(
async event => {
Expand Down Expand Up @@ -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, {
Expand All @@ -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()}`
},
},
)
Expand Down Expand Up @@ -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<ChangelogInfo | undefined> {
// 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
}
156 changes: 156 additions & 0 deletions server/utils/release-tag.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does tangled.sh have release pages? Do we have some better pattern for this or prior art?

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an assumption I am making, I could use some help here on reality

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<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<ReleaseTagResult> {
// 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 }
}
12 changes: 12 additions & 0 deletions shared/types/npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
48 changes: 48 additions & 0 deletions shared/utils/changelog.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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
}
6 changes: 6 additions & 0 deletions shared/utils/package-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

/**
Expand Down