From 3fe683abc2393a8961c30d98243ffe8beff67bd4 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:03:23 -0700 Subject: [PATCH 1/2] fix(ui): normalize README heading fragments to lowercase slugs - Decode and slugify in-page README anchors before prefixing - Keep already prefixed `user-content` fragments unchanged - Add coverage for mixed-case heading links --- server/utils/readme.ts | 19 +++++++++++++++++-- test/unit/server/utils/readme.spec.ts | 7 +++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index b5befe67b7..1c03a4e4be 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -298,6 +298,14 @@ function toUserContentHash(value: string): string { return `#${withUserContentPrefix(value)}` } +function decodeHashFragment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + function normalizePreservedAnchorAttrs(attrs: string): string { const cleanedAttrs = attrs .replace(/\s+href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') @@ -333,8 +341,15 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) if (!url) return url if (url.startsWith('#')) { // Prefix anchor links to match heading IDs (avoids collision with page IDs) - // Idempotent: don't double-prefix if already prefixed - return toUserContentHash(url.slice(1)) + // Normalize markdown-style heading fragments to the same slug format used + // for generated README heading IDs, but leave already-prefixed values as-is. + const fragment = url.slice(1) + if (fragment.startsWith(USER_CONTENT_PREFIX)) { + return `#${fragment}` + } + + 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 diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index 3269f01850..5d85902158 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -293,6 +293,13 @@ describe('Markdown File URL Resolution', () => { expect(result.html).toContain('href="#user-content-installation"') }) + + it('normalizes mixed-case heading fragments to lowercase slugs', async () => { + const markdown = `[Associations section](#Associations)` + const result = await renderReadmeHtml(markdown, 'test-pkg') + + expect(result.html).toContain('href="#user-content-associations"') + }) }) describe('different git providers', () => { From 2ec90faa36dfd6e68a88ab50e99794172fe6453a Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:34:31 -0700 Subject: [PATCH 2/2] fix: handle empty hash fragments while resolving URLs --- server/utils/readme.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 1c03a4e4be..a97e620ebd 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -344,6 +344,9 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) // Normalize markdown-style heading fragments to the same slug format used // for generated README heading IDs, but leave already-prefixed values as-is. const fragment = url.slice(1) + if (!fragment) { + return '#' + } if (fragment.startsWith(USER_CONTENT_PREFIX)) { return `#${fragment}` }