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( - `

My Section

\n`, + `

My Section

\n`, ) })