Skip to content

Commit 4f63c4c

Browse files
authored
fix: anchor navigation in details README (#254)
1 parent ebf4a2e commit 4f63c4c

File tree

2 files changed

+48
-2
lines changed

2 files changed

+48
-2
lines changed

app/assets/main.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ html {
5858
-webkit-font-smoothing: antialiased;
5959
-moz-osx-font-smoothing: grayscale;
6060
text-rendering: optimizeLegibility;
61+
scroll-behavior: smooth;
62+
scroll-padding-top: 5rem; /* Offset for fixed header - otherwise anchor headers are cutted */
63+
}
64+
65+
/* Disable smooth scrolling if user prefers reduced motion */
66+
@media (prefers-reduced-motion: reduce) {
67+
html {
68+
scroll-behavior: auto;
69+
}
6170
}
6271

6372
/*

server/utils/readme.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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[]> = {
165184
function 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

Comments
 (0)