diff --git a/app/components/Changelog/Markdown.vue b/app/components/Changelog/Markdown.vue index a81fd86541..0cccb98780 100644 --- a/app/components/Changelog/Markdown.vue +++ b/app/components/Changelog/Markdown.vue @@ -10,6 +10,11 @@ const route = useRoute() const { data, error, pending } = useLazyFetch( () => `/api/changelog/md/${info.provider}/${info.repo}/${info.path}`, + { + query: { + host: computed(() => info.host), + }, + }, ) if (import.meta.client) { diff --git a/app/components/Changelog/Releases.vue b/app/components/Changelog/Releases.vue index f89504d7ff..539e1520eb 100644 --- a/app/components/Changelog/Releases.vue +++ b/app/components/Changelog/Releases.vue @@ -12,7 +12,11 @@ const { data: releases, error, pending, -} = useLazyFetch(() => `/api/changelog/releases/${info.provider}/${info.repo}`) +} = useLazyFetch(() => `/api/changelog/releases/${info.provider}/${info.repo}`, { + query: { + host: computed(() => info.host), + }, +}) const route = useRoute() diff --git a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts index a9be7b4251..d5f24d5e86 100644 --- a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts +++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { createForgejoRepoInfo, createGithubRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' import { ERROR_CHANGELOG_FILE_FAILED, ERROR_THROW_INCOMPLETE_PARAM, @@ -11,6 +12,9 @@ export default defineCachedEventHandler( const owner = getRouterParam(event, 'owner') const path = getRouterParam(event, 'path') + const rawQuery = getQuery(event) + const { host } = v.parse(v.object({ host: v.optional(v.string()) }), rawQuery) + if (!repo || !provider || !owner || !path) { throw createError({ status: 404, @@ -22,6 +26,9 @@ export default defineCachedEventHandler( switch (provider as ProviderId) { case 'github': return await getGithubMarkDown(owner, repo, path) + case 'codeberg': + case 'forgejo': + return await getForgejoMarkdown(owner, repo, path, host ?? 'codeberg.org') default: throw createError({ @@ -55,11 +62,13 @@ async function getGithubMarkDown(owner: string, repo: string, path: string) { 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) + return (await changelogRenderer(createGithubRepoInfo(owner, repo, path)))(markdown) +} + +async function getForgejoMarkdown(owner: string, repo: string, path: string, host: string) { + const data = await $fetch(`https://${host}/${owner}/${repo}/raw/branch/HEAD/${path}`) + + const markdown = v.parse(v.string(), data) + + return (await changelogRenderer(createForgejoRepoInfo(host, owner, repo, path)))(markdown) } diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts index eb249ca832..494be19a1f 100644 --- a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts +++ b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts @@ -1,12 +1,17 @@ import type { ProviderId } from '~~/shared/utils/git-providers' import type { ReleaseData } from '~~/shared/types/changelog' +import * as v from 'valibot' import { ERROR_CHANGELOG_RELEASES_FAILED, ERROR_THROW_INCOMPLETE_PARAM, } from '~~/shared/utils/constants' -import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release' +import { + GithubReleaseCollectionSchama, + ForgejoReleaseCollectionSchema, +} from '~~/shared/schemas/changelog/release' import { parse } from 'valibot' import { changelogRenderer } from '~~/server/utils/changelog/markdown' +import { createForgejoRepoInfo, createGithubRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' export default defineCachedEventHandler( async event => { @@ -14,6 +19,9 @@ export default defineCachedEventHandler( const repo = getRouterParam(event, 'repo') const owner = getRouterParam(event, 'owner') + const rawQuery = getQuery(event) + const { host } = v.parse(v.object({ host: v.optional(v.string()) }), rawQuery) + if (!repo || !provider || !owner) { throw createError({ status: 404, @@ -25,6 +33,9 @@ export default defineCachedEventHandler( switch (provider as ProviderId) { case 'github': return await getReleasesFromGithub(owner, repo) + case 'codeberg': + case 'forgejo': + return await getReleasesFromForgejo(owner, repo, host ?? 'codeberg.org') default: throw createError({ @@ -62,10 +73,7 @@ async function getReleasesFromGithub(owner: string, repo: string) { 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`, - }) + const render = await changelogRenderer(createGithubRepoInfo(owner, repo)) return releases.map(r => { const { html, toc } = render(r.markdown, r.id) @@ -82,3 +90,24 @@ async function getReleasesFromGithub(owner: string, repo: string) { } satisfies ReleaseData }) } + +async function getReleasesFromForgejo(owner: string, repo: string, host: string) { + const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases?draft=false`) + const releases = parse(ForgejoReleaseCollectionSchema, data) + + const render = await changelogRenderer(createForgejoRepoInfo(host, owner, repo)) + + return releases.map(r => { + const { html, toc } = render(r.body, r.id) + return { + id: r.id, + html: html?.replace(/(?)\n/g, '
') ?? null, + title: r.name || r.tag_name, + prerelease: r.prerelease, + toc, + link: r.html_url, + publishedAt: r.published_at, + draft: r.draft, + } satisfies ReleaseData + }) +} diff --git a/server/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts index 9c98e16416..f2d63f03ff 100644 --- a/server/utils/changelog/detectChangelog.ts +++ b/server/utils/changelog/detectChangelog.ts @@ -3,7 +3,11 @@ import { 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 { + GithubReleaseSchama, + ForgejoReleaseSchama, + GitlabReleaseSchame, +} from '~~/shared/schemas/changelog/release' import { resolveURL } from 'ufo' import * as v from 'valibot' @@ -56,12 +60,17 @@ async function checkReleases( case 'github': { return checkLatestGithubRelease(ref, directory) } + case 'codeberg': + case 'forgejo': { + return checkLatestForgejoRelease(ref, directory) + } + case 'gitlab': { + return checkLatestGitlabRelease(ref, directory) + } } return [false, null] } -/// releases - const MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\()(.*?)(?=\))/i const ROOT_ONLY_REGEX = /^\/[^/]+$/ @@ -180,8 +189,9 @@ async function checkFiles(ref: RepoRef, baseUrl: RepoFileUrl, dir?: string) { type: 'md', provider: ref.provider, path: resolveURL(dir ?? '', fileName), - repo: `${ref.owner}/${ref.repo}`, + repo: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`, link: resolveURL(baseUrl.blob, dir ?? '', fileName), + host: ref.host, } satisfies ChangelogMarkdownInfo } } @@ -200,6 +210,154 @@ function getBaseFileUrl(ref: RepoRef): RepoFileUrl | null { raw: `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/HEAD`, blob: `https://github.com/${ref.owner}/${ref.repo}/blob/HEAD`, } + case 'codeberg': + case 'forgejo': { + const host = ref.host ?? 'codeberg.org' + return { + blob: `https://${host}/${ref.owner}/${ref.repo}/src/branch/HEAD`, + raw: `https://${host}/${ref.owner}/${ref.repo}/raw/branch/HEAD`, + } + } + case 'gitlab': { + const host = ref.host ?? 'gitlab.com' + return { + blob: `https://${host}/${ref.owner}/${ref.repo}/-/blob/HEAD`, + raw: `https://${host}/${ref.owner}/${ref.repo}/-/raw/HEAD`, + } + } } return null } + +// codeberg / forgejo + +async function checkLatestForgejoRelease( + ref: RepoRef, + directory?: string, +): Promise> { + try { + const host = ref.host ?? 'codeberg.org' + + const response = await $fetch( + `https://${host}/api/v1/repos/${ref.owner}/${ref.repo}/releases/latest`, + { + headers: { + 'User-Agent': 'npmx.dev', + 'accept': 'application/json', + }, + }, + ) + + const release = v.parse(ForgejoReleaseSchama, response) + + const matchedChangelog = release.body?.match(MD_REGEX)?.at(0) + + // /src/branch/ can be similar to /blob/ + if (!matchedChangelog || !matchedChangelog.includes('/src/branch/')) { + return [ + { + type: 'release', + link: `https://${host}/${ref.owner}/${ref.repo}/releases`, + provider: ref.provider, + repo: `${ref.owner}/${ref.repo}`, + host: ref.host, + }, + null, + ] + } + + const path = matchedChangelog.replace(/^.*\/src\/branch\/[^/]+\//i, '') + if ( + directory && + !( + path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || + ROOT_ONLY_REGEX.test(path) + ) + ) { + return [false, null] as const + } + return [ + { + provider: ref.provider, + type: 'md', + path, + repo: `${ref.owner}/${ref.repo}`, + link: matchedChangelog, + host: ref.host, + }, + null, + ] + } catch (e) { + if (e instanceof Error) { + return [null, e] + } + } + return [false, null] +} + +// gitlab +async function checkLatestGitlabRelease( + ref: RepoRef, + directory?: string, +): Promise> { + try { + const host = ref.host ?? 'gitlab.com' + const repoPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) + + const response = await $fetch( + `https://${host}/api/v4/projects/${repoPath}/releases/permalink/latest`, + { + headers: { + 'User-Agent': 'npmx.dev', + 'accept': 'application/json', + }, + }, + ) + const release = v.parse(GitlabReleaseSchame, response) + + const matchedChangelog = release.description?.match(MD_REGEX)?.at(0) + + if (!matchedChangelog || !matchedChangelog.includes('/-/blob/')) { + return [ + { + type: 'release', + // I encode both just to be sure + link: `https://${host}/${ref.owner}/${ref.repo}/-/releases`, + provider: ref.provider, + repo: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`, + host: ref.host, + }, + null, + ] + } + + const path = matchedChangelog.replace(/^.*\/-\/blob\/[^/]+\//i, '') + + if ( + directory && + !( + path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || + ROOT_ONLY_REGEX.test(path) + ) + ) { + return [false, null] as const + } + + return [ + { + provider: ref.provider, + type: 'md', + path, + repo: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`, + link: matchedChangelog, + host: ref.host, + }, + null, + ] + } catch (e) { + if (e instanceof Error) { + return [null, e] + } + } + return [false, null] +} diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts index aa11c19f88..11b222ff0c 100644 --- a/server/utils/changelog/markdown.ts +++ b/server/utils/changelog/markdown.ts @@ -1,3 +1,4 @@ +import type { IOptions } from 'sanitize-html' import { type ProcessImageUrlFn, type ProcessLinkFn, @@ -17,7 +18,7 @@ import { } from '../mdKit' import { slugify } from '#shared/utils/html' import { Marked } from 'marked' -import { hasProtocol, joinRelativeURL, parseFilename } from 'ufo' +import { hasProtocol, joinRelativeURL, joinURL, parseFilename, parseURL } from 'ufo' import { convertToEmoji } from '#shared/utils/emoji' // cl = ChangeLog @@ -59,7 +60,7 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { return `${idPrefix}-${id}` } - const processLink: ProcessLinkFn = (href: string, _label: string) => { + const processLink: ProcessLinkFn = (href: string, label: string) => { const resolvedHref = resolveUrl(href, mdRepoInfo, toUserContentId) // Security attributes for external links @@ -68,7 +69,9 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { ? ' rel="nofollow noreferrer noopener" target="_blank"' : '' - return { resolvedHref, extraAttrs } + const resolvedText = resolveGitLinkText(resolvedHref, label, mdRepoInfo) + + return { resolvedHref, extraAttrs, resolvedText } } renderer.link = createLink(processLink) @@ -94,6 +97,7 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { processLink, toUserContentId, lastSemanticLevel, + textFilter: createResolveGitTextToLinks(mdRepoInfo), }), toc, } @@ -101,14 +105,30 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { } export interface MarkdownRepoInfo { + /** base url for the host */ + hostBaseUrl: string /** 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 to the markdown file, can't start with / */ path?: string + /** the base url of repository commit */ + commitBaseUrl: string + /** base url for a repository issue */ + issueBaseUrl: string + /** the text char that indicates an issue */ + issueChar: keyof typeof issuePrRegexes + /** base url for a repository pull/merge request */ + prBaseUrl: string + /** + * the text char that indicates a pull/merge request + * + * if it's the same as issueChar, than links will be parsed as issues and repo host is reponsible to redirect to pull/merge request + x*/ + prChar: keyof typeof issuePrRegexes + /** base url for a repository compare */ + compareBaseUrl: string } function resolveUrl(url: string, repoInfo: MarkdownRepoInfo, toUserContentId: ToUserContentIdFn) { @@ -181,3 +201,105 @@ function checkResolvedUrl(resolved: string, baseUrl: string) { } return joinRelativeURL(baseUrl, parseFilename(resolved) ?? '') } + +function resolveGitLinkText(href: string, label: string, repoInfo: MarkdownRepoInfo) { + if (!href || label !== href) { + // is autoLink or empty href + return + } + + const pathSegments = parseURL(href).pathname.split('/').filter(Boolean) + const lastSegment = pathSegments.at(-1) + if (!lastSegment) { + return + } + + switch (true) { + case href.startsWith(repoInfo.commitBaseUrl): { + return lastSegment.slice(0, 6) // only show the first 6 letters/numbers of a commit + } + case href.startsWith(repoInfo.issueBaseUrl): { + return `${repoInfo.issueChar}${lastSegment}` + } + case href.startsWith(repoInfo.prBaseUrl): { + return `${repoInfo.prChar}${lastSegment}` + } + case href.startsWith(repoInfo.compareBaseUrl): { + return lastSegment + } + // for account we don't resolve, this is something the git providers also don't do + } +} + +const issuePrRegexes = { + '#': /\B#\d+\b/g, + '!': /\B!\d+\b/g, +} as const + +const accountRegex = /\B@(?![.\d])(?![\w.-]*\/)[\w-]+\b/ +const commitRegex = /(? { + if (tagsToIgnore.has(tag)) return text + + // return text + + // issues + text = text + // commits come first to prevent matching issue/pr that has been formatted + .replace(commitRegex, match => { + if (excludeWordsFromCommitMatch.has(match.toLowerCase())) { + return match + } + + return `${match}` + }) + .replace(issuePrRegexes[mdInfo.issueChar], match => { + const id = match.replace(mdInfo.issueChar, '') + return `${match}` + }) + // account + .replace(accountRegex, match => { + const acc = match.replace('@', '') + return `${match}` + }) + + // pr/mr + if (mdInfo.issueChar != mdInfo.prChar) { + text = text.replace(issuePrRegexes[mdInfo.prChar], match => { + const id = match.replace(mdInfo.prChar, '') + return `${match}` + }) + } + + return text + } +} + +// source https://raw.githubusercontent.com/potch/sowpods/refs/heads/master/SOWPODS.txt and filtered with /^[a-f]{6,40}$/i +const excludeWordsFromCommitMatch = new Set([ + 'accede', + 'acceded', + 'baccae', + 'baffed', + 'beaded', + 'bedded', + 'beebee', + 'beefed', + 'cabbed', + 'dabbed', + 'dadded', + 'daffed', + 'deaded', + 'decade', + 'decaff', + 'deeded', + 'deface', + 'defaced', + 'efface', + 'effaced', + 'facade', + 'faffed', +]) diff --git a/server/utils/changelog/mdRepoInfo.ts b/server/utils/changelog/mdRepoInfo.ts new file mode 100644 index 0000000000..79510d9fc5 --- /dev/null +++ b/server/utils/changelog/mdRepoInfo.ts @@ -0,0 +1,63 @@ +import type { MarkdownRepoInfo } from './markdown' + +export function createGithubRepoInfo(owner: string, repo: string, path?: string): MarkdownRepoInfo { + const hostBaseUrl = 'https://github.com' + + return { + hostBaseUrl, + blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/blob/HEAD`, + rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, + path, + commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, + issueChar: '#', + issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, + prChar: '#', + prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pull`, + compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, + } +} + +export function createForgejoRepoInfo( + host: string, + owner: string, + repo: string, + path?: string, +): MarkdownRepoInfo { + const hostBaseUrl = `https://${host}` + + return { + hostBaseUrl, + blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/src/branch/HEAD`, + rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/branch/HEAD`, + path, + commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, + issueChar: '#', + issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, + prChar: '#', + prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pulls`, + compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, + } +} + +export function createGitLabRepoInfo( + host: string, + owner: string, + repo: string, + path?: string, +): MarkdownRepoInfo { + const hostBaseUrl = `https://${host}` + + return { + hostBaseUrl, + blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/blob/HEAD`, + rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/raw/HEAD`, + path, + commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/commit`, + issueChar: '#', + // it seems that issues are work items in gitlab + issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/work_items`, + prChar: '!', + prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/merge_requests`, + compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/compare`, + } +} diff --git a/server/utils/mdKit.ts b/server/utils/mdKit.ts index 3ebe367d95..205e3ba7e6 100644 --- a/server/utils/mdKit.ts +++ b/server/utils/mdKit.ts @@ -9,7 +9,7 @@ import { import { highlightCodeSync } from './shiki' import { decodeHtmlEntities, stripHtmlTags, slugify } from '#shared/utils/html' import { escapeHtml } from './docs/text' -import sanitizeHtml from 'sanitize-html' +import sanitizeHtml, { type IOptions } from 'sanitize-html' import { hasProtocol } from 'ufo' /// for marked @@ -76,7 +76,7 @@ export type ProcessLinkFn = ( href: string, label: string, // readme.ts also needs the extraAttrs for more things, so can't be a boolean -) => { resolvedHref: string; extraAttrs: string } +) => { resolvedHref: string; extraAttrs: string; resolvedText?: string } export function decodeHashFragment(value: string): string { try { @@ -105,16 +105,20 @@ export function createLink(processLink: ProcessLinkFn): RendererApi['link'] { plainText = tokens[0].text } - const { resolvedHref, extraAttrs } = processLink(href, plainText || eTitle || '') + const { + resolvedHref, + extraAttrs, + resolvedText = text, + } = processLink(href, plainText || eTitle || '') - if (!resolvedHref) return text + if (!resolvedHref) return resolvedText // prevents package@1.0.0 being made into an email if (href.startsWith('mailto:') && !EMAIL_REGEX.test(plainText)) { - return text + return resolvedText } - return `${text}` + return `${resolvedText}` } } @@ -166,9 +170,7 @@ export function createMarkedHeadingExtension(exemptIssuePr?: boolean): Tokenizer // Normal headings (with space) return false to fall through to marked's default tokenizer. const match = /^ {0,3}(#{1,6})([^\s#][^\n]*)(?:\n+|$)/.exec(src) if (!match) return false - if (exemptIssuePr && /^#\d+\b/.test(match[0])) return false - - console.log({ match, test: /^#\d+\b/.test(match[0]), exemptIssuePr }) + if (exemptIssuePr && /^#\d+\b/.test(match[0].trim())) return false let text = match[2]!.trim() @@ -438,11 +440,13 @@ export function sanitizeRawHTML( processLink, toUserContentId, lastSemanticLevel = 2, + textFilter, }: { processImageUrl: ProcessImageUrlFn processLink: ProcessLinkFn toUserContentId: ToUserContentIdFn lastSemanticLevel?: number + textFilter?: IOptions['textFilter'] }, ) { // Helper to prefix id attributes with 'user-content-' @@ -471,6 +475,8 @@ export function sanitizeRawHTML( '--shiki-light': [/^#[0-9a-f]{3,8}$/i], }, }, + + textFilter, transformTags: { // Headings are already processed to correct semantic levels by processHeading() // during the marked rendering pass. The sanitizer just needs to preserve them. @@ -506,6 +512,7 @@ export function sanitizeRawHTML( } return { tagName, attribs } }, + source: (tagName, attribs) => { if (attribs.src) { attribs.src = processImageUrl(attribs.src) diff --git a/shared/schemas/changelog/release.ts b/shared/schemas/changelog/release.ts index 77c36b89d9..3eaf85a92c 100644 --- a/shared/schemas/changelog/release.ts +++ b/shared/schemas/changelog/release.ts @@ -17,3 +17,24 @@ export const GithubReleaseCollectionSchama = v.object({ // keeping this here in case it's needed // export type GithubRelease = v.InferOutput // export type GithubReleaseCollection = v.InferOutput + +export const ForgejoReleaseSchama = v.object({ + id: v.number(), + tag_name: v.string(), + name: v.string(), + body: v.string(), + html_url: v.pipe(v.string(), v.url()), + draft: v.boolean(), + prerelease: v.boolean(), + published_at: v.pipe(v.string(), v.isoTimestamp()), +}) + +export const ForgejoReleaseCollectionSchema = v.array(ForgejoReleaseSchama) + +export const GitlabReleaseSchame = v.object({ + tag_name: v.string(), + name: v.string(), + description: v.string(), + released_at: v.pipe(v.string(), v.isoTimestamp()), + upcoming_release: v.boolean(), +}) diff --git a/shared/types/changelog.ts b/shared/types/changelog.ts index d3e5f5858b..d79fbaa720 100644 --- a/shared/types/changelog.ts +++ b/shared/types/changelog.ts @@ -6,6 +6,7 @@ export interface ChangelogReleaseInfo { provider: ProviderId repo: `${string}/${string}` link: string + host?: string } export interface ChangelogMarkdownInfo { @@ -20,6 +21,7 @@ export interface ChangelogMarkdownInfo { * link to a rendered changelog markdown file */ link: string + host?: string } export type ChangelogInfo = ChangelogReleaseInfo | ChangelogMarkdownInfo diff --git a/test/unit/server/utils/changelog/markdown.spec.ts b/test/unit/server/utils/changelog/markdown.spec.ts index 05302f3277..b4099845fc 100644 --- a/test/unit/server/utils/changelog/markdown.spec.ts +++ b/test/unit/server/utils/changelog/markdown.spec.ts @@ -1,5 +1,6 @@ import type { MarkdownRepoInfo } from '~~/server/utils/changelog/markdown' import { describe, expect, it, vi, beforeAll } from 'vitest' +import { createGithubRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' // testing changelog specific needs, others things are tested at ../readme.spec.ts @@ -22,18 +23,11 @@ beforeAll(() => { const { changelogRenderer } = await import('#server/utils/changelog/markdown') function changelogMdinfo(): MarkdownRepoInfo { - return { - blobBaseUrl: `https://github.com/test-owner/test-repo/blob/HEAD`, - rawBaseUrl: `https://raw.githubusercontent.com/test-owner/test-repo/HEAD`, - } + return createGithubRepoInfo('test-owner', 'test-repo') } function changelogMdInfoWithPath() { - return { - blobBaseUrl: `https://github.com/test-owner/test-repo/blob/HEAD`, - rawBaseUrl: `https://raw.githubusercontent.com/test-owner/test-repo/HEAD`, - path: 'packages/test/changelog.md', - } + return createGithubRepoInfo('test-owner', 'test-repo', 'packages/test/changelog.md') } describe('URL Resolution', () => { @@ -518,25 +512,216 @@ describe('Heading & toc resolution', () => { }) }) -describe('ATX heading #issue/#pr exemption', () => { - it("shouldn't turn issues/PRs into headings", async () => { +describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => { + describe('ATX heading #issue/#pr exemption & turn into links', () => { + it("shouldn't turn issues/PRs into headings but into links", async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `#2869 hello + + #2717 world` + + const result = renderer(markdown) + expect(result.html).toBe( + '

#2869 hello

\n

#2717 world

\n', + ) + }) + + it("shouldn't turn issues/PRs in list into headings but into links", async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `- #2869 hello +- #2717 world` + + const result = renderer(markdown) + expect(result.html).toBe( + '\n', + ) + }) + }) + + it('should turn issue/pr & account into links', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + // text from date-fns v4.3.0 + const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. +- Fixed zh-CN, zh-HK, and zh-TW locale month parsing for October, November, and December. See #4194 by @puneetdixit200. +` + const result = renderer(markdown) + + expect(result.html).toBe(`
    +
  • Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry.
  • +
  • Fixed zh-CN, zh-HK, and zh-TW locale month parsing for October, November, and December. See #4194 by @puneetdixit200.
  • +
+`) + }) + + it('should turn issue/pr into links between ()', async () => { const info = changelogMdinfo() const renderer = await changelogRenderer(info) - const markdown = `#2869 hello + // text comes from npmx release 0.15.0 + const markdown = `- Minor ui improvements (#2834) +- deps: Update module-replacements (#2838) +- Release v0.15.0 (#2835)` + + const result = renderer(markdown) -#2717 world` + expect(result.html).toBe(`
    +
  • Minor ui improvements (#2834)
  • +
  • deps: Update module-replacements (#2838)
  • +
  • Release v0.15.0 (#2835)
  • +
+`) + }) + + it('should turn mutliple issue/pr after each other issues/pr into links', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + // test from fullcalendar v6.1.18 + const markdown = 'fix: Optimize custom content-injection rerendering performance (#3003, #7650)' const result = renderer(markdown) - expect(result.html).toBe('

#2869 hello

\n

#2717 world

\n') + + expect(result.html).toBe( + `

fix: Optimize custom content-injection rerendering performance (#3003, #7650)

\n`, + ) }) - it("shouldn't turn issues/PRs in list into headings", async () => { + it('should turn accounts into lists', async () => { const info = changelogMdinfo() const renderer = await changelogRenderer(info) - const markdown = `- #2869 hello -- #2717 world` + // from npmx release 0.13.0, wanted to a release with many accounts mentioned + const markdown = `### ❤️ Contributors\n\n- Daniel Roe (@danielroe)\n- Alex Savelyev (@alexdln)\n- Alec Lloyd Probert (@graphieros)\n- cylewaitforit (@cylewaitforit)\n- Vinayak (@VinayakMaharaj)\n- Robin de Vos (@Codefoxdev)\n- Patrick Dewey (@ptdewey)\n- Dominik Dorfmeister 🔮 (@TkDodo)\n- Philippe Serhal (@serhalp)\n- Wilco (@WilcoSp)\n- Willow (GHOST) (@ghostdevv)\n- Aryan Pingle (@aryanpingle)\n- Roman (@gameroman)\n- Matteo Gabriele (@MatteoGabriele)\n- Alberto Rico (@alrico88)\n- TAKAHASHI Shuuji (@shuuji3)\n- Bugo (@dragomano)\n- Sasha (@Sasha125588)\n- Iestyn (@IestynGage)\n- Torben Haack (@t128n)\n- Mutsumi (@BabyLy233)\n- Bonsak Schiledrop (@bonsak)\n` const result = renderer(markdown) - expect(result.html).toBe('
    \n
  • #2869 hello
  • \n
  • #2717 world
  • \n
\n') + + expect(result.html) + .toBe(`

❤️ Contributors

+ +`) + }) + + it('should not turn @org/package into account link', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + + const markdown = `- Bump @tiptap/y-tiptap to version ^3.0.5\n- @tiptap/core@3.26.1\n - @tiptap/pm@3.26.1` + + const result = renderer(markdown) + + expect(result.html).toBe(`
    +
  • Bump @tiptap/y-tiptap to version ^3.0.5
  • +
  • @tiptap/core@3.26.1
      +
    • @tiptap/pm@3.26.1
    • +
    +
  • +
+`) + }) + + it('should turn commits into links', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 + const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character +- Updated dependencies [6270b99] +- 7fb19eb: Only add hash attributes to nodes, not to marks. +- Release v0.14.0 36128a54 +- Empty (4cab893c)` + + const result = renderer(markdown) + + expect(result.html).toBe(`
    +
  • a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character
  • +
  • Updated dependencies [6270b99]
  • +
  • 7fb19eb: Only add hash attributes to nodes, not to marks.
  • +
  • Release v0.14.0 36128a54
  • +
  • Empty (4cab893c)
  • +
+`) + }) + + it('should not format an issue/pr into a commit', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + + const markdown = `lorem ipsum is fixed in #1234567` + + const result = renderer(markdown) + + expect(result.html).toBe( + `

lorem ipsum is fixed in #1234567

\n`, + ) + }) + + // TODO add test for gitlab with !pr +}) + +describe('format unformatted/auto links to git', () => { + // links to account won't be formatted, this is something also git providers don't do + it('should turn issue, pr, commit & compare links to formatted links', async () => { + const info = createGithubRepoInfo('vueuse', 'vueuse') + const renderer = await changelogRenderer(info) + // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) + const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://github.com/vueuse/vueuse/issues/5295 https://github.com/vueuse/vueuse/commit/b1688bd2 +- createInjectionState: Non-undefined return when default specified - by Laupetin in https://github.com/vueuse/vueuse/issues/5306 https://github.com/vueuse/vueuse/commit/b0c51c27 +- createReusableTemplate: Add support for specifying component names - by wbolster in https://github.com/vueuse/vueuse/pull/5300 https://github.com/vueuse/vueuse/commit/ea29d5cb +- nuxt: Add composable variants to auto imports - by OrbisK in https://github.com/vueuse/vueuse/issues/5285 https://github.com/vueuse/vueuse/commit/ac2ef95d + +https://github.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` + + const result = renderer(markdown) + expect(result.html).toBe(`
    +
  • Expose pointer event onLongPress - by mrcwbr in #5295 b1688b
  • +
  • createInjectionState: Non-undefined return when default specified - by Laupetin in #5306 b0c51c
  • +
  • createReusableTemplate: Add support for specifying component names - by wbolster in #5300 ea29d5
  • +
  • nuxt: Add composable variants to auto imports - by OrbisK in #5285 ac2ef9
  • +
+

v14.2.1...v14.3.0

+`) + }) + + it('should ignore formatted links', async () => { + const info = createGithubRepoInfo('vueuse', 'vueuse') + const renderer = await changelogRenderer(info) + + const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://github.com/vueuse/vueuse/issues/5295 https://github.com/vueuse/vueuse/commit/b1688bd2 +- createInjectionState: Non-undefined return when default specified - by Laupetin in [!5306](https://github.com/vueuse/vueuse/issues/5306) [(b0c51)](https://github.com/vueuse/vueuse/commit/b0c51c27) +- createReusableTemplate: Add support for specifying component names - by wbolster in https://github.com/vueuse/vueuse/pull/5300 https://github.com/vueuse/vueuse/commit/ea29d5cb +- nuxt: Add composable variants to auto imports - by OrbisK in [$5285](https://github.com/vueuse/vueuse/issues/5285) [(ac2ef)](https://github.com/vueuse/vueuse/commit/ac2ef95d) + +[View changes on GitHub](https://github.com/vueuse/vueuse/compare/v14.2.1...v14.3.0)` + const result = renderer(markdown) + expect(result.html).toBe(`
    +
  • Expose pointer event onLongPress - by mrcwbr in #5295 b1688b
  • +
  • createInjectionState: Non-undefined return when default specified - by Laupetin in !5306 (b0c51)
  • +
  • createReusableTemplate: Add support for specifying component names - by wbolster in #5300 ea29d5
  • +
  • nuxt: Add composable variants to auto imports - by OrbisK in $5285 (ac2ef)
  • +
+

View changes on GitHub

+`) }) })