feat(page): server-rendered docs search + ⌘K palette, inside docs-nav#35
Merged
Conversation
…controller ## Summary Every docs-kit site now gets search from the gem — no external service, no build step, no JavaScript required, and no second Stimulus controller. The index is built structurally from the pages themselves: each authored registry page is rendered to its Markdown twin (DocsKit::MarkdownExport) and split on `## ` headings into searchable sections. The section anchor the twin drops is recomputed as `heading.parameterize` — the same slug DocsUI::Section stamps — so a result links straight to its `<section id>`. This replaces the hand-maintained "second registry" + regex-parsed text app-4 kept. Scoring is plain Ruby: title > heading > body, multi-token AND, capped at 20, each hit with an HTML-safe snippet (the term in <mark>, everything else escaped). - JS off: the topbar box is a plain GET form; Enter lands on a fully server-rendered results page (DocsUI::SearchResults) through the chrome, and each result jumps to its section anchor. Verified end-to-end. - JS on: the ONE docs-nav controller enhances the box into a palette — `/` or ⌘K focuses it, keystrokes fetch `search.json` (debounced) and fill a server-rendered dropdown, arrow keys navigate. The form still submits if the fetch fails, so search is never a dead end. Config: `c.search` (default true) + `c.search_path` (default "/docs/search"). The install generator draws the route ABOVE `docs/:doc` (or it swallows /docs/search as :doc); docs-kit new inherits it. JS-only palette classes (menu-title/menu-active) are forced via @source inline, like the Drawer classes. ## New - DocsKit::SearchIndex (+ SearchIndex::Snippet, DocsKit::SearchHit) — pure Ruby - DocsKit::SearchController (html + json), same shape as LlmsController - DocsUI::SearchBox (topbar form + palette markup), DocsUI::SearchResults ## Test Coverage - search_index_spec: per-section entries, recomputed anchors, title>heading>body ranking, multi-token AND, cap-20, HTML-escaped/highlighted snippet, empty twin - search_results_spec: grouping by page, anchored hrefs, zero-hit guidance, q escaping - search_box_spec / shell_spec: GET form to search_path, docs-nav targets/actions, hidden palette, absent when c.search = false - search_controller_spec: source wiring (ActionController::Base, forgery, html+json) - install_generator_spec: route drawn, idempotent, ordered above docs/:doc ## Verification - [x] bundle exec rake (377 examples, 0 failures; rubocop clean; 95.83% cov) - [x] cd docs && bun run build:css — palette classes present, none tree-shaken - [x] Dogfooded /docs/search (html + json) end-to-end; JS-off anchors resolve to real <section id>; /docs/search does not shadow /docs/:doc Closes #19
## The bug Cmd+K "did nothing" in Firefox Developer Edition. It looked like Firefox hijacking the key — but Cmd/Ctrl+K is a *cancellable* accelerator in every current browser (Firefox 58+, bug 380637), which is exactly why preventDefault() on a keydown suppresses the native search bar and why no docs framework special-cases Firefox. The real defect was ours: event.key.toLowerCase() was unguarded, so when event.key was momentarily undefined (autofill / IME composition) the handler THREW before preventDefault() ran — letting the browser's own Cmd+K fire. ## The fix - Guard event.key ((event.key || "").toLowerCase()) — the actual bug. - Capture phase + stopPropagation() so a third-party keydown handler can't swallow the shortcut; gate out shift/alt so Cmd+Shift+K (devtools) is ignored; add global Escape to close the palette / blur the input. - No browser detection: there's no reliable "the browser stole my key" signal and it's un-actionable anyway. Cmd/Ctrl+K works everywhere; "/" is the always-safe path. ## Make the fallback visible The palette had no visible cue for how to open it. Added a server-rendered <kbd> hint in DocsUI::SearchBox: two badges, "Ctrl K" + "/". It's correct with JS off; docs-nav only refines the modifier label to ⌘K on macOS (label only, never the binding). aria-hidden (the input carries the aria-label); shown from the sm breakpoint up. .kbd/.kbd-sm are scanned from the Ruby literal. ## Verification - bundle exec rake — 382 examples, 0 failures; rubocop clean; 95.85% cov - search_box_spec: <kbd> hint renders, advertises "/", modifier tagged for per-platform swap, shortcutHint target, aria-hidden - Headless handler check: undefined event.key no longer throws; Cmd+K / Ctrl+K / "/" trigger; Cmd+Shift+K and "/"-while-typing correctly ignored - Dogfooded the rendered topbar hint in the docs app - cd docs && bun run build:css — kbd/kbd-sm/sm:flex present Diagnosis backed by an adversarially-verified research pass (both reviewers confirmed preventDefault cancels Firefox's Cmd/Ctrl+K).
## Summary
The search palette's keyboard shortcut(s) are now configurable via
c.search_shortcuts (default ["/", "mod+k"] — the previous keys, so existing
sites are unchanged). A site can bind any set:
c.search_shortcuts = ["/", "mod+k", "s", "ctrl+shift+f"]
Each entry is a bare key ("/", "s", "?") or a chord ("mod+k", "ctrl+shift+f").
"mod" is the PLATFORM command key — ⌘ on macOS, Ctrl elsewhere — so one entry
works on every OS. Modifiers: mod, ctrl, shift, alt, meta (aliases command/cmd →
meta, control → ctrl, option → alt).
One config drives BOTH the key bindings and the <kbd> hint badges, so they can't
drift: DocsUI::SearchBox renders one badge per shortcut AND emits the parsed
list as JSON on the search scope; docs-nav reads that JSON and matches keydowns
generically — no key is hardcoded anymore.
## New
- DocsKit::Shortcut — a value object parsing "mod+k" / "/" / "ctrl+shift+f" into
a key + modifier set, shared by the config, the <kbd> label, and the JS matcher.
- c.search_shortcuts (writer takes strings; reader returns parsed Shortcuts,
dropping unparseable entries).
## Changed
- DocsUI::SearchBox: badges + data-docs-nav-shortcuts-value from config.
- docs_nav_controller.js: readShortcuts() + matchesShortcut() replace the
hardcoded cmdK/slash; the mac hint-refresh now swaps Ctrl→⌘ for any key. Keeps
the event.key guard, capture+stopPropagation, and Escape from the prior fix.
## Test Coverage
- shortcut_spec: parse bare/chord/aliased/whitespace, nil for modifier-only,
#label ("/", "Ctrl K", "Ctrl Shift F"), #to_h, .parse_list
- configuration_spec: default ["/", "mod+k"] → parsed Shortcuts, custom list,
drop-unparseable, empty
- search_box_spec: one <kbd> per shortcut, custom list reflected, empty list →
no badges, JSON payload present + reflects custom list
## Verification
- bundle exec rake — 406 examples, 0 failures; rubocop clean; 95.92% cov
- Headless matcher check (17 cases): mod→⌘ on mac / Ctrl off mac, Cmd+Shift+K
does NOT fire mod+k, bare keys don't hijack typing, ctrl+shift+f exact-match,
case-insensitive, empty config binds nothing, undefined event.key safe
- Headless hint-label check: "Ctrl K"→"⌘K", "Ctrl F"→"⌘F", "Ctrl Shift F"→"⌘Shift F"
- Dogfooded default + custom config in the docs app: badges + escaped JSON
payload decode to correct { key, mod, ctrl, ... }
- cd docs && bun run build:css — .kbd present
## Note
This covers the "multiple keys" ask. The "assign multiple teams" ask is NOT
included — docs-kit has no team concept, and the plausible meanings (team-scoped
search/nav vs. GitHub reviewer teams) differ enough that I didn't want to guess.
Awaiting clarification.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #19.
Every docs-kit site now gets search from the gem — no external service, no build step, no JavaScript required, and no second Stimulus controller. It's shaped by Critical Rules 3 & 4: the server renders a working page; the one
docs-navcontroller only enhances it.How it works
The index is built structurally from the pages themselves: each authored registry page is rendered to its Markdown twin (
DocsKit::MarkdownExport) and split on its##headings into searchable sections — the same twinsllms-full.txtserves, so search can never drift from what a page says. There's no hand-maintained second registry (the drift-prone thing app-4 kept).The section anchor the twin drops is recomputed as
heading.parameterize— exactly the slugDocsUI::Sectionstamps on its<section id>— so a result links straight to the section. Verified in the dogfood: a resulthref="/docs/authoring#one-command"resolves to a real<section id="one-command">.Scoring is plain Ruby: title > heading > body, all query words must match (AND), capped at 20, each hit carrying an HTML-safe snippet (the term wrapped in
<mark>, everything else escaped).JS off → works. JS on → palette.
GETform toc.search_path. Enter lands on a fully server-rendered results page (DocsUI::SearchResults) through the chrome, results grouped by page, each linking to its section anchor.docs-navcontroller upgrades the box into a command palette —/or⌘K/Ctrl+Kfocuses it, keystrokes fetchsearch.json(debounced) and fill a server-rendered dropdown, arrow keys + Enter navigate. If the fetch ever fails, Enter still submits the form to the results page — never a dead end.Config
Both optional; the defaults just work:
The install generator draws the route above
docs/:doc(or it would swallow/docs/searchas:doc);docs-kit newinherits it. The JS-only palette classes (menu-title/menu-active) are forced via@source inline, the same way the Drawer classes are —bin/build-cssscans the gems'.rb, not their.js.New surface
DocsKit::SearchIndex(+SearchIndex::Snippet,DocsKit::SearchHit)DocsKit::SearchControllerhtml+json, same shape asLlmsController(gem controller, host-drawn route)DocsUI::SearchBoxsearch_enabled?)DocsUI::SearchResultsTest plan
search_index_spec— per-section entries, recomputed anchors, title>heading>body ranking, multi-token AND, cap-20, HTML-escaped +<mark>-highlighted snippet, empty-twin, blank/no-matchsearch_results_spec— grouping by page, anchored hrefs, zero-hit guidance,qparam escapingsearch_box_spec/shell_spec— realGETform tosearch_path,docs-navtargets/actions, hidden palette, absent whenc.search = falsesearch_controller_spec— source wiring (ActionController::Base,protect_from_forgery,html+json, no#configshadow)install_generator_spec— route drawn, idempotent, ordered abovedocs/:docVerification
bundle exec rake— 377 examples, 0 failures, rubocop clean, 95.83% line coveragecd docs && bun run build:css—menu-title/menu-active/dropdown-content/inputall present, nothing tree-shaken/docs/search(html and json) end-to-end against the docs app: full chrome, grouped anchored results,<mark>highlights, blank→prompt, no-match→guidance<section id>;/docs/searchdoes not shadow/docs/:doc(both 200)