docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,619 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // docs-nav — client-only UX polish for the docs sidebar. No server round-trip:
4
+ // collapse state is per-browser UI state (localStorage), and scroll-spy is a
5
+ // pure viewport concern (IntersectionObserver). Attach to the sidebar root:
6
+ //
7
+ // <div data-controller="docs-nav"
8
+ // data-docs-nav-content-value="#doc-content"
9
+ // data-docs-nav-storage-key-value="phlex-reactive">
10
+ // ...nested <details><summary>...</summary></details>...
11
+ // </div>
12
+ //
13
+ // Two behaviors, each independent and each degrading to a harmless no-op:
14
+ //
15
+ // 1. Collapse persistence — remembers which <details> the reader opened/closed,
16
+ // keyed by the summary's text, so the sidebar stays how they left it across
17
+ // navigations. Server still renders every <details open> by default, so with
18
+ // JS off the sidebar is fully expanded (progressive enhancement).
19
+ //
20
+ // 2. Scroll-spy — as the reader scrolls the page, the heading nearest the top is
21
+ // "current"; the matching table-of-contents link (data-docs-nav-target=
22
+ // "tocLink" whose href fragment equals the heading id) gets .menu-active, and
23
+ // the heading itself gets [data-current]. No TOC on the page → no-op.
24
+ export default class extends Controller {
25
+ static values = {
26
+ // CSS selector for the page content whose headings drive scroll-spy.
27
+ content: { type: String, default: "main" },
28
+ // What to collect for the TOC. Defaults to docs-kit's anchored sections
29
+ // (Docs::Section renders <section id>) plus bare anchored headings, so it
30
+ // works whether the id sits on the section or directly on the heading.
31
+ headings: { type: String, default: "section[id], h2[id], h3[id]" },
32
+ // Namespaces the localStorage keys so multiple docs sites don't collide.
33
+ storageKey: { type: String, default: "docs" },
34
+ // Auto-TOC placement: "panel" | "toggle" | "sidebar" | "" (off). Panel and
35
+ // toggle fill a server-rendered [data-docs-nav-target=toc]; sidebar injects
36
+ // the list under the active left-nav link (no server slot).
37
+ onPage: { type: String, default: "" },
38
+ // Fewer than this many headings → hide the TOC entirely (short pages).
39
+ minHeadings: { type: Number, default: 2 },
40
+ // Debounce (ms) between a search keystroke and the fetch, so typing fast
41
+ // doesn't fire a request per character.
42
+ searchDebounce: { type: Number, default: 150 },
43
+ }
44
+
45
+ // tocLink: pre-rendered TOC links to spy on.
46
+ // toc: a server-rendered container the controller fills with heading links.
47
+ // tocRoot: the element hidden when the page has too few headings.
48
+ // tocPopover: the collapsible card revealed by the floating toggle button.
49
+ // codeGroup/codeTab/codePanel: a multi-language Docs::Example — the controller
50
+ // shows the panel for the globally-remembered language and hides the others.
51
+ // markdownLink: the "Markdown" masthead action; a plain link with JS off, the
52
+ // controller upgrades its click into copy-the-page's-markdown-to-clipboard.
53
+ // searchScope: the dropdown root (so a click outside closes the palette).
54
+ // searchInput: the topbar query field ("/" and Cmd/Ctrl+K focus it).
55
+ // searchResults: the empty <ul> the controller fills with fetched hits.
56
+ // shortcutHint: the <kbd> badge(s); the controller refines the modifier label
57
+ // to the platform (⌘K on mac). Server-rendered, so correct with JS off.
58
+ static targets = [
59
+ "tocLink", "toc", "tocRoot", "tocPopover",
60
+ "codeGroup", "codeTab", "codePanel",
61
+ "markdownLink",
62
+ "searchScope", "searchInput", "searchResults", "shortcutHint",
63
+ ]
64
+
65
+ connect() {
66
+ this.restoreCollapseState()
67
+ this.onToggle = this.persistToggle.bind(this)
68
+ // `toggle` doesn't bubble; capture it so one listener covers every <details>.
69
+ this.element.addEventListener("toggle", this.onToggle, true)
70
+ this.buildToc()
71
+ this.startScrollSpy()
72
+ this.applyLanguage(this.readLanguage())
73
+ this.applyTheme(this.readTheme())
74
+ this.connectSearch()
75
+ }
76
+
77
+ disconnect() {
78
+ this.element.removeEventListener("toggle", this.onToggle, true)
79
+ this.observer?.disconnect()
80
+ this.disconnectSearch()
81
+ }
82
+
83
+ // --- 1. Collapse persistence ------------------------------------------------
84
+
85
+ get storagePrefix() {
86
+ return `docs-kit:${this.storageKeyValue}:nav:`
87
+ }
88
+
89
+ // A stable key for a <details> from its summary text (position-independent, so
90
+ // reordering the nav doesn't lose state).
91
+ keyFor(details) {
92
+ const summary = details.querySelector(":scope > summary")
93
+ const label = (summary?.textContent || "").trim()
94
+ return label ? this.storagePrefix + label : null
95
+ }
96
+
97
+ restoreCollapseState() {
98
+ this.element.querySelectorAll("details").forEach((details) => {
99
+ const key = this.keyFor(details)
100
+ if (!key) return
101
+ const saved = this.read(key)
102
+ if (saved === "open") details.open = true
103
+ else if (saved === "closed") details.open = false
104
+ })
105
+ }
106
+
107
+ persistToggle(event) {
108
+ const details = event.target
109
+ if (details.tagName !== "DETAILS") return
110
+ const key = this.keyFor(details)
111
+ if (key) this.write(key, details.open ? "open" : "closed")
112
+ }
113
+
114
+ // --- 2. Scroll-spy ----------------------------------------------------------
115
+
116
+ // Build the "On this page" list from the page's headings — no server-side
117
+ // knowledge of them needed — and place it per the on_page mode. Too few
118
+ // headings hides the whole TOC (short pages show nothing).
119
+ buildToc() {
120
+ const content = document.querySelector(this.contentValue)
121
+ if (!content) return
122
+ const headings = Array.from(content.querySelectorAll(this.headingsValue))
123
+
124
+ if (headings.length < this.minHeadingsValue) {
125
+ this.hideToc()
126
+ return
127
+ }
128
+
129
+ const list = this.buildList(headings)
130
+ if (this.onPageValue === "sidebar") this.placeInSidebar(list)
131
+ else this.placeInSlot(list)
132
+ }
133
+
134
+ buildList(headings) {
135
+ const list = document.createElement("ul")
136
+ list.className = "menu menu-sm w-full"
137
+ list.setAttribute("data-docs-nav-generated", "")
138
+ headings.forEach((el) => {
139
+ // The anchor is the element's own id; the visible heading may be the
140
+ // element itself (h2/h3) or its inner heading (a Docs::Section wrapper).
141
+ const heading =
142
+ el.matches("h1,h2,h3,h4") ? el : el.querySelector("h1,h2,h3,h4")
143
+ const li = document.createElement("li")
144
+ if ((heading?.tagName || el.tagName) === "H3") li.className = "ml-3"
145
+ const a = document.createElement("a")
146
+ a.href = `#${el.id}`
147
+ const text = (heading?.textContent || el.textContent || "").replace(/#$/, "").trim()
148
+ a.textContent = text
149
+ a.title = text // full text on hover, since the label truncates
150
+ // Truncate long headings to one line so the card stays tidy.
151
+ a.className = "block truncate"
152
+ a.setAttribute("data-docs-nav-target", "tocLink")
153
+ li.appendChild(a)
154
+ list.appendChild(li)
155
+ })
156
+ return list
157
+ }
158
+
159
+ // panel / toggle: fill EVERY server-rendered [data-docs-nav-target=toc] (a
160
+ // :panel has two — the wide-screen card and the toggle popover — so clone the
161
+ // built list into each).
162
+ placeInSlot(list) {
163
+ if (!this.hasTocTarget) return
164
+ this.tocTargets.forEach((slot) => {
165
+ slot.querySelector("ul[data-docs-nav-generated]")?.remove()
166
+ slot.appendChild(list.cloneNode(true))
167
+ })
168
+ }
169
+
170
+ // sidebar: nest the list under the active left-nav link (.menu-active), so the
171
+ // current page's sub-headings appear right under it (GitBook style).
172
+ placeInSidebar(list) {
173
+ const active = this.element.querySelector("a.menu-active")
174
+ const host = active?.closest("li")
175
+ if (!host) return
176
+ host.querySelector("ul[data-docs-nav-generated]")?.remove()
177
+ host.appendChild(list)
178
+ }
179
+
180
+ hideToc() {
181
+ if (this.hasTocRootTarget) this.tocRootTargets.forEach((el) => (el.hidden = true))
182
+ }
183
+
184
+ startScrollSpy() {
185
+ const content = document.querySelector(this.contentValue)
186
+ if (!content) return
187
+ const headings = Array.from(content.querySelectorAll(this.headingsValue))
188
+ if (headings.length === 0) return
189
+
190
+ this.visible = new Set()
191
+ this.observer = new IntersectionObserver(
192
+ (entries) => this.onIntersect(entries, headings),
193
+ // A band near the top of the viewport: a heading is "current" once it
194
+ // crosses into the top ~15%, staying current until the next one does.
195
+ { rootMargin: "0px 0px -80% 0px", threshold: 0 },
196
+ )
197
+ headings.forEach((h) => this.observer.observe(h))
198
+
199
+ // Seed the highlight immediately (don't wait for the first scroll): the URL
200
+ // hash if present, else the first section. Makes deep-links land highlighted.
201
+ const hashId = decodeURIComponent((window.location.hash || "").slice(1))
202
+ const initial = headings.find((h) => h.id === hashId) || headings[0]
203
+ if (initial) this.highlight(initial.id)
204
+ }
205
+
206
+ onIntersect(entries, headings) {
207
+ entries.forEach((entry) => {
208
+ if (entry.isIntersecting) this.visible.add(entry.target)
209
+ else this.visible.delete(entry.target)
210
+ })
211
+ // The current heading is the topmost currently-visible one, or (when none is
212
+ // in the band, e.g. between two) the last heading scrolled past.
213
+ const current =
214
+ headings.find((h) => this.visible.has(h)) ||
215
+ headings.filter((h) => h.getBoundingClientRect().top < 0).at(-1)
216
+ if (current) this.highlight(current.id)
217
+ }
218
+
219
+ highlight(id) {
220
+ if (id === this.currentId) return
221
+ this.currentId = id
222
+
223
+ this.element
224
+ .querySelectorAll("[data-current]")
225
+ .forEach((el) => el.removeAttribute("data-current"))
226
+
227
+ this.tocLinkTargets.forEach((link) => {
228
+ const active = (link.getAttribute("href") || "").endsWith(`#${id}`)
229
+ link.classList.toggle("menu-active", active)
230
+ if (active) link.setAttribute("data-current", "")
231
+ })
232
+ }
233
+
234
+ // --- 3. Multi-language code groups (Docs::Example) --------------------------
235
+
236
+ get languageKey() {
237
+ return `docs-kit:${this.storageKeyValue}:code-lang`
238
+ }
239
+
240
+ readLanguage() {
241
+ return this.read(this.languageKey)
242
+ }
243
+
244
+ // Tab click: remember the language globally and re-apply to every code group.
245
+ selectLanguage(event) {
246
+ const lang = event.params.lang
247
+ if (!lang) return
248
+ this.write(this.languageKey, lang)
249
+ this.applyLanguage(lang)
250
+ }
251
+
252
+ // Show the chosen language in each code group. A group without that language
253
+ // falls back to its own first snippet, so switching never blanks a group.
254
+ applyLanguage(preferred) {
255
+ if (!this.hasCodeGroupTarget) return
256
+
257
+ this.codeGroupTargets.forEach((group) => {
258
+ const panels = this.groupPanels(group)
259
+ if (panels.length === 0) return
260
+
261
+ const chosen =
262
+ panels.find((p) => p.dataset.lang === preferred) || panels[0]
263
+
264
+ panels.forEach((p) => (p.hidden = p !== chosen))
265
+ this.groupTabs(group).forEach((tab) =>
266
+ tab.classList.toggle("tab-active", tab.dataset.docsNavLangParam === chosen.dataset.lang),
267
+ )
268
+ })
269
+ }
270
+
271
+ groupPanels(group) {
272
+ return this.codePanelTargets.filter((p) => group.contains(p))
273
+ }
274
+
275
+ groupTabs(group) {
276
+ return this.codeTabTargets.filter((t) => group.contains(t))
277
+ }
278
+
279
+ // --- 4. Theme (global sticky preference) -----------------------------------
280
+ //
281
+ // daisyUI swaps the theme visually via a CSS :has() selector with zero JS, but
282
+ // that state lives only in the DOM and is lost on navigation (the flash). We
283
+ // persist the chosen theme to localStorage here and re-apply it; the anti-flash
284
+ // <head> script (DocsUI::Shell) restores it BEFORE first paint so there's no
285
+ // flicker on load.
286
+
287
+ get themeKey() {
288
+ return `docs-kit:${this.storageKeyValue}:theme`
289
+ }
290
+
291
+ readTheme() {
292
+ return this.read(this.themeKey)
293
+ }
294
+
295
+ // A theme radio changed: persist + apply. Wired via change->docs-nav#selectTheme.
296
+ selectTheme(event) {
297
+ const theme = event.target?.value
298
+ if (!theme) return
299
+ this.write(this.themeKey, theme)
300
+ this.applyTheme(theme)
301
+ }
302
+
303
+ // Set data-theme on <html> and check the matching radio (so the switcher shows
304
+ // the current theme after a navigation). No saved theme → leave the server
305
+ // default in place.
306
+ applyTheme(theme) {
307
+ if (!theme) return
308
+ document.documentElement.setAttribute("data-theme", theme)
309
+ this.element
310
+ .querySelectorAll('input.theme-controller[type="radio"]')
311
+ .forEach((radio) => (radio.checked = radio.value === theme))
312
+ }
313
+
314
+ // --- 5. On-this-page toggle (mobile / :toggle mode popover) -----------------
315
+
316
+ toggleToc() {
317
+ this.tocPopoverTargets.forEach((el) => el.classList.toggle("hidden"))
318
+ }
319
+
320
+ // --- 6. Copy page as Markdown ----------------------------------------------
321
+ //
322
+ // The "Markdown" masthead action is an <a href="….md"> — with JS off it opens
323
+ // the raw Markdown twin (a working fallback). Here we intercept the click,
324
+ // fetch that same .md, and copy it to the clipboard so the reader can paste the
325
+ // page into an LLM. No server round-trip beyond fetching the page that already
326
+ // exists. Anything unavailable (no clipboard API, fetch fails) falls back to
327
+ // the link's default navigation, so the affordance is never a dead end.
328
+
329
+ async copyMarkdown(event) {
330
+ const link = event.currentTarget
331
+ const href = link.getAttribute("href")
332
+ if (!href || !navigator.clipboard) return // let the browser follow the link
333
+
334
+ event.preventDefault()
335
+ try {
336
+ const response = await fetch(href, { headers: { Accept: "text/markdown" } })
337
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
338
+ const markdown = await response.text()
339
+ await navigator.clipboard.writeText(markdown)
340
+ this.flashCopied(link)
341
+ } catch {
342
+ // Fetch/clipboard failed — navigate to the raw .md as the plain link would.
343
+ window.location.href = href
344
+ }
345
+ }
346
+
347
+ // Briefly swap the link's label to confirm the copy, then restore it. Uses the
348
+ // trailing text node so the leading icon (if any) is untouched.
349
+ flashCopied(link) {
350
+ const labelNode = Array.from(link.childNodes).reverse().find((n) => n.nodeType === 3)
351
+ if (!labelNode) return
352
+ const original = labelNode.textContent
353
+ labelNode.textContent = "Copied!"
354
+ setTimeout(() => (labelNode.textContent = original), 1500)
355
+ }
356
+
357
+ // --- 7. Search palette ------------------------------------------------------
358
+ //
359
+ // Progressive enhancement over the topbar search form (DocsUI::SearchBox). With
360
+ // JS off the form GETs config.search_path and the server renders a full results
361
+ // page. Here we upgrade it into a Cmd+K palette: "/" or Cmd/Ctrl+K focuses the
362
+ // input, keystrokes fetch `<search_path>.json?q=` (debounced) and fill the
363
+ // server-rendered dropdown, and arrow keys navigate. The native form submit is
364
+ // always the fallback — if a fetch fails, Enter still lands on the results page.
365
+
366
+ connectSearch() {
367
+ if (!this.hasSearchInputTarget) return
368
+ this.shortcuts = this.readShortcuts()
369
+ this.onSearchKeydown = this.handleSearchShortcut.bind(this)
370
+ this.onSearchClickOut = this.closeOnClickOutside.bind(this)
371
+ // Capture phase: run before any content script that might stopPropagation()
372
+ // the event, so the palette shortcut can't be swallowed on a page with
373
+ // third-party JS. (preventDefault below is what actually cancels the
374
+ // browser's native Cmd/Ctrl+K — capture just wins the race for the event.)
375
+ document.addEventListener("keydown", this.onSearchKeydown, true)
376
+ document.addEventListener("click", this.onSearchClickOut)
377
+ this.refreshShortcutHint()
378
+ }
379
+
380
+ disconnectSearch() {
381
+ if (this.onSearchKeydown) document.removeEventListener("keydown", this.onSearchKeydown, true)
382
+ if (this.onSearchClickOut) document.removeEventListener("click", this.onSearchClickOut)
383
+ clearTimeout(this.searchTimer)
384
+ }
385
+
386
+ // The configured shortcuts, parsed from data-docs-nav-shortcuts-value on the
387
+ // search scope (DocsUI::SearchBox emits it from config.search_shortcuts). Each
388
+ // is { key, mod, ctrl, shift, alt, meta }. A malformed value degrades to [] —
389
+ // "/" and Cmd+K just won't focus search, but the form still works.
390
+ readShortcuts() {
391
+ if (!this.hasSearchScopeTarget) return []
392
+ try {
393
+ const raw = this.searchScopeTarget.dataset.docsNavShortcutsValue
394
+ const list = JSON.parse(raw || "[]")
395
+ return Array.isArray(list) ? list : []
396
+ } catch {
397
+ return []
398
+ }
399
+ }
400
+
401
+ // Focus search when a keydown matches ANY configured shortcut; Escape blurs.
402
+ //
403
+ // preventDefault() on this keydown is what cancels the browser's native
404
+ // Cmd/Ctrl+K search bar — in EVERY current browser including Firefox (the combo
405
+ // is a cancellable accelerator, not a reserved shortcut), so there's no
406
+ // per-browser branch. `event.key` can be undefined (autofill / IME
407
+ // composition); calling .toLowerCase() on it would THROW and kill the handler
408
+ // before preventDefault() ran — which lets the browser's own Cmd+K fire. Guard
409
+ // it (this is the bug that made Cmd+K "do nothing" in Firefox).
410
+ handleSearchShortcut(event) {
411
+ const key = (event.key || "").toLowerCase()
412
+ const focused = document.activeElement === this.searchInputTarget
413
+
414
+ // Escape leaves the palette: close results, drop focus back to the page.
415
+ if (key === "escape" && (focused || this.resultsOpen)) {
416
+ this.closeResults()
417
+ if (focused) this.searchInputTarget.blur()
418
+ return
419
+ }
420
+
421
+ if (!this.matchesShortcut(event, key)) return
422
+
423
+ event.preventDefault() // cancels the browser's native Cmd/Ctrl+K search bar
424
+ event.stopPropagation() // hide from other content-level keydown handlers
425
+ this.searchInputTarget.focus()
426
+ this.searchInputTarget.select()
427
+ }
428
+
429
+ // Does this keydown match one of the configured shortcuts?
430
+ // - key must equal the shortcut's key
431
+ // - "mod" maps to ⌘ on mac / Ctrl elsewhere; explicit ctrl/meta/shift/alt
432
+ // must match exactly, so Cmd+Shift+K doesn't fire a plain "mod+k"
433
+ // - a shortcut with NO modifier (e.g. "/") must not fire while the reader is
434
+ // typing in a field, and must not fire if any modifier is held
435
+ matchesShortcut(event, key) {
436
+ return this.shortcuts.some((s) => {
437
+ if (key !== (s.key || "").toLowerCase()) return false
438
+ const wantCtrl = !!s.ctrl || (!!s.mod && !this.isMac)
439
+ const wantMeta = !!s.meta || (!!s.mod && this.isMac)
440
+ if (event.ctrlKey !== wantCtrl) return false
441
+ if (event.metaKey !== wantMeta) return false
442
+ if (event.shiftKey !== !!s.shift) return false
443
+ if (event.altKey !== !!s.alt) return false
444
+ // A bare (no-modifier) shortcut mustn't hijack typing.
445
+ const bare = !wantCtrl && !wantMeta && !s.shift && !s.alt
446
+ if (bare && this.isTypingField(event.target)) return false
447
+ return true
448
+ })
449
+ }
450
+
451
+ isTypingField(el) {
452
+ const tag = (el?.tagName || "").toLowerCase()
453
+ return tag === "input" || tag === "textarea" || el?.isContentEditable
454
+ }
455
+
456
+ get resultsOpen() {
457
+ return this.hasSearchResultsTarget && !this.searchResultsTarget.classList.contains("hidden")
458
+ }
459
+
460
+ // The <kbd> hint badges are server-rendered with the majority default ("Ctrl")
461
+ // so they're correct with JS off. Here we only refine a MODIFIER-tagged badge's
462
+ // label to the actual platform — swap a leading "Ctrl" for "⌘" on mac. This
463
+ // adjusts the LABEL only, never the key binding, and works for any key
464
+ // ("Ctrl K" → "⌘K", "Ctrl F" → "⌘F").
465
+ get isMac() {
466
+ return /\b(Mac|iPhone|iPad|iPod)\b/i.test(navigator.platform || navigator.userAgent || "")
467
+ }
468
+
469
+ refreshShortcutHint() {
470
+ if (!this.hasShortcutHintTarget || !this.isMac) return
471
+ this.shortcutHintTargets.forEach((el) => {
472
+ if (el.dataset.hint === "modifier") {
473
+ el.textContent = el.textContent.replace(/\bCtrl\b/, "⌘").replace(/⌘\s+/, "⌘")
474
+ }
475
+ })
476
+ }
477
+
478
+ // Debounced query → fetch JSON → render. An empty query just closes the palette.
479
+ performSearch() {
480
+ clearTimeout(this.searchTimer)
481
+ const query = this.searchInputTarget.value.trim()
482
+ if (!query) {
483
+ this.closeResults()
484
+ return
485
+ }
486
+ this.searchTimer = setTimeout(() => this.runSearch(query), this.searchDebounceValue)
487
+ }
488
+
489
+ async runSearch(query) {
490
+ const url = `${this.searchEndpoint}?q=${encodeURIComponent(query)}`
491
+ try {
492
+ const response = await fetch(url, { headers: { Accept: "application/json" } })
493
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
494
+ const data = await response.json()
495
+ this.renderResults(data.results || [])
496
+ } catch {
497
+ // Fetch failed — leave the palette closed; the form still submits on Enter.
498
+ this.closeResults()
499
+ }
500
+ }
501
+
502
+ // The JSON endpoint is the form's action with a `.json` extension (same route,
503
+ // json format), so a site that moved search_path is followed automatically.
504
+ get searchEndpoint() {
505
+ const action = this.searchInputTarget.form?.getAttribute("action") || "/docs/search"
506
+ return `${action}.json`
507
+ }
508
+
509
+ renderResults(results) {
510
+ const list = this.searchResultsTarget
511
+ list.replaceChildren()
512
+ if (results.length === 0) {
513
+ list.appendChild(this.emptyRow())
514
+ } else {
515
+ results.forEach((hit) => list.appendChild(this.resultRow(hit)))
516
+ }
517
+ this.openResults()
518
+ this.activeIndex = -1
519
+ }
520
+
521
+ emptyRow() {
522
+ const li = document.createElement("li")
523
+ li.className = "menu-title"
524
+ li.textContent = "No results"
525
+ return li
526
+ }
527
+
528
+ resultRow(hit) {
529
+ const li = document.createElement("li")
530
+ const a = document.createElement("a")
531
+ a.href = hit.href
532
+ const label = document.createElement("span")
533
+ label.className = "font-medium"
534
+ label.textContent = hit.label
535
+ a.appendChild(label)
536
+ // The snippet is server-produced, pre-escaped HTML (the match in <mark>); it's
537
+ // the same trusted string the SearchResults page renders.
538
+ if (hit.snippet) {
539
+ const snip = document.createElement("span")
540
+ snip.className = "block text-xs opacity-60"
541
+ snip.innerHTML = hit.snippet
542
+ a.appendChild(snip)
543
+ }
544
+ li.appendChild(a)
545
+ return li
546
+ }
547
+
548
+ // Arrow/Enter/Escape navigation over the rendered result links.
549
+ navigateResults(event) {
550
+ const links = this.resultLinks
551
+ if (event.key === "Escape") {
552
+ this.closeResults()
553
+ return
554
+ }
555
+ if (links.length === 0) return
556
+
557
+ if (event.key === "ArrowDown") {
558
+ event.preventDefault()
559
+ this.moveActive(1, links)
560
+ } else if (event.key === "ArrowUp") {
561
+ event.preventDefault()
562
+ this.moveActive(-1, links)
563
+ } else if (event.key === "Enter" && this.activeIndex >= 0) {
564
+ event.preventDefault()
565
+ links[this.activeIndex].click()
566
+ }
567
+ }
568
+
569
+ moveActive(delta, links) {
570
+ this.activeIndex = (this.activeIndex + delta + links.length) % links.length
571
+ links.forEach((link, i) => {
572
+ const on = i === this.activeIndex
573
+ link.classList.toggle("menu-active", on)
574
+ if (on) link.scrollIntoView({ block: "nearest" })
575
+ })
576
+ }
577
+
578
+ get resultLinks() {
579
+ return Array.from(this.searchResultsTarget.querySelectorAll("a"))
580
+ }
581
+
582
+ // Let the native form submit proceed (goes to the full results page); just
583
+ // close the palette so it doesn't linger over the new page.
584
+ submitSearch() {
585
+ this.closeResults()
586
+ }
587
+
588
+ openResults() {
589
+ if (this.hasSearchResultsTarget) this.searchResultsTarget.classList.remove("hidden")
590
+ }
591
+
592
+ closeResults() {
593
+ if (this.hasSearchResultsTarget) this.searchResultsTarget.classList.add("hidden")
594
+ this.activeIndex = -1
595
+ }
596
+
597
+ closeOnClickOutside(event) {
598
+ if (!this.hasSearchScopeTarget) return
599
+ if (!this.searchScopeTarget.contains(event.target)) this.closeResults()
600
+ }
601
+
602
+ // --- storage (private, fails safe if localStorage is unavailable) -----------
603
+
604
+ read(key) {
605
+ try {
606
+ return window.localStorage.getItem(key)
607
+ } catch {
608
+ return null
609
+ }
610
+ }
611
+
612
+ write(key, value) {
613
+ try {
614
+ window.localStorage.setItem(key, value)
615
+ } catch {
616
+ // Private mode / quota — collapse just won't persist. Not fatal.
617
+ }
618
+ }
619
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-pins the gem's bundled Stimulus controllers for importmap-rails consumers.
4
+ # Exposes `docs_kit/controllers/docs_nav_controller`. Register it in the host app:
5
+ #
6
+ # // app/javascript/controllers/index.js
7
+ # import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
8
+ # eagerLoadControllersFrom("docs_kit/controllers", application)
9
+ #
10
+ # Use eagerLoadControllersFrom (already imported in the default index.js), NOT
11
+ # lazyLoadControllersFrom — the latter isn't imported there, so calling it throws
12
+ # a ReferenceError that aborts the module and no controllers register at all.
13
+ pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
14
+ under: "docs_kit/controllers",
15
+ to: "docs_kit/controllers"
@@ -0,0 +1,24 @@
1
+ # docs-kit's custom RuboCop cops, shipped from the gem so consuming sites stop
2
+ # hand-copying them. Wire this into a site's `.rubocop.yml` with two lines
3
+ # (the install generator does it automatically):
4
+ #
5
+ # require:
6
+ # - docs_kit/rubocop
7
+ # inherit_gem:
8
+ # docs-kit: config/rubocop/docs_kit.yml
9
+ #
10
+ # Both cops are scoped to the docs page tree (app/views/docs/**/*) by default —
11
+ # that is where the kit-helper form and heredoc examples live. A site can widen
12
+ # or narrow the `Include` in its own `.rubocop.yml`.
13
+
14
+ DocsKit/RenderComponentPreferred:
15
+ Description: "Prefer the Phlex-kit helper form (DocsUI::Code(...)) over `render DocsUI::Code.new(...)`."
16
+ Enabled: true
17
+ Include:
18
+ - "app/views/docs/**/*"
19
+
20
+ DocsKit/EscapedInterpolationInHeredoc:
21
+ Description: "Use a single-quoted heredoc delimiter instead of escaping `\\#{...}` in a double-quoted one."
22
+ Enabled: true
23
+ Include:
24
+ - "app/views/docs/**/*"