diff --git a/app/components/Changelog/Card.vue b/app/components/Changelog/Card.vue new file mode 100644 index 0000000000..bb0cb54728 --- /dev/null +++ b/app/components/Changelog/Card.vue @@ -0,0 +1,71 @@ + + + + diff --git a/app/components/Changelog/ErrorMsg.vue b/app/components/Changelog/ErrorMsg.vue new file mode 100644 index 0000000000..de107979ac --- /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..0eb063442f --- /dev/null +++ b/app/components/Changelog/Markdown.vue @@ -0,0 +1,46 @@ + + diff --git a/app/components/Changelog/Releases.vue b/app/components/Changelog/Releases.vue new file mode 100644 index 0000000000..e455c8de76 --- /dev/null +++ b/app/components/Changelog/Releases.vue @@ -0,0 +1,67 @@ + + diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 1e08528b0d..d899bbf8c9 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -5,6 +5,7 @@ import { useModal } from '~/composables/useModal' import { useAtproto } from '~/composables/atproto/useAtproto' import { togglePackageLike } from '~/utils/atproto/likes' import { isEditableElement } from '~/utils/input' +import { usePackageChangelog } from '~/composables/usePackageChangelog' const props = defineProps<{ pkg?: Pick | null @@ -13,7 +14,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' + page: 'main' | 'docs' | 'code' | 'diff' | 'changes' versionUrlPattern: string }>() @@ -126,6 +127,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 == 'changes') || + props.pkg == null || + props.resolvedVersion == null + ) { + return null + } + return changelogRoute(props.pkg.name, props.resolvedVersion) +}) + const keyboardShortcuts = useKeyboardShortcuts() onKeyStroke( @@ -180,6 +195,16 @@ 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 }, +) + //atproto // TODO: Maybe set this where it's not loaded here every load? const { user } = useAtproto() @@ -428,6 +453,15 @@ const likeAction = async () => { > {{ $t('compare.compare_versions') }} + + {{ $t('package.links.changelog') }} + diff --git a/app/components/Readme.vue b/app/components/Readme.vue index c571e63bc2..238742ce36 100644 --- a/app/components/Readme.vue +++ b/app/components/Readme.vue @@ -150,7 +150,7 @@ function handleClick(event: MouseEvent) { @apply inline i-lucide:external-link rtl-flip ms-1 opacity-50; } -.readme :deep(:is(h1, h2, h3, h4, h5, h6) a[href^='#']::after) { +.readme :deep(:is(h1, h2, h3, h4, h5, h6) a[href^='#']:not([content-none])::after) { /* I don't know what kind of sorcery this is, but it ensures this icon can't wrap to a new line on its own. */ content: '__'; @apply inline i-lucide:link rtl-flip ms-1 opacity-0; 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/pages/package-changes/[[org]]/[name].vue b/app/pages/package-changes/[[org]]/[name].vue new file mode 100644 index 0000000000..ceb8e17252 --- /dev/null +++ b/app/pages/package-changes/[[org]]/[name].vue @@ -0,0 +1,193 @@ + + + + diff --git a/app/pages/package-changes/[[org]]/[name]/v/[version].vue b/app/pages/package-changes/[[org]]/[name]/v/[version].vue new file mode 100644 index 0000000000..6bb4f9ab88 --- /dev/null +++ b/app/pages/package-changes/[[org]]/[name]/v/[version].vue @@ -0,0 +1,11 @@ + + diff --git a/app/utils/router.ts b/app/utils/router.ts index 0c22d92860..ed1f1b8de3 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: 'changes-version', + params: { + org, + name, + // remove spaces to be correctly resolved by router + version: version.replace(/\s+/g, ''), + }, + hash, + } + } + + return { + name: 'changes', + 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 02082388c3..e80b569f8f 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -295,7 +295,8 @@ "code": "code", "docs": "docs", "fund": "fund", - "compare": "compare" + "compare": "compare", + "changelog": "changelog" }, "likes": { "like": "Like this package", @@ -1371,5 +1372,14 @@ "all": "all" } } + }, + "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}" + } } } diff --git a/i18n/locales/kn-IN.json b/i18n/locales/kn-IN.json index 51b8302df6..864bd07330 100644 --- a/i18n/locales/kn-IN.json +++ b/i18n/locales/kn-IN.json @@ -125,6 +125,7 @@ "version": "ಈ ಆವೃತ್ತಿ ಅಮಾನ್ಯಗೊಳಿಸಲಾಗಿದೆ.", "no_reason": "ಕಾರಣ ನೀಡಲಾಗಿಲ್ಲ" }, + "size_increase": {}, "replacement": { "title": "ನಿಮಗೆ ಈ ಅವಲಂಬನೆ ಅಗತ್ಯವಿಲ್ಲದಿರಬಹುದು.", "native": "ಇದನ್ನು {replacement} ಮೂಲಕ ಬದಲಾಯಿಸಬಹುದು, ಇದು Node {nodeVersion} ರಿಂದ ಲಭ್ಯವಿದೆ.", @@ -220,7 +221,8 @@ "more_tagged": "{count} ಇನ್ನಷ್ಟು ಟ್ಯಾಗ್ ಮಾಡಲಾಗಿದೆ", "all_covered": "ಎಲ್ಲಾ ಆವೃತ್ತಿಗಳು ಮೇಲಿನ ಟ್ಯಾಗ್‌ಗಳಿಂದ ಒಳಗೊಂಡಿವೆ", "deprecated_title": "{version} (ಅಮಾನ್ಯಗೊಳಿಸಲಾಗಿದೆ)", - "view_all": "{count} ಆವೃತ್ತಿ ನೋಡಿ | ಎಲ್ಲಾ {count} ಆವೃತ್ತಿಗಳನ್ನು ನೋಡಿ" + "view_all": "{count} ಆವೃತ್ತಿ ನೋಡಿ | ಎಲ್ಲಾ {count} ಆವೃತ್ತಿಗಳನ್ನು ನೋಡಿ", + "copy_alt": {} }, "dependencies": { "title": "ಅವಲಂಬನೆಗಳು ({count})", @@ -269,7 +271,8 @@ "date_range_multiline": "{start}\nರಿಂದ {end}", "download_file": "{fileType} ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ", "toggle_annotator": "ಅನೋಟೇಟರ್ ಟಾಗಲ್ ಮಾಡಿ", - "items": {} + "items": {}, + "copy_alt": {} }, "downloads": { "title": "ವಾರದ ಡೌನ್‌ಲೋಡ್‌ಗಳು" @@ -698,6 +701,8 @@ "managers": "ನಿರ್ವಾಹಕರು" } }, + "sponsors": {}, + "oss_partners": {}, "team": {}, "contributors": { "title": "ಸಹಯೋಗಿಗಳು", @@ -829,7 +834,9 @@ }, "values": {}, "trends": {} - } + }, + "file_filter_option": {}, + "filter": {} }, "privacy_policy": { "cookies": { @@ -850,5 +857,6 @@ "measures": {}, "limitations": {}, "contact": {} - } + }, + "changelog": {} } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index ac6066ce08..b209d9c941 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1308,5 +1308,6 @@ "p1": "Erişilebilirlik sorunları için:", "link": "GitHub'da issue açın" } - } + }, + "changelog": {} } diff --git a/i18n/schema.json b/i18n/schema.json index 96922412cc..fd592062d0 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -891,6 +891,9 @@ }, "compare": { "type": "string" + }, + "changelog": { + "type": "string" } }, "additionalProperties": false @@ -4120,6 +4123,33 @@ }, "additionalProperties": false }, + "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 + } + }, + "additionalProperties": false + }, "$schema": { "type": "string" } diff --git a/server/api/changelog/info/[...pkg].get.ts b/server/api/changelog/info/[...pkg].get.ts new file mode 100644 index 0000000000..71d9e87e8a --- /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' +// CACHE_MAX_AGE_ONE_DAY, + +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.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 0000000000..8407561f7a --- /dev/null +++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts @@ -0,0 +1,66 @@ +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, + }) + } + }, + { + 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..0948a3af25 --- /dev/null +++ b/server/utils/changelog/detectChangelog.ts @@ -0,0 +1,170 @@ +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' +import { resolveURL } from 'ufo' + +/** + * 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, pkg.repository.directory)) || + (await checkChangelogFile(repoRef, pkg.repository.directory)) + + 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, 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(() => { + 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..6588a7ab0a --- /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 0817bdd519..7236b5ac3e 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -3,10 +3,9 @@ import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' import type { ReadmeResponse, TocItem } from '#shared/types/readme' 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' /** @@ -139,7 +138,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', @@ -180,9 +179,9 @@ const ALLOWED_TAGS = [ 'button', ] -const ALLOWED_ATTR: Record = { +export const ALLOWED_ATTR: Record = { '*': ['id'], // Allow id on all tags - 'a': ['href', 'title', 'target', 'rel'], + 'a': ['href', 'title', 'target', 'rel', 'content-none'], 'img': ['src', 'alt', 'title', 'width', 'height', 'align'], 'source': ['src', 'srcset', 'type', 'media'], 'button': ['class', 'title', 'type', 'aria-label', 'data-copy'], @@ -201,24 +200,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 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 -} - /** * Lazy ATX heading extension for marked: allows headings without a space after `#`. * @@ -273,7 +254,7 @@ const reservedPathsNpmJs = [ const npmJsHosts = new Set(['www.npmjs.com', 'npmjs.com', 'www.npmjs.org', 'npmjs.org']) -const isNpmJsUrlThatCanBeRedirected = (url: URL) => { +export const isNpmJsUrlThatCanBeRedirected = (url: URL) => { if (!npmJsHosts.has(url.host)) { return false } @@ -387,7 +368,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}` } @@ -397,7 +378,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..7e75761bd1 --- /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 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..0be9deb798 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,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/shared/utils/html.ts b/shared/utils/html.ts index 2a868be0b8..22b0c4182d 100644 --- a/shared/utils/html.ts +++ b/shared/utils/html.ts @@ -27,3 +27,21 @@ 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 stripHtmlTags(text) + .replace(/ ?/g, '') // remove non breaking spaces + .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 8629dd33c0..3e2bf2d617 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -136,6 +136,7 @@ import { ButtonBase, LinkBase, CallToAction, + ChangelogCard, CodeDirectoryListing, CodeFileTree, CodeMobileTreeDrawer, @@ -150,6 +151,7 @@ import { CompareLineChart, ComparePackageSelector, CompareReplacementSuggestion, + ChangelogErrorMsg, DateTime, DependencyPathPopup, FilterChips, @@ -2166,6 +2168,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..637d5a1dbf --- /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 f6f6ea845a..7cf5dc5cc0 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -47,6 +47,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', } /**