diff --git a/app/components/Changelog/Card.vue b/app/components/Changelog/Card.vue new file mode 100644 index 000000000..89097334a --- /dev/null +++ b/app/components/Changelog/Card.vue @@ -0,0 +1,17 @@ + + + + diff --git a/app/components/Changelog/Releases.vue b/app/components/Changelog/Releases.vue new file mode 100644 index 000000000..440f0618a --- /dev/null +++ b/app/components/Changelog/Releases.vue @@ -0,0 +1,15 @@ + + diff --git a/app/composables/usePackageChangelog.ts b/app/composables/usePackageChangelog.ts new file mode 100644 index 000000000..ce2357194 --- /dev/null +++ b/app/composables/usePackageChangelog.ts @@ -0,0 +1,13 @@ +import type { ChangelogInfo } from '~~/shared/types/changelog' + +export function usePackageChangelog( + packageName: MaybeRefOrGetter, + version?: MaybeRefOrGetter, +) { + return useLazyFetch(() => { + const name = toValue(packageName) + const ver = toValue(version) + const base = `/api/changelog/info/${name}` + return ver ? `${base}/v/${ver}` : base + }) +} diff --git a/app/pages/package-changes/[...path].vue b/app/pages/package-changes/[...path].vue new file mode 100644 index 000000000..1896ffe8b --- /dev/null +++ b/app/pages/package-changes/[...path].vue @@ -0,0 +1,98 @@ + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index e806983be..52048489a 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -117,6 +117,7 @@ const { data: skillsData } = useLazyFetch( const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) const { data: moduleReplacement } = useModuleReplacement(packageName) +const { data: hasChangelog } = usePackageChangelog(packageName, requestedVersion) const { data: resolvedVersion, @@ -745,6 +746,14 @@ onKeyStroke( {{ $t('package.links.issues') }} +
  • + + {{ $t('package.links.changelog') }} + +
  • { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) + + try { + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + const encodedName = encodePackageName(packageName) + const versionSuffix = version ? `/${version}` : '/latest' + const pkg = await $fetch( + `${NPM_REGISTRY}/${encodedName}${versionSuffix}`, + ) + + return await detectChangelog(pkg) + } catch (error) { + handleApiError(error, { + statusCode: 502, + message: ERROR_PACKAGE_DETECT_CHANGELOG, + }) + } + }, + // { + // maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes + // swr: true, + // getKey: event => { + // const pkg = getRouterParam(event, 'pkg') ?? '' + // return `changelog:v1:${pkg.replace(/\/+$/, '').trim()}` + // }, + // }, +) diff --git a/server/api/changelog/releases/[provider]/[...repo].ts b/server/api/changelog/releases/[provider]/[...repo].ts new file mode 100644 index 000000000..3f14d4d02 --- /dev/null +++ b/server/api/changelog/releases/[provider]/[...repo].ts @@ -0,0 +1,56 @@ +import type { ProviderId } from '~~/shared/utils/git-providers' +import type { ReleaseData } from '~~/shared/types/changelog' +import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release' +import { ERROR_CHANGELOG_RELEASES_FAILED, THROW_INCOMPLETE_PARAM } from '~~/shared/utils/constants' +import { parse } from 'valibot' + +export default defineCachedEventHandler(async event => { + const provider = getRouterParam(event, 'provider') + const repo = getRouterParam(event, 'repo') + + if (!repo || !provider || !/^[\w-]+\/[\w-]+$/.test(repo)) { + throw createError({ + status: 404, + statusMessage: THROW_INCOMPLETE_PARAM, + }) + } + + try { + switch (provider as ProviderId) { + case 'github': + return getReleasesFromGithub(repo) + + default: + return false + } + } catch (error) { + handleApiError(error, { + statusCode: 502, + // message: 'temp', + message: ERROR_CHANGELOG_RELEASES_FAILED, + }) + } +}) + +async function getReleasesFromGithub(repo: string) { + const data = await $fetch(`https://ungh.cc/repos/${repo}/releases`, { + headers: { + 'Accept': '*/*', + 'User-Agent': 'npmx.dev', + }, + }) + + const { releases } = parse(GithubReleaseCollectionSchama, data) + + return releases.map( + r => + ({ + id: r.id, + html: r.html, + title: r.name, + draft: r.draft, + prerelease: r.prerelease, + publishedAt: r.publishedAt, + }) satisfies ReleaseData, + ) +} diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts index a397f4cb6..4db880fb3 100644 --- a/server/api/registry/analysis/[...pkg].get.ts +++ b/server/api/registry/analysis/[...pkg].get.ts @@ -54,7 +54,6 @@ export default defineCachedEventHandler( const createPackage = await findAssociatedCreatePackage(packageName, pkg) const analysis = analyzePackage(pkg, { typesPackage, createPackage }) - return { package: packageName, version: pkg.version ?? version ?? 'latest', diff --git a/server/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts new file mode 100644 index 000000000..e6859eef8 --- /dev/null +++ b/server/utils/changelog/detectChangelog.ts @@ -0,0 +1,120 @@ +import type { ChangelogReleaseInfo } from '~~/shared/types/changelog' +import { type RepoRef, parseRepoUrl } from '~~/shared/utils/git-providers' +import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis' +// ChangelogInfo + +/** + * Detect whether changelogs/releases are available for this package + * + * first checks if releases are available and then changelog.md + */ +export async function detectChangelog( + pkg: ExtendedPackageJson, + // packageName: string, + // version: string, +) { + if (!pkg.repository?.url) { + return false + } + + const repoRef = parseRepoUrl(pkg.repository.url) + if (!repoRef) { + return false + } + + const releaseInfo = await checkReleases(repoRef) + + return releaseInfo || checkChangelogFile(repoRef) +} + +/** + * check whether releases are being used with this repo + * @returns true if in use + */ +async function checkReleases(ref: RepoRef): Promise { + const checkUrls = getLatestReleaseUrl(ref) + + for (const checkUrl of checkUrls ?? []) { + const exists = await fetch(checkUrl, { + headers: { + // GitHub API requires User-Agent + 'User-Agent': 'npmx.dev', + }, + method: 'HEAD', // we just need to know if it exists or not + }) + .then(r => r.ok) + .catch(() => false) + if (exists) { + return { + provider: ref.provider, + type: 'release', + repo: `${ref.owner}/${ref.repo}`, + } + } + } + return false +} + +/** + * get the url to check if releases are being used. + * + * @returns returns an array so that if providers don't have a latest that we can check for versions + */ +function getLatestReleaseUrl(ref: RepoRef): null | string[] { + switch (ref.provider) { + case 'github': + return [`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`] + } + + return null +} + +const CHANGELOG_FILENAMES = ['changelog', 'history', 'changes', 'news', 'releases'] as const + +async function checkChangelogFile(ref: RepoRef) { + const checkUrls = getChangelogUrls(ref) + + for (const checkUrl of checkUrls ?? []) { + const exists = await fetch(checkUrl, { + headers: { + // GitHub API requires User-Agent + 'User-Agent': 'npmx.dev', + }, + method: 'HEAD', // we just need to know if it exists or not + }) + .then(r => r.ok) + .catch(() => false) + if (exists) { + console.log('exists', checkUrl) + return true + } + } + return false +} + +function getChangelogUrls(ref: RepoRef) { + const baseUrl = getBaseFileUrl(ref) + if (!baseUrl) { + return + } + + return CHANGELOG_FILENAMES.flatMap(fileName => { + const fileNameUpCase = fileName.toUpperCase() + return [ + `${baseUrl}/${fileNameUpCase}.md`, + `${baseUrl}/${fileName}.md`, + `${baseUrl}/${fileNameUpCase}`, + `${baseUrl}/${fileName}`, + `${baseUrl}/${fileNameUpCase}.txt`, + `${baseUrl}/${fileName}.txt`, + ] + }) +} + +function getBaseFileUrl(ref: RepoRef) { + switch (ref.provider) { + case 'github': + return `https://ungh.cc/repos/${ref.owner}/${ref.repo}/files/HEAD` + } + return null +} diff --git a/shared/schemas/changelog/release.ts b/shared/schemas/changelog/release.ts new file mode 100644 index 000000000..680d90769 --- /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.string(), + draft: v.boolean(), + prerelease: v.boolean(), + // publishedAt: v.pipe(v.string(), v.isoDateTime()), + html: v.string(), + markdown: v.string(), + publishedAt: v.pipe(v.string(), v.isoTimestamp()), +}) + +export const GithubReleaseCollectionSchama = v.object({ + releases: v.array(GithubReleaseSchama), +}) + +export type GithubRelease = v.InferOutput +export type GithubReleaseCollection = v.InferOutput diff --git a/shared/types/changelog.ts b/shared/types/changelog.ts new file mode 100644 index 000000000..480d6a23b --- /dev/null +++ b/shared/types/changelog.ts @@ -0,0 +1,27 @@ +import type { ProviderId } from '../utils/git-providers' + +export interface ChangelogReleaseInfo { + type: 'release' + provider: ProviderId + repo: `${string}/${string}` +} + +export interface ChangelogMarkdownInfo { + type: 'md' + provider: ProviderId + /** + * location within the repository + */ + location: string +} + +export type ChangelogInfo = ChangelogReleaseInfo | ChangelogMarkdownInfo + +export interface ReleaseData { + title: string // example "v1.x.x", + html: string + prerelease?: boolean + draft?: boolean + id: string | number + publishedAt?: string +} diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index ceac47f59..b77b56318 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -17,6 +17,7 @@ export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' export const ERROR_PACKAGE_REQUIREMENTS_FAILED = 'Package name, version, and file path are required.' +export const ERROR_PACKAGE_DETECT_CHANGELOG = 'failed to detect package has changelog' export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.' export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.' export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' @@ -34,6 +35,9 @@ export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.' export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." export const ERROR_NEED_REAUTH = 'User needs to reauthenticate' +export const ERROR_CHANGELOG_RELEASES_FAILED = 'Failed to get releases' +export const THROW_INCOMPLETE_PARAM = "Couldn't do request due to incomplete parameters" + // microcosm services export const CONSTELLATION_HOST = 'constellation.microcosm.blue' export const SLINGSHOT_HOST = 'slingshot.microcosm.blue'