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
data/README.md ADDED
@@ -0,0 +1,939 @@
1
+ # docs-kit
2
+
3
+ [![CI](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml)
4
+
5
+ Shared [Phlex](https://www.phlex.fun) chrome for documentation sites built on
6
+ [daisyUI](https://daisyui.com). Extract the shell, sidebar, code blocks, theme
7
+ switcher, and page kit into one gem so multiple docs sites look identical and are
8
+ maintained in one place.
9
+
10
+ Reactive demos ([phlex-reactive](https://github.com/mhenrixon/phlex-reactive))
11
+ and Postgres-SSE transport ([pgbus](https://github.com/mhenrixon/pgbus)) are
12
+ **optional, runtime-detected** add-ons — docs-kit does not depend on them.
13
+
14
+ ## What you get
15
+
16
+ A `DocsUI::` Phlex kit, configured once per site:
17
+
18
+ | Component | Role |
19
+ |-----------|------|
20
+ | `DocsUI::Shell` | The full HTML document: daisyUI Drawer shell, sticky topbar, sidebar, scrollable main. |
21
+ | `DocsUI::Sidebar` | Config-driven grouped nav with active-link highlighting + an optional version badge. |
22
+ | `DocsUI::ThemeSwitcher` | Zero-JS daisyUI theme dropdown (themes come from config). |
23
+ | `DocsUI::Icon` | Inline lucide SVG via `rails_icons`. |
24
+ | `DocsUI::BrandMark` | Inline developer/social brand logo (GitHub, Discord, …) for [topbar links](#topbar-links-repo--social); falls through to a lucide `Icon` for non-brand tokens. |
25
+ | `DocsUI::Code` | Rouge-highlighted code block (any of Rouge's ~200 languages) with an inline theme. |
26
+ | `DocsUI::Page` | Base class for a hand-authored doc page; renders inside `DocsUI::Shell`. |
27
+ | `DocsUI::Header` / `Section` / `Prose` / `Callout` | The page-authoring kit. |
28
+ | `DocsUI::Markdown` | GFM Markdown island — prose as Markdown, styled like `Prose`, fenced code through Rouge. |
29
+ | `DocsUI::Table` / `PropTable` | Reference tables — generic headers+rows, and a name/type/default/description preset. |
30
+ | `DocsUI::Endpoint` | HTTP method badge (coloured per verb) + monospace path; renders inline (drops into a `Section` description). |
31
+ | `DocsUI::FieldTable` / `ErrorTable` | API-reference presets over `Table` — an object's fields, and an endpoint's errors (Param column auto-hidden when unused). |
32
+ | `DocsUI::RequestExample` | One request declaration → one code tab per configured client (curl / JS / Ruby / Python by default). |
33
+ | `DocsUI::JsonResponse` | A Ruby Hash (or String) rendered as a pretty-printed JSON response block. |
34
+ | `DocsUI::OpenApiOperation` | One OpenAPI 3.x operation → a full endpoint reference (badge + tables + request tabs + response), composed from the kit. The `operation "id"` page helper is the front door. See [OpenAPI bridge](#openapi-bridge--an-endpoint-from-your-spec-no-hand-restatement). |
35
+ | `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
36
+ | `DocsUI::MarkdownAction` | The "Markdown" masthead action → the page's `.md` twin; `docs-nav` enhances it into copy-to-clipboard. |
37
+ | `DocsUI::SearchBox` / `SearchResults` | Topbar [search](#search) — a JS-off `GET` form + server-rendered results, enhanced into a `⌘K` palette by `docs-nav`. |
38
+
39
+ Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
40
+ (sidebar link value object), `DocsKit::TopbarLink` ([topbar link](#topbar-links-repo--social)
41
+ value object), `DocsKit::MarkdownExport` ([every page as
42
+ Markdown](#every-page-is-also-markdown)), `DocsKit::SearchIndex` (the
43
+ [search](#search) index, built from the Markdown twins), and
44
+ `DocsKit::Controller#render_page`.
45
+
46
+ ## Install
47
+
48
+ ```ruby
49
+ # Gemfile
50
+ gem "docs-kit"
51
+ gem "daisyui", require: "daisy_ui" # the daisyUI Phlex components
52
+ gem "phlex-rails"
53
+ gem "rails_icons", "~> 1.1"
54
+ gem "rouge"
55
+ # Optional, for reactive demos:
56
+ # gem "phlex-reactive"
57
+ ```
58
+
59
+ ## Keeping a site in sync
60
+
61
+ The install generator is the **upgrade path**, not a one-shot. Every step is
62
+ idempotent — safe to re-run on a years-old site — so bumping the gem and
63
+ re-running it pulls in whatever wiring newer docs-kit versions added (routes,
64
+ initializer hints, the AGENTS.md authoring block, the RuboCop cops) without
65
+ touching a byte you've edited. Your config initializer is skipped (never
66
+ clobbered); routes you already drew are skipped even if you wrote them in your
67
+ own style (single quotes, `to:` vs `=>`).
68
+
69
+ To upgrade an existing site:
70
+
71
+ ```bash
72
+ bundle update docs-kit
73
+ bin/rails g docs_kit:install --sync # wiring only — scaffolds no site content
74
+ # → act on any "manual cleanup needed" warnings it prints (see below)
75
+ bun run build:css # pick up any newly emitted classes
76
+ bundle exec rspec # confirm the site still boots + renders
77
+ ```
78
+
79
+ `--sync` runs only the additive/wiring steps and **never** re-scaffolds
80
+ site-owned content (your `Doc` registry, your pages, your themed
81
+ `application.tailwind.css`). Drop `--sync` to also (re)scaffold missing content
82
+ files — Thor prompts before overwriting anything that exists.
83
+
84
+ ### One-time cleanup for sites created before these landed
85
+
86
+ `--sync` detects drift it can't safely automate and prints a checklist — it
87
+ **warns, never deletes**. The common items on sites scaffolded by older
88
+ generators:
89
+
90
+ | Drift | Why it's dead | Fix |
91
+ |-------|---------------|-----|
92
+ | `ApplicationController#render_page` defined by hand | `DocsKit::Controller#render_page` is included (the generator injects `include DocsKit::Controller`) | Delete the method — keep the `include`. |
93
+ | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. |
94
+ | Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. |
95
+
96
+ ## Configure (per site)
97
+
98
+ ```ruby
99
+ # config/initializers/docs_kit.rb
100
+ DocsKit.configure do |c|
101
+ c.brand = "phlex-reactive"
102
+ c.brand_href = "/docs" # brand link target (default "/")
103
+ c.title_suffix = "phlex-reactive"
104
+ c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
105
+ c.version_badge = -> { "v#{Phlex::Reactive::VERSION}" } # optional
106
+
107
+ # Repo/social links in the topbar (next to the theme switcher).
108
+ c.topbar_links = [
109
+ { href: "https://github.com/you/phlex-reactive", label: "GitHub", icon: :github },
110
+ ]
111
+
112
+ # Code blocks: a light theme by default, a dark theme on dark daisyUI themes.
113
+ c.code_theme = "Rouge::Themes::Github" # base (light) theme
114
+ c.code_theme_dark = "Rouge::Themes::Monokai" # optional dark override
115
+
116
+ # The sidebar derives from your registries — one heading → one registry.
117
+ c.nav_registries = { "Docs" => Doc }
118
+ end
119
+ ```
120
+
121
+ The nav is **derived from the registry**, so you never hand-write it. Each
122
+ registry maps a heading to its authored pages (`Doc.nav_items`); a page that
123
+ isn't written yet is skipped, so there are no dead links. Register a page with
124
+ one line (see [Add a page](#add-a-page)) and it appears in the sidebar.
125
+
126
+ ### Brand link and dark code themes
127
+
128
+ Three knobs cover what sites used to shim by subclassing `DocsUI::Shell`:
129
+
130
+ | Knob | Default | What it does |
131
+ |------|---------|--------------|
132
+ | `c.brand_href` | `"/"` | The href of the topbar brand link. Set it (e.g. `"/docs"`) instead of subclassing `Shell` to copy-paste `#topbar`. |
133
+ | `c.code_theme_dark` | `nil` | A second Rouge theme for **dark** daisyUI themes. `nil` keeps the single-theme behavior (fully backwards compatible). When set, `DocsUI::Code` also emits this theme's CSS scoped under `[data-theme=X] .code-highlight` for each shipped dark theme, so code blocks stay readable when the switcher flips to a dark theme. |
134
+ | `c.dark_themes` | daisyUI's built-in dark theme names | Which theme names count as dark for `code_theme_dark`. Intersected with `c.themes` at render time, so only shipped themes emit CSS. Override to name custom dark themes (e.g. `%w[zazu-dark]`). |
135
+
136
+ The dark restyle is **CSS-only** — daisyUI's `[data-theme]` selector is more
137
+ specific than the un-scoped base rule, so the theme switcher restyles code
138
+ blocks with no JavaScript and no flash. The Rouge CSS is inlined per block
139
+ (not part of the Tailwind build), so the [theme-sync invariant](#css--the-canonical-build)
140
+ is unaffected — a `code_theme_dark` doesn't need a CSS rebuild.
141
+
142
+ ### Topbar links (repo & social)
143
+
144
+ Point readers at your source repo, chat, or socials from the topbar (next to the
145
+ theme switcher) with `c.topbar_links` — a list of `{ href:, label:, icon: }`.
146
+ Each renders as an **icon-only ghost button**; the `label` is its accessible name
147
+ (`aria-label` + tooltip). External links open in a new tab with `rel="noopener"`;
148
+ a site-relative `href` (e.g. `"/changelog"`) opens in place.
149
+
150
+ ```ruby
151
+ c.topbar_links = [
152
+ { href: "https://github.com/you/repo", label: "GitHub", icon: :github },
153
+ { href: "https://discord.gg/invite", label: "Discord", icon: :discord },
154
+ { href: "/changelog", label: "Changelog", icon: "history" }, # a lucide icon
155
+ ]
156
+ ```
157
+
158
+ `icon:` is either a **shipped brand mark** or **any lucide icon name**. lucide
159
+ dropped its brand logos, so the kit ships its own curated set of developer/social
160
+ marks as inline SVG (`DocsUI::BrandMark`) — no icon sync needed for these:
161
+
162
+ > `:github` · `:gitlab` · `:discord` · `:x` · `:rubygems` · `:bluesky` ·
163
+ > `:mastodon` · `:slack` · `:whatsapp` · `:telegram` · `:linkedin` · `:youtube` ·
164
+ > `:reddit` · `:stackoverflow`
165
+
166
+ Any other `icon:` value is treated as a **lucide** name and rendered through
167
+ `DocsUI::Icon` (so it must be in your synced set). Omit `icon:` to render the
168
+ `label` as a text button instead. `c.topbar_links` defaults to `[]` — a site that
169
+ sets nothing has an unchanged topbar. The marks use `fill: currentColor`, so they
170
+ recolor with the active daisyUI theme like the rest of the chrome.
171
+
172
+ ### Custom nav (advanced)
173
+
174
+ Sites that interleave several registries under a heading, or need custom
175
+ subgroups, set an explicit `c.nav` lambda instead — it wins over
176
+ `nav_registries`:
177
+
178
+ ```ruby
179
+ c.nav = lambda do
180
+ {
181
+ "Demos" => Demo.grouped.transform_values { |demos|
182
+ demos.map { |d| DocsKit::NavItem.new(href: "/demos/#{d.slug}", label: d.title, icon: d.icon) }
183
+ },
184
+ "Docs" => Doc.nav_items
185
+ }
186
+ end
187
+ ```
188
+
189
+ ## Render
190
+
191
+ ```ruby
192
+ # app/controllers/application_controller.rb
193
+ class ApplicationController < ActionController::Base
194
+ include DocsKit::Controller # adds #render_page
195
+ end
196
+
197
+ # any page controller
198
+ def show = render_page(Views::Docs::Pages::Installation.new)
199
+ ```
200
+
201
+ `render_page(view)` renders the Phlex page with `layout: false`, because
202
+ `DocsUI::Shell` IS the full HTML document. phlex-rails still renders through a real
203
+ view context, so CSRF, `dom_id`, url helpers, and the reactive token signer all
204
+ work inside components.
205
+
206
+ ### Add a page
207
+
208
+ One command scaffolds the page class **and** its registry line, both derived
209
+ from the title:
210
+
211
+ ```bash
212
+ rails g docs_kit:page "Getting Started" --group=Guide
213
+ ```
214
+
215
+ That writes `app/views/docs/pages/getting_started.rb` (a `DocsUI::Page` subclass
216
+ with a starter Markdown section) and injects `page "Getting Started", group:
217
+ "Guide"` into your `Doc` registry — so the page is routed and in the sidebar the
218
+ moment you write its content. Every derivation is overridable:
219
+
220
+ ```bash
221
+ rails g docs_kit:page "OAuth" --group=Guide --slug=auth --view=OauthGuide
222
+ rails g docs_kit:page "Metrics" --group=Reference --eyebrow="Advanced"
223
+ rails g docs_kit:page "Guides Intro" --group=Guide --registry=Guide # a differently-named registry
224
+ ```
225
+
226
+ Re-running is idempotent (no duplicate registry line, no clobbered file). If your
227
+ registry still uses the legacy hash `entries [...]` form, the generator writes
228
+ the page but prints the entry for you to add by hand instead of corrupting it.
229
+
230
+ #### Under the hood
231
+
232
+ A page is a `DocsUI::Page` subclass — the generator just writes this for you:
233
+
234
+ ```ruby
235
+ # app/views/docs/pages/getting_started.rb — Zeitwerk resolves the compact
236
+ # reference through the directory-implied namespaces (no nested modules).
237
+ class Views::Docs::Pages::GettingStarted < DocsUI::Page
238
+ title "Getting Started"
239
+ eyebrow "Guide"
240
+ def lead = "Add the gem and render your first component."
241
+
242
+ def content
243
+ DocsUI::Section("Add the gem") do
244
+ md <<~'MD'
245
+ Components are plain Ruby classes.
246
+ MD
247
+ DocsUI::Code(<<~RUBY, filename: "Gemfile")
248
+ gem "docs-kit"
249
+ RUBY
250
+ end
251
+ end
252
+ end
253
+ ```
254
+
255
+ …plus one line in the registry (`view_namespace` lets it derive the class):
256
+
257
+ ```ruby
258
+ # app/models/doc.rb
259
+ class Doc
260
+ extend DocsKit::Registry
261
+ path_prefix "/docs"
262
+ view_namespace "Views::Docs::Pages"
263
+
264
+ page "Getting Started", group: "Guide" # slug "getting-started", view "GettingStarted"
265
+ end
266
+ ```
267
+
268
+ `DocsUI::Page` includes the kit, so inside `#content` you call the components
269
+ directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`.
270
+
271
+ ### The authoring convention
272
+
273
+ One rule covers the whole kit: **the primary argument is positional; modifiers
274
+ are keyword arguments.**
275
+
276
+ ```ruby
277
+ DocsUI::Header("Installation", eyebrow: "Guide") # title positional
278
+ DocsUI::Section("Add the gem", id: "add", description: …) # title positional
279
+ DocsUI::Code(source, lexer: :ruby, filename: "Gemfile") # source positional
280
+ ```
281
+
282
+ For the two wrappers that take **no** positional argument — prose and a
283
+ multi-language example — `DocsUI::Page` gives you lowercase helpers so a block
284
+ needs no parens:
285
+
286
+ ```ruby
287
+ prose { p { "Hand-authored prose." } } # → DocsUI::Prose
288
+ example { |ex| ex.code(:ruby) { source } } # → DocsUI::Example
289
+ md(<<~'MD') # → DocsUI::Markdown
290
+ A block of **Markdown**.
291
+ MD
292
+ ```
293
+
294
+ The kit forms `DocsUI::Prose() { … }` / `DocsUI::Example() { … }` still work —
295
+ they just need the empty `()`, because a bare `DocsUI::Prose do … end` parses as
296
+ a constant reference (a Ruby `SyntaxError`). The lowercase helpers sidestep that
297
+ entirely, so they're the everyday path.
298
+
299
+ ## Authoring with Markdown
300
+
301
+ Prose is the most-written content type — and the noisiest to hand-build from
302
+ `p`/`code`/`plain` calls. `DocsUI::Page` gives you `md(source)`: write a block of
303
+ GitHub-Flavored Markdown and it renders styled identically to `DocsUI::Prose`,
304
+ with fenced code routed through `DocsUI::Code` (Rouge).
305
+
306
+ ```ruby
307
+ def content
308
+ DocsUI::Section("Configure") do
309
+ md <<~'MD'
310
+ Set `brand` and `themes` in the initializer. Everything that differs
311
+ between two sites is **configuration**, not markup:
312
+
313
+ - `brand` — the topbar/sidebar heading,
314
+ - `themes` — the ThemeSwitcher options.
315
+
316
+ ```ruby
317
+ DocsKit.configure { |c| c.brand = "My Docs" }
318
+ ```
319
+
320
+ | Option | Type |
321
+ |--------|--------|
322
+ | brand | String |
323
+ | themes | Array |
324
+ MD
325
+ end
326
+ end
327
+ ```
328
+
329
+ That renders paragraphs, **bold**/*italic*, inline `code`, links, bullet/ordered
330
+ lists, block quotes, GFM tables (with the kit's table classes), and
331
+ strikethrough. A fenced ` ```ruby ` block is highlighted by Rouge exactly like a
332
+ hand-written `DocsUI::Code`; an unknown fence language falls back to plaintext.
333
+
334
+ Two things to know:
335
+
336
+ - **`md` is a lowercase page helper (like `prose`/`example`), so `md <<~MD … MD`
337
+ needs no parens** — see [the authoring convention](#the-authoring-convention).
338
+ - **Use a single-quoted heredoc, `<<~'MD'`.** Then `#{…}` in your prose is
339
+ literal text (Phlex escapes author text — no `html_safe`, no interpolation).
340
+
341
+ Markdown headings render as styled `h3`/`h4`. Document **structure and the "On
342
+ this page" TOC still come from `DocsUI::Section`** — keep section titles as
343
+ `Section`, and use Markdown headings only for sub-headings inside a section. Raw
344
+ HTML in the Markdown source is dropped (no `<script>`, no passthrough).
345
+
346
+ ## Every page is also Markdown
347
+
348
+ Every doc page is **also** served as Markdown — append `.md` to its URL:
349
+
350
+ ```bash
351
+ curl https://your-docs.example/docs/installation.md
352
+ ```
353
+
354
+ returns a faithful GFM twin of exactly what `/docs/installation` renders —
355
+ headings, fenced code (with the right language), callouts as `> **Tip:**`
356
+ blockquotes, GFM tables, links (relative links absolutized to full URLs). You
357
+ write **nothing extra**: the twin is derived from the page's own render
358
+ (`DocsKit::MarkdownExport` walks the rendered HTML), so it can never drift from
359
+ the page the way a hand-written `to_text` copy does.
360
+
361
+ Each page's masthead carries a small **"Markdown"** action. With JavaScript off
362
+ it's a plain link that opens the raw `.md`; with JS on, `docs-nav` upgrades the
363
+ click into **copy-the-page-to-clipboard** — one click to paste a whole doc page
364
+ into an LLM. This is the machine-readable layer `llms.txt`, search, and MCP build
365
+ on.
366
+
367
+ Nothing to wire up — the install generator's route allows the `.:format`
368
+ segment, the engine registers the `text/markdown` MIME, and
369
+ `DocsKit::Controller#render_page` returns the twin for a `.md`/`.text` request.
370
+ To hide the masthead action site-wide (the `.md` route still works):
371
+
372
+ ```ruby
373
+ DocsKit.configure { |c| c.page_markdown_action = false }
374
+ ```
375
+
376
+ **Existing sites:** re-run `bin/rails g docs_kit:install` (or add `(.:format)`
377
+ to your `get "docs/:doc"` route) to enable the `.md` URLs. Sites that don't
378
+ re-run simply have no `.md` route match — HTML rendering is untouched.
379
+
380
+ ## AI-assisted authoring
381
+
382
+ The install generator scaffolds the authoring contract in a **machine-readable**
383
+ form, so "document this endpoint" works out of the box — an agent doesn't have to
384
+ reverse-engineer the kit's idioms. Two files, both brand-substituted and
385
+ maintained in one place (the gem's templates):
386
+
387
+ - **`AGENTS.md`** (site root) — the cross-tool convention file (Claude Code,
388
+ Cursor, Copilot, Aider, …). A terse, example-first authoring contract: the
389
+ one-command page flow (`rails g docs_kit:page`), the `md <<~'MD'` prose idiom,
390
+ that `DocsUI::Section` owns structure and the TOC, the reference-material
391
+ helpers, and the invariants an agent must not break (the registry line is
392
+ required, JS-off must work, themes ↔ CSS build). It links the live
393
+ [Authoring pages](#the-authoring-convention) doc for depth.
394
+ - **`.claude/skills/write-docs-page/SKILL.md`** — a Claude Code skill: the recipe
395
+ for the task (gather the subject → `rails g docs_kit:page` → write sections
396
+ md-first → self-review against a checklist → run the gates). Its frontmatter
397
+ targets "write / add / update a documentation page," so Claude Code reaches for
398
+ it automatically.
399
+
400
+ If a site already has an `AGENTS.md`, the generator injects the docs-kit block
401
+ between `<!-- BEGIN docs-kit -->` / `<!-- END docs-kit -->` markers — your own
402
+ content is preserved, and a re-run only rewrites what's between the markers. An
403
+ existing skill file is never clobbered. `docs-kit new` inherits both files
404
+ automatically (it runs the install generator).
405
+
406
+ ## Search
407
+
408
+ Every site gets search from the gem — no external service, no build step, no
409
+ JavaScript required. The topbar grows a search box; the reader types a query and
410
+ gets results grouped by page, each linking straight to the matching section.
411
+
412
+ The index is built **from the pages themselves**: each page's Markdown twin (the
413
+ same `.md` from the section above) is split on its `## ` headings into searchable
414
+ sections, so the index can never drift from what a page actually says — there is
415
+ no second registry to maintain. Scoring is plain Ruby: a title match outranks a
416
+ heading match outranks a body match, all query words must match (AND), and each
417
+ result carries a snippet with the term highlighted.
418
+
419
+ **Works with JavaScript off.** The box is a plain `GET` form; pressing Enter
420
+ lands on a fully server-rendered results page (`DocsUI::SearchResults`) through
421
+ the normal chrome, and each result's link jumps to the section anchor.
422
+
423
+ **Enhanced with JavaScript on.** The one `docs-nav` controller upgrades the box
424
+ into a command palette: press any configured shortcut to focus it, type to see
425
+ results appear inline (debounced, fetched as JSON from the same route), arrow
426
+ keys + Enter to jump to a result, and `Escape` to close. Each shortcut shows as a
427
+ `<kbd>` badge (server-rendered, so the hint is right with JS off too). If the
428
+ fetch ever fails, Enter still submits the form to the results page — never a dead
429
+ end.
430
+
431
+ ### Keyboard shortcuts
432
+
433
+ The shortcuts that open the palette are configurable — `c.search_shortcuts`
434
+ defaults to `["/", "mod+k"]`:
435
+
436
+ ```ruby
437
+ DocsKit.configure do |c|
438
+ c.search_shortcuts = ["/", "mod+k", "s"] # bind "/", ⌘K/Ctrl+K, and "s"
439
+ end
440
+ ```
441
+
442
+ Each entry is a shortcut string: a bare key (`"/"`, `"s"`, `"?"`) or a chord
443
+ (`"mod+k"`, `"ctrl+shift+f"`). **`mod` is the platform command key** — `⌘` on
444
+ macOS, `Ctrl` elsewhere — so one entry works on every OS (and the `<kbd>` badge
445
+ shows `Ctrl` by default, swapping to `⌘` on macOS in JS). Modifiers accepted:
446
+ `mod`, `ctrl`, `shift`, `alt`, `meta` (aliases: `command`/`cmd` → `meta`,
447
+ `control` → `ctrl`, `option` → `alt`). A bare-key shortcut never fires while the
448
+ reader is typing in a field, and none of them collide with the browser —
449
+ `⌘K`/`Ctrl+K` is a *cancellable* accelerator (the palette calls `preventDefault`),
450
+ and `"/"` is never hijacked. Set `c.search_shortcuts = []` to bind no key (the
451
+ form still works). Whatever you configure drives both the key bindings and the
452
+ `<kbd>` hints from one source, so they can't drift.
453
+
454
+ ### Other knobs
455
+
456
+ The controller ships in the gem (`DocsKit::SearchController`, `html` + `json`);
457
+ like llms.txt, the **route lives in your app**. The install generator scaffolds
458
+ it (above `docs/:doc`, so it isn't swallowed as a `:doc`):
459
+
460
+ ```ruby
461
+ get "/docs/search" => "docs_kit/search#index", as: :docs_search
462
+ ```
463
+
464
+ Two more knobs tune it (both optional — the defaults just work):
465
+
466
+ ```ruby
467
+ DocsKit.configure do |c|
468
+ c.search = true # default; set false to hide the box site-wide
469
+ c.search_path = "/docs/search" # default; match your route if you move it
470
+ end
471
+ ```
472
+
473
+ **Existing sites:** re-run `bin/rails g docs_kit:install` (it adds the route
474
+ idempotently), or paste the route line above into `config/routes.rb`.
475
+
476
+ ## AI-readable docs (llms.txt)
477
+
478
+ Every site serves the two [llmstxt.org](https://llmstxt.org) artifacts, built
479
+ from the **registry** with zero authoring:
480
+
481
+ ```bash
482
+ curl https://your-docs.example/llms.txt # the index
483
+ curl https://your-docs.example/llms-full.txt # every page, concatenated
484
+ ```
485
+
486
+ `/llms.txt` is the index an agent fetches first: an H1 brand, an optional
487
+ one-line summary blockquote, one `##` section per nav group, and a
488
+ `- [Title](…/page.md)` link to each authored page's Markdown twin. `/llms-full.txt`
489
+ concatenates every page's Markdown (the same twin as `.md`) into one document,
490
+ separated by `---`. Both are `text/plain`, HTTP-cached (they revalidate on the
491
+ registry's content plus the gem version), and derived from the same registry the
492
+ sidebar uses — an unwritten page never appears, so there are no dead links.
493
+
494
+ Set the summary blockquote with the `tagline` knob (default `nil` → the line is
495
+ omitted):
496
+
497
+ ```ruby
498
+ DocsKit.configure { |c| c.tagline = "The one-line description agents see." }
499
+ ```
500
+
501
+ The controller ships in the gem (`DocsKit::LlmsController`); the **routes live in
502
+ your app** so you keep full control over path, auth, and omission. The install
503
+ generator scaffolds them:
504
+
505
+ ```ruby
506
+ get "/llms.txt" => "docs_kit/llms#index", as: :llms
507
+ get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full
508
+ ```
509
+
510
+ **Existing sites:** re-run `bin/rails g docs_kit:install` (it adds the two routes
511
+ idempotently), or paste the two lines above into `config/routes.rb`.
512
+
513
+ ## Add your docs to an agent (MCP)
514
+
515
+ `llms.txt` covers fetch-style consumption; **MCP** (the Model Context Protocol) is
516
+ the native one — a reader adds one URL and your docs become first-class agent
517
+ tools instead of scraped text. docs-kit ships a **read-only, stateless** MCP
518
+ server that any site can turn on with one gem + one route. It exposes three tools
519
+ over the SAME registry the docs render from (so an agent queries live docs, never
520
+ a stale copy):
521
+
522
+ | Tool | Returns |
523
+ |------|---------|
524
+ | `list_pages` | every authored page — `slug`, `title`, `group`, `url` |
525
+ | `get_page(slug:)` | one page's Markdown twin (the same `.md` twin `/llms.txt` links) |
526
+ | `search_docs(query:)` | ranked hits — `page_title`, `section_title`, `url`, `snippet` |
527
+
528
+ The `mcp` gem is **optional** — docs-kit depends on it in no gemspec list, and the
529
+ endpoint stays off (byte-identical to before) unless you opt in. Two steps:
530
+
531
+ ```ruby
532
+ # Gemfile
533
+ gem "mcp"
534
+ ```
535
+
536
+ ```ruby
537
+ # config/routes.rb — the install generator scaffolds these COMMENTED; uncomment.
538
+ post "/mcp" => "docs_kit/mcp#create", as: :mcp
539
+ match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete]
540
+ ```
541
+
542
+ Then a reader connects — for Claude Code:
543
+
544
+ ```bash
545
+ claude mcp add --transport http docs https://your-docs.example/mcp
546
+ ```
547
+
548
+ and can ask Claude to search or read your docs, which now appear as tools. The
549
+ JSON-RPC is stateless (each `POST` is independent — no SSE session), so it works
550
+ behind the existing Kamal/Cloudflare deploy unchanged; `GET`/`DELETE` return
551
+ `405`. When enabled, `/llms.txt` grows a final `## MCP` line advertising the
552
+ endpoint so agents discover it.
553
+
554
+ `c.mcp` defaults to `true`, so once the gem + route are present the endpoint is
555
+ live. Set it `false` to keep it off even on a site that bundles the gem:
556
+
557
+ ```ruby
558
+ DocsKit.configure { |c| c.mcp = false }
559
+ ```
560
+
561
+ The endpoint is read-only over already-public content — writing docs is still
562
+ git, and private-docs auth is a host concern (the route is yours to wrap). Rate
563
+ limiting is the host's responsibility too (e.g. `rate_limit` in your base
564
+ controller). The server ships in the gem (`DocsKit::McpServer` /
565
+ `DocsKit::McpController`); the **route lives in your app**, like `llms.txt`.
566
+
567
+ ## API docs — one request, every client tab
568
+
569
+ An endpoint example is a request shown in several clients (curl, JavaScript,
570
+ Ruby, Python, …) plus a JSON response. Writing each client by hand means a field
571
+ rename edits every language. `DocsUI::RequestExample` derives all the tabs from
572
+ **one** declaration; `DocsUI::JsonResponse` renders a Ruby Hash as a
573
+ pretty-printed response block.
574
+
575
+ ```ruby
576
+ def content
577
+ DocsUI::Section("Create a payment link",
578
+ description: DocsUI::Endpoint.new(:post, "/v1/payment_links")) do
579
+
580
+ render DocsUI::RequestExample.new(
581
+ method: :post,
582
+ path: "/v1/payment_links",
583
+ body: { amount: 4900, currency: "usd", description: "Pro plan" }
584
+ )
585
+
586
+ render DocsUI::JsonResponse.new(
587
+ { id: "plink_1a2b3c", object: "payment_link", amount: 4900,
588
+ currency: "usd", url: "https://pay.example.com/plink_1a2b3c" }
589
+ )
590
+ end
591
+ end
592
+ ```
593
+
594
+ `RequestExample` renders a `DocsUI::Example`, so the global sticky language
595
+ choice works exactly as with a hand-built example (pick Ruby once, every request
596
+ on the site shows Ruby). With JS off, every client snippet is visible stacked.
597
+
598
+ **Configure the client set and host once:**
599
+
600
+ ```ruby
601
+ # config/initializers/docs_kit.rb
602
+ DocsKit.configure do |c|
603
+ c.api_base_url = "https://api.acme.com" # prefixed onto every path
604
+ c.api_auth_header = "Authorization: Bearer sk_live_..." # nil ⇒ no auth line
605
+
606
+ # Swap a default for an SDK-flavored snippet, or add a new tab (e.g. a CLI):
607
+ c.api_clients = {
608
+ ruby: DocsKit::ApiClient.new(
609
+ label: "Ruby", lexer: :ruby, filename: "app.rb",
610
+ template: ->(req) { %(Acme.new.payment_links.create(#{req.pretty_body_json})) }
611
+ ),
612
+ cli: DocsKit::ApiClient.new(
613
+ label: "CLI", lexer: :shell, filename: "acme",
614
+ template: ->(req) { "acme payment_links create --amount #{req.body[:amount]}" }
615
+ )
616
+ }
617
+ end
618
+ ```
619
+
620
+ The gem ships four generic-HTTP clients (`curl`, `javascript`, `ruby`,
621
+ `python`). A `c.api_clients` entry merges **over** them: reuse a token
622
+ (`ruby`) to replace that client with your SDK's snippet, or use a new token
623
+ (`cli`) to append a tab. Order is stable — reused tokens keep their position, new
624
+ ones append. Each `template` is a `(DocsKit::ApiRequest) -> String` callable;
625
+ the request exposes `#http_method`, `#url`, `#url_with_query`, `#headers`,
626
+ `#body?`, and `#pretty_body_json` so a template stays one short heredoc.
627
+
628
+ Pass `clients:` to a single call to filter/order the tabs:
629
+ `DocsUI::RequestExample.new(method: :get, path: "/v1/things", clients: [:curl, :ruby])`.
630
+
631
+ ### OpenAPI bridge — an endpoint from your spec, no hand-restatement
632
+
633
+ If you already maintain an OpenAPI 3.x spec, you don't have to restate a single
634
+ method, path, field, or response in the docs. Point `c.openapi` at the spec and
635
+ one line renders the whole endpoint:
636
+
637
+ ```ruby
638
+ # config/initializers/docs_kit.rb
639
+ DocsKit.configure do |c|
640
+ c.openapi = Rails.root.join("openapi.yaml") # String/Pathname (.json ⇒ JSON, else YAML) or a parsed Hash
641
+ end
642
+ ```
643
+
644
+ ```ruby
645
+ # app/views/docs/pages/invoices.rb
646
+ def content
647
+ operation "createInvoice" # the whole endpoint, derived from the spec
648
+ end
649
+ ```
650
+
651
+ One `operation` call expands to a full `DocsUI::Section`:
652
+
653
+ | Spec source | Renders as |
654
+ |-------------|------------|
655
+ | `operationId` + `summary` + method/path | the Section title + a `DocsUI::Endpoint` badge |
656
+ | `description` | Markdown prose |
657
+ | `parameters` (query/path) | a `DocsUI::FieldTable` |
658
+ | `requestBody` schema (`$ref`, `allOf`, nested objects) | a `DocsUI::FieldTable` (nested names dotted: `customer.id`) |
659
+ | `4xx`/`5xx` responses | a `DocsUI::ErrorTable` (the error `type` is read from a response example when present) |
660
+ | `x-codeSamples` / `x-code-samples` | `DocsUI::Example` tabs (a lone sample → a plain `DocsUI::Code`) |
661
+ | no code samples | a generated `DocsUI::RequestExample` (curl / JS / Ruby / Python) |
662
+ | first `2xx` example (explicit or synthesized) | a `DocsUI::JsonResponse` |
663
+
664
+ The snippet URL uses each path parameter's `example` when the spec provides one
665
+ (so it's copy-pasteable), and query params appear only when they carry an
666
+ explicit `example` — a required-but-example-less param stays documentation-only.
667
+
668
+ Look up by `operationId`, or by verb + path for a spec whose operations have no
669
+ ids; append hand-authored prose with a block; filter the client tabs with
670
+ `clients:`:
671
+
672
+ ```ruby
673
+ operation :delete, "/v1/invoices/{id}" # method + path lookup
674
+ operation "createInvoice", clients: %i[curl ruby] # only these tabs
675
+ operation "createInvoice" do |op| # append prose in the section
676
+ op.md("Idempotency keys are honored for 24 hours.")
677
+ end
678
+ ```
679
+
680
+ An unknown `operationId` raises `DocsKit::OpenApi::OperationNotFound` naming the
681
+ available ids; an external/remote `$ref` raises `DocsKit::OpenApi::UnsupportedRef`.
682
+ Because the whole thing is composed from the kit, the `.md` twin, `llms.txt`,
683
+ search, and MCP surfaces derive from it for free.
684
+
685
+ **Out of scope:** authoring or validating the spec (bring your own), OpenAPI 2.0 /
686
+ Swagger, AsyncAPI, GraphQL SDL, external-file `$ref`s, and round-tripping docs
687
+ back to a spec.
688
+
689
+ ## Scaffold a new docs site in one command
690
+
691
+ ```bash
692
+ docs-kit new my-docs # → a complete, deployable docs app
693
+ docs-kit new my-docs --image mhenrixon/my-repo --service my-repo
694
+ ```
695
+
696
+ `docs-kit new` runs `rails new` (propshaft + importmap + turbo/stimulus, no DB)
697
+ and applies docs-kit's application template, which:
698
+
699
+ - adds docs-kit + its deps to the Gemfile,
700
+ - runs `rails g docs_kit:install` (initializers, controllers, a Doc registry, a
701
+ sample guide page, the Bun/Tailwind build, the docs-nav Stimulus wiring),
702
+ - syncs the lucide icons and builds the CSS,
703
+ - scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, `Dockerfile`) and a
704
+ thin `.github/workflows/deploy-docs.yml` that calls the reusable workflow.
705
+
706
+ Then `cd my-docs && bin/dev`. Already have a Rails app? Run the install generator
707
+ instead:
708
+
709
+ ```bash
710
+ rails g docs_kit:install
711
+ rails g rails_icons:sync --library=lucide
712
+ bun install && bun run build:css
713
+ ```
714
+
715
+ Then add pages one command at a time — `rails g docs_kit:page "Title"
716
+ --group=Guide` (see [Add a page](#add-a-page)).
717
+
718
+ ## Lint — the docs-kit RuboCop cops
719
+
720
+ docs-kit ships two custom cops so every site enforces the same authoring idioms
721
+ instead of hand-copying a cop file that drifts:
722
+
723
+ - **`DocsKit/RenderComponentPreferred`** — prefers the Phlex-kit helper form
724
+ `DocsUI::Code(...)` over `render DocsUI::Code.new(...)` (autocorrectable).
725
+ - **`DocsKit/EscapedInterpolationInHeredoc`** — flags the `\#{...}` "escape tax"
726
+ inside a double-quoted heredoc and steers you to a single-quoted delimiter
727
+ (`<<~'RUBY'`), where `#{...}` is literal. Autocorrects when the heredoc has no
728
+ live interpolation; otherwise it reports and leaves the fix to you.
729
+
730
+ Both are scoped to `app/views/docs/**/*` by default. The install generator wires
731
+ them into your `.rubocop.yml` automatically — two lines, merged idempotently
732
+ (your existing `inherit_gem` / `require` entries are preserved):
733
+
734
+ ```yaml
735
+ # .rubocop.yml
736
+ require:
737
+ - docs_kit/rubocop
738
+ inherit_gem:
739
+ docs-kit: config/rubocop/docs_kit.yml
740
+ ```
741
+
742
+ RuboCop is a **development-time** dependency of your app, never a runtime
743
+ dependency of docs-kit — `docs_kit/rubocop` requires `rubocop` lazily. Every
744
+ generated site already has `rubocop` in its Gemfile (via `rubocop-rails-omakase`
745
+ from `rails new`); if yours doesn't, add `gem "rubocop"` to the `:development`
746
+ group. Then `bundle exec rubocop` runs the docs-kit cops.
747
+
748
+ ## Deploy a new docs site
749
+
750
+ The build + deploy is defined **once** in this gem's reusable workflow
751
+ (`.github/workflows/deploy.yml`). `docs-kit new` scaffolds the caller for you; to
752
+ wire it by hand a site adds five small things and it deploys to the
753
+ oss-infrastructure server (Kamal + GHCR + Cloudflare Tunnel).
754
+
755
+ **1. A thin caller** — `.github/workflows/deploy-docs.yml`:
756
+
757
+ ```yaml
758
+ name: Deploy docs
759
+ on:
760
+ release: { types: [published] }
761
+ workflow_dispatch:
762
+ jobs:
763
+ deploy:
764
+ uses: mhenrixon/docs-kit/.github/workflows/deploy.yml@main
765
+ with:
766
+ image: mhenrixon/<repo> # OWNER/REPO — see naming note below
767
+ service: <repo>
768
+ secrets: inherit
769
+ ```
770
+
771
+ **2. `docs/config/deploy.yml`** — `service:` and `image:` MUST match the caller:
772
+
773
+ ```yaml
774
+ service: <repo>
775
+ image: mhenrixon/<repo>
776
+ registry: { server: ghcr.io, username: mhenrixon, password: [KAMAL_REGISTRY_PASSWORD] }
777
+ builder: { arch: amd64, context: .., dockerfile: Dockerfile } # repo root = build context
778
+ proxy: { host: <%= ENV["DEPLOY_DOMAIN"] %>, app_port: 3000, ssl: false, healthcheck: { path: /up } }
779
+ servers: { web: { hosts: [<%= ENV["DEPLOY_HOST"] %>] } }
780
+ ssh: { user: oss }
781
+ ```
782
+
783
+ **3. `docs/Dockerfile`** — end the final stage with the matching label:
784
+
785
+ ```dockerfile
786
+ LABEL service="<repo>"
787
+ ```
788
+
789
+ **4. `docs/.kamal/secrets`** — `KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD`.
790
+
791
+ **5. GitHub** — a `docs` environment with secrets `SSH_PRIVATE_KEY`,
792
+ `DEPLOY_HOST`, `DEPLOY_DOMAIN`. (The registry password is the auto-provided
793
+ `GITHUB_TOKEN` — no PAT.)
794
+
795
+ > **Naming — use the repo name, not `<repo>-docs`.** `image`/`service` must be
796
+ > the calling repo's `OWNER/REPO`. Pushing `ghcr.io/mhenrixon/<repo>` from the
797
+ > repo's own Actions run auto-links the package to the repo, so `GITHUB_TOKEN`
798
+ > can both push (build job) and pull (deploy) it. A different name becomes an
799
+ > unlinked user-scoped package `GITHUB_TOKEN` can't pull → the deploy fails.
800
+
801
+ **First deploy per host:** run `kamal setup` (or `bin/deploy setup`) once to boot
802
+ any accessories (e.g. a Postgres accessory); the release workflow runs plain
803
+ `kamal deploy`, which doesn't boot accessories.
804
+
805
+ ## CSS — the canonical build
806
+
807
+ daisyUI (and docs-kit) ship **no CSS** — your app builds Tailwind. To keep sites
808
+ identical, docs-kit standardizes on **Tailwind CSS v4 via the standalone CLI
809
+ (Bun)**.
810
+
811
+ `app/assets/stylesheets/application.tailwind.css`:
812
+
813
+ ```css
814
+ @import "tailwindcss";
815
+ @plugin "daisyui" {
816
+ themes: dark --default, light, synthwave, retro, cyberpunk, dracula, night, nord, sunset;
817
+ }
818
+
819
+ /* Tailwind must scan the Ruby that emits classes — the daisyUI gem, docs-kit,
820
+ and your own views. */
821
+ @source "../../../app/views/**/*.rb";
822
+ @source "../../../../.bundle/gems/daisyui*/**/*.rb";
823
+ @source "../../../../.bundle/gems/docs-kit*/**/*.rb";
824
+ /* daisyUI Drawer classes are generated at render time, never literal — force them: */
825
+ @source inline("drawer drawer-content drawer-side drawer-toggle drawer-overlay {lg:}drawer-open drawer-end");
826
+ ```
827
+
828
+ `package.json`:
829
+
830
+ ```json
831
+ {
832
+ "scripts": {
833
+ "build:css": "bunx @tailwindcss/cli -i app/assets/stylesheets/application.tailwind.css -o app/assets/builds/application.css --minify",
834
+ "watch:css": "bunx @tailwindcss/cli -i app/assets/stylesheets/application.tailwind.css -o app/assets/builds/application.css --watch"
835
+ }
836
+ }
837
+ ```
838
+
839
+ The themes in `@plugin "daisyui" { themes: ... }` **must** match
840
+ `DocsKit.configuration.themes`, or the switcher offers a theme the CSS doesn't
841
+ ship.
842
+
843
+ ## JavaScript
844
+
845
+ docs-kit ships **one** Stimulus controller, `docs-nav`, auto-pinned by the engine
846
+ (like the daisyUI gem's dropdown controller). It's client-only UX polish — no
847
+ server round-trip:
848
+
849
+ - **Collapse persistence** — remembers which sidebar `<details>` the reader
850
+ opened/closed (localStorage, namespaced by `config.nav_storage_key`), so the
851
+ sidebar stays how they left it across navigations. The server always renders
852
+ every section `open`, so with JS off the sidebar is simply fully expanded
853
+ (progressive enhancement).
854
+ - **"On this page" auto-TOC** — collects the current page's `DocsUI::Section`
855
+ anchors from the DOM and renders a live, scroll-spied table of contents in one
856
+ of three placements, auto-hiding on short pages. No server-side knowledge of
857
+ the headings, no per-page wiring.
858
+
859
+ ### On this page (auto-TOC)
860
+
861
+ `DocsUI::Page` renders it automatically. The placement is a strategy — set the
862
+ site-wide default, override per page:
863
+
864
+ ```ruby
865
+ DocsKit.configure { |c| c.on_page_default = :panel } # :panel | :toggle | :sidebar | false
866
+
867
+ class Views::Docs::Pages::Installation < DocsUI::Page
868
+ on_page :toggle # override just this page; `false` opts out
869
+ end
870
+ ```
871
+
872
+ | Mode | Placement |
873
+ |------|-----------|
874
+ | `:panel` (default) | A sticky card floating top-right of the content column. |
875
+ | `:toggle` | A sticky floating button (top-right) that opens a dropdown. |
876
+ | `:sidebar` | Nested under the active nav item in the left sidebar (GitBook-style). |
877
+
878
+ All three are driven by the same `docs-nav` controller reading the page's
879
+ headings, so they need zero per-page data. A page with fewer than 2 sections
880
+ hides the TOC. Scroll-spy highlights the section you're reading.
881
+
882
+ `DocsUI::Sidebar` already carries `data-controller="docs-nav"`. Register the
883
+ controller in the host app:
884
+
885
+ ```js
886
+ // app/javascript/controllers/index.js
887
+ import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
888
+ lazyLoadControllersFrom("docs_kit/controllers", application)
889
+ ```
890
+
891
+ The **active nav item** needs no JS — it's server-rendered from the request path.
892
+
893
+ Reactive sites also load the auto-pinned `phlex/reactive/reactive_controller`
894
+ (from phlex-reactive) and register it eagerly:
895
+
896
+ ```js
897
+ import ReactiveController from "phlex/reactive/reactive_controller"
898
+ application.register("reactive", ReactiveController)
899
+ ```
900
+
901
+ ## Releasing (maintainers)
902
+
903
+ Cut a release with the version-bumping Rake task — never `gem push` by hand:
904
+
905
+ ```bash
906
+ rake release[1.0.0] # bump → build-verify → commit → push → GitHub Release
907
+ rake release[1.1.0.rc1] # a pre-release (auto-flagged --prerelease)
908
+ rake release[1.0.0,force] # delete + re-create an existing tag/release
909
+ ```
910
+
911
+ The task (on `main`, clean tree only) bumps `lib/docs_kit/version.rb`, updates the
912
+ lockfiles (incl. `docs/Gemfile.lock`), verifies `gem build --strict`, commits,
913
+ pushes, and creates the GitHub Release. Publishing the tag fires
914
+ `.github/workflows/release.yml`, which runs the suite, rebuilds + content-checks
915
+ the gem, signs it with Sigstore, and pushes to RubyGems over **OIDC trusted
916
+ publishing** (no API token stored anywhere).
917
+
918
+ ### One-time setup (before the first release)
919
+
920
+ Trusted publishing needs two things wired once — the first release fails without
921
+ them:
922
+
923
+ 1. **RubyGems pending trusted publisher.** On [rubygems.org](https://rubygems.org)
924
+ → your profile → *Trusted Publishers* → *Create*, add a **pending** publisher
925
+ (works for a gem not yet pushed) with:
926
+ - Gem name: `docs-kit`
927
+ - Repository: `mhenrixon/docs-kit`
928
+ - Workflow filename: `release.yml`
929
+ - Environment: `rubygems`
930
+ 2. **GitHub `rubygems` environment.** Repo *Settings → Environments → New
931
+ environment* named `rubygems` (the `publish-rubygems` job pins it and requests
932
+ `id-token: write`). Add reviewers there if you want a manual gate before a push.
933
+
934
+ After the first successful push the pending publisher converts to a normal one; no
935
+ further setup is needed for later releases.
936
+
937
+ ## License
938
+
939
+ MIT.