@@ -157,6 +157,25 @@ const ALLOWED_ATTR: Record<string, string[]> = {
157157// GitHub-style callout types
158158// Format: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
159159
160+ /**
161+ * Generate a GitHub-style slug from heading text.
162+ * - Convert to lowercase
163+ * - Remove HTML tags
164+ * - Replace spaces with hyphens
165+ * - Remove special characters (keep alphanumeric, hyphens, underscores)
166+ * - Collapse multiple hyphens
167+ */
168+ function slugify ( text : string ) : string {
169+ return text
170+ . replace ( / < [ ^ > ] * > / g, '' ) // Strip HTML tags
171+ . toLowerCase ( )
172+ . trim ( )
173+ . replace ( / \s + / g, '-' ) // Spaces to hyphens
174+ . replace ( / [ ^ \w \u4e00 - \u9fff \u3040 - \u309f \u30a0 - \u30ff - ] / g, '' ) // Keep alphanumeric, CJK, hyphens
175+ . replace ( / - + / g, '-' ) // Collapse multiple hyphens
176+ . replace ( / ^ - | - $ / g, '' ) // Trim leading/trailing hyphens
177+ }
178+
160179/**
161180 * Resolve a relative URL to an absolute URL.
162181 * If repository info is available, resolve to provider's raw file URLs.
@@ -165,7 +184,8 @@ const ALLOWED_ATTR: Record<string, string[]> = {
165184function resolveUrl ( url : string , packageName : string , repoInfo ?: RepositoryInfo ) : string {
166185 if ( ! url ) return url
167186 if ( url . startsWith ( '#' ) ) {
168- return url
187+ // Prefix anchor links to match heading IDs (avoids collision with page IDs)
188+ return `#user-content-${ url . slice ( 1 ) } `
169189 }
170190 if ( hasProtocol ( url , { acceptRelative : true } ) ) {
171191 try {
@@ -240,6 +260,9 @@ export async function renderReadmeHtml(
240260 const collectedLinks : PlaygroundLink [ ] = [ ]
241261 const seenUrls = new Set < string > ( )
242262
263+ // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2)
264+ const usedSlugs = new Map < string , number > ( )
265+
243266 // Track heading hierarchy to ensure sequential order for accessibility
244267 // Page h1 = package name, h2 = "Readme" section heading
245268 // So README starts at h3, and we ensure no levels are skipped
@@ -262,7 +285,21 @@ export async function renderReadmeHtml(
262285
263286 lastSemanticLevel = semanticLevel
264287 const text = this . parser . parseInline ( tokens )
265- return `<h${ semanticLevel } data-level="${ depth } ">${ text } </h${ semanticLevel } >\n`
288+
289+ // Generate GitHub-style slug for anchor links
290+ let slug = slugify ( text )
291+ if ( ! slug ) slug = 'heading' // Fallback for empty headings
292+
293+ // Handle duplicate slugs (GitHub-style: foo, foo-1, foo-2)
294+ const count = usedSlugs . get ( slug ) ?? 0
295+ usedSlugs . set ( slug , count + 1 )
296+ const uniqueSlug = count === 0 ? slug : `${ slug } -${ count } `
297+
298+ // Prefix with 'user-content-' to avoid collisions with page IDs
299+ // (e.g., #install, #dependencies, #versions are used by the package page)
300+ const id = `user-content-${ uniqueSlug } `
301+
302+ return `<h${ semanticLevel } id="${ id } " data-level="${ depth } ">${ text } </h${ semanticLevel } >\n`
266303 }
267304
268305 // Syntax highlighting for code blocks (uses shared highlighter)
0 commit comments