(
+ `${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..51c1a23a1
--- /dev/null
+++ b/server/utils/changelog/detectChangelog.ts
@@ -0,0 +1,174 @@
+import type {
+ ChangelogMarkdownInfo,
+ ChangelogInfo,
+ ChangelogReleaseInfo,
+} from '~~/shared/types/changelog'
+import { type RepoRef, parseRepoUrl } from '~~/shared/utils/git-providers'
+import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis'
+import { ERROR_CHANGELOG_NOT_FOUND } from '~~/shared/utils/constants'
+import * as v from 'valibot'
+import { GithubReleaseSchama } from '~~/shared/schemas/changelog/release'
+
+// 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 {
+ switch (ref.provider) {
+ case 'github': {
+ return checkLatestGithubRelease(ref)
+ }
+ }
+
+ // 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
+}
+
+/// releases
+
+// /**
+// * 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 MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\()(.*?)(?=\))/i
+
+function checkLatestGithubRelease(ref: RepoRef): Promise {
+ return $fetch(`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`)
+ .then(r => {
+ console.log(r)
+ const { release } = v.parse(v.object({ release: GithubReleaseSchama }), r)
+
+ const matchedChangelog = release.markdown?.match(MD_REGEX)?.at(0)
+
+ // if no changelog.md or the url doesn't contain /blob/
+ if (!matchedChangelog || !matchedChangelog.includes('/blob/')) {
+ return {
+ provider: ref.provider,
+ type: 'release',
+ repo: `${ref.owner}/${ref.repo}`,
+ } satisfies ChangelogReleaseInfo
+ }
+
+ const path = matchedChangelog.replace(/^.*\/blob\/[^/]+\//i, '')
+ return {
+ provider: ref.provider,
+ type: 'md',
+ path,
+ repo: `${ref.owner}/${ref.repo}`,
+ link: matchedChangelog,
+ } satisfies ChangelogMarkdownInfo
+ })
+ .catch(e => {
+ console.log('changelog error', e)
+ return false
+ })
+}
+
+/// changelog markdown
+
+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..18c84ccb0
--- /dev/null
+++ b/server/utils/changelog/markdown.ts
@@ -0,0 +1,211 @@
+import { marked, type Tokens } from 'marked'
+import {
+ ALLOWED_ATTR,
+ ALLOWED_TAGS,
+ calculateSemanticDepth,
+ prefixId,
+ slugify,
+ stripHtmlTags,
+} 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 & emoji's added)
+ const plainText = convertToEmoji(stripHtmlTags(text))
+ .replace(/ ?/g, '') // remove non breaking spaces
+ .trim()
+ if (plainText) {
+ toc.push({ text: plainText, id, depth })
+ }
+
+ return `${text} \n`
+ }
+
+ return {
+ html: convertToEmoji(
+ 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 d7c296117..fc6c983b6 100644
--- a/server/utils/readme.ts
+++ b/server/utils/readme.ts
@@ -112,7 +112,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',
@@ -153,7 +153,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'],
@@ -179,7 +179,7 @@ const ALLOWED_ATTR: Record = {
* incomplete sanitization from nested/interleaved tags
* (e.g. `ipt>` → `