diff --git a/app/components/Changelog/Card.vue b/app/components/Changelog/Card.vue new file mode 100644 index 000000000..ebffcf373 --- /dev/null +++ b/app/components/Changelog/Card.vue @@ -0,0 +1,59 @@ + + + + diff --git a/app/components/Changelog/Markdown.vue b/app/components/Changelog/Markdown.vue new file mode 100644 index 000000000..be03557b9 --- /dev/null +++ b/app/components/Changelog/Markdown.vue @@ -0,0 +1,11 @@ + + 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/components/Readme.vue b/app/components/Readme.vue index 8cb59ee49..e571e47bd 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(a[href^='#']::after) { +.readme :deep(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 000000000..7d3dcd943 --- /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..4f03a6568 --- /dev/null +++ b/app/pages/package-changes/[...path].vue @@ -0,0 +1,100 @@ + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 72c799c9d..5f3dab24c 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -194,6 +194,7 @@ const { data: skillsData } = useLazyFetch( const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) const { data: moduleReplacement } = useModuleReplacement(packageName) +const { data: changelog } = usePackageChangelog(packageName, requestedVersion) const { data: resolvedVersion, @@ -907,6 +908,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 + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `changelogInfo:pr1:${pkg.replace(/\/+$/, '').trim()}` + }, + }, +) diff --git a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts new file mode 100644 index 000000000..b8dff6eda --- /dev/null +++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts @@ -0,0 +1,47 @@ +import * as v from 'valibot' +import { + ERROR_CHANGELOG_FILE_FAILED, + ERROR_THROW_INCOMPLETE_PARAM, +} from '~~/shared/utils/constants' + +export default defineCachedEventHandler(async event => { + const provider = getRouterParam(event, 'provider') + const repo = getRouterParam(event, 'repo') + const owner = getRouterParam(event, 'owner') + const path = getRouterParam(event, 'path') + + if (!repo || !provider || !owner || !path) { + throw createError({ + status: 404, + statusMessage: ERROR_THROW_INCOMPLETE_PARAM, + }) + } + + try { + console.log({ provider }) + + switch (provider as ProviderId) { + case 'github': + return await getGithubMarkDown(owner, repo, path) + + default: + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } + } catch (error) { + handleApiError(error, { + statusCode: 502, + message: ERROR_CHANGELOG_FILE_FAILED, + }) + } +}) + +async function getGithubMarkDown(owner: string, repo: string, path: string) { + const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/files/HEAD/${path}`) + + const markdown = v.parse(v.string(), data) + + return (await changelogRenderer())(markdown) +} diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts new file mode 100644 index 000000000..86ecd17e9 --- /dev/null +++ b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts @@ -0,0 +1,67 @@ +import type { ProviderId } from '~~/shared/utils/git-providers' +import type { ReleaseData } from '~~/shared/types/changelog' +import { + ERROR_CHANGELOG_RELEASES_FAILED, + ERROR_THROW_INCOMPLETE_PARAM, +} from '~~/shared/utils/constants' +import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release' +import { parse } from 'valibot' +import { changelogRenderer } from '~~/server/utils/changelog/markdown' + +export default defineCachedEventHandler(async event => { + const provider = getRouterParam(event, 'provider') + const repo = getRouterParam(event, 'repo') + const owner = getRouterParam(event, 'owner') + + if (!repo || !provider || !owner) { + throw createError({ + status: 404, + statusMessage: ERROR_THROW_INCOMPLETE_PARAM, + }) + } + + try { + switch (provider as ProviderId) { + case 'github': + return await getReleasesFromGithub(owner, repo) + + default: + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } + } catch (error) { + handleApiError(error, { + statusCode: 502, + message: ERROR_CHANGELOG_RELEASES_FAILED, + }) + } +}) + +async function getReleasesFromGithub(owner: string, repo: string) { + const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { + headers: { + 'Accept': '*/*', + 'User-Agent': 'npmx.dev', + }, + }) + + const { releases } = parse(GithubReleaseCollectionSchama, data) + + const render = await changelogRenderer() + + return releases.map(r => { + const { html, toc } = render(r.markdown, r.id) + return { + id: r.id, + // replace single \n within

    like with Vue's releases + html: html?.replace(/(?)\n/g, '
    ') ?? null, + title: r.name || r.tag, + draft: r.draft, + prerelease: r.prerelease, + toc, + publishedAt: r.publishedAt, + } satisfies ReleaseData + }) +} diff --git a/server/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts new file mode 100644 index 000000000..51c1a23a1 --- /dev/null +++ b/server/utils/changelog/detectChangelog.ts @@ -0,0 +1,174 @@ +import type { + ChangelogMarkdownInfo, + ChangelogInfo, + ChangelogReleaseInfo, +} from '~~/shared/types/changelog' +import { type RepoRef, parseRepoUrl } from '~~/shared/utils/git-providers' +import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis' +import { ERROR_CHANGELOG_NOT_FOUND } from '~~/shared/utils/constants' +import * as v from 'valibot' +import { GithubReleaseSchama } from '~~/shared/schemas/changelog/release' + +// ChangelogInfo + +/** + * Detect whether changelogs/releases are available for this package + * + * first checks if releases are available and then changelog.md + */ +export async function detectChangelog( + pkg: ExtendedPackageJson, + // packageName: string, + // version: string, +) { + if (!pkg.repository?.url) { + return false + } + + const repoRef = parseRepoUrl(pkg.repository.url) + if (!repoRef) { + return false + } + + const changelog = (await checkReleases(repoRef)) || (await checkChangelogFile(repoRef)) + + if (changelog) { + return changelog + } + + throw createError({ + statusCode: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) +} + +/** + * check whether releases are being used with this repo + * @returns true if in use + */ +async function checkReleases(ref: RepoRef): Promise { + switch (ref.provider) { + case 'github': { + return checkLatestGithubRelease(ref) + } + } + + // const checkUrls = getLatestReleaseUrl(ref) + + // for (const checkUrl of checkUrls ?? []) { + // const exists = await fetch(checkUrl, { + // headers: { + // // GitHub API requires User-Agent + // 'User-Agent': 'npmx.dev', + // }, + // method: 'HEAD', // we just need to know if it exists or not + // }) + // .then(r => r.ok) + // .catch(() => false) + // if (exists) { + // return { + // provider: ref.provider, + // type: 'release', + // repo: `${ref.owner}/${ref.repo}`, + // } + // } + // } + return false +} + +/// releases + +// /** +// * get the url to check if releases are being used. +// * +// * @returns returns an array so that if providers don't have a latest that we can check for versions +// */ +// function getLatestReleaseUrl(ref: RepoRef): null | string[] { +// switch (ref.provider) { +// case 'github': +// return [`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`] +// } + +// return null +// } + +const MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\()(.*?)(?=\))/i + +function checkLatestGithubRelease(ref: RepoRef): Promise { + return $fetch(`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`) + .then(r => { + console.log(r) + const { release } = v.parse(v.object({ release: GithubReleaseSchama }), r) + + const matchedChangelog = release.markdown?.match(MD_REGEX)?.at(0) + + // if no changelog.md or the url doesn't contain /blob/ + if (!matchedChangelog || !matchedChangelog.includes('/blob/')) { + return { + provider: ref.provider, + type: 'release', + repo: `${ref.owner}/${ref.repo}`, + } satisfies ChangelogReleaseInfo + } + + const path = matchedChangelog.replace(/^.*\/blob\/[^/]+\//i, '') + return { + provider: ref.provider, + type: 'md', + path, + repo: `${ref.owner}/${ref.repo}`, + link: matchedChangelog, + } satisfies ChangelogMarkdownInfo + }) + .catch(e => { + console.log('changelog error', e) + return false + }) +} + +/// changelog markdown + +const EXTENSIONS = ['.md', ''] as const + +const CHANGELOG_FILENAMES = ['changelog', 'releases', 'changes', 'history', 'news'] + .map(fileName => { + const fileNameUpperCase = fileName.toUpperCase() + return EXTENSIONS.map(ext => [`${fileNameUpperCase}${ext}`, `${fileName}${ext}`]) + }) + .flat(3) + +async function checkChangelogFile(ref: RepoRef): Promise { + const baseUrl = getBaseFileUrl(ref) + if (!baseUrl) { + return false + } + + for (const fileName of CHANGELOG_FILENAMES) { + const exists = await fetch(`${baseUrl}/${fileName}`, { + headers: { + // GitHub API requires User-Agent + 'User-Agent': 'npmx.dev', + }, + method: 'HEAD', // we just need to know if it exists or not + }) + .then(r => r.ok) + .catch(() => false) + if (exists) { + return { + type: 'md', + provider: ref.provider, + path: fileName, + repo: `${ref.owner}/${ref.repo}`, + } satisfies ChangelogMarkdownInfo + } + } + return false +} + +function getBaseFileUrl(ref: RepoRef) { + switch (ref.provider) { + case 'github': + return `https://ungh.cc/repos/${ref.owner}/${ref.repo}/files/HEAD` + } + return null +} diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts new file mode 100644 index 000000000..18c84ccb0 --- /dev/null +++ b/server/utils/changelog/markdown.ts @@ -0,0 +1,211 @@ +import { marked, type Tokens } from 'marked' +import { + ALLOWED_ATTR, + ALLOWED_TAGS, + calculateSemanticDepth, + prefixId, + slugify, + stripHtmlTags, +} from '../readme' +import sanitizeHtml from 'sanitize-html' + +export async function changelogRenderer() { + const renderer = new marked.Renderer({ + gfm: true, + }) + + const shiki = await getShikiHighlighter() + + renderer.link = function ({ href, title, tokens }: Tokens.Link) { + const text = this.parser.parseInline(tokens) + const titleAttr = title ? ` title="${title}"` : '' + const plainText = text.replace(/<[^>]*>/g, '').trim() + + const intermediateTitleAttr = `${` data-title-intermediate="${plainText || title}"`}` + + return `${text}` + } + + // GitHub-style callouts: > [!NOTE], > [!TIP], etc. + renderer.blockquote = function ({ tokens }: Tokens.Blockquote) { + const body = this.parser.parse(tokens) + + const calloutMatch = body.match(/^

    \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:
    )?\s*/i) + + if (calloutMatch?.[1]) { + const calloutType = calloutMatch[1].toLowerCase() + const cleanedBody = body.replace(calloutMatch[0], '

    ') + return `

    ${cleanedBody}
    \n` + } + + return `
    ${body}
    \n` + } + + // Syntax highlighting for code blocks (uses shared highlighter) + renderer.code = ({ text, lang }: Tokens.Code) => { + const html = highlightCodeSync(shiki, text, lang || 'text') + // Add copy button + return `
    + + ${html} +
    ` + } + + return (markdown: string | null, releaseId?: string | number) => { + // Collect table of contents items during parsing + const toc: TocItem[] = [] + + if (!markdown) { + return { + html: null, + toc, + } + } + + // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2) + const usedSlugs = new Map() + + let lastSemanticLevel = releaseId ? 2 : 1 // Start after h2 (the "Readme" section heading) + renderer.heading = function ({ tokens, depth }: Tokens.Heading) { + // Calculate the target semantic level based on document structure + // Start at h3 (since page h1 + section h2 already exist) + // But ensure we never skip levels - can only go down by 1 or stay same/go up + const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel) + lastSemanticLevel = semanticLevel + const text = this.parser.parseInline(tokens) + + // Generate GitHub-style slug for anchor links + // adding release id to prevent conflicts + let slug = slugify(text) + if (!slug) slug = 'heading' // Fallback for empty headings + + // Handle duplicate slugs (GitHub-style: foo, foo-1, foo-2) + const count = usedSlugs.get(slug) ?? 0 + usedSlugs.set(slug, count + 1) + const uniqueSlug = count === 0 ? slug : `${slug}-${count}` + + // Prefix with 'user-content-' to avoid collisions with page IDs + // (e.g., #install, #dependencies, #versions are used by the package page) + const id = releaseId + ? `user-content-${releaseId}-${uniqueSlug}` + : `user-content-${uniqueSlug}` + + // Collect TOC item with plain text (HTML stripped & emoji's added) + const plainText = convertToEmoji(stripHtmlTags(text)) + .replace(/ ?/g, '') // remove non breaking spaces + .trim() + if (plainText) { + toc.push({ text: plainText, id, depth }) + } + + return `${text} \n` + } + + return { + html: convertToEmoji( + marked.parse(markdown, { + renderer, + }) as string, + ), + toc, + } + } +} + +export function sanitizeRawHTML(rawHtml: string) { + return sanitizeHtml(rawHtml, { + allowedTags: ALLOWED_TAGS, + allowedAttributes: ALLOWED_ATTR, + allowedSchemes: ['http', 'https', 'mailto'], + // Transform img src URLs (GitHub blob → raw, relative → GitHub raw) + transformTags: { + h1: (_, attribs) => { + return { tagName: 'h3', attribs: { ...attribs, 'data-level': '1' } } + }, + h2: (_, attribs) => { + return { tagName: 'h4', attribs: { ...attribs, 'data-level': '2' } } + }, + h3: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h3', attribs: attribs } + return { tagName: 'h5', attribs: { ...attribs, 'data-level': '3' } } + }, + h4: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h4', attribs: attribs } + return { tagName: 'h6', attribs: { ...attribs, 'data-level': '4' } } + }, + h5: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h5', attribs: attribs } + return { tagName: 'h6', attribs: { ...attribs, 'data-level': '5' } } + }, + h6: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h6', attribs: attribs } + return { tagName: 'h6', attribs: { ...attribs, 'data-level': '6' } } + }, + // img: (tagName, attribs) => { + // if (attribs.src) { + // attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) + // } + // return { tagName, attribs } + // }, + // source: (tagName, attribs) => { + // if (attribs.src) { + // attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) + // } + // if (attribs.srcset) { + // attribs.srcset = attribs.srcset + // .split(',') + // .map(entry => { + // const parts = entry.trim().split(/\s+/) + // const url = parts[0] + // if (!url) return entry.trim() + // const descriptor = parts[1] + // const resolvedUrl = resolveImageUrl(url, packageName, repoInfo) + // return descriptor ? `${resolvedUrl} ${descriptor}` : resolvedUrl + // }) + // .join(', ') + // } + // return { tagName, attribs } + // }, + // a: (tagName, attribs) => { + // if (!attribs.href) { + // return { tagName, attribs } + // } + + // const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo) + + // const provider = matchPlaygroundProvider(resolvedHref) + // if (provider && !seenUrls.has(resolvedHref)) { + // seenUrls.add(resolvedHref) + + // collectedLinks.push({ + // url: resolvedHref, + // provider: provider.id, + // providerName: provider.name, + // /** + // * We need to set some data attribute before hand because `transformTags` doesn't + // * provide the text of the element. This will automatically be removed, because there + // * is an allow list for link attributes. + // * */ + // label: attribs['data-title-intermediate'] || provider.name, + // }) + // } + + // // Add security attributes for external links + // if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { + // attribs.rel = 'nofollow noreferrer noopener' + // attribs.target = '_blank' + // } + // attribs.href = resolvedHref + // return { tagName, attribs } + // }, + div: prefixId, + p: prefixId, + span: prefixId, + section: prefixId, + article: prefixId, + }, + }) +} diff --git a/server/utils/readme.ts b/server/utils/readme.ts index d7c296117..fc6c983b6 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -112,7 +112,7 @@ function matchPlaygroundProvider(url: string): PlaygroundProvider | null { // allow h1-h6, but replace h1-h2 later since we shift README headings down by 2 levels // (page h1 = package name, h2 = "Readme" section, so README h1 → h3) -const ALLOWED_TAGS = [ +export const ALLOWED_TAGS = [ 'h1', 'h2', 'h3', @@ -153,7 +153,7 @@ const ALLOWED_TAGS = [ 'button', ] -const ALLOWED_ATTR: Record = { +export const ALLOWED_ATTR: Record = { '*': ['id'], // Allow id on all tags 'a': ['href', 'title', 'target', 'rel'], 'img': ['src', 'alt', 'title', 'width', 'height', 'align'], @@ -179,7 +179,7 @@ const ALLOWED_ATTR: Record = { * incomplete sanitization from nested/interleaved tags * (e.g. `ipt>` → `