Skip to content

Commit 5324b96

Browse files
authored
fix(ui): normalize README heading fragments to lowercase slugs (#2385)
1 parent a2254fd commit 5324b96

File tree

2 files changed

+27
-2
lines changed

2 files changed

+27
-2
lines changed

server/utils/readme.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,14 @@ function toUserContentHash(value: string): string {
298298
return `#${withUserContentPrefix(value)}`
299299
}
300300

301+
function decodeHashFragment(value: string): string {
302+
try {
303+
return decodeURIComponent(value)
304+
} catch {
305+
return value
306+
}
307+
}
308+
301309
function normalizePreservedAnchorAttrs(attrs: string): string {
302310
const cleanedAttrs = attrs
303311
.replace(/\s+href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
@@ -333,8 +341,18 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
333341
if (!url) return url
334342
if (url.startsWith('#')) {
335343
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
336-
// Idempotent: don't double-prefix if already prefixed
337-
return toUserContentHash(url.slice(1))
344+
// Normalize markdown-style heading fragments to the same slug format used
345+
// for generated README heading IDs, but leave already-prefixed values as-is.
346+
const fragment = url.slice(1)
347+
if (!fragment) {
348+
return '#'
349+
}
350+
if (fragment.startsWith(USER_CONTENT_PREFIX)) {
351+
return `#${fragment}`
352+
}
353+
354+
const normalizedFragment = slugify(decodeHashFragment(fragment))
355+
return toUserContentHash(normalizedFragment || fragment)
338356
}
339357
// Absolute paths (e.g. /package/foo from a previous npmjs redirect) are already resolved
340358
if (url.startsWith('/')) return url

test/unit/server/utils/readme.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ describe('Markdown File URL Resolution', () => {
293293

294294
expect(result.html).toContain('href="#user-content-installation"')
295295
})
296+
297+
it('normalizes mixed-case heading fragments to lowercase slugs', async () => {
298+
const markdown = `[Associations section](#Associations)`
299+
const result = await renderReadmeHtml(markdown, 'test-pkg')
300+
301+
expect(result.html).toContain('href="#user-content-associations"')
302+
})
296303
})
297304

298305
describe('different git providers', () => {

0 commit comments

Comments
 (0)