diff --git a/server/utils/mdKit.ts b/server/utils/mdKit.ts
index 3ebe367d95..f4ba2ed219 100644
--- a/server/utils/mdKit.ts
+++ b/server/utils/mdKit.ts
@@ -215,6 +215,8 @@ export function getHeadingSlugSource(text: string): string {
}
const htmlAnchorRe = /]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi
+const anchorTokenOpenRe = /^$/i
+const anchorTokenCloseRe = /^<\/a>$/i
export type ToUserContentIdFn = (id: string) => string
@@ -239,7 +241,11 @@ export function createHeading(options: {
this: Renderer,
{ 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)
diff --git a/server/utils/readme.ts b/server/utils/readme.ts
index 717136f981..266dd87840 100644
--- a/server/utils/readme.ts
+++ b/server/utils/readme.ts
@@ -157,6 +157,7 @@ marked.use({
},
})
+const LOCAL_NPMX_REDIRECT_PREFIX = '$npmx-local:'
function withUserContentPrefix(value: string): string {
return value.startsWith(USER_CONTENT_PREFIX) ? value : `${USER_CONTENT_PREFIX}${value}`
}
@@ -169,6 +170,14 @@ function toUserContentHash(value: string): string {
return `#${withUserContentPrefix(value)}`
}
+function toLocalNpmxRedirect(path: string): string {
+ return `${LOCAL_NPMX_REDIRECT_PREFIX}${path}`
+}
+
+function isMarkdownFileUrl(url: string): boolean {
+ return /\.(?:md|markdown)$/i.test(url.split('?')[0]?.split('#')[0] ?? '')
+}
+
/**
* Resolve a relative URL to an absolute URL.
* If repository info is available, resolve to provider's raw file URLs.
@@ -177,6 +186,9 @@ function toUserContentHash(value: string): string {
*/
function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
if (!url) return url
+ if (url.startsWith(LOCAL_NPMX_REDIRECT_PREFIX)) {
+ return url.slice(LOCAL_NPMX_REDIRECT_PREFIX.length)
+ }
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
@@ -192,15 +204,24 @@ 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
+ return toLocalNpmxRedirect(parsed.pathname + parsed.search + parsed.hash)
}
return url
}
@@ -214,9 +235,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) {
diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts
index d24158d777..21e80915d2 100644
--- a/test/unit/server/utils/readme.spec.ts
+++ b/test/unit/server/utils/readme.spec.ts
@@ -249,6 +249,100 @@ 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 = `Contributing`
+ 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"')
+ })
+
+ 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', () => {
@@ -284,6 +378,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', () => {
@@ -592,13 +694,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 = '## My Section'
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(
- `\n`,
+ `\n`,
)
})