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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +939 -0
- data/app/components/docs_ui/brand_mark.rb +88 -0
- data/app/components/docs_ui/callout.rb +37 -0
- data/app/components/docs_ui/code.rb +123 -0
- data/app/components/docs_ui/endpoint.rb +44 -0
- data/app/components/docs_ui/error_table.rb +72 -0
- data/app/components/docs_ui/example.rb +102 -0
- data/app/components/docs_ui/field_table.rb +46 -0
- data/app/components/docs_ui/header.rb +30 -0
- data/app/components/docs_ui/icon.rb +65 -0
- data/app/components/docs_ui/json_response.rb +46 -0
- data/app/components/docs_ui/markdown.rb +187 -0
- data/app/components/docs_ui/markdown_action.rb +45 -0
- data/app/components/docs_ui/on_this_page.rb +104 -0
- data/app/components/docs_ui/open_api_operation.rb +126 -0
- data/app/components/docs_ui/page.rb +83 -0
- data/app/components/docs_ui/page_helpers.rb +52 -0
- data/app/components/docs_ui/prop_table.rb +43 -0
- data/app/components/docs_ui/prose.rb +30 -0
- data/app/components/docs_ui/request_example.rb +85 -0
- data/app/components/docs_ui/search_box.rb +106 -0
- data/app/components/docs_ui/search_results.rb +95 -0
- data/app/components/docs_ui/section.rb +94 -0
- data/app/components/docs_ui/shell.rb +161 -0
- data/app/components/docs_ui/sidebar.rb +106 -0
- data/app/components/docs_ui/table.rb +64 -0
- data/app/components/docs_ui/theme_switcher.rb +46 -0
- data/app/components/docs_ui/topbar_links.rb +42 -0
- data/app/controllers/docs_kit/llms_controller.rb +76 -0
- data/app/controllers/docs_kit/mcp_controller.rb +60 -0
- data/app/controllers/docs_kit/search_controller.rb +72 -0
- data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
- data/config/importmap.rb +15 -0
- data/config/rubocop/docs_kit.yml +24 -0
- data/exe/docs-kit +80 -0
- data/lib/docs-kit.rb +5 -0
- data/lib/docs_kit/api_client.rb +52 -0
- data/lib/docs_kit/api_request.rb +66 -0
- data/lib/docs_kit/api_templates.rb +92 -0
- data/lib/docs_kit/configuration.rb +485 -0
- data/lib/docs_kit/controller.rb +47 -0
- data/lib/docs_kit/engine.rb +49 -0
- data/lib/docs_kit/llms_text.rb +105 -0
- data/lib/docs_kit/markdown_export/blocks.rb +160 -0
- data/lib/docs_kit/markdown_export/inline.rb +95 -0
- data/lib/docs_kit/markdown_export/table.rb +53 -0
- data/lib/docs_kit/markdown_export.rb +92 -0
- data/lib/docs_kit/mcp_server.rb +128 -0
- data/lib/docs_kit/mcp_tools.rb +118 -0
- data/lib/docs_kit/nav_item.rb +22 -0
- data/lib/docs_kit/open_api/document.rb +91 -0
- data/lib/docs_kit/open_api/operation.rb +213 -0
- data/lib/docs_kit/open_api/schema.rb +178 -0
- data/lib/docs_kit/open_api.rb +55 -0
- data/lib/docs_kit/registry.rb +152 -0
- data/lib/docs_kit/rubocop.rb +19 -0
- data/lib/docs_kit/search_hit.rb +28 -0
- data/lib/docs_kit/search_index/snippet.rb +65 -0
- data/lib/docs_kit/search_index.rb +169 -0
- data/lib/docs_kit/shortcut.rb +99 -0
- data/lib/docs_kit/templates/new_site.rb +175 -0
- data/lib/docs_kit/topbar_link.rb +39 -0
- data/lib/docs_kit/version.rb +5 -0
- data/lib/docs_kit.rb +72 -0
- data/lib/generators/docs_kit/install/USAGE +15 -0
- data/lib/generators/docs_kit/install/install_generator.rb +447 -0
- data/lib/generators/docs_kit/install/sync_report.rb +64 -0
- data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
- data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
- data/lib/generators/docs_kit/install/templates/build-css +34 -0
- data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
- data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
- data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
- data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
- data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
- data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
- data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
- data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
- data/lib/generators/docs_kit/page/USAGE +26 -0
- data/lib/generators/docs_kit/page/page_generator.rb +127 -0
- data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
- data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
- data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
- 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
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -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/**/*"
|