(
+ `${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
+ swr: true,
+ getKey: event => {
+ const pkg = getRouterParam(event, 'pkg') ?? ''
+ return `changelogInfo:pr1:${pkg.replace(/\/+$/, '').trim()}`
+ },
+ },
+)
diff --git a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts
new file mode 100644
index 000000000..b8dff6eda
--- /dev/null
+++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts
@@ -0,0 +1,47 @@
+import * as v from 'valibot'
+import {
+ ERROR_CHANGELOG_FILE_FAILED,
+ ERROR_THROW_INCOMPLETE_PARAM,
+} from '~~/shared/utils/constants'
+
+export default defineCachedEventHandler(async event => {
+ const provider = getRouterParam(event, 'provider')
+ const repo = getRouterParam(event, 'repo')
+ const owner = getRouterParam(event, 'owner')
+ const path = getRouterParam(event, 'path')
+
+ if (!repo || !provider || !owner || !path) {
+ throw createError({
+ status: 404,
+ statusMessage: ERROR_THROW_INCOMPLETE_PARAM,
+ })
+ }
+
+ try {
+ console.log({ provider })
+
+ switch (provider as ProviderId) {
+ case 'github':
+ return await getGithubMarkDown(owner, repo, path)
+
+ default:
+ throw createError({
+ status: 404,
+ statusMessage: ERROR_CHANGELOG_NOT_FOUND,
+ })
+ }
+ } catch (error) {
+ handleApiError(error, {
+ statusCode: 502,
+ message: ERROR_CHANGELOG_FILE_FAILED,
+ })
+ }
+})
+
+async function getGithubMarkDown(owner: string, repo: string, path: string) {
+ const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/files/HEAD/${path}`)
+
+ const markdown = v.parse(v.string(), data)
+
+ return (await changelogRenderer())(markdown)
+}
diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts
new file mode 100644
index 000000000..86ecd17e9
--- /dev/null
+++ b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts
@@ -0,0 +1,67 @@
+import type { ProviderId } from '~~/shared/utils/git-providers'
+import type { ReleaseData } from '~~/shared/types/changelog'
+import {
+ ERROR_CHANGELOG_RELEASES_FAILED,
+ ERROR_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: ERROR_THROW_INCOMPLETE_PARAM,
+ })
+ }
+
+ try {
+ switch (provider as ProviderId) {
+ case 'github':
+ return await getReleasesFromGithub(owner, repo)
+
+ default:
+ throw createError({
+ status: 404,
+ statusMessage: ERROR_CHANGELOG_NOT_FOUND,
+ })
+ }
+ } catch (error) {
+ handleApiError(error, {
+ statusCode: 502,
+ 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/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts
new file mode 100644
index 000000000..e4fcc556c
--- /dev/null
+++ b/server/utils/changelog/detectChangelog.ts
@@ -0,0 +1,123 @@
+import type { ChangelogReleaseInfo, ChangelogMarkdownInfo } from '~~/shared/types/changelog'
+import { ERROR_CHANGELOG_NOT_FOUND } from '~~/shared/utils/constants'
+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 changelog = (await checkReleases(repoRef)) || (await checkChangelogFile(repoRef))
+
+ if (changelog) {
+ return changelog
+ }
+
+ throw createError({
+ statusCode: 404,
+ statusMessage: ERROR_CHANGELOG_NOT_FOUND,
+ })
+}
+
+/**
+ * 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 EXTENSIONS = ['.md', ''] as const
+
+const CHANGELOG_FILENAMES = ['changelog', 'releases', 'changes', 'history', 'news']
+ .map(fileName => {
+ const fileNameUpperCase = fileName.toUpperCase()
+ return EXTENSIONS.map(ext => [`${fileNameUpperCase}${ext}`, `${fileName}${ext}`])
+ })
+ .flat(3)
+
+async function checkChangelogFile(ref: RepoRef): Promise {
+ const baseUrl = getBaseFileUrl(ref)
+ if (!baseUrl) {
+ return false
+ }
+
+ for (const fileName of CHANGELOG_FILENAMES) {
+ const exists = await fetch(`${baseUrl}/${fileName}`, {
+ 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 {
+ type: 'md',
+ provider: ref.provider,
+ path: fileName,
+ repo: `${ref.owner}/${ref.repo}`,
+ } satisfies ChangelogMarkdownInfo
+ }
+ }
+ return false
+}
+
+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..02954e9f3
--- /dev/null
+++ b/server/utils/changelog/markdown.ts
@@ -0,0 +1,204 @@
+import { marked, type Tokens } from 'marked'
+import { ALLOWED_ATTR, ALLOWED_TAGS, calculateSemanticDepth, prefixId, slugify } from '../readme'
+import sanitizeHtml from 'sanitize-html'
+
+export async function changelogRenderer() {
+ const renderer = new marked.Renderer({
+ gfm: true,
+ })
+
+ 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 = releaseId ? 2 : 1 // 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 = releaseId
+ ? `user-content-${releaseId}-${uniqueSlug}`
+ : `user-content-${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,
+ }) 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 fff2cea28..326a78964 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()
@@ -309,7 +310,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}`
}
@@ -319,7 +320,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..ec4cfc2e6
--- /dev/null
+++ b/shared/types/changelog.ts
@@ -0,0 +1,30 @@
+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
+ */
+ path: string
+ repo: `${string}/${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 1d3575673..3bbf554b8 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -18,6 +18,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!'
@@ -37,6 +38,12 @@ 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_NOT_FOUND =
+ 'No releases or changelogs have been found for this package'
+export const ERROR_CHANGELOG_RELEASES_FAILED = 'Failed to get releases'
+export const ERROR_CHANGELOG_FILE_FAILED = 'Failed to get changelog markdown'
+export const ERROR_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',
}
/**