diff --git a/.rubocop.yml b/.rubocop.yml index 0544a6b..f2deb02 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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 diff --git a/README.md b/README.md index 65df2d4..bb41ab3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 +`` 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 `` 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 +`` 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 diff --git a/app/components/docs_ui/search_box.rb b/app/components/docs_ui/search_box.rb new file mode 100644 index 0000000..bed52be --- /dev/null +++ b/app/components/docs_ui/search_box.rb @@ -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 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 + # 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 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 diff --git a/app/components/docs_ui/search_results.rb b/app/components/docs_ui/search_results.rb new file mode 100644 index 0000000..8fc3118 --- /dev/null +++ b/app/components/docs_ui/search_results.rb @@ -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 , 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 diff --git a/app/components/docs_ui/shell.rb b/app/components/docs_ui/shell.rb index c1316e2..e068bb6 100644 --- a/app/components/docs_ui/shell.rb +++ b/app/components/docs_ui/shell.rb @@ -140,7 +140,7 @@ 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 @@ -148,6 +148,7 @@ def topbar 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 diff --git a/app/controllers/docs_kit/search_controller.rb b/app/controllers/docs_kit/search_controller.rb new file mode 100644 index 0000000..af9262d --- /dev/null +++ b/app/controllers/docs_kit/search_controller.rb @@ -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 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 diff --git a/app/javascript/docs_kit/controllers/docs_nav_controller.js b/app/javascript/docs_kit/controllers/docs_nav_controller.js index c13d295..5d28f29 100644 --- a/app/javascript/docs_kit/controllers/docs_nav_controller.js +++ b/app/javascript/docs_kit/controllers/docs_nav_controller.js @@ -37,6 +37,9 @@ export default class extends Controller { onPage: { type: String, default: "" }, // Fewer than this many headings → hide the TOC entirely (short pages). minHeadings: { type: Number, default: 2 }, + // Debounce (ms) between a search keystroke and the fetch, so typing fast + // doesn't fire a request per character. + searchDebounce: { type: Number, default: 150 }, } // tocLink: pre-rendered TOC links to spy on. @@ -47,10 +50,16 @@ export default class extends Controller { // shows the panel for the globally-remembered language and hides the others. // markdownLink: the "Markdown" masthead action; a plain link with JS off, the // controller upgrades its click into copy-the-page's-markdown-to-clipboard. + // searchScope: the dropdown root (so a click outside closes the palette). + // searchInput: the topbar query field ("/" and Cmd/Ctrl+K focus it). + // searchResults: the empty
    the controller fills with fetched hits. + // shortcutHint: the badge(s); the controller refines the modifier label + // to the platform (⌘K on mac). Server-rendered, so correct with JS off. static targets = [ "tocLink", "toc", "tocRoot", "tocPopover", "codeGroup", "codeTab", "codePanel", "markdownLink", + "searchScope", "searchInput", "searchResults", "shortcutHint", ] connect() { @@ -62,11 +71,13 @@ export default class extends Controller { this.startScrollSpy() this.applyLanguage(this.readLanguage()) this.applyTheme(this.readTheme()) + this.connectSearch() } disconnect() { this.element.removeEventListener("toggle", this.onToggle, true) this.observer?.disconnect() + this.disconnectSearch() } // --- 1. Collapse persistence ------------------------------------------------ @@ -343,6 +354,251 @@ export default class extends Controller { setTimeout(() => (labelNode.textContent = original), 1500) } + // --- 7. Search palette ------------------------------------------------------ + // + // Progressive enhancement over the topbar search form (DocsUI::SearchBox). With + // JS off the form GETs config.search_path and the server renders a full results + // page. Here we upgrade it into a Cmd+K palette: "/" or Cmd/Ctrl+K focuses the + // input, keystrokes fetch `.json?q=` (debounced) and fill the + // server-rendered dropdown, and arrow keys navigate. The native form submit is + // always the fallback — if a fetch fails, Enter still lands on the results page. + + connectSearch() { + if (!this.hasSearchInputTarget) return + this.shortcuts = this.readShortcuts() + this.onSearchKeydown = this.handleSearchShortcut.bind(this) + this.onSearchClickOut = this.closeOnClickOutside.bind(this) + // Capture phase: run before any content script that might stopPropagation() + // the event, so the palette shortcut can't be swallowed on a page with + // third-party JS. (preventDefault below is what actually cancels the + // browser's native Cmd/Ctrl+K — capture just wins the race for the event.) + document.addEventListener("keydown", this.onSearchKeydown, true) + document.addEventListener("click", this.onSearchClickOut) + this.refreshShortcutHint() + } + + disconnectSearch() { + if (this.onSearchKeydown) document.removeEventListener("keydown", this.onSearchKeydown, true) + if (this.onSearchClickOut) document.removeEventListener("click", this.onSearchClickOut) + clearTimeout(this.searchTimer) + } + + // The configured shortcuts, parsed from data-docs-nav-shortcuts-value on the + // search scope (DocsUI::SearchBox emits it from config.search_shortcuts). Each + // is { key, mod, ctrl, shift, alt, meta }. A malformed value degrades to [] — + // "/" and Cmd+K just won't focus search, but the form still works. + readShortcuts() { + if (!this.hasSearchScopeTarget) return [] + try { + const raw = this.searchScopeTarget.dataset.docsNavShortcutsValue + const list = JSON.parse(raw || "[]") + return Array.isArray(list) ? list : [] + } catch { + return [] + } + } + + // Focus search when a keydown matches ANY configured shortcut; Escape blurs. + // + // preventDefault() on this keydown is what cancels the browser's native + // Cmd/Ctrl+K search bar — in EVERY current browser including Firefox (the combo + // is a cancellable accelerator, not a reserved shortcut), so there's no + // per-browser branch. `event.key` can be undefined (autofill / IME + // composition); calling .toLowerCase() on it would THROW and kill the handler + // before preventDefault() ran — which lets the browser's own Cmd+K fire. Guard + // it (this is the bug that made Cmd+K "do nothing" in Firefox). + handleSearchShortcut(event) { + const key = (event.key || "").toLowerCase() + const focused = document.activeElement === this.searchInputTarget + + // Escape leaves the palette: close results, drop focus back to the page. + if (key === "escape" && (focused || this.resultsOpen)) { + this.closeResults() + if (focused) this.searchInputTarget.blur() + return + } + + if (!this.matchesShortcut(event, key)) return + + event.preventDefault() // cancels the browser's native Cmd/Ctrl+K search bar + event.stopPropagation() // hide from other content-level keydown handlers + this.searchInputTarget.focus() + this.searchInputTarget.select() + } + + // Does this keydown match one of the configured shortcuts? + // - key must equal the shortcut's key + // - "mod" maps to ⌘ on mac / Ctrl elsewhere; explicit ctrl/meta/shift/alt + // must match exactly, so Cmd+Shift+K doesn't fire a plain "mod+k" + // - a shortcut with NO modifier (e.g. "/") must not fire while the reader is + // typing in a field, and must not fire if any modifier is held + matchesShortcut(event, key) { + return this.shortcuts.some((s) => { + if (key !== (s.key || "").toLowerCase()) return false + const wantCtrl = !!s.ctrl || (!!s.mod && !this.isMac) + const wantMeta = !!s.meta || (!!s.mod && this.isMac) + if (event.ctrlKey !== wantCtrl) return false + if (event.metaKey !== wantMeta) return false + if (event.shiftKey !== !!s.shift) return false + if (event.altKey !== !!s.alt) return false + // A bare (no-modifier) shortcut mustn't hijack typing. + const bare = !wantCtrl && !wantMeta && !s.shift && !s.alt + if (bare && this.isTypingField(event.target)) return false + return true + }) + } + + isTypingField(el) { + const tag = (el?.tagName || "").toLowerCase() + return tag === "input" || tag === "textarea" || el?.isContentEditable + } + + get resultsOpen() { + return this.hasSearchResultsTarget && !this.searchResultsTarget.classList.contains("hidden") + } + + // The hint badges are server-rendered with the majority default ("Ctrl") + // so they're correct with JS off. Here we only refine a MODIFIER-tagged badge's + // label to the actual platform — swap a leading "Ctrl" for "⌘" on mac. This + // adjusts the LABEL only, never the key binding, and works for any key + // ("Ctrl K" → "⌘K", "Ctrl F" → "⌘F"). + get isMac() { + return /\b(Mac|iPhone|iPad|iPod)\b/i.test(navigator.platform || navigator.userAgent || "") + } + + refreshShortcutHint() { + if (!this.hasShortcutHintTarget || !this.isMac) return + this.shortcutHintTargets.forEach((el) => { + if (el.dataset.hint === "modifier") { + el.textContent = el.textContent.replace(/\bCtrl\b/, "⌘").replace(/⌘\s+/, "⌘") + } + }) + } + + // Debounced query → fetch JSON → render. An empty query just closes the palette. + performSearch() { + clearTimeout(this.searchTimer) + const query = this.searchInputTarget.value.trim() + if (!query) { + this.closeResults() + return + } + this.searchTimer = setTimeout(() => this.runSearch(query), this.searchDebounceValue) + } + + async runSearch(query) { + const url = `${this.searchEndpoint}?q=${encodeURIComponent(query)}` + try { + const response = await fetch(url, { headers: { Accept: "application/json" } }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const data = await response.json() + this.renderResults(data.results || []) + } catch { + // Fetch failed — leave the palette closed; the form still submits on Enter. + this.closeResults() + } + } + + // The JSON endpoint is the form's action with a `.json` extension (same route, + // json format), so a site that moved search_path is followed automatically. + get searchEndpoint() { + const action = this.searchInputTarget.form?.getAttribute("action") || "/docs/search" + return `${action}.json` + } + + renderResults(results) { + const list = this.searchResultsTarget + list.replaceChildren() + if (results.length === 0) { + list.appendChild(this.emptyRow()) + } else { + results.forEach((hit) => list.appendChild(this.resultRow(hit))) + } + this.openResults() + this.activeIndex = -1 + } + + emptyRow() { + const li = document.createElement("li") + li.className = "menu-title" + li.textContent = "No results" + return li + } + + resultRow(hit) { + const li = document.createElement("li") + const a = document.createElement("a") + a.href = hit.href + const label = document.createElement("span") + label.className = "font-medium" + label.textContent = hit.label + a.appendChild(label) + // The snippet is server-produced, pre-escaped HTML (the match in ); it's + // the same trusted string the SearchResults page renders. + if (hit.snippet) { + const snip = document.createElement("span") + snip.className = "block text-xs opacity-60" + snip.innerHTML = hit.snippet + a.appendChild(snip) + } + li.appendChild(a) + return li + } + + // Arrow/Enter/Escape navigation over the rendered result links. + navigateResults(event) { + const links = this.resultLinks + if (event.key === "Escape") { + this.closeResults() + return + } + if (links.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + this.moveActive(1, links) + } else if (event.key === "ArrowUp") { + event.preventDefault() + this.moveActive(-1, links) + } else if (event.key === "Enter" && this.activeIndex >= 0) { + event.preventDefault() + links[this.activeIndex].click() + } + } + + moveActive(delta, links) { + this.activeIndex = (this.activeIndex + delta + links.length) % links.length + links.forEach((link, i) => { + const on = i === this.activeIndex + link.classList.toggle("menu-active", on) + if (on) link.scrollIntoView({ block: "nearest" }) + }) + } + + get resultLinks() { + return Array.from(this.searchResultsTarget.querySelectorAll("a")) + } + + // Let the native form submit proceed (goes to the full results page); just + // close the palette so it doesn't linger over the new page. + submitSearch() { + this.closeResults() + } + + openResults() { + if (this.hasSearchResultsTarget) this.searchResultsTarget.classList.remove("hidden") + } + + closeResults() { + if (this.hasSearchResultsTarget) this.searchResultsTarget.classList.add("hidden") + this.activeIndex = -1 + } + + closeOnClickOutside(event) { + if (!this.hasSearchScopeTarget) return + if (!this.searchScopeTarget.contains(event.target)) this.closeResults() + } + // --- storage (private, fails safe if localStorage is unavailable) ----------- read(key) { diff --git a/docs/app/assets/stylesheets/application.tailwind.css b/docs/app/assets/stylesheets/application.tailwind.css index 3cb0072..459c6a0 100644 --- a/docs/app/assets/stylesheets/application.tailwind.css +++ b/docs/app/assets/stylesheets/application.tailwind.css @@ -19,6 +19,10 @@ so the shell isn't tree-shaken. */ @source inline("drawer drawer-content drawer-side drawer-toggle drawer-overlay {lg:}drawer-open drawer-end"); +/* Search palette classes the docs-nav controller applies at RUN TIME (JS only — + bin/build-css scans the gems' .rb, not their .js), so force them here. */ +@source inline("menu-title menu-active"); + /* Responsive drawer-open — sidebar always visible on desktop. */ @media (width >= 1024px) { .lg\:drawer-open { display: grid !important; grid-auto-columns: max-content auto !important; } diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 11ad09b..2c21573 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -1,5 +1,10 @@ Rails.application.routes.draw do root "landings#show" + + # Docs search — served from the registry by the gem's DocsKit::SearchController + # (matches the default c.search_path). MUST come before `docs/:doc` or that + # route swallows /docs/search as :doc. + get "/docs/search" => "docs_kit/search#index", as: :docs_search get "docs/:doc(.:format)" => "docs#show", as: :doc # AI-readable docs (llmstxt.org) — served from the registry by the gem's diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index 130046e..b3954c3 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -128,6 +128,26 @@ class Configuration # works). See DocsKit::MarkdownExport / DocsKit::Controller#render_page. attr_accessor :page_markdown_action + # Whether the topbar renders the docs-search form (and the docs-nav palette + # markup). Defaults to true. Set false to hide search site-wide — the route + # can stay drawn, but no affordance points at it. Gated together with a + # present #search_path by #search_enabled?, which the Shell reads. + attr_accessor :search + + # The path the topbar search form submits to (GET ?q=), and the base the + # palette fetches `.json` from. Defaults to "/docs/search" — the route the + # install generator draws. A site that mounts search elsewhere sets its own; + # blank it to disable the affordance without touching #search. + attr_accessor :search_path + + # The keyboard shortcut STRINGS that open the search palette, e.g. + # %w[/ mod+k s]. Defaults to DEFAULT_SEARCH_SHORTCUTS (["/", "mod+k"] — the + # keys shipped before this was configurable, so existing sites are unchanged). + # "mod" is the platform modifier (⌘ on mac, Ctrl elsewhere), so one entry + # works on every OS. Read the parsed form via #search_shortcuts (which maps to + # DocsKit::Shortcut and drops anything unparseable), never @search_shortcuts. + attr_writer :search_shortcuts + # The API base URL prefixed onto a DocsUI::RequestExample path so copy-pasted # snippets point at a real host. Defaults to a neutral example host; a site # sets its own (e.g. "https://api.acme.com"). @@ -149,6 +169,11 @@ class Configuration # identity to decide whether to derive the sidebar from #nav_registries. DEFAULT_NAV = -> { {} } + # The search-palette shortcuts before this was configurable — "/" and the + # platform command chord — so a site that never sets #search_shortcuts keeps + # exactly the previous behavior. + DEFAULT_SEARCH_SHORTCUTS = ["/", "mod+k"].freeze + # The built-in daisyUI theme names that are dark. #dark_themes defaults to # this; #dark_themes_shipped intersects it with the site's #themes so only # shipped themes ever generate dark code CSS. A site with custom dark themes @@ -194,6 +219,9 @@ def initialize @code_lexer_fallback = "plaintext" @code_language_labels = {} @page_markdown_action = true + @search = true + @search_path = "/docs/search" + @search_shortcuts = DEFAULT_SEARCH_SHORTCUTS @api_base_url = "https://api.example.com" @api_auth_header = nil @api_clients = {} @@ -271,6 +299,20 @@ def title_suffix @title_suffix || @brand end + # Whether the Shell renders the search affordance: search is on AND a path is + # set to submit to. A site with @search_path blanked (or nil) gets no form + # even if @search is true — there'd be nothing to submit to. + def search_enabled? + !!@search && !@search_path.to_s.empty? + end + + # The parsed search-palette shortcuts (DocsKit::Shortcut list), with anything + # unparseable dropped. The topbar renders one per entry and docs-nav + # binds each; an empty list means no keyboard shortcut (the form still works). + def search_shortcuts + DocsKit::Shortcut.parse_list(@search_shortcuts) + end + def default_theme @default_theme || Array(@themes).first end diff --git a/lib/docs_kit/search_hit.rb b/lib/docs_kit/search_hit.rb new file mode 100644 index 0000000..62df7c2 --- /dev/null +++ b/lib/docs_kit/search_hit.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DocsKit + # One ranked search result. Built by DocsKit::SearchIndex#search and rendered by + # DocsUI::SearchResults (html) / serialized to JSON for the docs-nav palette. + # + # page_title — the page the hit lives on (results group by this) + # section_title — the `## ` section, or nil for a page-intro hit + # href — the page href + "#anchor" (nil section → bare page href) + # snippet — an HTML-safe excerpt around the match, the term in + # score — the rank weight (title > heading > body); higher wins + SearchHit = Data.define(:page_title, :section_title, :href, :snippet, :score) do + def initialize(page_title:, href:, snippet:, score:, section_title: nil) + super + end + + # The label a result row shows: "Page → Section", or just the page title for + # a page-intro hit. + def label + section_title ? "#{page_title} → #{section_title}" : page_title + end + + # JSON shape the palette fetches (matches #label / #href / #snippet). + def as_json(*) + { "label" => label, "href" => href, "snippet" => snippet } + end + end +end diff --git a/lib/docs_kit/search_index.rb b/lib/docs_kit/search_index.rb new file mode 100644 index 0000000..a765097 --- /dev/null +++ b/lib/docs_kit/search_index.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/inflections" + +module DocsKit + # An in-memory docs search index, built straight from the pages' Markdown twins + # — zero authoring, no external service, no build step. This is the structural + # replacement for the hand-maintained "second registry" + regex-parsed text a + # site used to keep: the twin already IS the page's content, split on its `## ` + # headings into searchable sections. + # + # DocsKit::SearchIndex.new(triples).search("theme switcher") + # + # `triples` is [[page_title, page_href, markdown], ...] — the controller renders + # each registry page through DocsKit::MarkdownExport and hands the triples in, + # exactly as DocsKit::LlmsText separates pure shaping from the controller's + # rendering. So the whole index + scorer is unit-testable with no Rails. + # + # One entry per section (plus a page-intro entry for the text before the first + # `## `). Scoring is plain Ruby: case-insensitive token match, all tokens must + # hit (AND), a title hit outranks a heading hit outranks a body hit. Results cap + # at MAX_RESULTS with an HTML-safe snippet around the match (the term in + # ). No dependencies, no fuzzy matching (revisit if usage demands it). + class SearchIndex + # Field weights — a match in the page title beats a section heading beats body + # text, so the most on-topic section floats up. + TITLE_WEIGHT = 100 + HEADING_WEIGHT = 10 + BODY_WEIGHT = 1 + + # Never return more than this — a docs site is tens of pages, and a reader + # scans the top matches, not a hundred. + MAX_RESULTS = 20 + + # An indexed section (or page intro). `haystacks` holds the lowercased text of + # each weighted field so scoring is a simple include? per token. + # + # The page title is searchable ONLY on the page-intro entry (section_title + # nil), not on every section: a title token matches all sections of a page + # equally, so weighting each section by the title would flood the results with + # near-identical rows from one page. A pure title match therefore surfaces + # once (the intro), while a section still ranks on its own heading/body. + Entry = Struct.new(:page_title, :section_title, :href, :body, keyword_init: true) do + # { weight => lowercased searchable text } for this entry. + def haystacks + @haystacks ||= begin + fields = { + HEADING_WEIGHT => section_title.to_s.downcase, + BODY_WEIGHT => body.to_s.downcase + } + fields[TITLE_WEIGHT] = page_title.to_s.downcase if section_title.nil? + fields + end + end + end + + # triples: [[page_title, page_href, markdown], ...]. + def initialize(triples = []) + @entries = triples.flat_map { |title, href, markdown| entries_for(title, href, markdown) } + end + + attr_reader :entries + + # The top MAX_RESULTS SearchHits for `query`, best first. Blank query → []. + # Every whitespace-split token must match the entry somewhere (AND); the + # entry's score is the sum, per token, of the best field it matched. + def search(query) + tokens = tokenize(query) + return [] if tokens.empty? + + scored = @entries.filter_map { |entry| score_entry(entry, tokens) } + scored.sort_by { |hit| [-hit.score, hit.page_title, hit.section_title.to_s] } + .first(MAX_RESULTS) + end + + private + + # Split a page's Markdown twin into entries: the intro text (before the first + # `## `) becomes a page-level entry; each `## Heading` starts a section entry + # whose href carries the recomputed anchor. + def entries_for(page_title, page_href, markdown) + intro, sections = split_sections(markdown.to_s) + built = [] + built << build_entry(page_title, nil, page_href, intro) unless intro.strip.empty? + sections.each do |heading, body| + anchor = "#{page_href}##{slugify(heading)}" + built << build_entry(page_title, heading, anchor, body) + end + # A page with no intro and no sections (empty twin) still gets one entry, so + # its title is searchable. + built << build_entry(page_title, nil, page_href, "") if built.empty? + built + end + + def build_entry(page_title, section_title, href, body) + Entry.new(page_title: page_title, section_title: section_title, href: href, body: body.strip) + end + + # → [intro_text, [[heading, body], ...]]. Splits on lines that are exactly a + # level-2 ATX heading (`## Foo`), matching MarkdownExport's twin output. + def split_sections(markdown) + parts = markdown.split(/^\#\#[ \t]+(.+?)[ \t]*$/) + intro = parts.shift.to_s + sections = parts.each_slice(2).map { |heading, body| [heading.to_s.strip, body.to_s] } + [intro, sections] + end + + # The section anchor the twin dropped: the same slug DocsUI::Section stamps on + # its
    (ActiveSupport #parameterize when available, else a minimal + # ASCII slug so the index works off-Rails too). + def slugify(text) + return text.parameterize if text.respond_to?(:parameterize) + + text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") + end + + def tokenize(query) + query.to_s.downcase.split(/\s+/).reject(&:empty?) + end + + # A SearchHit if EVERY token matched somewhere in the entry (AND), else nil. + # Each token scores the heaviest field it appears in; the entry score sums + # those, so a section matching more tokens (and in heavier fields) ranks higher. + def score_entry(entry, tokens) + total = 0 + tokens.each do |token| + best = best_field_weight(entry, token) + return nil unless best # this token matched nothing → entry is out (AND) + + total += best + end + SearchHit.new( + page_title: entry.page_title, section_title: entry.section_title, + href: entry.href, snippet: snippet_for(entry, tokens), score: total + ) + end + + # The heaviest field weight whose text contains `token`, or nil if none do. + def best_field_weight(entry, token) + entry.haystacks.select { |_weight, text| text.include?(token) }.keys.max + end + + # An HTML-safe snippet around the match. Prefer the body; if the match is + # title-only (empty body), fall back to the section or page title so the row + # still has context. Snippet windowing + highlighting + escaping live + # in SearchIndex::Snippet. + def snippet_for(entry, tokens) + source = entry.body.to_s + if source.strip.empty? + source = entry.section_title.to_s.empty? ? entry.page_title.to_s : entry.section_title.to_s + end + Snippet.build(source, tokens) + end + end +end diff --git a/lib/docs_kit/search_index/snippet.rb b/lib/docs_kit/search_index/snippet.rb new file mode 100644 index 0000000..3a0315c --- /dev/null +++ b/lib/docs_kit/search_index/snippet.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "cgi" + +module DocsKit + class SearchIndex + # Builds the HTML-safe excerpt shown under a search result: a short window of + # text centered on the first query-token match, with every token wrapped in + # . Surrounding text is HTML-escaped so an angle bracket in the source + # can never inject markup — the returned String is safe to render. + class Snippet + # Characters of context on either side of the first match. + RADIUS = 80 + + def self.build(text, tokens) + new(text, tokens).build + end + + def initialize(text, tokens) + @flat = text.to_s.gsub(/\s+/, " ").strip + @tokens = tokens + end + + def build + highlight(window) + end + + private + + # A ~RADIUS-on-each-side slice around the first token match, with leading/ + # trailing ellipses when the window is cut from a longer body. No match (the + # hit was title-only) → the head of the text. + def window + idx = first_match_index + return head if idx.nil? + + start = [idx - RADIUS, 0].max + finish = [idx + RADIUS, @flat.length].min + "#{'…' if start.positive?}#{@flat[start...finish].strip}#{'…' if finish < @flat.length}" + end + + def head + slice = @flat[0, RADIUS * 2].to_s.strip + @flat.length > RADIUS * 2 ? "#{slice}…" : slice + end + + def first_match_index + down = @flat.downcase + @tokens.filter_map { |token| down.index(token) }.min + end + + # Escape the window, then wrap each token's (case-insensitive) occurrences in + # . Tokens are escaped before matching so the search runs against the + # same escaped text and no token can smuggle in HTML. + def highlight(window) + escaped = CGI.escapeHTML(window) + @tokens.each do |token| + pattern = Regexp.new(Regexp.escape(CGI.escapeHTML(token)), Regexp::IGNORECASE) + escaped = escaped.gsub(pattern) { |match| "#{match}" } + end + escaped + end + end + end +end diff --git a/lib/docs_kit/shortcut.rb b/lib/docs_kit/shortcut.rb new file mode 100644 index 0000000..c813a32 --- /dev/null +++ b/lib/docs_kit/shortcut.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module DocsKit + # A parsed keyboard shortcut for the docs-search palette — one entry of + # DocsKit.configuration.search_shortcuts. A site writes shortcut STRINGS + # ("mod+k", "/", "s", "ctrl+shift+f") and this turns each into a key + modifier + # set that three places share: the config surface, the server-rendered + # hint (#label), and the docs-nav matcher (#to_h, serialized to JSON). + # + # DocsKit::Shortcut.parse("mod+k").label # => "Ctrl K" (JS swaps to "⌘K" on mac) + # DocsKit::Shortcut.parse("mod+k").to_h # => { "key" => "k", "mod" => true, ... } + # + # "mod" is the PLATFORM modifier — ⌘ on mac, Ctrl elsewhere — left abstract here + # (the server can't know the OS) and resolved in the browser by docs-nav. Use + # "mod" for the "primary command" chord so one config works on every platform; + # use explicit "ctrl"/"meta" only when you truly mean that physical key. + # + # Modifier tokens (case-insensitive): mod, ctrl/control, shift, alt/option, + # cmd/command/meta. The final token is the key (single char or a named key like + # "escape"); a string with no key (e.g. "mod+") is unparseable and yields nil. + class Shortcut + # Canonical modifier token → the flag it sets. + MODIFIER_ALIASES = { + "mod" => :mod, + "ctrl" => :ctrl, "control" => :ctrl, + "shift" => :shift, + "alt" => :alt, "option" => :alt, + "cmd" => :meta, "command" => :meta, "meta" => :meta + }.freeze + + # The order modifiers appear in a #label (matches the common convention). + LABEL_ORDER = %i[mod ctrl meta alt shift].freeze + + # Human labels for the modifier flags in a #label. "mod" renders as the + # majority default "Ctrl"; docs-nav swaps it to "⌘" on mac. + MODIFIER_LABELS = { mod: "Ctrl", ctrl: "Ctrl", meta: "Meta", alt: "Alt", shift: "Shift" }.freeze + + # Parse one shortcut string → a Shortcut, or nil when there's no key to bind. + def self.parse(string) + tokens = string.to_s.downcase.split("+").map(&:strip).reject(&:empty?) + key = tokens.pop + return if key.nil? || MODIFIER_ALIASES.key?(key) + + mods = tokens.filter_map { |token| MODIFIER_ALIASES[token] }.to_set + new(key, mods) + end + + # Parse a list of shortcut strings, dropping any that don't parse. + def self.parse_list(strings) + Array(strings).filter_map { |string| parse(string) } + end + + attr_reader :key + + # key: the final key token (lowercased). mods: a Set of modifier flag symbols. + def initialize(key, mods) + @key = key + @mods = mods + freeze + end + + def mod? = @mods.include?(:mod) + def ctrl? = @mods.include?(:ctrl) + def shift? = @mods.include?(:shift) + def alt? = @mods.include?(:alt) + def meta? = @mods.include?(:meta) + + # The badge text: modifiers (in LABEL_ORDER) then the key. In a CHORD + # (with a modifier) a single-char key is uppercased for legibility ("mod+k" → + # "Ctrl K"); a BARE key is shown exactly as authored ("/", "s"). A named key + # (e.g. "escape") is left as-is either way. + def label + mods = LABEL_ORDER.select { |flag| @mods.include?(flag) }.map { |flag| MODIFIER_LABELS[flag] } + (mods << key_label(chord: !mods.empty?)).join(" ") + end + + # The shape docs-nav matches a keydown against (booleans always present so the + # JSON is uniform). String keys → clean JSON without symbol quoting. + def to_h + { + "key" => key, "mod" => mod?, "ctrl" => ctrl?, + "shift" => shift?, "alt" => alt?, "meta" => meta? + } + end + alias as_json to_h + + def ==(other) + other.is_a?(Shortcut) && to_h == other.to_h + end + + private + + # The key as it appears in the badge — uppercased only in a chord, and only for + # a single char; a named key (length > 1) is always left as authored. + def key_label(chord:) + chord && key.length == 1 ? key.upcase : key + end + end +end diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 22260d2..c88798c 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -71,6 +71,11 @@ def add_routes # returns the page's GFM. No `defaults: { format: "html" }` — that would # pin html and defeat the .md route. route %(get "docs/:doc(.:format)" => "docs#show", as: :doc) + # Docs search, served from the registry by the gem's DocsKit::SearchController + # (matches the default c.search_path). Thor's `route` PREPENDS, so this call + # — after the docs route above — lands ABOVE `docs/:doc` in the file, where + # it must be: otherwise `docs/:doc` would swallow /docs/search as :doc. + route %(get "/docs/search" => "docs_kit/search#index", as: :docs_search) route %(root "landings#show") # AI-readable docs (llmstxt.org), served from the registry by the gem's diff --git a/lib/generators/docs_kit/install/templates/application.tailwind.css.erb b/lib/generators/docs_kit/install/templates/application.tailwind.css.erb index 3cb0072..b2b4023 100644 --- a/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +++ b/lib/generators/docs_kit/install/templates/application.tailwind.css.erb @@ -19,6 +19,12 @@ so the shell isn't tree-shaken. */ @source inline("drawer drawer-content drawer-side drawer-toggle drawer-overlay {lg:}drawer-open drawer-end"); +/* Search palette classes the docs-nav controller applies at RUN TIME (JS only — + bin/build-css scans the gems' .rb, not their .js), so force them here. The + dropdown/menu/input structure is literal in DocsUI::SearchBox and scanned; only + these JS-toggled classes need forcing. */ +@source inline("menu-title menu-active"); + /* Responsive drawer-open — sidebar always visible on desktop. */ @media (width >= 1024px) { .lg\:drawer-open { display: grid !important; grid-auto-columns: max-content auto !important; } diff --git a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb index 1630b61..74021d1 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -39,6 +39,15 @@ Rails.application.config.to_prepare do # Set false to hide the action site-wide (the .md route still works): # c.page_markdown_action = false + # Search is on by default — the topbar grows a search box (a JS-off GET form + # that docs-nav enhances into a command palette), served by + # DocsKit::SearchController at the route the install generator drew. Hide it, + # point it at a moved route, or change the keyboard shortcuts: + # c.search = false # hide the topbar search box site-wide + # c.search_path = "/docs/search" # default; match your route if you move it + # c.search_shortcuts = ["/", "mod+k"] # default; "mod" = ⌘ on mac, Ctrl elsewhere. + # Each is a bare key ("/", "s") or a chord ("mod+k", "ctrl+shift+f"); [] binds none. + # The sidebar nav derives from the registry — one heading → one registry. # Each registry's authored pages become NavItems automatically (an unwritten # page is skipped, so no dead links). For bespoke nav (interleaved diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index cb37c41..c70a9f2 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -37,6 +37,84 @@ end end + describe "#search" do + it "defaults to true (the topbar search form renders)" do + expect(described_class.new.search).to be(true) + end + + it "is overridable so a site can hide search site-wide" do + DocsKit.configure { |c| c.search = false } + + expect(DocsKit.configuration.search).to be(false) + end + end + + describe "#search_path" do + it "defaults to \"/docs/search\" (the route the generator draws)" do + expect(described_class.new.search_path).to eq("/docs/search") + end + + it "is overridable so a site can mount search elsewhere" do + DocsKit.configure { |c| c.search_path = "/guides/search" } + + expect(DocsKit.configuration.search_path).to eq("/guides/search") + end + end + + describe "#search_shortcuts" do + it "defaults to \"/\" and the platform \"mod+k\" chord" do + shortcuts = described_class.new.search_shortcuts + + expect(shortcuts.map(&:key)).to eq(%w[/ k]) + # The chord is the platform modifier so one config works on every OS. + slash, modk = shortcuts + expect(slash.mod?).to be(false) + expect(modk.mod?).to be(true) + end + + it "returns parsed DocsKit::Shortcut objects, not raw strings" do + expect(described_class.new.search_shortcuts).to all(be_a(DocsKit::Shortcut)) + end + + it "accepts a site's own list of shortcut strings" do + DocsKit.configure { |c| c.search_shortcuts = ["mod+k", "s", "ctrl+shift+f"] } + + shortcuts = DocsKit.configuration.search_shortcuts + expect(shortcuts.map(&:key)).to eq(%w[k s f]) + expect(shortcuts.last.shift?).to be(true) + end + + it "drops entries that don't parse (a modifier-only string)" do + DocsKit.configure { |c| c.search_shortcuts = ["/", "mod+", ""] } + + expect(DocsKit.configuration.search_shortcuts.map(&:key)).to eq(%w[/]) + end + + it "is empty when a site clears the list" do + DocsKit.configure { |c| c.search_shortcuts = [] } + + expect(DocsKit.configuration.search_shortcuts).to eq([]) + end + end + + describe "#search_enabled?" do + it "is true by default (search on + a path set)" do + expect(described_class.new.search_enabled?).to be(true) + end + + it "is false when search is disabled" do + DocsKit.configure { |c| c.search = false } + + expect(DocsKit.configuration.search_enabled?).to be(false) + end + + it "is false when search_path is blanked (nothing to submit to)" do + DocsKit.configure { |c| c.search_path = "" } + + expect(DocsKit.configuration.search_enabled?).to be(false) + end + end + describe "#code_theme_dark" do it "defaults to nil (single-theme behavior, fully backwards compatible)" do expect(described_class.new.code_theme_dark).to be_nil diff --git a/spec/docs_kit/search_controller_spec.rb b/spec/docs_kit/search_controller_spec.rb new file mode 100644 index 0000000..a5c96fa --- /dev/null +++ b/spec/docs_kit/search_controller_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Like DocsKit::LlmsController, DocsKit::SearchController subclasses +# ActionController::Base, so it can't load in the standalone suite (no Rails +# request stack). Its index-building + scoring is covered by +# spec/docs_kit/search_index_spec.rb and its results markup by +# spec/docs_ui/search_results_spec.rb; here we prove the SHIPPED FILE is where +# Rails autoloads DocsKit::SearchController from, and that it wires the index + +# the html/json seams the way the JS-off form and the palette need. The +# end-to-end request behavior is dogfooded against the docs/ app (see the PR). +# rubocop:disable RSpec/DescribeClass -- the class is Rails-only, can't constantize here +RSpec.describe "DocsKit::SearchController (source wiring)" do + let(:path) do + File.expand_path("../../app/controllers/docs_kit/search_controller.rb", __dir__) + end + let(:source) { File.read(path) } + + it "ships at the path Rails autoloads DocsKit::SearchController from" do + expect(File.exist?(path)).to be(true) + end + + it "declares DocsKit::SearchController < ActionController::Base" do + expect(source).to include("module DocsKit") + expect(source).to include("class SearchController < ActionController::Base") + end + + it "exposes the #index action" do + expect(source).to match(/def index\b/) + end + + it "declares protect_from_forgery (the Shell's calls csrf_meta_tags)" do + # A bare ActionController::Base subclass doesn't inherit the host's forgery + # default, and #index renders the Shell whose emits csrf_meta_tags. + expect(source).to include("protect_from_forgery") + end + + it "does not shadow ActionController::Base#config (forgery delegates to it)" do + # Same guard as LlmsController: a `def config` breaks csrf_meta_tags when the + # Shell renders. The DocsKit config reader is #docs_config. + expect(source).not_to match(/^\s*def config\b/) + expect(source).to include("DocsKit.configuration") + end + + it "builds the index from DocsKit::SearchIndex" do + expect(source).to include("DocsKit::SearchIndex") + end + + it "renders each registry page's Markdown twin via DocsKit::MarkdownExport" do + # The index is built from the SAME twins llms-full.txt uses, so search never + # drifts from the pages. + expect(source).to include("DocsKit::MarkdownExport") + end + + it "responds to both html (the JS-off form) and json (the palette)" do + expect(source).to match(/respond_to\b/) + expect(source).to match(/\.html\b/) + expect(source).to match(/\.json\b/) + end + + it "renders the DocsUI::SearchResults component for the html path" do + expect(source).to include("DocsUI::SearchResults") + end + + it "wraps results in DocsUI::Shell (a full chrome page, layout: false)" do + expect(source).to include("DocsUI::Shell") + expect(source).to include("layout: false") + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/docs_kit/search_index_spec.rb b/spec/docs_kit/search_index_spec.rb new file mode 100644 index 0000000..d7c918f --- /dev/null +++ b/spec/docs_kit/search_index_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +# DocsKit::SearchIndex builds an in-memory search index from the docs pages' +# Markdown twins — one entry per page SECTION (split on `## ` headings), plus a +# page-level entry for the intro text before the first heading. It's a pure Ruby +# builder + scorer: given [title, href, markdown] triples it produces entries, +# and #search ranks them (title > heading > body) with a highlighted snippet. +# No Rails — the controller owns rendering the twins (DocsKit::MarkdownExport), +# exactly as DocsKit::LlmsText separates shaping from the controller's rendering. +# +# The anchor is the crux: the Markdown twin drops the section's slug id (it emits +# `## Add the gem` text only), so the index RECOMPUTES it as the heading text +# parameterized — the same rule DocsUI::Section#slugify uses to stamp the id. +RSpec.describe DocsKit::SearchIndex do + # A realistic two-page corpus. Each triple is [page_title, page_href, markdown] + # — the shape the controller produces from MarkdownExport#to_md. + subject(:index) { described_class.new(pages) } + + let(:install_md) do + <<~MD + Install docs-kit in a fresh Rails app. + + ## Add the gem + + Add `gem "docs-kit"` to your Gemfile, then run bundle install. + + ## Run the generator + + Run the install generator to wire the chrome and CSS build. + MD + end + + let(:config_md) do + <<~MD + Every docs site differs only in configuration. + + ## Themes + + List the daisyUI themes the switcher offers. + MD + end + + let(:pages) do + [ + ["Installation", "/docs/installation", install_md], + ["Configuration", "/docs/configuration", config_md] + ] + end + + describe ".new / #entries" do + it "builds one entry per section plus a page-intro entry" do + # Installation: intro + 2 sections; Configuration: intro + 1 section = 5. + expect(index.entries.size).to eq(5) + end + + it "carries the page title on every entry" do + titles = index.entries.map(&:page_title).uniq + expect(titles).to contain_exactly("Installation", "Configuration") + end + + it "labels a section entry with its heading text" do + section = index.entries.find { |e| e.section_title == "Add the gem" } + expect(section).not_to be_nil + expect(section.page_title).to eq("Installation") + end + + it "leaves the page-intro entry with no section title" do + intro = index.entries.find { |e| e.page_title == "Installation" && e.section_title.nil? } + expect(intro).not_to be_nil + expect(intro.body).to include("fresh Rails app") + end + + it "anchors a section href to the recomputed slug (twin drops the id)" do + section = index.entries.find { |e| e.section_title == "Run the generator" } + # heading text → parameterize → the same id DocsUI::Section stamps. + expect(section.href).to eq("/docs/installation#run-the-generator") + end + + it "anchors the page-intro entry to the bare page href (no fragment)" do + intro = index.entries.find { |e| e.page_title == "Configuration" && e.section_title.nil? } + expect(intro.href).to eq("/docs/configuration") + end + end + + describe "#search" do + it "returns [] for a blank query" do + expect(index.search("")).to eq([]) + expect(index.search(" ")).to eq([]) + expect(index.search(nil)).to eq([]) + end + + it "finds a body match and returns a SearchHit carrying the page + section" do + hits = index.search("bundle install") + hit = hits.first + + expect(hit.page_title).to eq("Installation") + expect(hit.section_title).to eq("Add the gem") + expect(hit.href).to eq("/docs/installation#add-the-gem") + end + + it "is case-insensitive" do + expect(index.search("THEMES")).not_to be_empty + expect(index.search("themes")).not_to be_empty + end + + it "ranks a heading (section-title) match above a body-only match" do + # "generator" appears in the 'Run the generator' HEADING and in the + # Installation intro BODY ('install generator'). The heading hit wins. + hits = index.search("generator") + + expect(hits.first.section_title).to eq("Run the generator") + end + + it "ranks a page-title match above a heading match above a body match" do + # Craft three entries that each match 'alpha' in a different field. + corpus = [ + ["Alpha", "/docs/alpha", "Nothing relevant here.\n\n## Intro\n\nPlain body."], + ["Beta", "/docs/beta", "Body only.\n\n## Alpha thing\n\nA heading match."], + ["Gamma", "/docs/gamma", "This mentions alpha in the body.\n\n## Intro\n\nMore."] + ] + hits = described_class.new(corpus).search("alpha") + + expect(hits.map(&:page_title).first(3)).to eq(%w[Alpha Beta Gamma]) + end + + it "requires ALL tokens to match (multi-token AND)" do + # 'themes' is in Configuration; 'gemfile' is in Installation. No single + # entry has both, so the AND query returns nothing. + expect(index.search("themes gemfile")).to be_empty + + # Both tokens live in the same 'Add the gem' section. + hits = index.search("gem gemfile") + expect(hits).not_to be_empty + expect(hits.first.section_title).to eq("Add the gem") + end + + it "includes a snippet containing the matched term" do + hit = index.search("daisyUI").first + + expect(hit.snippet).to match(/daisyui/i) + end + + it "marks the matched term in the snippet with and escapes the rest" do + hit = index.search("bundle").first + + expect(hit.snippet).to include("").and include("") + # The snippet is HTML-safe: the marked term is wrapped, surrounding text is + # escaped, so an angle-bracket in the source can't inject markup. + expect(hit.snippet).to be_a(String) + end + + it "escapes HTML in the body so a snippet can't inject markup" do + corpus = [["Danger", "/docs/danger", "Uses tags here."]] + hit = described_class.new(corpus).search("tags").first + + expect(hit.snippet).to include("<script>") + expect(hit.snippet).not_to include("", hits: []) + + expect(html).not_to include("") + expect(html).to include("<script>") + end +end diff --git a/spec/docs_ui/shell_spec.rb b/spec/docs_ui/shell_spec.rb index a668c3e..f7fb2a7 100644 --- a/spec/docs_ui/shell_spec.rb +++ b/spec/docs_ui/shell_spec.rb @@ -81,6 +81,48 @@ def view_template = topbar end end + # The topbar search form is the JS-off search entry point: a plain GET form to + # config.search_path with an input named "q". It renders only when search is + # enabled, so a site can opt out with c.search = false. + describe "the topbar search form" do + let(:topbar_only) do + Class.new(described_class) do + def view_template = topbar + end + end + + it "renders a GET form to config.search_path with a q input by default" do + html = topbar_only.new.call + + expect(html).to include('action="/docs/search"') + expect(html).to include('method="get"') + expect(html).to include('name="q"') + end + + it "points the form at config.search_path when a site overrides it" do + DocsKit.configure { |c| c.search_path = "/guides/search" } + html = topbar_only.new.call + + expect(html).to include('action="/guides/search"') + end + + it "omits the form when search is disabled (c.search = false)" do + DocsKit.configure { |c| c.search = false } + html = topbar_only.new.call + + expect(html).not_to include('name="q"') + end + + it "wires the input as a docs-nav target so the palette can enhance it" do + html = topbar_only.new.call + + # The one docs-nav controller enhances the form into a Cmd+K palette; the + # input is a target and typing triggers performSearch. + expect(html).to include("docs-nav-target") + expect(html).to include("docs-nav#") + end + end + # A focused proof of the primitive the whole fix relies on: Phlex omits an # attribute whose value is nil (it does NOT render nonce=""), so the # no-nonce path degrades cleanly to the pre-fix, un-nonced markup. diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 6f1611c..1905893 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -158,6 +158,20 @@ def silence_stream expect(routes).to include(%(get "/llms-full.txt" => "docs_kit/llms#full")) end + it "adds the docs-search route (matches the default c.search_path)" do + routes = read("config/routes.rb") + + expect(routes).to include(%(get "/docs/search" => "docs_kit/search#index")) + end + + it "draws /docs/search ABOVE docs/:doc so it isn't swallowed as :doc" do + routes = read("config/routes.rb") + + search_at = routes.index(%(get "/docs/search" => "docs_kit/search#index")) + doc_at = routes.index(%(get "docs/:doc(.:format)" => "docs#show")) + expect(search_at).to be < doc_at + end + it "does not duplicate routes on re-run (idempotent)" do run_generator # second invocation against the same destination @@ -165,6 +179,7 @@ def silence_stream expect(routes.scan(%(get "/llms.txt" => "docs_kit/llms#index")).size).to eq(1) expect(routes.scan(%(get "/llms-full.txt" => "docs_kit/llms#full")).size).to eq(1) expect(routes.scan(%(get "docs/:doc(.:format)" => "docs#show", as: :doc)).size).to eq(1) + expect(routes.scan(%(get "/docs/search" => "docs_kit/search#index")).size).to eq(1) end end