@@ -3,15 +3,17 @@ import {
33 ALLOWED_ATTR ,
44 ALLOWED_TAGS ,
55 calculateSemanticDepth ,
6+ isNpmJsUrlThatCanBeRedirected ,
67 prefixId ,
78 slugify ,
89 stripHtmlTags ,
910} from '../readme'
1011import sanitizeHtml from 'sanitize-html'
12+ import { hasProtocol } from 'ufo'
1113
1214const 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 = / \. m d $ / 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+ }
0 commit comments