diff --git a/app/components/Changelog/Card.vue b/app/components/Changelog/Card.vue new file mode 100644 index 0000000000..efc8da2cd2 --- /dev/null +++ b/app/components/Changelog/Card.vue @@ -0,0 +1,78 @@ + + + + diff --git a/app/components/Changelog/ErrorMsg.vue b/app/components/Changelog/ErrorMsg.vue new file mode 100644 index 0000000000..22000d6cf9 --- /dev/null +++ b/app/components/Changelog/ErrorMsg.vue @@ -0,0 +1,15 @@ + + diff --git a/app/components/Changelog/Markdown.vue b/app/components/Changelog/Markdown.vue new file mode 100644 index 0000000000..782cd44a40 --- /dev/null +++ b/app/components/Changelog/Markdown.vue @@ -0,0 +1,57 @@ + + diff --git a/app/components/Changelog/Releases.vue b/app/components/Changelog/Releases.vue new file mode 100644 index 0000000000..a65ede945c --- /dev/null +++ b/app/components/Changelog/Releases.vue @@ -0,0 +1,82 @@ + + diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 7cf6b4c8bc..9c10f60623 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -2,6 +2,7 @@ import type { RouteLocationRaw } from 'vue-router' import { SCROLL_TO_TOP_THRESHOLD } from '~/composables/useScrollToTop' import { isEditableElement } from '~/utils/input' +import { usePackageChangelog } from '~/composables/usePackageChangelog' const props = defineProps<{ pkg?: Pick | null @@ -10,7 +11,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' + page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' versionUrlPattern: string }>() @@ -120,6 +121,20 @@ const diffLink = computed((): RouteLocationRaw | null => { return diffRoute(props.pkg.name, props.resolvedVersion, props.latestVersion.version) }) +const { data: changelog } = usePackageChangelog(packageName, requestedVersion) + +const changelogLink = computed((): RouteLocationRaw | null => { + if ( + // either changelog.value is available or current page is the changelog + !(changelog.value || props.page == 'changelog') || + props.pkg == null || + props.resolvedVersion == null + ) { + return null + } + return changelogRoute(props.pkg.name, props.resolvedVersion) +}) + const keyboardShortcuts = useKeyboardShortcuts() onKeyStroke( @@ -174,6 +189,26 @@ onKeyStroke( { dedupe: true }, ) +onKeyStroke( + e => keyboardShortcuts.value && isKeyWithoutModifiers(e, '-') && !isEditableElement(e.target), + e => { + if (changelogLink.value === null) return + e.preventDefault() + navigateTo(changelogLink.value) + }, + { dedupe: true }, +) + +onKeyStroke( + e => keyboardShortcuts.value && isKeyWithoutModifiers(e, '-') && !isEditableElement(e.target), + e => { + if (changelogLink.value === null) return + e.preventDefault() + navigateTo(changelogLink.value) + }, + { dedupe: true }, +) + const fundingUrl = computed(() => { let funding = props.displayVersion?.funding if (Array.isArray(funding)) funding = funding[0] @@ -343,6 +378,15 @@ const fundingUrl = computed(() => { > {{ $t('compare.compare_versions') }} + + {{ $t('package.links.changelog') }} + diff --git a/app/components/Readme.vue b/app/components/Readme.vue index c571e63bc2..26b2bf21a7 100644 --- a/app/components/Readme.vue +++ b/app/components/Readme.vue @@ -157,7 +157,8 @@ function handleClick(event: MouseEvent) { font-size: 0.75em; } -.readme :deep(:is(h1, h2, h3, h4, h5, h6) a[href^='#']:hover::after) { +.readme + :deep(:is(h1, h2, h3, h4, h5, h6):is(:hover, :focus-visible, :focus-within) a[href^='#']::after) { @apply opacity-100; } diff --git a/app/composables/usePackageChangelog.ts b/app/composables/usePackageChangelog.ts new file mode 100644 index 0000000000..7d3dcd9431 --- /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/composables/useProviderIcon.ts b/app/composables/useProviderIcon.ts new file mode 100644 index 0000000000..4da402f42d --- /dev/null +++ b/app/composables/useProviderIcon.ts @@ -0,0 +1,24 @@ +import type { ProviderId } from '#imports' +import type { IconClass } from '~/types/icon' +import { computed, toValue } from 'vue' + +const PROVIDER_ICONS: Record = { + github: 'i-simple-icons:github', + gitlab: 'i-simple-icons:gitlab', + bitbucket: 'i-simple-icons:bitbucket', + codeberg: 'i-simple-icons:codeberg', + gitea: 'i-simple-icons:gitea', + forgejo: 'i-simple-icons:forgejo', + gitee: 'i-simple-icons:gitee', + sourcehut: 'i-simple-icons:sourcehut', + tangled: 'i-custom:tangled', + radicle: 'i-lucide:network', // Radicle is a P2P network, using network icon +} + +export function useProviderIcon(provider: MaybeRefOrGetter) { + return computed((): IconClass => { + const uProvider = toValue(provider) + if (!uProvider) return 'i-simple-icons:github' + return PROVIDER_ICONS[uProvider] ?? 'i-lucide:code' + }) +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index a79c746c20..459fc0d36e 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -33,6 +33,8 @@ export interface AppSettings { instantSearch: boolean /** Enable/disable keyboard shortcuts */ keyboardShortcuts: boolean + /** Enable/disable auto scrolling to requested version at package changelog */ + changelogAutoScroll: boolean /** Connector preferences */ connector: { /** Automatically open the web auth page in the browser */ @@ -60,6 +62,7 @@ const DEFAULT_SETTINGS: AppSettings = { searchProvider: import.meta.test ? 'npm' : 'algolia', instantSearch: true, keyboardShortcuts: true, + changelogAutoScroll: true, connector: { autoOpenURL: false, }, diff --git a/app/pages/package-changelog/[[org]]/[name].vue b/app/pages/package-changelog/[[org]]/[name].vue new file mode 100644 index 0000000000..5a0ef3c467 --- /dev/null +++ b/app/pages/package-changelog/[[org]]/[name].vue @@ -0,0 +1,191 @@ + + diff --git a/app/pages/package-changelog/[[org]]/[name]/index.vue b/app/pages/package-changelog/[[org]]/[name]/index.vue new file mode 100644 index 0000000000..90e14dbddc --- /dev/null +++ b/app/pages/package-changelog/[[org]]/[name]/index.vue @@ -0,0 +1,10 @@ + + diff --git a/app/pages/package-changelog/[[org]]/[name]/v/[version].vue b/app/pages/package-changelog/[[org]]/[name]/v/[version].vue new file mode 100644 index 0000000000..d98daaf459 --- /dev/null +++ b/app/pages/package-changelog/[[org]]/[name]/v/[version].vue @@ -0,0 +1,12 @@ + + diff --git a/app/pages/settings.vue b/app/pages/settings.vue index e90faf551f..ae3b81c4f4 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -143,6 +143,15 @@ const setLocale: typeof setNuxti18nLocale = newLocale => { :description="$t('settings.enable_graph_pulse_loop_description')" v-model="settings.enableGraphPulseLooping" /> + +
+ + +
diff --git a/app/utils/router.ts b/app/utils/router.ts index 0c22d92860..383060d9d4 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -45,3 +45,32 @@ export function diffRoute( }, } } + +export function changelogRoute( + packageName: string, + version?: string | null, + hash?: string, +): RouteLocationRaw { + const [org, name = ''] = packageName.startsWith('@') ? packageName.split('/') : ['', packageName] + + if (version) { + return { + name: 'changelog-version', + params: { + org, + name, + // remove spaces to be correctly resolved by router + version: version.replace(/\s+/g, ''), + }, + hash, + } + } + + return { + name: 'changelog', + params: { + org, + name, + }, + } +} diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md index da0df3c6fe..ac5b22a0c7 100644 --- a/docs/content/2.guide/2.keyboard-shortcuts.md +++ b/docs/content/2.guide/2.keyboard-shortcuts.md @@ -29,8 +29,10 @@ These shortcuts work anywhere on the site. Press `/` from any page to quickly se ## Package page -| Key | Action | -| --- | ---------------- | -| `.` | Open code viewer | -| `d` | Open docs | -| `c` | Compare package | +| Key | Action | +| --- | ---------------------- | +| `m` | open main package | +| `.` | Open code viewer | +| `d` | Open docs | +| `c` | Compare package | +| `-` | open changelog package | diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 16c891ae47..9241d584a6 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -169,7 +169,9 @@ "black": "Black" }, "keyboard_shortcuts_enabled": "Enable keyboard shortcuts", - "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts" + "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts", + "enable_changelog_autoscroll": "Auto scroll to requested version", + "enable_changelog_autoscroll_description": "Auto scroll to or close to the requested version at package changelog" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", @@ -319,7 +321,8 @@ "docs": "docs", "fund": "fund", "compare": "compare", - "compare_this_package": "compare this package" + "compare_this_package": "compare this package", + "changelog": "changelog" }, "likes": { "like": "Like this package", @@ -1522,5 +1525,16 @@ "discord_link_text": "chat.npmx.dev" } }, - "alt_logo_kawaii": "A cute, rounded, and colorful version of the npmx logo." + "alt_logo_kawaii": "A cute, rounded, and colorful version of the npmx logo.", + "changelog": { + "pre_release": "Pre-release", + "draft": "Draft", + "no_logs": "Sorry, this package does not publish changelogs or its changelog format is not supported.", + "error": { + "p1": "Sorry, the changelog for {package} couldn't be loaded", + "p2": "Please try again later or {viewon}" + }, + "rate_limit_ungh": "Sorry, Github's rate limit has been hit, try again in a moment", + "version_unavailable": "The requested version is not available." + } } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index f6c17d5b9c..61ec3bf89f 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -169,7 +169,9 @@ "black": "Noir" }, "keyboard_shortcuts_enabled": "Activer les raccourcis clavier", - "keyboard_shortcuts_enabled_description": "Les raccourcis clavier peuvent être désactivés s'ils entrent en conflit avec d'autres raccourcis du navigateur ou du système" + "keyboard_shortcuts_enabled_description": "Les raccourcis clavier peuvent être désactivés s'ils entrent en conflit avec d'autres raccourcis du navigateur ou du système", + "enable_changelog_autoscroll": "Scroller automatiquement à la version voulue", + "enable_changelog_autoscroll_description": "Scroller automatiquement à — ou près — de la version voulue dans la page des changements" }, "i18n": { "missing_keys": "{count} traduction manquante | {count} traductions manquantes", @@ -319,7 +321,8 @@ "docs": "docs", "fund": "donner", "compare": "comparer", - "compare_this_package": "comparer ce paquet" + "compare_this_package": "comparer ce paquet", + "changelog": "changements" }, "likes": { "like": "Liker ce paquet", @@ -435,15 +438,15 @@ "filter_tooltip": "Filtrer les versions avec une {link}. Par exemple, ^3.0.0 affiche toutes les versions 3.x.", "filter_tooltip_link": "plage semver", "no_matches": "Aucune version ne correspond à cette plage", + "copy_alt": { + "per_version_analysis": "La version {version} a été téléchargée {downloads} fois", + "general_description": "Graphique en barres montrant les téléchargements par version pour {versions_count} versions {semver_grouping_mode} du paquet {package_name}, {date_range_label} de la version {first_version} à la version {last_version}. La version la plus téléchargée est {max_downloaded_version} avec {max_version_downloads} téléchargements. {per_version_analysis}. {watermark}." + }, "page_title": "Historique des versions", "current_tags": "Tags actuels", "version_filter_placeholder": "Filtrer les versions...", "version_filter_label": "Filtrer les versions", - "no_match_filter": "Aucune version ne correspond à {filter}", - "copy_alt": { - "per_version_analysis": "La version {version} a été téléchargée {downloads} fois", - "general_description": "Graphique en barres montrant les téléchargements par version pour {versions_count} versions {semver_grouping_mode} du paquet {package_name}, {date_range_label} de la version {first_version} à la version {last_version}. La version la plus téléchargée est {max_downloaded_version} avec {max_version_downloads} téléchargements. {per_version_analysis}. {watermark}." - } + "no_match_filter": "Aucune version ne correspond à {filter}" }, "dependencies": { "title": "Dépendances ({count})", @@ -1487,5 +1490,16 @@ "message": "Pour un usage commercial, veuillez nous contacter.", "discord_link_text": "Discussion Discord" } + }, + "changelog": { + "pre_release": "Pré-version", + "draft": "Brouillon", + "no_logs": "Désolé, ce paquet ne publie pas de changements ou le format de ses changements n'est pas pris en charge.", + "error": { + "p1": "Désolé, les changements de {package} n'ont pas pu être chargés", + "p2": "Veuillez réessayer plus tard ou {viewon}" + }, + "rate_limit_ungh": "Désolé, la limite de requêtes à l'API GitHub a été atteinte, réessayez plus tard", + "version_unavailable": "La version demandée n'est pas disponible." } } diff --git a/i18n/locales/kn-IN.json b/i18n/locales/kn-IN.json index f8ec04fa88..af68a46418 100644 --- a/i18n/locales/kn-IN.json +++ b/i18n/locales/kn-IN.json @@ -866,6 +866,7 @@ "limitations": {}, "contact": {} }, + "changelog": {}, "translation_status": { "table": {} }, diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 47ac20eb2b..46f749a64b 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1304,6 +1304,7 @@ "link": "GitHub'da issue açın" } }, + "changelog": {}, "translation_status": { "table": {} }, diff --git a/i18n/schema.json b/i18n/schema.json index 01c96fd953..cc7f947345 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -513,6 +513,12 @@ }, "keyboard_shortcuts_enabled_description": { "type": "string" + }, + "enable_changelog_autoscroll": { + "type": "string" + }, + "enable_changelog_autoscroll_description": { + "type": "string" } }, "additionalProperties": false @@ -963,6 +969,9 @@ }, "compare_this_package": { "type": "string" + }, + "changelog": { + "type": "string" } }, "additionalProperties": false @@ -4573,6 +4582,39 @@ "alt_logo_kawaii": { "type": "string" }, + "changelog": { + "type": "object", + "properties": { + "pre_release": { + "type": "string" + }, + "draft": { + "type": "string" + }, + "no_logs": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "p1": { + "type": "string" + }, + "p2": { + "type": "string" + } + }, + "additionalProperties": false + }, + "rate_limit_ungh": { + "type": "string" + }, + "version_unavailable": { + "type": "string" + } + }, + "additionalProperties": false + }, "$schema": { "type": "string" } diff --git a/public/robots.txt b/public/robots.txt index bdd4add700..d9df3f098a 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -11,11 +11,13 @@ Disallow: /compare # Auth flows Disallow: /auth/ -# Code explorer & generated API docs +# Code explorer, generated API docs & changelog Disallow: /package-code/ Disallow: /code/ Disallow: /package-docs/ Disallow: /docs/ +Disallow: /package-changelog/ +Disallow: /changelog/ # Internal API Disallow: /api/ diff --git a/server/api/changelog/info/[...pkg].get.ts b/server/api/changelog/info/[...pkg].get.ts new file mode 100644 index 0000000000..527214ed29 --- /dev/null +++ b/server/api/changelog/info/[...pkg].get.ts @@ -0,0 +1,42 @@ +import type { ExtendedPackageJson } from '#shared/utils/package-analysis' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { ERROR_PACKAGE_DETECT_CHANGELOG, NPM_REGISTRY } from '#shared/utils/constants' +import * as v from 'valibot' +import { detectChangelog } from '~~/server/utils/changelog/detectChangelog' + +export default defineCachedEventHandler( + async event => { + 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 + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `changelogInfo:v1:${pkg.trim().replace(/\/+$/, '')}` + }, + shouldBypassCache: () => import.meta.dev, + }, +) 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 0000000000..8b6252bd12 --- /dev/null +++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts @@ -0,0 +1,64 @@ +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 { + 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, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR * 2, // 2 hours + swr: true, + getKey: event => { + const provider = getRouterParam(event, 'provider') ?? '' + const repo = getRouterParam(event, 'repo') ?? '' + const owner = getRouterParam(event, 'owner') ?? '' + const path = getRouterParam(event, 'path') ?? '' + return `changelogMarkdown:v1:${provider}:${owner}:${repo}:${path.replaceAll('/', ':')}` + }, + }, +) + +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({ + blobBaseUrl: `https://github.com/${owner}/${repo}/blob/HEAD`, + rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, + path, + }) + )(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 0000000000..e7d2b528a5 --- /dev/null +++ b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts @@ -0,0 +1,82 @@ +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, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR * 2, // 2 hours + swr: true, + getKey: event => { + const provider = getRouterParam(event, 'provider') + const repo = getRouterParam(event, 'repo') + const owner = getRouterParam(event, 'owner') + return `changelogRelease:v1:${provider}:${owner}:${repo}` + }, + }, +) + +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({ + blobBaseUrl: `https://github.com/${owner}/${repo}/blob/HEAD`, + rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, + }) + + 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 0000000000..6f8393a4af --- /dev/null +++ b/server/utils/changelog/detectChangelog.ts @@ -0,0 +1,184 @@ +import type { + ChangelogMarkdownInfo, + ChangelogInfo, + ChangelogReleaseInfo, +} from '~~/shared/types/changelog' +import type { FetchError } from 'ofetch' +import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis' +import { type RepoRef, parseRepoUrl } from '~~/shared/utils/git-providers' +import { ERROR_CHANGELOG_NOT_FOUND, ERROR_UNGH_API_KEY_EXHAUSTED } from '~~/shared/utils/constants' +import { GithubReleaseSchama } from '~~/shared/schemas/changelog/release' +import { resolveURL } from 'ufo' +import * as v from 'valibot' + +/** + * 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) { + if (!pkg.repository?.url) { + return false + } + + const repoRef = parseRepoUrl(pkg.repository.url) + if (!repoRef) { + return false + } + + const releases = await checkReleases(repoRef, pkg.repository.directory) + if (releases && !isError(releases)) { + return releases + } + + const changelog = await checkChangelogFile(repoRef, pkg.repository.directory) + if (changelog) { + return changelog + } + + if (isError(releases)) { + throw releases + } + + throw createError({ + statusCode: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) +} + +/** + * check whether releases are being used with this repo + * @returns true if in use, false if not in use or an NuxtError in case of ungh's api keys being exhausted + */ +async function checkReleases( + ref: RepoRef, + directory?: string, +): Promise { + switch (ref.provider) { + case 'github': { + return checkLatestGithubRelease(ref, directory) + } + } + + return false +} + +/// releases + +const MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\()(.*?)(?=\))/i +const ROOT_ONLY_REGEX = /^\/[^/]+$/ + +function checkLatestGithubRelease( + ref: RepoRef, + directory?: string, +): Promise { + return $fetch(`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`) + .then(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}`, + link: `https://github.com/${ref.owner}/${ref.repo}/releases`, + } satisfies ChangelogReleaseInfo + } + + const path = matchedChangelog.replace(/^.*\/blob\/[^/]+\//i, '') + + if (directory && !(path.startsWith(directory) || ROOT_ONLY_REGEX.test(path))) { + return false as const + } + return { + provider: ref.provider, + type: 'md', + path, + repo: `${ref.owner}/${ref.repo}`, + link: matchedChangelog, + } satisfies ChangelogMarkdownInfo + }) + .catch((e: FetchError) => { + if (e.statusCode === 403 || e.statusCode === 429) { + // with 403/429 ungh.cc has exhausted it's api keys, returning error to indicate this + return createError({ + statusCode: 502, + statusMessage: ERROR_UNGH_API_KEY_EXHAUSTED, + }) + } + + return false as const + }) +} + +/// 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, + directory?: string, +): Promise { + const baseUrl = getBaseFileUrl(ref) + if (!baseUrl) { + return false + } + + if (directory) { + const inDir = await checkFiles(ref, baseUrl, directory) + if (inDir) { + return inDir + } + } + return checkFiles(ref, baseUrl) +} + +async function checkFiles(ref: RepoRef, baseUrl: RepoFileUrl, dir?: string) { + for (const fileName of CHANGELOG_FILENAMES) { + const exists = await fetch(resolveURL(baseUrl.raw, dir ?? '', 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: resolveURL(dir ?? '', fileName), + repo: `${ref.owner}/${ref.repo}`, + link: resolveURL(baseUrl.blob, dir ?? '', fileName), + } satisfies ChangelogMarkdownInfo + } + } + return false +} + +interface RepoFileUrl { + raw: string + blob: string +} + +function getBaseFileUrl(ref: RepoRef): RepoFileUrl | null { + switch (ref.provider) { + case 'github': + return { + raw: `https://ungh.cc/repos/${ref.owner}/${ref.repo}/files/HEAD`, + blob: `https://github.com/${ref.owner}/${ref.repo}/blob/HEAD`, + } + } + return null +} diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts new file mode 100644 index 0000000000..cd0d4bc411 --- /dev/null +++ b/server/utils/changelog/markdown.ts @@ -0,0 +1,288 @@ +import { type Tokens, marked } from 'marked' +import { + type prefixId as prefixIdFn, + ALLOWED_ATTR, + ALLOWED_TAGS, + calculateSemanticDepth, + isNpmJsUrlThatCanBeRedirected, +} from '../readme' +import { stripHtmlTags, slugify } from '#shared/utils/html' +import sanitizeHtml from 'sanitize-html' +import { hasProtocol } from 'ufo' + +const EMAIL_REGEX = /^[\w+\-.]+@[\w\-.]+\.[a-z]+$/i + +export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { + 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() + + if (href.startsWith('mailto:') && !EMAIL_REGEX.test(plainText)) { + return text + } + + 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, + } + } + + const idPrefix = releaseId ? `user-content-${releaseId}` : `user-content` + + // 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 = `${idPrefix}-${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` + } + + // Helper to prefix id attributes with 'user-content-' + const prefixId: typeof prefixIdFn = (tagName: string, attribs: sanitizeHtml.Attributes) => { + if (attribs.id && !attribs.id.startsWith('user-content-')) { + attribs.id = `${idPrefix}-${attribs.id}` + } + return { tagName, attribs } + } + + return { + html: sanitizeRawHTML( + convertToEmoji( + marked.parse(markdown, { + renderer, + }) as string, + ), + mdRepoInfo, + prefixId, + idPrefix, + ), + toc, + } + } +} + +export function sanitizeRawHTML( + rawHtml: string, + mdRepoInfo: MarkdownRepoInfo, + prefixId: typeof prefixIdFn, + idPrefix: 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 = resolveUrl(attribs.src, mdRepoInfo, idPrefix) + } + return { tagName, attribs } + }, + source: (tagName, attribs) => { + if (attribs.src) { + attribs.src = resolveUrl(attribs.src, mdRepoInfo, idPrefix) + } + 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 = resolveUrl(url, mdRepoInfo, idPrefix) + return descriptor ? `${resolvedUrl} ${descriptor}` : resolvedUrl + }) + .join(', ') + } + return { tagName, attribs } + }, + a: (tagName, attribs) => { + if (!attribs.href) { + return { tagName, attribs } + } + + const resolvedHref = resolveUrl(attribs.href, mdRepoInfo, idPrefix) + + // Add security attributes for external links + if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { + attribs.rel = 'nofollow noreferrer noopener' + attribs.target = '_blank' + } else { + attribs.target = '' + } + attribs.href = resolvedHref + return { tagName, attribs } + }, + div: prefixId, + p: prefixId, + span: prefixId, + section: prefixId, + article: prefixId, + }, + }) +} + +interface MarkdownRepoInfo { + /** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ + rawBaseUrl: string + /** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */ + blobBaseUrl: string + /** + * path to the markdown file, can't start with / + */ + path?: string +} + +function resolveUrl(url: string, repoInfo: MarkdownRepoInfo, idPrefix: string) { + if (!url) return url + if (url.startsWith('#')) { + if (url.startsWith('#user-content')) { + return url + } + // Prefix anchor links to match heading IDs (avoids collision with page IDs) + return `#${idPrefix}-${slugify(url.slice(1))}` + } + if (hasProtocol(url, { acceptRelative: true })) { + try { + const parsed = new URL(url, 'https://example.com') + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + // Redirect npmjs urls to ourself + if (isNpmJsUrlThatCanBeRedirected(parsed)) { + return parsed.pathname + parsed.search + parsed.hash + } + return url + } + } catch { + // Invalid URL, fall through to resolve as relative + } + // return protocol-relative URLs (//example.com) as-is + if (url.startsWith('//')) { + return url + } + // for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative + } + + // Check if this is a markdown file link + const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '') + const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl + + if (url.startsWith('/')) { + return checkResolvedUrl(new URL(`${baseUrl}${url}`).href, baseUrl) + } + + if (!hasProtocol(url)) { + // the '/' ensure bare relative links stay after "....../HEAD" + return checkResolvedUrl(new URL(url, `${baseUrl}/${repoInfo.path ?? '/'}`).href, baseUrl) + } + + return url +} + +/** + * check resolved url that it still contains the base url + * @returns the resolved url if starting with baseUrl else baseUrl + */ +function checkResolvedUrl(resolved: string, baseUrl: string) { + if (resolved.startsWith(baseUrl)) { + return resolved + } + return baseUrl +} diff --git a/server/utils/readme.ts b/server/utils/readme.ts index b5befe67b7..93a91e75c2 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -5,10 +5,9 @@ import { marked } from 'marked' import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' -import { decodeHtmlEntities, stripHtmlTags } from '#shared/utils/html' +import { decodeHtmlEntities, stripHtmlTags, slugify } from '#shared/utils/html' import { convertToEmoji } from '#shared/utils/emoji' import { toProxiedImageUrl } from '#server/utils/image-proxy' - import { highlightCodeSync } from './shiki' import { escapeHtml } from './docs/text' @@ -142,7 +141,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', @@ -183,7 +182,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'], @@ -204,24 +203,6 @@ const ALLOWED_ATTR: Record = { 'p': ['align'], } -/** - * Generate a GitHub-style slug from heading text. - * - Convert to lowercase - * - Remove HTML tags - * - Replace spaces with hyphens - * - Remove special characters (keep alphanumeric, hyphens, underscores) - * - Collapse multiple hyphens - */ -function slugify(text: string): string { - return decodeHtmlEntities(stripHtmlTags(text)) - .toLowerCase() - .trim() - .replace(/\s+/g, '-') // Spaces to hyphens - .replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff-]/g, '') // Keep alphanumeric, CJK, hyphens - .replace(/-+/g, '-') // Collapse multiple hyphens - .replace(/^-|-$/g, '') // Trim leading/trailing hyphens -} - function getHeadingPlainText(text: string): string { return decodeHtmlEntities(stripHtmlTags(text).trim()) } @@ -231,6 +212,7 @@ function getHeadingSlugSource(text: string): string { } /** +>>>>>>> main * Lazy ATX heading extension for marked: allows headings without a space after `#`. * * Reimplements the behavior of markdown-it-lazy-headers @@ -308,7 +290,7 @@ function normalizePreservedAnchorAttrs(attrs: string): string { return cleanedAttrs ? ` ${cleanedAttrs}` : '' } -const isNpmJsUrlThatCanBeRedirected = (url: URL) => { +export const isNpmJsUrlThatCanBeRedirected = (url: URL) => { if (!npmJsHosts.has(url.host)) { return false } @@ -425,7 +407,8 @@ 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 = withUserContentPrefix(attribs.id) } @@ -435,7 +418,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 0000000000..77c36b89d9 --- /dev/null +++ b/shared/schemas/changelog/release.ts @@ -0,0 +1,19 @@ +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), +}) + +// keeping this here in case it's needed +// 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 0000000000..3ec410d08d --- /dev/null +++ b/shared/types/changelog.ts @@ -0,0 +1,35 @@ +import type { ProviderId } from '../utils/git-providers' +import type { TocItem } from './readme' + +export interface ChangelogReleaseInfo { + type: 'release' + provider: ProviderId + repo: `${string}/${string}` + link: string +} + +export interface ChangelogMarkdownInfo { + type: 'md' + provider: ProviderId + /** + * location within the repository + */ + path: string + repo: `${string}/${string}` + /** + * link to a rendered changelog markdown file + */ + link: 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 72aa92148e..e20a026448 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -20,6 +20,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_BLUESKY_URL_FAILED = 'Invalid Bluesky URL format. Expected: https://bsky.app/profile/HANDLE/post/POST_ID' export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.' @@ -40,6 +41,15 @@ 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" +// for ungh.cc when api keys are exhausted, name is broad in case more proxies are going to be used +export const ERROR_UNGH_API_KEY_EXHAUSTED = + "Couldn't fetch resources due to ungh api keys being exhausted" + // microcosm services export const CONSTELLATION_HOST = 'constellation.microcosm.blue' export const SLINGSHOT_HOST = 'slingshot.microcosm.blue' diff --git a/shared/utils/html.ts b/shared/utils/html.ts index 2a868be0b8..1f9de45c67 100644 --- a/shared/utils/html.ts +++ b/shared/utils/html.ts @@ -27,3 +27,20 @@ export function stripHtmlTags(text: string): string { } while (result !== previous) return result } +/** + * Generate a GitHub-style slug from heading text. + * - Convert to lowercase + * - Remove HTML tags + * - Replace spaces with hyphens + * - Remove special characters (keep alphanumeric, hyphens, underscores) + * - Collapse multiple hyphens + */ +export function slugify(text: string): string { + return decodeHtmlEntities(stripHtmlTags(text)) + .toLowerCase() + .trim() + .replace(/\s+/g, '-') // Spaces to hyphens + .replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff-]/g, '') // Keep alphanumeric, CJK, hyphens + .replace(/-+/g, '-') // Collapse multiple hyphens + .replace(/^-|-$/g, '') // Trim leading/trailing hyphens +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 09f2f1f048..7ef06b5753 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -144,6 +144,8 @@ import { LandingLogo, LinkBase, CallToAction, + ChangelogCard, + ChangelogErrorMsg, ChartPatternSlot, CodeDirectoryListing, CodeFileTree, @@ -2298,6 +2300,36 @@ describe('component accessibility audits', () => { }) }) + describe('Changelog', () => { + it('ChangelogCard 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', + }, + tocHeaderClass: 'toc', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('ChangelogErrorMsg should have no accessibility violations for warning variant', async () => { + const component = await mountSuspended(ChangelogErrorMsg, { + props: { + changelogLink: 'https://github.com/npmx-dev/npmx.dev/releases/', + pkgName: 'npmx-dev', + viewOnGit: 'View on Github', + }, + }) + 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/nuxt/components/Changelog.spec.ts b/test/nuxt/components/Changelog.spec.ts new file mode 100644 index 0000000000..e83a89a9ad --- /dev/null +++ b/test/nuxt/components/Changelog.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { ChangelogErrorMsg } from '#components' +import { mountSuspended } from '@nuxt/test-utils/runtime' + +describe('Changelog', () => { + it('Should display error message', async () => { + const component = await mountSuspended(ChangelogErrorMsg, { + props: { + changelogLink: 'https://github.com/npmx-dev/npmx.dev/releases/', + pkgName: 'npmx-dev', + viewOnGit: 'View on Github', + }, + }) + + expect(component.text()).toContain(`Sorry, the changelog for npmx-dev couldn't be loaded`) + expect(component.text()).toContain(`Please try again later or View on Github`) + }) +}) diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index a73b58dd6d..69be6c017a 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -51,6 +51,8 @@ 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', + 'Changelog/Markdown.vue': 'Requires API call & only renders markdown html', 'Translation/StatusByFile.unused.vue': 'Unused component, might be needed in the future', }