Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion server/utils/mdKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ export function getHeadingSlugSource(text: string): string {
}

const htmlAnchorRe = /<a(\s[^>]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi
const anchorTokenOpenRe = /^<a(?:\s.+)?\/?>$/i
const anchorTokenCloseRe = /^<\/a>$/i

export type ToUserContentIdFn = (id: string) => string

Expand All @@ -239,7 +241,11 @@ export function createHeading(options: {
this: Renderer<string, string>,
{ tokens, depth },
) {
const displayHtml = this.parser.parseInline(tokens)
const isWrappedInSingleAnchor =
anchorTokenOpenRe.test(tokens[0]?.raw ?? '') &&
anchorTokenCloseRe.test(tokens[tokens.length - 1]?.raw ?? '')
const headingTokens = isWrappedInSingleAnchor ? tokens.slice(1, -1) : tokens
const displayHtml = this.parser.parseInline(headingTokens)
const plainText = getHeadingPlainText(displayHtml)
const slugSource = getHeadingSlugSource(displayHtml)
return processHeading(depth, displayHtml, plainText, slugSource)
Expand Down
26 changes: 19 additions & 7 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,18 @@ function toUserContentHash(value: string): string {
return `#${withUserContentPrefix(value)}`
}

function isMarkdownFileUrl(url: string): boolean {
return /\.(?:md|markdown)$/i.test(url.split('?')[0]?.split('#')[0] ?? '')
}

Comment on lines +179 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the isMarkdownFileUrl function could be moved to mdkit because changelog also needs to check whether an url is for markdown or not

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would also be good to use URL.parse too, something like URL.parse(url)?.pathname.endsWith...

/**
* Resolve a relative URL to an absolute URL.
* If repository info is available, resolve to provider's raw file URLs.
* For markdown files (.md), use blob URLs so they render properly.
* Otherwise, fall back to jsdelivr CDN (except for .md files which are left unchanged).
*/
function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
if (!url) return url
if (!url || url.startsWith('$')) return url
if (url.startsWith('#')) {
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
// Normalize markdown-style heading fragments to the same slug format used
Expand All @@ -199,15 +203,26 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
const normalizedFragment = slugify(decodeHashFragment(fragment))
return toUserContentHash(normalizedFragment || fragment)
}
// Absolute paths (e.g. /package/foo from a previous npmjs redirect) are already resolved
if (url.startsWith('/')) return url
// Check if this is a markdown file link
const isMarkdownFile = isMarkdownFileUrl(url)

if (url.startsWith('/') && !url.startsWith('//')) {
if (!repoInfo?.rawBaseUrl) {
return url
}

const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl
return `${baseUrl}${url}`
}
if (hasProtocol(url, { acceptRelative: true })) {
try {
const parsed = new URL(url, 'https://example.com')
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
// Redirect npmjs urls to ourself
if (isNpmJsUrlThatCanBeRedirected(parsed)) {
return parsed.pathname + parsed.search + parsed.hash
// Prefixed with $ so the sanitizing pass doesn't resolve the local route
// as a repository-root file (see mdKit sanitizer $ handling)
return '$' + parsed.pathname + parsed.search + parsed.hash
}
return url
}
Expand All @@ -221,9 +236,6 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
// for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative
}

// Check if this is a markdown file link
const isMarkdownFile = /\.md$/i.test(url.split('?')[0]?.split('#')[0] ?? '')

// Use provider's URL base when repository info is available
// This handles assets that exist in the repo but not in the npm tarball
if (repoInfo?.rawBaseUrl) {
Expand Down
116 changes: 114 additions & 2 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,110 @@ describe('Markdown File URL Resolution', () => {
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves root-relative .md links to the repository root blob URL', async () => {
const repoInfo = createRepoInfo({
directory: 'packages/core',
})
const markdown = `[Root Contributing](/CONTRIBUTING.md)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves issue #2928 root-relative markdown links from the repo root', async () => {
const repoInfo = createRepoInfo({
owner: 'withastro',
repo: 'astro',
rawBaseUrl: 'https://raw.githubusercontent.com/withastro/astro/HEAD',
blobBaseUrl: 'https://github.com/withastro/astro/blob/HEAD',
directory: 'packages/astro',
})
const markdown = `[contributing guide](/CONTRIBUTING.md)`
const result = await renderReadmeHtml(markdown, 'astro', repoInfo)

expect(result.html).toContain(
'href="https://github.com/withastro/astro/blob/HEAD/CONTRIBUTING.md"',
)
expect(result.html).not.toContain('href="/CONTRIBUTING.md"')
expect(result.html).not.toContain('href="https://npmx.dev/CONTRIBUTING.md"')
})

it('resolves root-relative .markdown links to the repository root blob URL', async () => {
const repoInfo = createRepoInfo({
directory: 'packages/core',
})
const markdown = `[Root Contributing](/CONTRIBUTING.markdown)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.markdown"',
)
})

it('resolves root-relative .md links in raw HTML anchors', async () => {
const repoInfo = createRepoInfo()
const markdown = `<a href="/CONTRIBUTING.md">Contributing</a>`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"',
)
})

it('resolves root-relative non-.md links to the repository root raw URL', async () => {
const repoInfo = createRepoInfo({
directory: 'packages/core',
})
const markdown = `[Logo](/assets/logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
)
})

it('resolves authored root-relative npmx-like paths to the repository root raw URL', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Package](/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/package/test-pkg"',
)
})

it('keeps npmjs redirects local when repository info is available', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Package](https://www.npmjs.com/package/test-pkg)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain('href="/package/test-pkg"')
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we want this behaviour for non-markdown links?

Why not something similar to what is done for this?

it('resolves non-.md files to raw URL (not blob)', async () => {
const repoInfo = createRepoInfo()
const markdown = `[Image](./assets/logo.png)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)
expect(result.html).toContain(
'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"',
)
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I updated this to resolve root-relative non-markdown links via rawBaseUrl, consistent with existing relative-link handling. Markdown files still resolve via blobBaseUrl.

Local npmx routes (/package, /org, /search, etc.) are preserved separately, and I added regression tests covering both behaviors.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason / place where preserving Local npmx routes would be useful instead of them resolving to rawBaseUrl too?

@BittuBarnwal7479 BittuBarnwal7479 Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. the main case is npmjs links that the README renderer intentionally converts to local npmx routes. For example, https://www.npmjs.com/package/test-pkg becomes /package/test-pkg.

If we treated every root-relative path as a repo file, that converted route would incorrectly become rawBaseUrl/package/test-pkg.

So the updated logic preserves known npmx routes separately, while root-relative repo files like /CONTRIBUTING.md and /assets/logo.png resolve to blobBaseUrl / rawBaseUrl.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is there no way to differentiate a /package that was because of https://www.npmjs.com/package/test-pkg from a /package that someone wrote in their readme?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. i will update this to preserve npmjs-originated links via an internal marker rather than path matching. README-authored root-relative paths now resolve normally against the repository. Added regression tests for both cases.


it('keeps raw HTML block-level npmjs links local when repository info is available', async () => {
const repoInfo = createRepoInfo()
const markdown = `<a href="https://www.npmjs.com/package/test-pkg">Package</a>`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

// These links bypass renderer.link/createHtml, so only the sanitizer pass runs.
// They must still resolve to the local route and keep their href.
expect(result.html).toContain('href="/package/test-pkg"')
})

it('keeps npmjs route roots local when repository info is available', async () => {
const repoInfo = createRepoInfo()
const markdown = [
`[Packages](https://www.npmjs.com/package)`,
`[Organizations](https://www.npmjs.com/org)`,
].join('\n')
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain('href="/package"')
expect(result.html).toContain('href="/org"')
})
})

describe('without repository info', () => {
Expand Down Expand Up @@ -296,6 +400,14 @@ describe('Markdown File URL Resolution', () => {

expect(result.html).toContain('href="https://docs.example.com/"')
})

it('leaves protocol-relative URLs unchanged with repository info', async () => {
const repoInfo = createRepoInfo()
const markdown = `[CDN](//cdn.example.com/file.css)`
const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo)

expect(result.html).toContain('href="//cdn.example.com/file.css"')
})
})

describe('anchor links', () => {
Expand Down Expand Up @@ -604,13 +716,13 @@ describe('HTML output', () => {
})

describe('heading anchors (renderer.heading)', () => {
it('keeps the full-line anchor wrapper and places the link to the heading at the end', async () => {
it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {
const markdown = '## <a href="https://example.com">My Section</a>'
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.toc).toEqual([{ text: 'My Section', depth: 2, id: 'user-content-my-section' }])
expect(result.html).toBe(
`<h3 id="user-content-my-section" data-level="2"><a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">My Section</a><a href="#user-content-my-section" aria-hidden="true" tabindex="-1"></a></h3>\n`,
`<h3 id="user-content-my-section" data-level="2"><a href="#user-content-my-section">My Section</a></h3>\n`,
)
})

Expand Down
Loading