diff --git a/app/components/Changelog/Card.vue b/app/components/Changelog/Card.vue new file mode 100644 index 000000000..e763c07d7 --- /dev/null +++ b/app/components/Changelog/Card.vue @@ -0,0 +1,25 @@ + + + + diff --git a/app/components/Changelog/Releases.vue b/app/components/Changelog/Releases.vue new file mode 100644 index 000000000..1ac27a8fa --- /dev/null +++ b/app/components/Changelog/Releases.vue @@ -0,0 +1,12 @@ + + diff --git a/app/composables/usePackageChangelog.ts b/app/composables/usePackageChangelog.ts new file mode 100644 index 000000000..ce2357194 --- /dev/null +++ b/app/composables/usePackageChangelog.ts @@ -0,0 +1,13 @@ +import type { ChangelogInfo } from '~~/shared/types/changelog' + +export function usePackageChangelog( + packageName: MaybeRefOrGetter, + version?: MaybeRefOrGetter, +) { + return useLazyFetch(() => { + const name = toValue(packageName) + const ver = toValue(version) + const base = `/api/changelog/info/${name}` + return ver ? `${base}/v/${ver}` : base + }) +} diff --git a/app/pages/package-changes/[...path].vue b/app/pages/package-changes/[...path].vue new file mode 100644 index 000000000..3c32d10fe --- /dev/null +++ b/app/pages/package-changes/[...path].vue @@ -0,0 +1,98 @@ + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 6990e3046..ca65c1f64 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -195,6 +195,7 @@ const { data: skillsData } = useLazyFetch( const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) const { data: moduleReplacement } = useModuleReplacement(packageName) +const { data: hasChangelog } = usePackageChangelog(packageName, requestedVersion) const { data: resolvedVersion, @@ -882,6 +883,14 @@ const showSkeleton = shallowRef(false) {{ $t('package.links.issues') }} +
  • + + {{ $t('package.links.changelog') }} + +
  • { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) + + try { + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + const encodedName = encodePackageName(packageName) + const versionSuffix = version ? `/${version}` : '/latest' + const pkg = await $fetch( + `${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..55c53d38d --- /dev/null +++ b/server/api/changelog/releases/[provider]/[owner]/[repo].ts @@ -0,0 +1,61 @@ +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, + html, + 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..050b2a03d --- /dev/null +++ b/server/utils/changelog/markdown.ts @@ -0,0 +1,161 @@ +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() + + 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() + + // settings will need to be added still + 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, + }) 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..7686dc9a9 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() @@ -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', } /**