Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ Metrics/ClassLength:

Metrics/MethodLength:
Max: 25
# Configuration#initialize is a flat one-assignment-per-knob default list; its
# length is the knob count, not logic (AbcSize is excluded for the same reason).
Exclude:
- "lib/docs_kit/configuration.rb"

RSpec/ExampleLength:
Max: 12
Expand Down
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ A `DocsUI::` Phlex kit, configured once per site:
| `DocsUI::JsonResponse` | A Ruby Hash (or String) rendered as a pretty-printed JSON response block. |
| `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
| `DocsUI::MarkdownAction` | The "Markdown" masthead action → the page's `.md` twin; `docs-nav` enhances it into copy-to-clipboard. |
| `DocsUI::SearchBox` / `SearchResults` | Topbar [search](#search) — a JS-off `GET` form + server-rendered results, enhanced into a `⌘K` palette by `docs-nav`. |

Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
(sidebar link value object), `DocsKit::MarkdownExport` ([every page as
Markdown](#every-page-is-also-markdown)), and `DocsKit::Controller#render_page`.
Markdown](#every-page-is-also-markdown)), `DocsKit::SearchIndex` (the
[search](#search) index, built from the Markdown twins), and
`DocsKit::Controller#render_page`.

## Install

Expand Down Expand Up @@ -299,6 +302,76 @@ DocsKit.configure { |c| c.page_markdown_action = false }
to your `get "docs/:doc"` route) to enable the `.md` URLs. Sites that don't
re-run simply have no `.md` route match — HTML rendering is untouched.

## Search

Every site gets search from the gem — no external service, no build step, no
JavaScript required. The topbar grows a search box; the reader types a query and
gets results grouped by page, each linking straight to the matching section.

The index is built **from the pages themselves**: each page's Markdown twin (the
same `.md` from the section above) is split on its `## ` headings into searchable
sections, so the index can never drift from what a page actually says — there is
no second registry to maintain. Scoring is plain Ruby: a title match outranks a
heading match outranks a body match, all query words must match (AND), and each
result carries a snippet with the term highlighted.

**Works with JavaScript off.** The box is a plain `GET` form; pressing Enter
lands on a fully server-rendered results page (`DocsUI::SearchResults`) through
the normal chrome, and each result's link jumps to the section anchor.

**Enhanced with JavaScript on.** The one `docs-nav` controller upgrades the box
into a command palette: press any configured shortcut to focus it, type to see
results appear inline (debounced, fetched as JSON from the same route), arrow
keys + Enter to jump to a result, and `Escape` to close. Each shortcut shows as a
`<kbd>` badge (server-rendered, so the hint is right with JS off too). If the
fetch ever fails, Enter still submits the form to the results page — never a dead
end.

### Keyboard shortcuts

The shortcuts that open the palette are configurable — `c.search_shortcuts`
defaults to `["/", "mod+k"]`:

```ruby
DocsKit.configure do |c|
c.search_shortcuts = ["/", "mod+k", "s"] # bind "/", ⌘K/Ctrl+K, and "s"
end
```

Each entry is a shortcut string: 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 (and the `<kbd>` badge
shows `Ctrl` by default, swapping to `⌘` on macOS in JS). Modifiers accepted:
`mod`, `ctrl`, `shift`, `alt`, `meta` (aliases: `command`/`cmd` → `meta`,
`control` → `ctrl`, `option` → `alt`). A bare-key shortcut never fires while the
reader is typing in a field, and none of them collide with the browser —
`⌘K`/`Ctrl+K` is a *cancellable* accelerator (the palette calls `preventDefault`),
and `"/"` is never hijacked. Set `c.search_shortcuts = []` to bind no key (the
form still works). Whatever you configure drives both the key bindings and the
`<kbd>` hints from one source, so they can't drift.

### Other knobs

The controller ships in the gem (`DocsKit::SearchController`, `html` + `json`);
like llms.txt, the **route lives in your app**. The install generator scaffolds
it (above `docs/:doc`, so it isn't swallowed as a `:doc`):

```ruby
get "/docs/search" => "docs_kit/search#index", as: :docs_search
```

Two more knobs tune it (both optional — the defaults just work):

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

**Existing sites:** re-run `bin/rails g docs_kit:install` (it adds the route
idempotently), or paste the route line above into `config/routes.rb`.

## AI-readable docs (llms.txt)

Every site serves the two [llmstxt.org](https://llmstxt.org) artifacts, built
Expand Down
102 changes: 102 additions & 0 deletions app/components/docs_ui/search_box.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module DocsUI
# The topbar docs-search affordance: a plain GET form to config.search_path (the
# JS-off path — Enter lands on the server-rendered results page) that the ONE
# docs-nav controller enhances into a keyboard-shortcut palette. The shortcuts
# come from config.search_shortcuts (default "/" and "mod+k"): this component
# renders one <kbd> hint per shortcut AND emits the parsed list as JSON on the
# scope, so the badges and the key bindings share one source and can't drift.
# The results dropdown is server-rendered here EMPTY and hidden; docs-nav fills
# it from `search.json?q=` as the reader types and toggles it. The form still
# submits normally if JS dies mid-typing, so search never depends on JavaScript.
#
# Rendered by DocsUI::Shell only when DocsKit.configuration.search_enabled?.
#
# The dropdown/menu/hidden classes are render-time LITERALS (never
# interpolated), so Tailwind's scan of this file keeps them; the CSS also
# @source inline()s them belt-and-suspenders, since they only appear at render
# time (like the Drawer classes).
class SearchBox < Phlex::HTML
def view_template
# data-docs-nav-target="searchScope" roots the palette so the shortcut keys
# can focus the input, and the results dropdown is a sibling.
# data-docs-nav-shortcuts-value carries the parsed shortcut list as JSON so
# docs-nav binds each configured key without hardcoding any.
div(
class: "dropdown flex-none",
data: { docs_nav_target: "searchScope", docs_nav_shortcuts_value: shortcuts_json }
) do
form(
action: config.search_path, method: "get", role: "search",
class: "flex items-center", data: { action: "submit->docs-nav#submitSearch" }
) do
label(class: "input input-sm flex items-center gap-2") do
render DocsUI::Icon.new("search", class: "size-4 opacity-60")
search_input
shortcut_hint
end
end
results_dropdown
end
end

private

def config = DocsKit.configuration

def search_input
input(
type: "search", name: "q", placeholder: "Search…", autocomplete: "off",
aria_label: "Search docs", class: "grow bg-transparent",
data: {
docs_nav_target: "searchInput",
action: "input->docs-nav#performSearch keydown->docs-nav#navigateResults"
}
)
end

# The configured shortcuts (DocsKit::Shortcut list) — drives both the visible
# <kbd> badges and the JSON docs-nav binds against, so they can never drift.
def shortcuts = config.search_shortcuts

# The shortcut list as JSON for docs-nav's Value API (data-docs-nav-shortcuts-
# value). Each entry is { key, mod, ctrl, shift, alt, meta } — everything the
# controller needs to match a keydown without hardcoding any key.
def shortcuts_json = shortcuts.map(&:to_h).to_json

# The keyboard-shortcut hint the reader SEES — one <kbd> badge per configured
# shortcut, rendered from DocsKit.configuration.search_shortcuts. A badge's
# label is the parsed Shortcut#label ("/", "Ctrl K", "S", …); a mod-chord
# badge is tagged data-hint=modifier so docs-nav swaps just its label to ⌘ on
# mac — it never changes the key BINDING. Nothing renders when the site
# configures no shortcuts. aria-hidden: the badges are decorative (the input
# has aria-label).
#
# The class strings are render-time LITERALS so Tailwind's file scan keeps
# them (kbd/kbd-sm are also @source inline'd in the CSS, belt-and-suspenders).
def shortcut_hint
return if shortcuts.empty?

span(class: "ml-1 hidden items-center gap-1 sm:flex", aria_hidden: "true") do
shortcuts.each { |shortcut| shortcut_badge(shortcut) }
end
end

def shortcut_badge(shortcut)
kbd(
class: "kbd kbd-sm opacity-60",
data: { docs_nav_target: "shortcutHint", hint: (shortcut.mod? ? "modifier" : "static") }
) { shortcut.label }
end

# The palette results list — server-rendered EMPTY + hidden; docs-nav fills it.
def results_dropdown
ul(
class: "dropdown-content menu bg-base-200 rounded-box z-40 mt-1 hidden max-h-96 " \
"w-80 flex-nowrap overflow-y-auto p-2 shadow-2xl",
data: { docs_nav_target: "searchResults" }
)
end
end
end
95 changes: 95 additions & 0 deletions app/components/docs_ui/search_results.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

module DocsUI
# The server-rendered search results — the JS-off path. A plain page body (the
# host renders it inside DocsUI::Shell) that echoes the query, lists the hits
# grouped by page, and links each result to its section anchor. With JavaScript
# off this IS the search UX; the docs-nav palette is a progressive enhancement
# over the same DocsKit::SearchController that renders this.
#
# render DocsUI::SearchResults.new(query: params[:q], hits: index.search(params[:q]))
#
# hits are DocsKit::SearchHit value objects (already ranked, snippet pre-marked
# and HTML-safe). A blank query prompts the reader; a query with no hits renders
# guidance instead of an empty list.
class SearchResults < Phlex::HTML
def initialize(query:, hits:)
@query = query.to_s
@hits = hits
end

def view_template
div(class: "mx-auto max-w-3xl") do
header
if @query.strip.empty?
prompt
elsif @hits.empty?
empty_state
else
results
end
end
end

private

def header
h1(class: "mb-2 text-3xl font-bold tracking-tight") { "Search" }
return if @query.strip.empty?

p(class: "mb-8 text-base-content/60") do
plain "#{result_count} for "
span(class: "font-semibold text-base-content") { "“#{@query}”" }
end
end

def result_count
n = @hits.size
"#{n} result#{'s' unless n == 1}"
end

# Blank query — the bare /docs/search page. Tell the reader what to do.
def prompt
p(class: "text-base-content/60") { "Type a query above to search the docs." }
end

# A query that matched nothing — guidance, not a dead end.
def empty_state
div(class: "rounded-box border border-base-300 bg-base-200 p-6 text-center") do
p(class: "mb-1 font-medium") { "No results for “#{@query}”." }
p(class: "text-sm text-base-content/60") { "Try fewer or more general words." }
end
end

# Hits grouped by page: one heading per page, each hit a linked card with its
# section label + highlighted snippet. group_by preserves first-seen (rank)
# order, so the best-scoring page leads.
def results
div(class: "space-y-8") do
@hits.group_by(&:page_title).each do |page_title, page_hits|
section(class: "space-y-2") do
h2(class: "text-sm font-semibold uppercase tracking-wider text-base-content/60") { page_title }
page_hits.each { |hit| result_row(hit) }
end
end
end
end

def result_row(hit)
a(
href: hit.href,
class: "block rounded-box border border-base-300 bg-base-100 p-4 transition " \
"hover:border-primary hover:bg-base-200"
) do
# A page-intro hit (no section) is the page overview; label it so as not
# to duplicate the page-group heading above it.
span(class: "block font-medium text-primary") { hit.section_title || "Overview" }
# The snippet is a gem-produced, pre-escaped HTML string (the matched term
# wrapped in <mark>, everything else escaped by SearchIndex::Snippet), so
# it's trusted markup — raw(safe) is the same idiom DocsUI::Code uses for
# its highlighted output. NEVER pass user/config free text here unescaped.
p(class: "mt-1 text-sm text-base-content/70") { raw(safe(hit.snippet)) }
end
end
end
end
3 changes: 2 additions & 1 deletion app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,15 @@ def shell(&block)
end
end

# Sticky topbar: hamburger (mobile only), brand, theme switcher.
# Sticky topbar: hamburger (mobile only), brand, search, theme switcher.
def topbar
div(class: "navbar bg-base-200 border-b border-base-300 sticky top-0 z-30 px-4") do
div(class: "flex-1 items-center gap-2") do
label(for: DRAWER_ID, class: "btn btn-square btn-ghost btn-sm lg:hidden",
aria_label: "Open menu") { render DocsUI::Icon.new("menu", class: "size-5") }
a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
end
render DocsUI::SearchBox.new if config.search_enabled?
div(class: "flex-none") do
render DocsUI::ThemeSwitcher.new
end
Expand Down
72 changes: 72 additions & 0 deletions app/controllers/docs_kit/search_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module DocsKit
# Serves the docs search — one gem controller, host-drawn route (same shape as
# DocsKit::LlmsController; the engine is glue-only and adds no routes):
#
# # config/routes.rb
# get "/docs/search" => "docs_kit/search#index"
#
# #index answers BOTH formats off the same index:
#
# * html — the JS-off path: renders DocsUI::SearchResults inside DocsUI::Shell,
# a full working results page. The topbar form (GET ?q=) lands here.
# * json — the enhancement path: the docs-nav palette fetches `search.json?q=`
# debounced and renders the hits client-side. The form still submits to the
# html path if JS dies mid-typing.
#
# The index is built lazily per request from DocsKit::SearchIndex, whose entries
# come from each registry page's Markdown twin (DocsKit::MarkdownExport) split on
# its `## ` headings — the SAME twins llms-full.txt serves, so search can never
# drift from the pages. Sites are tens of pages; there's no external index, no
# build step, no second registry.
class SearchController < ActionController::Base
# Like LlmsController: a bare ActionController::Base subclass doesn't inherit
# the host's default_protect_from_forgery, and #index renders DocsUI::Shell,
# whose <head> calls csrf_meta_tags (which needs protect_against_forgery?
# registered as a view helper). :null_session fits this GET-only, sessionless,
# public endpoint.
protect_from_forgery with: :null_session

def index
hits = search_index.search(query)

respond_to do |format|
format.html { render_results_page(hits) }
format.json { render json: { "query" => query, "results" => hits.map(&:as_json) } }
end
end

private

# NOT named #config — ActionController::Base#config is the Rails config object
# and RequestForgeryProtection delegates to it; shadowing it breaks
# csrf_meta_tags when the Shell renders (see LlmsController).
def docs_config = DocsKit.configuration

def query = params[:q].to_s

# The index built from every authored registry page's Markdown twin. Each page
# is rendered through THIS controller's view context (url helpers/CSRF resolve)
# and absolutized against the request base URL, exactly as LlmsController#full
# renders each twin.
def search_index
triples = DocsKit::LlmsText.pages(docs_config).map do |page|
markdown = DocsKit::MarkdownExport.new(
page.view_class.new, view_context:, base_url: request.base_url
).to_md
[page.title, page.href, markdown]
end
DocsKit::SearchIndex.new(triples)
end

# The full chrome results page. DocsUI::Shell IS the whole document, so render
# with layout: false (the same contract as DocsKit::Controller#render_page).
def render_results_page(hits)
page = DocsUI::Shell.new(title: "Search") do
render DocsUI::SearchResults.new(query:, hits:)
end
render page, layout: false
end
end
end
Loading
Loading