(
+ `${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
+ )
+
+ return await detectChangelog(pkg)
+ } catch (error) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_PACKAGE_DETECT_CHANGELOG,
+ })
+ }
+ },
+ // {
+ // maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes
+ // swr: true,
+ // getKey: event => {
+ // const pkg = getRouterParam(event, 'pkg') ?? ''
+ // return `changelog:v1:${pkg.replace(/\/+$/, '').trim()}`
+ // },
+ // },
+)
diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo].ts b/server/api/changelog/releases/[provider]/[owner]/[repo].ts
new file mode 100644
index 000000000..40bda2ca3
--- /dev/null
+++ b/server/api/changelog/releases/[provider]/[owner]/[repo].ts
@@ -0,0 +1,62 @@
+import type { ProviderId } from '~~/shared/utils/git-providers'
+import type { ReleaseData } from '~~/shared/types/changelog'
+import { ERROR_CHANGELOG_RELEASES_FAILED, THROW_INCOMPLETE_PARAM } from '~~/shared/utils/constants'
+import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release'
+import { parse } from 'valibot'
+import { changelogRenderer } from '~~/server/utils/changelog/markdown'
+
+export default defineCachedEventHandler(async event => {
+ const provider = getRouterParam(event, 'provider')
+ const repo = getRouterParam(event, 'repo')
+ const owner = getRouterParam(event, 'owner')
+
+ if (!repo || !provider || !owner) {
+ throw createError({
+ status: 404,
+ statusMessage: THROW_INCOMPLETE_PARAM,
+ })
+ }
+
+ try {
+ switch (provider as ProviderId) {
+ case 'github':
+ return await getReleasesFromGithub(owner, repo)
+
+ default:
+ return false
+ }
+ } catch (error) {
+ handleApiError(error, {
+ statusCode: 502,
+ // message: 'temp',
+ message: ERROR_CHANGELOG_RELEASES_FAILED,
+ })
+ }
+})
+
+async function getReleasesFromGithub(owner: string, repo: string) {
+ const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, {
+ headers: {
+ 'Accept': '*/*',
+ 'User-Agent': 'npmx.dev',
+ },
+ })
+
+ const { releases } = parse(GithubReleaseCollectionSchama, data)
+
+ const render = await changelogRenderer()
+
+ return releases.map(r => {
+ const { html, toc } = render(r.markdown, r.id)
+ return {
+ id: r.id,
+ // replace single \n within like with Vue's releases
+ html: html?.replace(/(?)\n/g, '
') ?? null,
+ title: r.name ?? r.tag,
+ draft: r.draft,
+ prerelease: r.prerelease,
+ toc,
+ publishedAt: r.publishedAt,
+ } satisfies ReleaseData
+ })
+}
diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts
index a397f4cb6..4db880fb3 100644
--- a/server/api/registry/analysis/[...pkg].get.ts
+++ b/server/api/registry/analysis/[...pkg].get.ts
@@ -54,7 +54,6 @@ export default defineCachedEventHandler(
const createPackage = await findAssociatedCreatePackage(packageName, pkg)
const analysis = analyzePackage(pkg, { typesPackage, createPackage })
-
return {
package: packageName,
version: pkg.version ?? version ?? 'latest',
diff --git a/server/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts
new file mode 100644
index 000000000..e6859eef8
--- /dev/null
+++ b/server/utils/changelog/detectChangelog.ts
@@ -0,0 +1,120 @@
+import type { ChangelogReleaseInfo } from '~~/shared/types/changelog'
+import { type RepoRef, parseRepoUrl } from '~~/shared/utils/git-providers'
+import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis'
+// ChangelogInfo
+
+/**
+ * Detect whether changelogs/releases are available for this package
+ *
+ * first checks if releases are available and then changelog.md
+ */
+export async function detectChangelog(
+ pkg: ExtendedPackageJson,
+ // packageName: string,
+ // version: string,
+) {
+ if (!pkg.repository?.url) {
+ return false
+ }
+
+ const repoRef = parseRepoUrl(pkg.repository.url)
+ if (!repoRef) {
+ return false
+ }
+
+ const releaseInfo = await checkReleases(repoRef)
+
+ return releaseInfo || checkChangelogFile(repoRef)
+}
+
+/**
+ * check whether releases are being used with this repo
+ * @returns true if in use
+ */
+async function checkReleases(ref: RepoRef): Promise {
+ const checkUrls = getLatestReleaseUrl(ref)
+
+ for (const checkUrl of checkUrls ?? []) {
+ const exists = await fetch(checkUrl, {
+ headers: {
+ // GitHub API requires User-Agent
+ 'User-Agent': 'npmx.dev',
+ },
+ method: 'HEAD', // we just need to know if it exists or not
+ })
+ .then(r => r.ok)
+ .catch(() => false)
+ if (exists) {
+ return {
+ provider: ref.provider,
+ type: 'release',
+ repo: `${ref.owner}/${ref.repo}`,
+ }
+ }
+ }
+ return false
+}
+
+/**
+ * get the url to check if releases are being used.
+ *
+ * @returns returns an array so that if providers don't have a latest that we can check for versions
+ */
+function getLatestReleaseUrl(ref: RepoRef): null | string[] {
+ switch (ref.provider) {
+ case 'github':
+ return [`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`]
+ }
+
+ return null
+}
+
+const CHANGELOG_FILENAMES = ['changelog', 'history', 'changes', 'news', 'releases'] as const
+
+async function checkChangelogFile(ref: RepoRef) {
+ const checkUrls = getChangelogUrls(ref)
+
+ for (const checkUrl of checkUrls ?? []) {
+ const exists = await fetch(checkUrl, {
+ headers: {
+ // GitHub API requires User-Agent
+ 'User-Agent': 'npmx.dev',
+ },
+ method: 'HEAD', // we just need to know if it exists or not
+ })
+ .then(r => r.ok)
+ .catch(() => false)
+ if (exists) {
+ console.log('exists', checkUrl)
+ return true
+ }
+ }
+ return false
+}
+
+function getChangelogUrls(ref: RepoRef) {
+ const baseUrl = getBaseFileUrl(ref)
+ if (!baseUrl) {
+ return
+ }
+
+ return CHANGELOG_FILENAMES.flatMap(fileName => {
+ const fileNameUpCase = fileName.toUpperCase()
+ return [
+ `${baseUrl}/${fileNameUpCase}.md`,
+ `${baseUrl}/${fileName}.md`,
+ `${baseUrl}/${fileNameUpCase}`,
+ `${baseUrl}/${fileName}`,
+ `${baseUrl}/${fileNameUpCase}.txt`,
+ `${baseUrl}/${fileName}.txt`,
+ ]
+ })
+}
+
+function getBaseFileUrl(ref: RepoRef) {
+ switch (ref.provider) {
+ case 'github':
+ return `https://ungh.cc/repos/${ref.owner}/${ref.repo}/files/HEAD`
+ }
+ return null
+}
diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts
new file mode 100644
index 000000000..02ce4e43d
--- /dev/null
+++ b/server/utils/changelog/markdown.ts
@@ -0,0 +1,212 @@
+import { marked, type Tokens } from 'marked'
+import {
+ ALLOWED_ATTR,
+ ALLOWED_TAGS,
+ calculateSemanticDepth,
+ prefixId,
+ replaceHtmlLink,
+ slugify,
+} from '../readme'
+import sanitizeHtml from 'sanitize-html'
+
+export async function changelogRenderer() {
+ const renderer = new marked.Renderer()
+
+ const shiki = await getShikiHighlighter()
+
+ renderer.link = function ({ href, title, tokens }: Tokens.Link) {
+ const text = this.parser.parseInline(tokens)
+ const titleAttr = title ? ` title="${title}"` : ''
+ const plainText = text.replace(/<[^>]*>/g, '').trim()
+
+ const intermediateTitleAttr = `${` data-title-intermediate="${plainText || title}"`}`
+
+ return `${text}`
+ }
+
+ // GitHub-style callouts: > [!NOTE], > [!TIP], etc.
+ renderer.blockquote = function ({ tokens }: Tokens.Blockquote) {
+ const body = this.parser.parse(tokens)
+
+ const calloutMatch = body.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:
)?\s*/i)
+
+ if (calloutMatch?.[1]) {
+ const calloutType = calloutMatch[1].toLowerCase()
+ const cleanedBody = body.replace(calloutMatch[0], '
')
+ return `
${cleanedBody}
\n`
+ }
+
+ return `${body}
\n`
+ }
+
+ // Syntax highlighting for code blocks (uses shared highlighter)
+ renderer.code = ({ text, lang }: Tokens.Code) => {
+ const html = highlightCodeSync(shiki, text, lang || 'text')
+ // Add copy button
+ return `
+
+ ${html}
+
`
+ }
+
+ return (markdown: string | null, releaseId: string | number) => {
+ // Collect table of contents items during parsing
+ const toc: TocItem[] = []
+
+ if (!markdown) {
+ return {
+ html: null,
+ toc,
+ }
+ }
+
+ // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2)
+ const usedSlugs = new Map()
+
+ let lastSemanticLevel = 2 // Start after h2 (the "Readme" section heading)
+ renderer.heading = function ({ tokens, depth }: Tokens.Heading) {
+ // Calculate the target semantic level based on document structure
+ // Start at h3 (since page h1 + section h2 already exist)
+ // But ensure we never skip levels - can only go down by 1 or stay same/go up
+ const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel)
+ lastSemanticLevel = semanticLevel
+ const text = this.parser.parseInline(tokens)
+
+ // Generate GitHub-style slug for anchor links
+ // adding release id to prevent conflicts
+ let slug = slugify(text)
+ if (!slug) slug = 'heading' // Fallback for empty headings
+
+ // Handle duplicate slugs (GitHub-style: foo, foo-1, foo-2)
+ const count = usedSlugs.get(slug) ?? 0
+ usedSlugs.set(slug, count + 1)
+ const uniqueSlug = count === 0 ? slug : `${slug}-${count}`
+
+ // Prefix with 'user-content-' to avoid collisions with page IDs
+ // (e.g., #install, #dependencies, #versions are used by the package page)
+ const id = `user-content-${releaseId}-${uniqueSlug}`
+
+ // Collect TOC item with plain text (HTML stripped)
+ const plainText = text
+ .replace(/<[^>]*>/g, '')
+ // remove non breaking spaces
+ .replace(/ ?/g, '')
+ .trim()
+ if (plainText) {
+ toc.push({ text: plainText, id, depth })
+ }
+
+ return `${text}\n`
+ }
+
+ return {
+ html: marked.parse(markdown, {
+ renderer,
+ walkTokens: token => {
+ if (token.type === 'html') {
+ token.text = replaceHtmlLink(token.text)
+ }
+ },
+ }) as string,
+ toc,
+ }
+ }
+}
+
+export function sanitizeRawHTML(rawHtml: string) {
+ return sanitizeHtml(rawHtml, {
+ allowedTags: ALLOWED_TAGS,
+ allowedAttributes: ALLOWED_ATTR,
+ allowedSchemes: ['http', 'https', 'mailto'],
+ // Transform img src URLs (GitHub blob → raw, relative → GitHub raw)
+ transformTags: {
+ h1: (_, attribs) => {
+ return { tagName: 'h3', attribs: { ...attribs, 'data-level': '1' } }
+ },
+ h2: (_, attribs) => {
+ return { tagName: 'h4', attribs: { ...attribs, 'data-level': '2' } }
+ },
+ h3: (_, attribs) => {
+ if (attribs['data-level']) return { tagName: 'h3', attribs: attribs }
+ return { tagName: 'h5', attribs: { ...attribs, 'data-level': '3' } }
+ },
+ h4: (_, attribs) => {
+ if (attribs['data-level']) return { tagName: 'h4', attribs: attribs }
+ return { tagName: 'h6', attribs: { ...attribs, 'data-level': '4' } }
+ },
+ h5: (_, attribs) => {
+ if (attribs['data-level']) return { tagName: 'h5', attribs: attribs }
+ return { tagName: 'h6', attribs: { ...attribs, 'data-level': '5' } }
+ },
+ h6: (_, attribs) => {
+ if (attribs['data-level']) return { tagName: 'h6', attribs: attribs }
+ return { tagName: 'h6', attribs: { ...attribs, 'data-level': '6' } }
+ },
+ // img: (tagName, attribs) => {
+ // if (attribs.src) {
+ // attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo)
+ // }
+ // return { tagName, attribs }
+ // },
+ // source: (tagName, attribs) => {
+ // if (attribs.src) {
+ // attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo)
+ // }
+ // if (attribs.srcset) {
+ // attribs.srcset = attribs.srcset
+ // .split(',')
+ // .map(entry => {
+ // const parts = entry.trim().split(/\s+/)
+ // const url = parts[0]
+ // if (!url) return entry.trim()
+ // const descriptor = parts[1]
+ // const resolvedUrl = resolveImageUrl(url, packageName, repoInfo)
+ // return descriptor ? `${resolvedUrl} ${descriptor}` : resolvedUrl
+ // })
+ // .join(', ')
+ // }
+ // return { tagName, attribs }
+ // },
+ // a: (tagName, attribs) => {
+ // if (!attribs.href) {
+ // return { tagName, attribs }
+ // }
+
+ // const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo)
+
+ // const provider = matchPlaygroundProvider(resolvedHref)
+ // if (provider && !seenUrls.has(resolvedHref)) {
+ // seenUrls.add(resolvedHref)
+
+ // collectedLinks.push({
+ // url: resolvedHref,
+ // provider: provider.id,
+ // providerName: provider.name,
+ // /**
+ // * We need to set some data attribute before hand because `transformTags` doesn't
+ // * provide the text of the element. This will automatically be removed, because there
+ // * is an allow list for link attributes.
+ // * */
+ // label: attribs['data-title-intermediate'] || provider.name,
+ // })
+ // }
+
+ // // Add security attributes for external links
+ // if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) {
+ // attribs.rel = 'nofollow noreferrer noopener'
+ // attribs.target = '_blank'
+ // }
+ // attribs.href = resolvedHref
+ // return { tagName, attribs }
+ // },
+ div: prefixId,
+ p: prefixId,
+ span: prefixId,
+ section: prefixId,
+ article: prefixId,
+ },
+ })
+}
diff --git a/server/utils/readme.ts b/server/utils/readme.ts
index 9920eb2af..07e41ba16 100644
--- a/server/utils/readme.ts
+++ b/server/utils/readme.ts
@@ -110,7 +110,7 @@ function matchPlaygroundProvider(url: string): PlaygroundProvider | null {
// allow h1-h6, but replace h1-h2 later since we shift README headings down by 2 levels
// (page h1 = package name, h2 = "Readme" section, so README h1 → h3)
-const ALLOWED_TAGS = [
+export const ALLOWED_TAGS = [
'h1',
'h2',
'h3',
@@ -151,7 +151,7 @@ const ALLOWED_TAGS = [
'button',
]
-const ALLOWED_ATTR: Record = {
+export const ALLOWED_ATTR: Record = {
'*': ['id'], // Allow id on all tags
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height', 'align'],
@@ -183,8 +183,9 @@ const ALLOWED_ATTR: Record = {
* - Remove special characters (keep alphanumeric, hyphens, underscores)
* - Collapse multiple hyphens
*/
-function slugify(text: string): string {
+export function slugify(text: string): string {
return text
+ .replace(/ ?/g, '') // remove non breaking spaces
.replace(/<[^>]*>/g, '') // Strip HTML tags
.toLowerCase()
.trim()
@@ -221,7 +222,7 @@ const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
return true
}
-const replaceHtmlLink = (html: string) => {
+export const replaceHtmlLink = (html: string) => {
return html.replace(/href="([^"]+)"/g, (match, href) => {
if (isNpmJsUrlThatCanBeRedirected(new URL(href, 'https://www.npmjs.com'))) {
const newHref = href.replace(/^https?:\/\/(www\.)?npmjs\.com/, '')
@@ -319,7 +320,7 @@ function resolveImageUrl(url: string, packageName: string, repoInfo?: Repository
}
// Helper to prefix id attributes with 'user-content-'
-function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
+export function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.id && !attribs.id.startsWith('user-content-')) {
attribs.id = `user-content-${attribs.id}`
}
@@ -329,7 +330,7 @@ function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
// README h1 always becomes h3
// For deeper levels, ensure sequential order
// Don't allow jumping more than 1 level deeper than previous
-function calculateSemanticDepth(depth: number, lastSemanticLevel: number) {
+export function calculateSemanticDepth(depth: number, lastSemanticLevel: number) {
if (depth === 1) return 3
const maxAllowed = Math.min(lastSemanticLevel + 1, 6)
return Math.min(depth + 2, maxAllowed)
diff --git a/shared/schemas/changelog/release.ts b/shared/schemas/changelog/release.ts
new file mode 100644
index 000000000..7e75761bd
--- /dev/null
+++ b/shared/schemas/changelog/release.ts
@@ -0,0 +1,18 @@
+import * as v from 'valibot'
+
+export const GithubReleaseSchama = v.object({
+ id: v.pipe(v.number(), v.integer()),
+ name: v.nullable(v.string()),
+ tag: v.string(),
+ draft: v.boolean(),
+ prerelease: v.boolean(),
+ markdown: v.nullable(v.string()), // can be null if no descroption was made
+ publishedAt: v.pipe(v.string(), v.isoTimestamp()),
+})
+
+export const GithubReleaseCollectionSchama = v.object({
+ releases: v.array(GithubReleaseSchama),
+})
+
+export type GithubRelease = v.InferOutput
+export type GithubReleaseCollection = v.InferOutput
diff --git a/shared/types/changelog.ts b/shared/types/changelog.ts
new file mode 100644
index 000000000..aeeb02585
--- /dev/null
+++ b/shared/types/changelog.ts
@@ -0,0 +1,29 @@
+import type { ProviderId } from '../utils/git-providers'
+import type { TocItem } from './readme'
+
+export interface ChangelogReleaseInfo {
+ type: 'release'
+ provider: ProviderId
+ repo: `${string}/${string}`
+}
+
+export interface ChangelogMarkdownInfo {
+ type: 'md'
+ provider: ProviderId
+ /**
+ * location within the repository
+ */
+ location: string
+}
+
+export type ChangelogInfo = ChangelogReleaseInfo | ChangelogMarkdownInfo
+
+export interface ReleaseData {
+ title: string // example "v1.x.x",
+ html: string | null
+ prerelease?: boolean
+ draft?: boolean
+ id: string | number
+ publishedAt?: string
+ toc?: TocItem[]
+}
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index 4d940ce93..ca690eecf 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -17,6 +17,7 @@ export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.'
export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.'
export const ERROR_PACKAGE_REQUIREMENTS_FAILED =
'Package name, version, and file path are required.'
+export const ERROR_PACKAGE_DETECT_CHANGELOG = 'failed to detect package has changelog'
export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.'
export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.'
export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!'
@@ -34,6 +35,9 @@ export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.'
export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible."
export const ERROR_NEED_REAUTH = 'User needs to reauthenticate'
+export const ERROR_CHANGELOG_RELEASES_FAILED = 'Failed to get releases'
+export const THROW_INCOMPLETE_PARAM = "Couldn't do request due to incomplete parameters"
+
// microcosm services
export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
export const SLINGSHOT_HOST = 'slingshot.microcosm.blue'
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index 23ffb915a..675ee799c 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -120,6 +120,7 @@ import {
ButtonBase,
LinkBase,
CallToAction,
+ ChangelogCard,
CodeDirectoryListing,
CodeFileTree,
CodeMobileTreeDrawer,
@@ -1895,6 +1896,23 @@ describe('component accessibility audits', () => {
})
})
+ describe('Changelog', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(ChangelogCard, {
+ props: {
+ release: {
+ html: 'test a11y
',
+ id: 'a11y',
+ title: '1.0.0',
+ publishedAt: '2026-02-11 10:00:00.000Z',
+ },
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
describe('CollapsibleSection', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(CollapsibleSection, {
diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts
index 0f18a000e..9bbda8195 100644
--- a/test/unit/a11y-component-coverage.spec.ts
+++ b/test/unit/a11y-component-coverage.spec.ts
@@ -46,6 +46,7 @@ const SKIPPED_COMPONENTS: Record = {
'SkeletonBlock.vue': 'Already covered indirectly via other component tests',
'SkeletonInline.vue': 'Already covered indirectly via other component tests',
'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here",
+ 'Changelog/Releases.vue': 'Requires API calls',
}
/**