Skip to content

feat(page): server-rendered docs search + ⌘K palette, inside docs-nav#35

Merged
mhenrixon merged 3 commits into
mainfrom
issue-19-server-rendered-search
Jul 3, 2026
Merged

feat(page): server-rendered docs search + ⌘K palette, inside docs-nav#35
mhenrixon merged 3 commits into
mainfrom
issue-19-server-rendered-search

Conversation

@mhenrixon

Copy link
Copy Markdown
Owner

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-nav controller 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 twins llms-full.txt serves, 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 slug DocsUI::Section stamps on its <section id> — so a result links straight to the section. Verified in the dogfood: a result href="/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.

  • JS off: the topbar box is a plain GET form to c.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.
  • JS on: the one docs-nav controller upgrades the box into a command palette — / or ⌘K/Ctrl+K focuses it, keystrokes fetch search.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:

DocsKit.configure do |c|
  c.search      = true           # default; false hides the box site-wide
  c.search_path = "/docs/search" # default; match your route if you move it
end

The install generator draws the route above docs/:doc (or it would swallow /docs/search as :doc); docs-kit new inherits it. The JS-only palette classes (menu-title/menu-active) are forced via @source inline, the same way the Drawer classes are — bin/build-css scans the gems' .rb, not their .js.

New surface

File Role
DocsKit::SearchIndex (+ SearchIndex::Snippet, DocsKit::SearchHit) Pure-Ruby index + scorer, no Rails
DocsKit::SearchController html + json, same shape as LlmsController (gem controller, host-drawn route)
DocsUI::SearchBox Topbar form + palette markup (rendered when search_enabled?)
DocsUI::SearchResults The JS-off results page body

Test 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-match
  • search_results_spec — grouping by page, anchored hrefs, zero-hit guidance, q param escaping
  • search_box_spec / shell_spec — real GET form to search_path, docs-nav targets/actions, hidden palette, absent when c.search = false
  • search_controller_spec — source wiring (ActionController::Base, protect_from_forgery, html+json, no #config shadow)
  • install_generator_spec — route drawn, idempotent, ordered above docs/:doc

Verification

  • bundle exec rake377 examples, 0 failures, rubocop clean, 95.83% line coverage
  • cd docs && bun run build:cssmenu-title/menu-active/dropdown-content/input all present, nothing tree-shaken
  • Dogfooded /docs/search (html and json) end-to-end against the docs app: full chrome, grouped anchored results, <mark> highlights, blank→prompt, no-match→guidance
  • JS-off result anchors resolve to real <section id>; /docs/search does not shadow /docs/:doc (both 200)
  • Backwards compatible — new knobs default on with sensible values; existing sites re-run the generator (or paste one route line)

…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
@mhenrixon mhenrixon self-assigned this Jul 3, 2026
mhenrixon added 2 commits July 3, 2026 12:34
## 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.
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 3, 2026
@mhenrixon mhenrixon merged commit fbb0c84 into main Jul 3, 2026
3 checks passed
@mhenrixon mhenrixon deleted the issue-19-server-rendered-search branch July 4, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(search): server-rendered docs search + palette, inside the docs-nav controller

1 participant