Skip to content

Commit a429fa3

Browse files
committed
fix(docs): slug correctness, anchor fallback, search UX overhaul
Audit on 17 pages of nodyx.dev surfaced 108 broken TOC links and 60 ugly leading-dash heading ids. Root causes were three independent bugs in the slug + heading pipeline. ** Slug pipeline (src/lib/docs.server.ts) ** 1. HTML entity mismatch. marked v12 passes the rendered HTML to `renderer.heading`, where apostrophes have already been encoded to `&#39;`. The slugifier was stripping `&` and `;` (non-word) but keeping `39`, producing ids like `the-real-fight-isn39t-between-us`. Meanwhile `extractHeadings` ran on the raw markdown where the same apostrophe is just `'` (stripped cleanly), yielding `the-real-fight-isnt-between-us`. TOC href and heading id never matched. Both call sites now go through `decodeHtmlEntities` first. 2. Leading + trailing dash from emoji-prefixed headings. `## 🎯 Pick your mode` lowercased to ` pick your mode` (emoji stripped to nothing, leaving a leading space) which became `-pick-your-mode` after space-to-dash. Now trim leading/trailing dashes after spacification and collapse internal runs (so em-dash + space + `--` doesn't yield `a---b`). 3. Phantom TOC entries from code-block comments. The heading regex matched `# 1. Stop pm2` inside ```bash fences as if it were an H1. On INSTALL.md alone this leaked 11 fake "Clean Uninstall" steps into the right sidebar, every one a dead link. New `stripFencedCode` helper blanks fenced lines (preserving line count for offset math) before extraction. ** Search modal (src/lib/components/Header.svelte) ** Reported by @lukasMega in #12: clicking the header search-trigger required a second click on the input because focus didn't transfer. Cmd+K worked. Root cause: HTML `autofocus` is unreliable for elements mounted to the DOM after a user click (browser focus-trap mitigation). Replaced with `bind:this` + `$effect` that calls `.focus()` on every open. While in here: - Keyboard nav: ↑/↓ moves selection, Enter opens, Esc closes, selected row scrolls into view as the user arrows past the visible area - Matched terms wrapped in <mark> in result excerpts (longest-first so phrase wraps don't double-mark individual words) - Race-safe fetch: ignore stale responses if the user kept typing while the previous request was in flight - Hint footer (↑↓ navigate · ↵ open · esc close) ** Client-side anchor fallback (src/routes/[...slug]/+page.svelte) ** Old hand-written `[Foo](#-foo)` markdown links from when emoji slugs had a leading dash now point at non-existent ids. Rather than rewriting 12 pages of source markdown, `applyLooseAnchor` resolves stale hashes on mount and on every hashchange: 1. exact match (browser already handled it) 2. strip leading/trailing dashes + collapse internal runs 3. substring containment in either direction 4. token-based fallback (every dash-separated token must appear in the heading text) When a fallback resolves, `history.replaceState` rewrites the URL to the canonical id so refresh + share-link both work. ** Audit numbers ** Heading ids: 362 (unchanged) Leading-dash ids: 60 → 0 TOC links broken: 108 → 0 In-content broken: 10 → 34 (legacy `#-foo` links, now handled by client-side fallback so users still land on the right section) Closes the focus bug from #12 second-pass feedback.
1 parent 8722878 commit a429fa3

3 files changed

Lines changed: 339 additions & 39 deletions

File tree

nodyx-docs/src/lib/components/Header.svelte

Lines changed: 217 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script lang="ts">
2+
import { tick } from 'svelte'
3+
24
interface SearchResult {
35
slug: string
46
title: string
@@ -8,10 +10,13 @@
810
headingLevel?: number
911
}
1012
11-
let searchOpen = $state(false)
12-
let theme = $state<'light' | 'dark'>('light')
13-
let results = $state<SearchResult[]>([])
14-
let query = $state('')
13+
let searchOpen = $state(false)
14+
let theme = $state<'light' | 'dark'>('light')
15+
let results = $state<SearchResult[]>([])
16+
let query = $state('')
17+
let selectedIdx = $state(0)
18+
let inputEl: HTMLInputElement | undefined = $state()
19+
let resultsEl: HTMLUListElement | undefined = $state()
1520
1621
function resultHref(r: SearchResult): string {
1722
return r.headingId ? `/${r.slug}#${r.headingId}` : `/${r.slug}`
@@ -23,16 +28,138 @@
2328
localStorage.setItem('theme', theme)
2429
}
2530
26-
function openSearch() { searchOpen = true }
27-
function closeSearch() { searchOpen = false }
31+
async function openSearch() {
32+
searchOpen = true
33+
await tick()
34+
inputEl?.focus()
35+
}
36+
37+
function closeSearch() {
38+
searchOpen = false
39+
query = ''
40+
results = []
41+
selectedIdx = 0
42+
}
43+
44+
// The HTML autofocus attribute is unreliable for elements added to the DOM
45+
// after a user click (browsers gate it as a focus-trap mitigation). When
46+
// searchOpen flips from false to true we explicitly call .focus() so the
47+
// input is ready for typing immediately, regardless of how the modal was
48+
// opened (button click vs ⌘K). Reported by @lukasMega in #12.
49+
$effect(() => {
50+
if (searchOpen && inputEl) {
51+
inputEl.focus()
52+
}
53+
})
54+
55+
// Reset selection on every new query, but only if we have results to
56+
// select. Avoids "phantom selection" pointing at index 0 when results=[].
57+
$effect(() => {
58+
selectedIdx = results.length > 0 ? 0 : -1
59+
})
60+
61+
// Scroll the active result into the visible area as the user arrows
62+
// through. Without this the highlight moves but the row stays clipped
63+
// when the list overflows.
64+
$effect(() => {
65+
if (selectedIdx < 0 || !resultsEl) return
66+
const el = resultsEl.querySelectorAll<HTMLElement>('.search-result-item')[selectedIdx]
67+
el?.scrollIntoView({ block: 'nearest' })
68+
})
69+
70+
function onInput(e: Event) {
71+
const q = (e.target as HTMLInputElement).value.trim()
72+
query = q
73+
if (q.length < 2) { results = []; return }
74+
void runSearch(q)
75+
}
76+
77+
// Race-safe: ignore stale responses if the user kept typing while the
78+
// previous fetch was in flight.
79+
let _searchToken = 0
80+
async function runSearch(q: string) {
81+
const my = ++_searchToken
82+
try {
83+
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
84+
const data: SearchResult[] = await res.json()
85+
if (my === _searchToken) results = data
86+
} catch {
87+
if (my === _searchToken) results = []
88+
}
89+
}
90+
91+
function onModalKeydown(e: KeyboardEvent) {
92+
if (e.key === 'ArrowDown') {
93+
if (results.length === 0) return
94+
e.preventDefault()
95+
selectedIdx = (selectedIdx + 1) % results.length
96+
} else if (e.key === 'ArrowUp') {
97+
if (results.length === 0) return
98+
e.preventDefault()
99+
selectedIdx = (selectedIdx - 1 + results.length) % results.length
100+
} else if (e.key === 'Enter') {
101+
if (selectedIdx >= 0 && selectedIdx < results.length) {
102+
e.preventDefault()
103+
const href = resultHref(results[selectedIdx])
104+
closeSearch()
105+
// Use a real navigation so the browser handles the hash properly
106+
// (and our +page.svelte anchor-fallback runs).
107+
window.location.href = href
108+
}
109+
}
110+
}
28111
29-
// Keyboard shortcut Ctrl+K / Cmd+K
112+
// Global ⌘K / Ctrl+K toggle + Esc close
30113
function onKeydown(e: KeyboardEvent) {
31-
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
114+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
32115
e.preventDefault()
33-
searchOpen = !searchOpen
116+
if (searchOpen) closeSearch()
117+
else void openSearch()
118+
} else if (e.key === 'Escape' && searchOpen) {
119+
closeSearch()
120+
}
121+
}
122+
123+
// ── Render snippet with matched terms wrapped in <mark> ────────────────────
124+
// Input is plain-text from the server (already excerpt-trimmed). We escape
125+
// HTML defensively then wrap each occurrence of each query term, longest
126+
// first so "install tunnel" wraps the phrase before the individual words
127+
// and we don't double-mark.
128+
function escHtml(s: string): string {
129+
return s
130+
.replace(/&/g, '&amp;')
131+
.replace(/</g, '&lt;')
132+
.replace(/>/g, '&gt;')
133+
.replace(/"/g, '&quot;')
134+
}
135+
136+
function highlight(snippet: string, q: string): string {
137+
const safe = escHtml(snippet)
138+
const trimmed = q.trim()
139+
if (trimmed.length < 2) return safe
140+
141+
// Try the full phrase first, then individual terms (longest-first so
142+
// a longer term that overlaps a shorter one wins the wrap).
143+
const terms = [
144+
trimmed,
145+
...trimmed.split(/\s+/).filter(t => t.length >= 2)
146+
]
147+
const seen = new Set<string>()
148+
const ordered = terms.filter(t => {
149+
const k = t.toLowerCase()
150+
if (seen.has(k)) return false
151+
seen.add(k)
152+
return true
153+
}).sort((a, b) => b.length - a.length)
154+
155+
let out = safe
156+
for (const t of ordered) {
157+
const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
158+
out = out.replace(new RegExp(`(${escaped})`, 'gi'), '<mark>$1</mark>')
34159
}
35-
if (e.key === 'Escape') closeSearch()
160+
// Avoid nested <mark><mark>...</mark></mark> from overlapping wraps
161+
out = out.replace(/<mark>(<mark>)+/g, '<mark>').replace(/(<\/mark>)+<\/mark>/g, '</mark>')
162+
return out
36163
}
37164
</script>
38165

@@ -103,48 +230,62 @@
103230
</div>
104231
</header>
105232

106-
<!-- Search modal -->
233+
<!-- Search modal. The overlay is a click-target only (close-on-outside);
234+
the actual interactive surface is the input + button + listbox inside.
235+
Esc-to-close is wired via the global keydown handler. -->
107236
{#if searchOpen}
108-
<div class="search-overlay" onclick={closeSearch} role="dialog" aria-modal="true" aria-label="Search">
109-
<div class="search-modal" onclick={e => e.stopPropagation()}>
237+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
238+
<div class="search-overlay" onclick={closeSearch} role="dialog" aria-modal="true" aria-label="Search" tabindex="-1">
239+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
240+
<div class="search-modal" onclick={e => e.stopPropagation()} onkeydown={onModalKeydown} role="presentation">
110241
<div class="search-input-wrap">
111242
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
112243
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
113244
</svg>
114245
<input
246+
bind:this={inputEl}
115247
type="text"
116248
class="search-input"
117249
placeholder="Search documentation…"
118250
autocomplete="off"
119251
spellcheck="false"
120-
autofocus
121-
oninput={async (e) => {
122-
const q = (e.target as HTMLInputElement).value.trim()
123-
query = q
124-
if (q.length < 2) { results = []; return }
125-
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
126-
results = await res.json()
127-
}}
252+
oninput={onInput}
253+
aria-controls="search-results"
254+
aria-activedescendant={selectedIdx >= 0 ? `search-result-${selectedIdx}` : undefined}
128255
/>
129256
<button class="search-close" onclick={closeSearch} aria-label="Close search">ESC</button>
130257
</div>
131258
{#if results.length > 0}
132-
<ul class="search-results" role="listbox">
133-
{#each results as r}
134-
<li role="option">
135-
<a href={resultHref(r)} class="search-result-item" onclick={closeSearch}>
259+
<ul class="search-results" role="listbox" id="search-results" bind:this={resultsEl}>
260+
{#each results as r, i (r.slug + '#' + (r.headingId ?? ''))}
261+
<li role="presentation">
262+
<a
263+
href={resultHref(r)}
264+
class="search-result-item"
265+
class:selected={i === selectedIdx}
266+
id="search-result-{i}"
267+
role="option"
268+
aria-selected={i === selectedIdx}
269+
onmouseenter={() => selectedIdx = i}
270+
onclick={closeSearch}
271+
>
136272
<span class="search-result-title">
137273
{r.title}
138274
{#if r.headingText}
139275
<span class="search-result-sep" aria-hidden="true">›</span>
140276
<span class="search-result-heading" data-level={r.headingLevel ?? 2}>{r.headingText}</span>
141277
{/if}
142278
</span>
143-
<span class="search-result-excerpt">{r.excerpt}</span>
279+
<span class="search-result-excerpt">{@html highlight(r.excerpt, query)}</span>
144280
</a>
145281
</li>
146282
{/each}
147283
</ul>
284+
<div class="search-hint">
285+
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
286+
<span><kbd>↵</kbd> open</span>
287+
<span><kbd>esc</kbd> close</span>
288+
</div>
148289
{:else if query.length >= 2}
149290
<div class="search-empty">No results for "{query}"</div>
150291
{/if}
@@ -287,6 +428,8 @@ kbd {
287428
border-radius: 12px;
288429
overflow: hidden;
289430
box-shadow: 0 24px 80px rgba(0,0,0,0.2);
431+
display: flex;
432+
flex-direction: column;
290433
}
291434
292435
.search-input-wrap {
@@ -317,7 +460,14 @@ kbd {
317460
cursor: pointer;
318461
}
319462
320-
.search-results { list-style: none; margin: 0; padding: 0.5rem; max-height: 360px; overflow-y: auto; }
463+
.search-results {
464+
list-style: none;
465+
margin: 0;
466+
padding: 0.5rem;
467+
max-height: 360px;
468+
overflow-y: auto;
469+
scroll-behavior: smooth;
470+
}
321471
322472
.search-result-item {
323473
display: flex;
@@ -327,15 +477,53 @@ kbd {
327477
border-radius: 6px;
328478
text-decoration: none;
329479
transition: background 0.1s;
480+
border: 1px solid transparent;
481+
}
482+
483+
.search-result-item:hover,
484+
.search-result-item.selected {
485+
background: var(--bg-hover);
486+
border-color: var(--accent-subtle);
330487
}
331488
332-
.search-result-item:hover { background: var(--bg-hover); }
489+
.search-result-item.selected {
490+
outline: 2px solid var(--accent);
491+
outline-offset: -2px;
492+
}
333493
334494
.search-result-title { font-size: 0.875rem; font-weight: 500; color: var(--text); }
335-
.search-result-excerpt { font-size: 0.78rem; color: var(--text-muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
495+
.search-result-excerpt {
496+
font-size: 0.78rem;
497+
color: var(--text-muted);
498+
overflow: hidden;
499+
white-space: nowrap;
500+
text-overflow: ellipsis;
501+
}
502+
.search-result-excerpt :global(mark) {
503+
background: var(--accent-subtle, rgba(124, 58, 237, 0.18));
504+
color: var(--text);
505+
padding: 0 2px;
506+
border-radius: 2px;
507+
font-weight: 600;
508+
}
336509
.search-result-sep { color: var(--text-muted); margin: 0 0.35rem; opacity: 0.7; }
337510
.search-result-heading { color: var(--accent); font-weight: 500; }
338511
.search-result-heading[data-level="3"] { font-weight: 400; opacity: 0.85; }
339512
340513
.search-empty { padding: 1.5rem; text-align: center; color: var(--text-muted); font-size: 0.875rem; }
514+
515+
.search-hint {
516+
display: flex;
517+
gap: 1rem;
518+
padding: 0.5rem 0.875rem;
519+
border-top: 1px solid var(--border);
520+
background: var(--bg-subtle);
521+
font-size: 0.7rem;
522+
color: var(--text-muted);
523+
}
524+
.search-hint kbd {
525+
font-size: 0.65rem;
526+
margin-right: 0.25rem;
527+
padding: 0 0.3em;
528+
}
341529
</style>

0 commit comments

Comments
 (0)