Skip to content

Commit 1d93405

Browse files
committed
for changelog.md now resolving url
1 parent 6cbe6e8 commit 1d93405

File tree

5 files changed

+89
-41
lines changed

5 files changed

+89
-41
lines changed

server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,11 @@ async function getGithubMarkDown(owner: string, repo: string, path: string) {
4343

4444
const markdown = v.parse(v.string(), data)
4545

46-
return (await changelogRenderer())(markdown)
46+
return (
47+
await changelogRenderer({
48+
blobBaseUrl: `https://github.com/${owner}/${repo}/blob/HEAD`,
49+
rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`,
50+
path,
51+
})
52+
)(markdown)
4753
}

server/api/changelog/releases/[provider]/[owner]/[repo].get.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ async function getReleasesFromGithub(owner: string, repo: string) {
4949

5050
const { releases } = parse(GithubReleaseCollectionSchama, data)
5151

52-
const render = await changelogRenderer()
52+
const render = await changelogRenderer({
53+
blobBaseUrl: `https://github.com/${owner}/${repo}/blob/HEAD`,
54+
rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`,
55+
})
5356

5457
return releases.map(r => {
5558
const { html, toc } = render(r.markdown, r.id)

server/utils/changelog/detectChangelog.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ const MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\()
9797
function checkLatestGithubRelease(ref: RepoRef): Promise<ChangelogInfo | false> {
9898
return $fetch(`https://ungh.cc/repos/${ref.owner}/${ref.repo}/releases/latest`)
9999
.then(r => {
100-
console.log(r)
101100
const { release } = v.parse(v.object({ release: GithubReleaseSchama }), r)
102101

103102
const matchedChangelog = release.markdown?.match(MD_REGEX)?.at(0)
@@ -120,8 +119,7 @@ function checkLatestGithubRelease(ref: RepoRef): Promise<ChangelogInfo | false>
120119
link: matchedChangelog,
121120
} satisfies ChangelogMarkdownInfo
122121
})
123-
.catch(e => {
124-
console.log('changelog error', e)
122+
.catch(() => {
125123
return false
126124
})
127125
}

server/utils/changelog/markdown.ts

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import {
33
ALLOWED_ATTR,
44
ALLOWED_TAGS,
55
calculateSemanticDepth,
6+
isNpmJsUrlThatCanBeRedirected,
67
prefixId,
78
slugify,
89
stripHtmlTags,
910
} from '../readme'
1011
import sanitizeHtml from 'sanitize-html'
12+
import { hasProtocol } from 'ufo'
1113

1214
const EMAIL_REGEX = /^[\w+\-.]+@[\w\-.]+\.[a-z]+$/i
1315

14-
export async function changelogRenderer() {
16+
export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) {
1517
const renderer = new marked.Renderer({
1618
gfm: true,
1719
})
@@ -71,6 +73,8 @@ export async function changelogRenderer() {
7173
}
7274
}
7375

76+
const idPrefix = releaseId ? `user-content-${releaseId}` : `user-content`
77+
7478
// Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2)
7579
const usedSlugs = new Map<string, number>()
7680

@@ -95,9 +99,7 @@ export async function changelogRenderer() {
9599

96100
// Prefix with 'user-content-' to avoid collisions with page IDs
97101
// (e.g., #install, #dependencies, #versions are used by the package page)
98-
const id = releaseId
99-
? `user-content-${releaseId}-${uniqueSlug}`
100-
: `user-content-${uniqueSlug}`
102+
const id = `${idPrefix}-${uniqueSlug}`
101103

102104
// Collect TOC item with plain text (HTML stripped & emoji's added)
103105
const plainText = convertToEmoji(stripHtmlTags(text))
@@ -117,13 +119,14 @@ export async function changelogRenderer() {
117119
renderer,
118120
}) as string,
119121
),
122+
mdRepoInfo,
120123
),
121124
toc,
122125
}
123126
}
124127
}
125128

126-
export function sanitizeRawHTML(rawHtml: string) {
129+
export function sanitizeRawHTML(rawHtml: string, mdRepoInfo: MarkdownRepoInfo) {
127130
return sanitizeHtml(rawHtml, {
128131
allowedTags: ALLOWED_TAGS,
129132
allowedAttributes: ALLOWED_ATTR,
@@ -177,38 +180,21 @@ export function sanitizeRawHTML(rawHtml: string) {
177180
// }
178181
// return { tagName, attribs }
179182
// },
180-
// a: (tagName, attribs) => {
181-
// if (!attribs.href) {
182-
// return { tagName, attribs }
183-
// }
183+
a: (tagName, attribs) => {
184+
if (!attribs.href) {
185+
return { tagName, attribs }
186+
}
184187

185-
// const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo)
186-
187-
// const provider = matchPlaygroundProvider(resolvedHref)
188-
// if (provider && !seenUrls.has(resolvedHref)) {
189-
// seenUrls.add(resolvedHref)
190-
191-
// collectedLinks.push({
192-
// url: resolvedHref,
193-
// provider: provider.id,
194-
// providerName: provider.name,
195-
// /**
196-
// * We need to set some data attribute before hand because `transformTags` doesn't
197-
// * provide the text of the element. This will automatically be removed, because there
198-
// * is an allow list for link attributes.
199-
// * */
200-
// label: attribs['data-title-intermediate'] || provider.name,
201-
// })
202-
// }
188+
const resolvedHref = resolveUrl(attribs.href, mdRepoInfo)
203189

204-
// // Add security attributes for external links
205-
// if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) {
206-
// attribs.rel = 'nofollow noreferrer noopener'
207-
// attribs.target = '_blank'
208-
// }
209-
// attribs.href = resolvedHref
210-
// return { tagName, attribs }
211-
// },
190+
// Add security attributes for external links
191+
if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) {
192+
attribs.rel = 'nofollow noreferrer noopener'
193+
attribs.target = '_blank'
194+
}
195+
attribs.href = resolvedHref
196+
return { tagName, attribs }
197+
},
212198
div: prefixId,
213199
p: prefixId,
214200
span: prefixId,
@@ -217,3 +203,58 @@ export function sanitizeRawHTML(rawHtml: string) {
217203
},
218204
})
219205
}
206+
207+
interface MarkdownRepoInfo {
208+
/** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */
209+
rawBaseUrl: string
210+
/** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */
211+
blobBaseUrl: string
212+
/**
213+
* path to the markdown file, can't start with /
214+
*/
215+
path?: string
216+
}
217+
218+
function resolveUrl(url: string, repoInfo: MarkdownRepoInfo) {
219+
if (!url) return url
220+
if (url.startsWith('#')) {
221+
if (url.startsWith('#user-content')) {
222+
return url
223+
}
224+
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
225+
return `#user-content-${url.slice(1)}`
226+
}
227+
if (hasProtocol(url, { acceptRelative: true })) {
228+
try {
229+
const parsed = new URL(url, 'https://example.com')
230+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
231+
// Redirect npmjs urls to ourself
232+
if (isNpmJsUrlThatCanBeRedirected(parsed)) {
233+
return parsed.pathname + parsed.search + parsed.hash
234+
}
235+
return url
236+
}
237+
} catch {
238+
// Invalid URL, fall through to resolve as relative
239+
}
240+
// return protocol-relative URLs (//example.com) as-is
241+
if (url.startsWith('//')) {
242+
return url
243+
}
244+
// for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative
245+
}
246+
247+
// Check if this is a markdown file link
248+
const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '')
249+
250+
const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl
251+
if (url.startsWith('./') || url.startsWith('../')) {
252+
// url constructor handles relative paths
253+
return new URL(url, `${baseUrl}/${repoInfo.path ?? ''}`).href
254+
}
255+
if (url.startsWith('/')) {
256+
return new URL(`${baseUrl}${url}`).href
257+
}
258+
259+
return url
260+
}

server/utils/readme.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ const reservedPathsNpmJs = [
241241

242242
const npmJsHosts = new Set(['www.npmjs.com', 'npmjs.com', 'www.npmjs.org', 'npmjs.org'])
243243

244-
const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
244+
export const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
245245
if (!npmJsHosts.has(url.host)) {
246246
return false
247247
}

0 commit comments

Comments
 (0)