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
data/README.md
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
# docs-kit
|
|
2
|
+
|
|
3
|
+
[](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.
|