# Overview Getting started # Overview The shared Phlex chrome for a Rails docs site — you write page bodies, docs-kit renders the rest. ## What is docs-kit A gem, not a template. **docs-kit** is a Ruby gem that gives you the shared chrome for a Rails documentation site: the topbar, the responsive sidebar, the theme switcher, the content column, an automatic "On this page" TOC, and syntax-highlighted code blocks. It's built on [`phlex-rails`](https://www.phlex.fun) and [`daisyUI`](https://daisyui.com). You write page bodies as Phlex components — docs-kit renders everything around them. ## The mental model Configure the chrome; don't re-author it. The chrome — `Shell`, `Sidebar`, `Page` — is byte-identical across every site that uses docs-kit. The only thing that differs is `DocsKit.configure`. Two sites look and behave consistently for free, because they share the same components. You change the brand, the themes, and the nav — never the layout code. ```ruby DocsKit.configure do |c| c.brand = "My Project" # only this differs per site c.themes = %w[dark light] # the chrome itself is identical c.nav_registries = { "Docs" => Doc } # sidebar derives from the registry end ``` > **Tip:** This very site is built with docs-kit — the topbar, sidebar, and TOC you're looking at are the exact chrome your site will get. ## What you get The whole surface, in the box — each row links to its page. #### The chrome - **Shared shell + responsive sidebar + theme switcher** — the same topbar, nav, and layout on every screen size, remembered in `localStorage`. See [Components](https://docs-kit.zoolutions.llc/docs/components). - **A theme switcher** whose list is your `c.themes` — it must match the daisyUI `@plugin` block in your Tailwind entry. See [Styling & CSS](https://docs-kit.zoolutions.llc/docs/styling). - **Syntax highlighting for ~200 languages** via Rouge, with a light + dark theme pair emitted as inline CSS — no allowlist, no flash. See [Code languages](https://docs-kit.zoolutions.llc/docs/languages). #### Authoring - **Markdown islands** — drop `md <<~'MD' … MD` anywhere in a page and get GFM (tables, lists, inline code, links) styled with the reading rhythm. See [Markdown authoring](https://docs-kit.zoolutions.llc/docs/markdown). - **The component kit** — `Section`, `Code`, `Example`, `Callout`, `Table`/`PropTable`, plus the **API-docs kit** (`Endpoint`, `RequestExample`, `JsonResponse`) that turns one request declaration into every client tab. See [Components](https://docs-kit.zoolutions.llc/docs/components) and the [API reference](https://docs-kit.zoolutions.llc/docs/api). - **A one-command page generator** — `rails g docs_kit:page "Title"` writes the Phlex class AND its one-line Registry v2 entry, both derived from the title. See [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring). #### For machines - **An automatic `.md` twin** — every page answers at `/docs/x.md` with its Markdown source, and the masthead "Markdown" action becomes copy-to-clipboard. - **`/llms.txt` + `/llms-full.txt`** — an [llmstxt.org](https://llmstxt.org) index and a full concatenation, served from the registry with zero authoring. - **Server-rendered search + a ⌘K palette** — a working `GET /docs/search` form the `docs-nav` controller enhances into a fuzzy palette. See [Search](https://docs-kit.zoolutions.llc/docs/search). - **An optional read-only MCP server** — `POST /mcp` exposing `list_pages` / `get_page` / `search_docs` over the registry when the `mcp` gem is present. See [AI & agents](https://docs-kit.zoolutions.llc/docs/ai). - **AGENTS.md scaffolding** — the install generator writes an `AGENTS.md` authoring contract plus a Claude Code `write-docs-page` skill, so agents author pages the right way. #### Toolchain - **Shipped RuboCop cops** — `DocsKit/RenderComponentPreferred` (steer to the kit helper form) and `DocsKit/EscapedInterpolationInHeredoc` (kill the `\#{…}` escape tax in Markdown heredocs). See [Configuration](https://docs-kit.zoolutions.llc/docs/configuration). - **An idempotent install** — `docs_kit:install` is safe to re-run, and `--sync` runs only the additive wiring to upgrade an existing site without touching your pages. See [Installation](https://docs-kit.zoolutions.llc/docs/installation). - **`docs-kit new` + a single reusable deploy workflow** — scaffold a whole site, then ship it with dash + GHCR. See [Deploy](https://docs-kit.zoolutions.llc/docs/deploy). ## Next steps Start with [Installation](https://docs-kit.zoolutions.llc/docs/installation) to add the gem and render your first page. Then read [Configuration](https://docs-kit.zoolutions.llc/docs/configuration) to set your brand, themes, and nav, and [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring) to learn the DocsUI kit — the building blocks for every page body. --- # Installation Getting started # Installation Scaffold a new docs site in one command, add docs-kit to an existing Rails app, or re-run the generator with --sync to upgrade. ## New site in one command The fastest path — a deployable app from scratch. ```shell docs-kit new my-docs --image OWNER/REPO --service my-repo ``` This runs `rails new` (propshaft + importmap + turbo/stimulus, no database) and applies the docs-kit template, which: - adds the gem and its dependencies, - runs the install generator, - syncs the lucide icons, - builds the Tailwind CSS, and - scaffolds the dash deploy. Then boot it: ```shell cd my-docs && bin/dev ``` > **Tip:** The generator path (docs-kit new) performs every step in the “Add to an existing Rails app” section below automatically. Reach for the manual steps only when adding docs-kit to an app you already have. ## Add to an existing Rails app Four steps: the gems, the generator, the icons, the CSS. **1. Add the gems.** ```ruby gem "docs-kit" gem "daisyui", require: "daisy_ui" gem "phlex-rails" gem "rails_icons", "~> 1.1" gem "rouge" ``` Then run `bundle install`. **2. Run the install generator.** ```shell rails g docs_kit:install ``` It is fully idempotent — safe on a fresh app AND a years-old site, so re-running it is the sanctioned [upgrade path](#upgrade-an-existing-site). File creations skip what already exists; the config initializer is never clobbered; routes are skipped even when the site wrote them in its own style. The generator wires the following into your app: | What | Why | | --- | --- | | `config/initializers/docs_kit.rb` | The site config — brand, themes, nav. Skipped if present (never clobbered). | | `config/initializers/phlex.rb` | Phlex autoload namespaces (Views::, Components::). | | `config/initializers/rails_icons.rb` | The rails_icons config for the lucide chrome icons. | | `app/models/doc.rb` | The Doc registry, seeded with a sample page. | | `app/views/docs/pages/installation.rb` | A sample page to prove the render path. | | `routes` | docs/:doc(.:format), the search / llms.txt / llms-full.txt routes, and a commented MCP route. | | `bin/build-css + application.tailwind.css` | The Bun/Tailwind CSS build, carrying the theme @plugin block. | | `controllers/index.js` | Registers the docs-nav Stimulus controller (eager-loaded). | | `AGENTS.md + .claude skill` | The AI-authoring contract and a write-docs-page Claude Code skill. | | `.rubocop.yml` | docs-kit's shipped cops, merged into an existing config. | > **Note:** The generator also injects `include DocsKit::Controller` into your `ApplicationController` — that is what provides the `#render_page` helper the docs controller calls. **3. Sync the icons.** ```shell rails g rails_icons:sync --library=lucide ``` **4. Build the CSS.** ```shell bun install && bun run build:css ``` Then set your brand, themes, and nav in `config/initializers/docs_kit.rb` — see [Configuration](https://docs-kit.zoolutions.llc/docs/configuration) for every knob — and write your first page (see [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring)). ## Upgrade an existing site rails g docs_kit:install --sync pulls new wiring without touching your content. docs-kit ships new wiring over time — new routes, a new Stimulus registration, updated AGENTS.md guidance, RuboCop cops. To pull those into an existing site, re-run the generator with `--sync`. This is the **sanctioned upgrade path**. ```shell rails g docs_kit:install --sync ``` `--sync` is *additive*. It runs ONLY the idempotent wiring steps and scaffolds no content: - **Runs** the routes, the initializer hint, the importmap/Stimulus registration, the AGENTS.md block, and the `.rubocop.yml` cops. - **Skips** everything you own — the `Doc` registry, your pages, and the `application.tailwind.css` build. Those already exist and are yours to edit, so a sync never touches them. A sync also prints a **drift report** — manual cleanup it detects but won't do for you, because it can't safely automate a delete. It warns, never deletes, and never fails the run. The two items it looks for: | Drift | What to do | | --- | --- | | `A hand-rolled render_page` | app/controllers/application_controller.rb defines its own #render_page — DocsKit::Controller already provides it, so the copy shadows the gem's. Delete it. | | `A dead IconHelper` | app/helpers/icon_helper.rb is dead code — docs-kit renders icons via rails_icons (DocsUI::Icon). Delete it. | > **Tip:** After a sync: run `bun run build:css` to pick up any new emitted classes, then `bundle exec rspec` to confirm the site still boots and renders. ## Requirements | Requirement | Version/Note | | --- | --- | | `Ruby` | >= 3.2 | | `Rails` | >= 7.1 | | `Bun` | for the Tailwind CSS build | | `PostgreSQL` | not required (docs sites are stateless) | ## Verify Boot the app with `bin/dev` and visit `/docs`. You should see the shell with the sidebar, the theme switcher, and this page's content. --- # Configuration Getting started # Configuration One initializer drives the shared chrome. Everything that differs between two sites — brand, themes, nav, search, API examples — is config; the Shell, Sidebar, and ThemeSwitcher are identical everywhere. ## DocsKit.configure Set it once; the shared chrome reads it everywhere. Call `DocsKit.configure` with a block and set `c.` on the yielded `DocsKit::Configuration` singleton. Read any value back with `DocsKit.configuration`. Every knob has a sensible default, so a brand-new site works with an empty block. Configure inside `config.to_prepare` so the block re-runs on every code reload — that keeps the derived nav pointing at the current registry in development. ```ruby Rails.application.config.to_prepare do DocsKit.configure do |c| c.brand = "Acme Docs" c.tagline = "Everything the Acme API can do." c.themes = %w[dark light dracula night] c.default_theme = "dark" c.version_badge = -> { "v#{Acme::VERSION}" } c.nav_registries = { "Docs" => Doc, "API" => ApiDoc } end end ``` ## Brand & themes Identity, the topbar, the , and the theme switcher. The one required-ish knob is `brand` — it names the topbar and sidebar header, and is the fallback for both `title_suffix` and `nav_storage_key`, so setting it alone gets you sensible page titles and per-site localStorage namespacing. The theme list is the contract with your CSS: the values in `themes` MUST match the daisyUI `@plugin "daisyui" { themes: ... }` block in your Tailwind entry. A theme offered here that the build never generated is a dead switcher entry. See the [Styling & CSS](https://docs-kit.zoolutions.llc/docs/styling) page for wiring that up. | Option | Type | Default | Description | | --- | --- | --- | --- | | `brand` | String | "Docs" | Topbar + sidebar heading. Fallback for title_suffix and nav_storage_key. | | `tagline` | String, nil | nil | One-line summary; rendered as the llms.txt blockquote. AI-index only — the chrome never shows it. | | `brand_href` | String | "/" | Where the topbar brand link points (e.g. "/docs" for a subpath site). | | `title_suffix` | String | = brand | Appended to the page <title> ("Installation · Acme"). Writer only; reader falls back to brand. | | `themes` | Array | %w[dark light] | ThemeSwitcher options; must match the daisyUI @plugin themes: block. | | `default_theme` | String | = themes.first | The data-theme applied on first paint. Writer only; reader falls back to themes.first. | | `version_badge` | String or callable | nil | Short badge string for the sidebar header. A callable is invoked; a plain String is used as-is; nil = no badge. | | `stylesheets` | Array | %w[application] | Stylesheet logical names linked in <head>, in order. | | `default_group_icon` | String | "file-text" | lucide icon for a nav group with no explicit icon. | | `icon_library` | String, nil | "lucide" | The RailsIcons library the chrome renders its own icons from. nil defers to the host default. | | `nav_storage_key` | String | = brand slug | Namespaces the sidebar localStorage (collapse state) so two sites on one origin don't collide. Writer only. | | `page_markdown_action` | Boolean | true | Show the "Markdown" masthead action (a link to the .md twin). false hides it; the .md route still works. | | `on_page_default` | :panel \| :toggle \| :sidebar \| false | :panel | Default auto-TOC placement when a page doesn't set its own on_page:. | > **Note:** The version badge accepts a **String OR a callable** — `c.version_badge = "v1.2"` and `c.version_badge = -> { "v#{Acme::VERSION}" }` both render. A lambda is handy when the version lives in a constant that loads after the initializer. ## The sidebar nav Registries are the canonical path; a nav lambda is the escape hatch. The common case is `nav_registries` — an ordered `{ "Heading" => registry_class }` map. Each registry answers `.nav_items` (a `DocsKit::Registry` method returning `{ group => [NavItem] }`), and the whole sidebar derives from it with zero site nav code. A heading whose registry has no authored pages is dropped, so no empty group renders. The registry's groups are the top level of the menu; the heading itself only renders — as a static label — when several headings are registered, so a one-registry site gets no redundant top fold. ```ruby c.nav_registries = { "Docs" => Doc, "API" => ApiDoc } ``` Reach for the explicit `nav` lambda only for bespoke nav — interleaving multiple registries, or hand-built subgroups. It must return an ordered `{ "Heading" => { "Subgroup" => [items] } }` Hash where each item responds to `#href`, `#label`, and optional `#icon`. ```ruby c.nav = lambda do grouped = Doc.all.group_by(&:group).transform_values do |docs| docs.map { |d| DocsKit::NavItem.new(href: "/docs/#{d.slug}", label: d.title) } end { "Getting started" => { "Basics" => grouped.fetch("basics", []) }, "Reference" => { "API" => grouped.fetch("api", []) } } end ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `nav_registries` | Hash | {} | { "Heading" => registry_class }; each registry answers .nav_items. The canonical, zero-code nav path. | | `nav` | callable | -> {} | Explicit nav lambda; wins over nav_registries when assigned. For bespoke interleaved nav only. | > **Warning:** Assigning `nav` to **any** value — even `-> { {} }` — marks it explicit and stops derivation from `nav_registries`. If you set an empty nav lambda while relying on registries, you get an empty sidebar. Leave `nav` unset unless you truly need the escape hatch. ## Code highlighting The Rouge themes and the lexer/label maps DocsUI::Code and Example read. Syntax highlighting is inline Rouge CSS. `code_theme` is the base (light) theme, emitted un-scoped so it applies everywhere. Set `code_theme_dark` and docs-kit additionally emits that theme's CSS scoped under each shipped **dark** theme — CSS-only, no JS, no flash. Which themes count as dark comes from `dark_themes` (defaults to the 13 built-in daisyUI dark themes), intersected with your `themes`. The lexer and label maps are merged **over** the built-ins, so you only add or override. Any of Rouge's ~200 languages already works by its own name — see the [Code languages](https://docs-kit.zoolutions.llc/docs/languages) page. | Option | Type | Default | Description | | --- | --- | --- | --- | | `code_theme` | String or Class | "Rouge::Themes::Monokai" | The base (light) Rouge theme for inline highlight CSS. An unresolvable name degrades to the default. | | `code_theme_dark` | String, Class, nil | nil | Optional second Rouge theme, scoped under each shipped dark theme. nil = single-theme behavior. | | `dark_themes` | Array | 13 built-in dark themes | Which theme names are treated as dark for code_theme_dark scoping. Override for custom dark themes. | | `code_lexer_aliases` | Hash | {} | Friendly-name → Rouge lexer aliases, merged over built-ins ({ dockerfile: "docker" }). | | `code_lexer_fallback` | String | "plaintext" | The lexer used when a language can't be resolved (no highlighting, never raises). | | `code_language_labels` | Hash | {} | Human labels for Example language tabs, merged over built-ins ({ elixir: "Elixir" }). | ## Search The topbar search form and the ⌘K command palette. Search is on by default. The Shell renders the affordance when `search` is true **and** `search_path` is non-blank (the gate is `#search_enabled?`). Blank the path to disable the form without touching the toggle. The keyboard shortcuts that open the palette are configurable — `mod` is the platform modifier (⌘ on mac, Ctrl elsewhere), so one entry works on every OS. See the [Search](https://docs-kit.zoolutions.llc/docs/search) page for how the index is built and served. | Option | Type | Default | Description | | --- | --- | --- | --- | | `search` | Boolean | true | Whether the topbar renders the search form + palette markup. | | `search_path` | String | "/docs/search" | Where the form submits (GET ?q=) and the palette fetches .json. Blank to disable. | | `search_shortcuts` | Array | %w[/ mod+k] | Keyboard shortcuts that open the palette. Writer only; read the parsed form via #search_shortcuts. | ## API examples The base URL, auth line, and client tabs DocsUI::RequestExample renders. The API-docs kit turns one request declaration into a tab per client. `api_base_url` is prefixed onto each snippet's path; `api_auth_header` is an optional example auth line merged into every snippet. `api_clients` overrides or extends the four shipped defaults (`curl`, `javascript`, `ruby`, `python`) — reusing a token replaces that client, a new token appends a tab. The [API reference](https://docs-kit.zoolutions.llc/docs/api) page shows the kit rendered live. ```ruby c.api_base_url = "https://api.acme.com" c.api_auth_header = "Authorization: Bearer sk_live_..." c.api_clients = { cli: DocsKit::ApiClient.new( label: "CLI", lexer: :shell, template: ->(req) { "acme #{req.http_method.downcase} #{req.path}" } ) } ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `api_base_url` | String | "https://api.example.com" | Prefixed onto each RequestExample path so snippets point at a real host. | | `api_auth_header` | String, nil | nil | Example Authorization header line merged into every snippet. nil = no auth line. | | `api_clients` | Hash | 4 shipped defaults | { token => DocsKit::ApiClient } merged over curl/javascript/ruby/python. Writer only; read the merged map via #api_clients. | ## AI & tooling The built-in MCP endpoint. docs-kit ships an optional read-only MCP endpoint (`POST /mcp`, JSON-RPC exposing `list_pages` / `get_page` / `search_docs` over the docs registry). `mcp` is true by default, but the endpoint only turns on when the optional `mcp` gem is **also** loadable and the host draws the route — gate on `#mcp_enabled?`, not the raw toggle. See the [AI & agents](https://docs-kit.zoolutions.llc/docs/ai) page for llms.txt, the .md twins, and the MCP server. | Option | Type | Default | Description | | --- | --- | --- | --- | | `mcp` | Boolean | true | Whether the built-in MCP endpoint is active. Actually gated by #mcp_enabled? (toggle AND the mcp gem loadable). | --- # Styling & CSS Getting started # Styling & CSS Each site builds its own Tailwind + daisyUI stylesheet so the chrome is themed to match — and code blocks restyle light↔dark with the switcher, CSS-only. ## The canonical build docs-kit ships no compiled CSS — you build it. docs-kit ships **no compiled CSS**. Each site builds its own with the Tailwind CLI (run via Bun), so the `@source` globs can see **both** your app **and** the gem's Phlex components. Without the gem in scope, every class the shared chrome uses would be tree-shaken away. The bundled `bin/build-css` resolves the docs-kit (and daisyUI) gem paths and adds them as extra `@source` entries, so you never hand-write a gem's install location. ```shell bun run build:css # one-shot, for deploys bun run watch:css # rebuild on change, for development ``` ## application.tailwind.css Your Tailwind entry point wires up daisyUI, the themes, and the sources. The `themes:` list here **must match** `c.themes` in your initializer — the CSS build ships exactly those themes and the `ThemeSwitcher` offers exactly those names. A theme in one list but not the other is either a dead switcher entry or an unreachable build. This is the single most important invariant on this page. ```css @import "tailwindcss"; /* daisyUI — the theme list MUST match DocsKit.configuration.themes. */ @plugin "daisyui" { themes: dark --default, light --prefersdark, synthwave, retro, cyberpunk, dracula, night, nord, sunset; } /* Your app's views + components + the gem's Phlex chrome. bin/build-css resolves the gem paths, so you never hard-code them. */ @source "../../../app/views/**/*.{rb,erb,haml,html,slim}"; @source "../../../app/components/**/*.rb"; @import "./tailwind.sources.css"; /* gem @source lines, generated */ ``` The `--default` modifier picks the theme applied on first paint and `--prefersdark` the one used when the OS asks for a dark scheme. That block above is this very site's — its nine themes are the nine in `c.themes`. > **Warning:** Interpolated Tailwind class names get tree-shaken. Always write **literal** class strings — e.g. `class: "badge badge-primary"`, never `class: "badge badge-\#{color}"`. The scanner can't see the built name, so the style never ships. New render-time classes (like the Drawer) need an `@source inline(...)` line. ## Adding a theme Two edits and a rebuild — CSS block, config, done. Themes come from daisyUI. To add one, keep the two lists in step: 1. Add the name to the `@plugin "daisyui" { themes: ... }` block in `application.tailwind.css`. 2. Add the same name to `c.themes` in `config/initializers/docs_kit.rb`. 3. Rebuild the CSS (`bun run build:css`). First entry in `c.themes` is the page default; override with `c.default_theme`. See [Configuration](https://docs-kit.zoolutions.llc/docs/configuration) for the full theme surface. ```ruby DocsKit.configure do |c| c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset] end ``` ## Code highlighting: one light theme, one dark Rouge highlights code; two config knobs make it follow the switcher. `DocsUI::Code` highlights with [Rouge](https://docs-kit.zoolutions.llc/docs/languages) and injects its **own** inline theme CSS — no separate stylesheet asset. Which theme that CSS uses is config: - `c.code_theme` — the **base** Rouge theme, emitted **un-scoped** so it applies under every daisyUI theme. Default `Rouge::Themes::Monokai`. - `c.code_theme_dark` — an **optional** second Rouge theme. When set, `Code` additionally emits that theme's CSS scoped under `[data-theme=X] .code-highlight` for each shipped dark theme. daisyUI's more-specific `[data-theme]` selector wins, so code blocks restyle when the switcher lands on a dark theme. **CSS-only — no JS, no flash.** Default `nil` (single-theme, byte-for-byte backwards compatible). - `c.dark_themes` — which theme names count as dark for that scoping. Defaults to the built-in daisyUI dark themes and is intersected with `c.themes` at render time, so only **shipped** dark themes emit CSS. A custom/branded dark theme must be listed here or its code CSS won't scope — docs-kit can't inspect the compiled daisyUI CSS to detect darkness. **This site sets both.** Its initializer picks a light base and a dark override, so every code block on the page you're reading restyles as you flip the theme switcher between a light theme (`light`, `retro`, `cyberpunk`, `nord`) and a dark one: ```ruby DocsKit.configure do |c| c.code_theme = "Rouge::Themes::Github" # light themes c.code_theme_dark = "Rouge::Themes::Monokai" # dark themes # c.dark_themes defaults to daisyUI's dark set; override only # for a custom dark theme the built-in list doesn't know. end ``` Try it: switch the theme in the topbar and watch this next block change palette. It's the same highlighter, two scoped stylesheets. ```ruby class Doc extend DocsKit::Registry path_prefix "/docs" view_namespace "Views::Docs::Pages" page "Overview", group: "Getting started" page "Styling & CSS", group: "Getting started" end ``` For this site, the shipped dark themes (the intersection of `c.dark_themes` and `c.themes`) are **dark, synthwave, dracula, night, sunset** — those five each get a `[data-theme=…]`-scoped Monokai block; the four light themes fall through to the un-scoped GitHub base. | Option | Type | Default | Description | | --- | --- | --- | --- | | `c.code_theme` | String or Class | Rouge::Themes::Monokai | Base (light) Rouge theme, emitted un-scoped. | | `c.code_theme_dark` | String, Class, nil | nil | Optional dark override, scoped per shipped dark theme. nil = single-theme. | | `c.dark_themes` | Array<String> | daisyUI dark set | Which theme names count as dark; intersected with c.themes at render. | > **Note:** A String theme name is resolved to its Rouge constant. A typo'd or unloaded name **degrades gracefully** — the base theme falls back to the default and a bad `code_theme_dark` simply emits no dark CSS, so a mistake never crashes a code block. ## Custom styles Plain CSS, @apply, @layer, or extra stylesheets. Add your own CSS below the imports in `application.tailwind.css` — plain rules, `@apply`, or `@layer` all work. To pull in additional, separately-built stylesheets (linked after the Tailwind build), list their logical names via `c.stylesheets` in your initializer. Default is `%w[application]` — the Bun/Tailwind build. ```ruby DocsKit.configure do |c| c.stylesheets = %w[application announcements] end ``` Next: see [Languages](https://docs-kit.zoolutions.llc/docs/languages) for the Rouge lexer surface, [Components](https://docs-kit.zoolutions.llc/docs/components) for the kit `Code` and `Example` render live, and [Configuration](https://docs-kit.zoolutions.llc/docs/configuration) for every config knob in one place. --- # Authoring pages Authoring # Authoring pages One command scaffolds a page — the class and its registry line. Then write content; the shell, masthead, and TOC come free. ## One command rails g docs_kit:page writes the class AND registers it — both derived from the title. ```shell rails g docs_kit:page "Getting Started" --group=Guide ``` That writes `app/views/docs/pages/getting_started.rb` (slug `getting-started`, class `GettingStarted`) and injects `page "Getting Started", group: "Guide"` into the `Doc` registry, so the page is routed and in the sidebar the moment you fill in `#content`. Every derivation is overridable: - `--slug=auth` — the URL slug, - `--view=OauthGuide` — the class basename, - `--eyebrow="Advanced"` — the eyebrow (defaults to the group), - `--registry=Guide` — a differently-named registry class. Re-running is idempotent, and a legacy hash-`entries` registry is left untouched (the generator prints the entry to add by hand). > **Tip:** The rest of this page is what the generator produces — the shape to reach for when you hand-write or edit a page. ## A page is a Phlex class Subclass DocsUI::Page, declare its metadata, fill in #content. ```ruby # frozen_string_literal: true # Compact class reference — Zeitwerk resolves it through the # directory-implied namespaces, so no nested-module ceremony. class Views::Docs::Pages::Guide < DocsUI::Page title "Guide" eyebrow "Getting started" def lead = "One sentence that sits under the page title." def content DocsUI::Section("First steps", description: "What this section covers.") do md <<~'MD' Prose written as Markdown, styled with the reading rhythm. MD DocsUI::Code(<<~SOURCE, filename: "config/routes.rb") Rails.application.routes.draw do mount DocsKit::Engine, at: "/docs" end SOURCE end end end ``` `title` names the page, `eyebrow` groups it above the title, and `lead` is the summary sentence under it. Everything you render lives in `content`. The shell (topbar, sidebar, theme switcher), the page masthead, and the **On this page** TOC are added automatically — you only write the body. ## Register the page One line in the Doc registry — slug and view derive from the title. A page shows up once it has a `page` line in the `Doc` registry. `slug` and `view` derive from the title (both overridable per line), and `group:` sets its sidebar heading. The generator injects this line for you. ```ruby class Doc extend DocsKit::Registry path_prefix "/docs" view_namespace "Views::Docs::Pages" page "Overview", group: "Getting started" page "Guide", group: "Getting started" # overrides win: page "OAuth", group: "Guide", slug: "auth", view: "OauthGuide" end ``` The sidebar derives from the registry — set `c.nav_registries = { "Docs" => Doc }` in the initializer and never hand-write a nav lambda again. > **Note:** The sidebar only links a page whose class exists, so a page line without its class yet is a no-op — no dead links. ## The building blocks The DocsUI kit you compose inside #content — and where each one is documented in full. Inside `#content` you reach for a small kit. The everyday four — a `DocsUI::Section` wrapper, `md` for Markdown prose, `DocsUI::Code` for a highlighted block, and `DocsUI::Callout` for an aside — carry most pages. The rest are specialised; each has its own reference page rather than being re-explained here. | Block | Use for | Full reference | | --- | --- | --- | | `DocsUI::Section(title)` | an anchored subsection with a heading (+ optional description:) | [Components](https://docs-kit.zoolutions.llc/docs/components) | | `md(source)` | a block of GFM Markdown — the everyday prose helper | [Markdown authoring](https://docs-kit.zoolutions.llc/docs/markdown) | | `prose { … }` | hand-authored prose (p/ul/code) in a reading-rhythm wrapper | [Components](https://docs-kit.zoolutions.llc/docs/components) | | `DocsUI::Code(source)` | a Rouge-highlighted code block | [Code languages](https://docs-kit.zoolutions.llc/docs/languages) | | `example { \|ex\| … }` | multi-language tabbed code | [Code languages](https://docs-kit.zoolutions.llc/docs/languages) | | `DocsUI::Callout(level)` | note / tip / warning boxes | [Components](https://docs-kit.zoolutions.llc/docs/components) | | `DocsUI::Table / PropTable` | reference tables — headers + rows, or the args preset | [Components](https://docs-kit.zoolutions.llc/docs/components) | | `DocsUI::Endpoint / RequestExample` | the API-reference kit — a method+path line, client tabs, a fields table | [API reference](https://docs-kit.zoolutions.llc/docs/api) | The primary argument is always positional — `Section("Title")`, `Code(source, filename:)` (the filename picks the language), `Header("Title")` — with modifiers as keywords (`description:`, `eyebrow:`). For the wrappers that take no argument, use the lowercase page helpers `prose` / `example` (and `md` for Markdown). A lowercase method takes a block without parens, so `prose do … end` just works. The kit forms `DocsUI::Prose()` / `DocsUI::Example()` stay valid — they only need the empty `()` because a bare `DocsUI::Prose do` parses as a constant reference (a SyntaxError). > **Tip:** Prose is a **Markdown island**: `md <<~'MD'` parses GFM with commonmarker and emits native Phlex nodes — tables, fenced code (routed through `DocsUI::Code`), links, all Phlex-escaped. Use a single-quoted heredoc so `#{...}` stays literal author text. See [Markdown authoring](https://docs-kit.zoolutions.llc/docs/markdown) for the full vocabulary. ## The "On this page" TOC Built for you from your section headings. Every `DocsUI::Section` heading becomes an entry in the automatic **On this page** table of contents — you never list them by hand. Override its placement per page with `on_page`. ```ruby class Views::Docs::Pages::Guide < DocsUI::Page on_page :toggle # :toggle | :panel | :sidebar | false end ``` See the [On this page](https://docs-kit.zoolutions.llc/docs/on-this-page) reference for every mode and the site-wide default. --- # Markdown authoring Authoring # Markdown authoring Write prose as Markdown with the md helper — GFM in, Prose-identical typography out, and every fence highlighted by Rouge. ## The md helper The everyday authoring entry point — a block of GFM, styled like Prose. `md` is the prose path you reach for on almost every page. Hand it a heredoc of GFM Markdown and it renders a `DocsUI::Markdown` island — a block styled with the **exact** typography of a hand-authored [Prose](https://docs-kit.zoolutions.llc/docs/components) block, so `md` prose and `prose do … end` read identically. ```ruby md <<~'MD' Write **prose** as Markdown — `inline code`, [links](/docs/overview), lists, and tables all styled like Prose. MD ``` This whole page is written with `md`. Under the hood the helper is `render DocsUI::Markdown.new(source)` — a lowercase method so the heredoc lands without the parens-with-blocks Ruby trap. It lives on `DocsUI::Page` (via the `PageHelpers` mixin) alongside `prose` and `example`; it is not a global, so a bare Phlex component would need to include `DocsUI::PageHelpers` to get it. > **Tip:** Markdown is prose-only. Document structure — the section headings that feed the [On this page](https://docs-kit.zoolutions.llc/docs/on-this-page) TOC — stays with `DocsUI::Section`, not with an island heading. ## Always single-quote the heredoc A single-quoted heredoc passes interpolation and backslashes through as literal author text. Use a **single-quoted** heredoc — `<<~'MD'` — for every `md` block. With single quotes Ruby does no interpolation, so `#{...}` and backslashes reach the parser as the literal characters you typed. That matters constantly in docs prose, where you *write about* interpolation and escapes rather than perform them. ```ruby # Good — single-quoted: the reader sees the literal text. md <<~'MD' Write `#{user.name}` to interpolate, and `\d+` for a digit. MD # Trap — double-quoted: Ruby evaluates #{user.name} before Phlex # ever sees it, and eats the backslash in \d. md <<~MD Write `#{user.name}` ... MD ``` Even a double-quoted heredoc is still *safe* — Phlex escapes all author free text, so nothing injects markup (see [Escaping](#escaping-raw-html) below). But it will silently *change your words*: `#{user.name}` becomes whatever that expression evaluates to, and `\d` loses its backslash. Single-quoting is the intended convention precisely so the prose you wrote is the prose the reader gets. ## The GFM you can write Headings, lists, tables, fenced code, links, blockquotes — parsed by commonmarker. Islands parse **GitHub-Flavored Markdown** with commonmarker (v2 / comrak). The full everyday vocabulary is here: #### Headings, emphasis, lists A `#` heading renders as an `<h3>`; `##` and anything deeper collapse to `<h4>` — demoted so an island heading never collides with the page masthead or a `DocsUI::Section` heading. Hierarchy inside an island is intentionally flat. Inline you get **strong**, *emphasis*, ~~strikethrough~~, and `inline code`. Lists come tight or loose, bullet or ordered, nested: - a bullet item, - another, with a nested list: 1. first ordered step, 2. second ordered step. #### Tables A GFM pipe table renders as the kit's daisyUI table — a `not-prose` overflow wrapper around a `table table-sm table-zebra`. The first row is the header, the rest the body: | Syntax | Renders as | | --- | --- | | `**bold**` | strong | | `~~gone~~` | strikethrough | | `` `code` `` | inline code | #### Fenced code → Rouge A fenced ```` ```lang ```` block routes through [`DocsUI::Code`](https://docs-kit.zoolutions.llc/docs/components), so it is Rouge-highlighted exactly like a hand-written Code block — same wrapper, same token spans, same configured [language aliases](https://docs-kit.zoolutions.llc/docs/languages): ```ruby class Doc extend DocsKit::Registry page "Overview", group: "Getting started" end ``` No fence language — or an unknown one — falls back to plaintext and never raises. #### Links, blockquotes, rules [Links](https://docs-kit.zoolutions.llc/docs/authoring) are ordinary `[text](url)`. A `>` line is a blockquote: > Prose written as Markdown, styled with the reading rhythm. And a line of three dashes is a thematic break — the horizontal rule just below this paragraph: --- Everything above the rule was one `md` island. > **Note:** A soft line break (a single newline inside a paragraph) becomes a single space, not a `<br>`. Only a hard break — two trailing spaces or a trailing backslash — becomes a `<br>`. ## Inline markdown in a table cell Markdown.inline renders inline children with no Prose wrapper — for a [:md, …] cell. `DocsUI::Markdown.inline(source)` is the inline sibling: **no** Prose wrapper div, and a single top-level paragraph is unwrapped so its inline children — strong, em, code, a link — sit directly in the surrounding element. It exists for the `[:md, "…"]` cell form of [`DocsUI::Table` / `PropTable` / `FieldTable`](https://docs-kit.zoolutions.llc/docs/components), where the `<td>` is already the container and a block paragraph would be wrong. | name | description | | --- | --- | | `events` | Event types, e.g. `payment_link.paid`. | | `amount` | Amount in the **smallest** currency unit. | The description cells above are inline markdown — the call that produced the table: ```ruby DocsUI::Table( [ "name", "description" ], [ [ [ :code, "events" ], [ :md, "Event types, e.g. `payment_link.paid`." ] ], [ [ :code, "amount" ], [ :md, "Amount in the **smallest** currency unit." ] ] ] ) ``` Adjacent top-level blocks get a joining space when unwrapped, so two paragraphs never fuse (`"one"` + `"two"` → `"one two"`, not `"onetwo"`). You rarely call `.inline` directly — the `[:md, …]` cell form invokes it for you. ## Escaping & raw HTML Author free text is Phlex-escaped; raw HTML tags are dropped entirely. Because the island *walks the commonmarker AST and emits native Phlex nodes* — it never `raw`s commonmarker's HTML string — all author free text is Phlex-escaped. There is no `html_safe` on prose (Critical Rule 7 holds), and `<`, `>`, `&` inside inline `` `code` `` are safe. Raw HTML is dropped: `html_block` and `html_inline` AST nodes are skipped entirely, so author Markdown can never inject a live `<script>` or `<div onclick>` tag. There is no config to re-enable it. > **Warning:** Only the tags are dropped, not the text between them. The body of `<script>alert(1)</script>` survives as a separate commonmarker text node — inert, Phlex-escaped prose that reads as the literal words `alert(1)`. Never executable, but not erased either. Input is also normalized at the boundary: the initializer does `source.to_s.encode(Encoding::UTF_8)`, so a `nil` source renders an empty wrapper (never raises) and a US-ASCII heredoc parses fine. ## Markdown flows into the .md twin Every page has a raw-Markdown twin; the masthead links it. Every docs page has a `.md` twin — the same page served as raw Markdown at its path plus `.md`. The **Markdown** button in this page's masthead points at it. With JavaScript off, the link simply opens the raw Markdown (a working no-JS fallback); with JS on, the one [`docs-nav`](https://docs-kit.zoolutions.llc/docs/ai) controller intercepts the click, fetches the `.md`, copies it to your clipboard, and prevents the navigation. ```ruby # DocsUI::Page renders this automatically when # DocsKit.configuration.page_markdown_action is true (the default). render DocsUI::MarkdownAction.new(request.path) ``` The affordance is a new target + action on the single `docs-nav` controller — the one-controller rule holds. The `.md` twin *content* itself is produced by `DocsKit::Controller#render_page` → `DocsKit::MarkdownExport`, not by this button. Disable the button site-wide with `c.page_markdown_action = false`. > **Note:** The twin href is idempotent and query-preserving: `/docs/markdown` → `/docs/markdown.md`, `/x?a=1` → `/x.md?a=1`, and a path already ending in `.md` is left untouched. ## DocsUI::Markdown args The component behind the md helper. | Arg | Type | Default | Description | | --- | --- | --- | --- | | `source` | String, nil | — | The GFM to render. nil/non-UTF-8 is normalized (never raises). | | `inline:` | Boolean | false | No Prose wrapper; unwrap a lone top-level paragraph (for a [:md, …] cell). | | `md(source)` | page helper | — | render DocsUI::Markdown.new(source) — the everyday path. | | `.inline(source)` | class method | — | == new(source, inline: true); used by [:md, …] table cells. | --- # Code languages Reference # Code languages Any language Rouge knows works. Multi-language examples remember your choice. ## Multi-language examples Pick a tab — every example on the page follows. `DocsUI::Example` renders one example in several languages with tabs. The choice is a **global sticky preference** — persisted in `localStorage` and synced across every example group on the page and across pages. Switch Ruby → Python once and it sticks everywhere. Ruby Python JavaScript ```ruby client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"]) msg = client.messages.create( model: "claude-opus-4-8", max_tokens: 1024, messages: [{ role: "user", content: "Hello" }] ) puts msg.content.first.text ``` ```python client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) msg = client.messages.create( model="claude-opus-4-8", max_tokens=1024, messages=[{"role": "user", "content": "Hello"}], ) print(msg.content[0].text) ``` ```javascript const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const msg = await client.messages.create({ model: "claude-opus-4-8", max_tokens: 1024, messages: [{ role: "user", content: "Hello" }], }); console.log(msg.content[0].text); ``` Author it by handing each language a code block: ```ruby example do |ex| ex.code(:ruby, filename: "client.rb") { ruby_source } ex.code(:python, filename: "client.py") { python_source } ex.code(:javascript) { js_source } end ``` ## Any language No allowlist — Rouge's full registry (~200 lexers). ```go package main import "fmt" func main() { fmt.Println("go works") } ``` ```rust fn main() { println!("rust works"); } ``` ```elixir defmodule Demo do def hello, do: IO.puts("elixir works") end ``` ## Configuring languages Pass any Rouge lexer name to `lexer:` directly. Register **aliases** to map a custom name onto a real lexer, and **labels** to control the tab caption an example shows: ```ruby DocsKit.configure do |c| c.code_lexer_aliases = { curl: "console" } c.code_language_labels = { elixir: "Elixir" } end ``` Now `DocsUI::Code(src, lexer: :curl)` highlights with the `console` lexer. An unknown lexer falls back to `code_lexer_fallback` (plaintext) instead of raising. | Option | Purpose | | --- | --- | | `code_lexer_aliases` | Map friendly names onto real Rouge lexers, e.g. { curl: "console" }. Merged over the built-in aliases. | | `code_lexer_fallback` | Lexer used when a requested name is unknown. Defaults to "plaintext" — no highlighting, no error. | | `code_language_labels` | Override the tab caption per language in DocsUI::Example, e.g. { elixir: "Elixir" }. | > **Tip:** Aliases and labels are optional — every Rouge lexer already works by name. Reach for these only to rename a lexer (curl → console) or polish a tab caption. --- # API reference Authoring # API reference Declare an endpoint once — method, path, fields, one request — and the kit renders the badge, the tables, and a code tab per client. Base URL, auth, and the client set are config. ## The API-reference kit Six components compose a full endpoint reference — no hand-rolled badges or per-language heredocs. An API-reference page is the same page you'd write for anything else — a Phlex class of `DocsUI::Section`s (see [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring)). What's different is the vocabulary you reach for inside `#content`: - `DocsUI::Endpoint` — a verb badge + monospace path, inline; - `DocsUI::FieldTable` / `DocsUI::ErrorTable` — schema-driven tables; - `DocsUI::RequestExample` — one request declaration → one code tab per client; - `DocsUI::JsonResponse` — a Ruby Hash rendered as pretty JSON. Three config knobs — `api_base_url`, `api_auth_header`, `api_clients` — feed every `RequestExample`, so a snippet is authored once and points at the right host with the right auth. The [Components](https://docs-kit.zoolutions.llc/docs/components) reference lists every arg; this page walks the workflow. ## Endpoint — the method + path line A colour-coded verb badge and a monospace path, rendered inline. `DocsUI::Endpoint.new(method, path)` renders an HTTP method badge coloured per verb — `GET` success, `POST` primary, `PUT`/`PATCH` warning, `DELETE` error, anything else neutral — followed by the path in a `<code>`. It renders **inline** (no wrapper), on purpose: drop it straight into a `Section`'s `description:`. ```ruby DocsUI::Section("List customers", description: DocsUI::Endpoint.new(:get, "/v1/customers")) do # fields, request example, response… end ``` The badge live, for each verb: GET /v1/customers POST /v1/customers PATCH /v1/customers/:id DELETE /v1/customers/:id > **Note:** The verb → badge map is a frozen Hash of **literal** class strings so Tailwind's Ruby scan generates each colour — never interpolate them. The path is Phlex-escaped, and an unknown verb (e.g. `:trace`) degrades to a neutral badge rather than raising. ## FieldTable & ErrorTable — the schemas Keyword-schema presets over DocsUI::Table for request fields and endpoint errors. `DocsUI::FieldTable` takes an Array of field Hashes and renders **Name / Type / Required / Description**. `name` is auto code-styled, `required:` defaults to `false` (a `✓` when true, the canonical `—` when not), and the description cell follows `DocsUI::Table`'s convention — a plain `String` is escaped text, `[:code, "x"]` is inline code, `[:md, "…"]` is inline Markdown. ```ruby render DocsUI::FieldTable.new([ { name: "email", type: "string", required: true, description: "The customer's email address." }, { name: "metadata", type: "object", description: [:md, "Up to 50 keys, e.g. `plan: pro`."] } ]) ``` `DocsUI::ErrorTable` renders **Scenario / Status / Type** — plus a **Param** column, but only when at least one error names a `param:`. An endpoint whose errors are all param-free renders a clean three-column table; when the column IS shown, a param-free row gets the em-dash. `type` and `param` are auto code-styled. ```ruby render DocsUI::ErrorTable.new([ { scenario: "Missing or invalid API key", status: "401", type: "authentication_error" }, { scenario: "Email already taken", status: "422", type: "validation_error", param: "email" } ]) ``` > **Tip:** The `—` placeholder is the kit's ONE canonical "no value" glyph (an em-dash, never an ASCII hyphen) — both tables share it, so a page never types a stray `-`. ## RequestExample — one declaration, every client Declare method/path/body once; get a syntax-highlighted tab per configured client. `DocsUI::RequestExample` is the payoff. Declare the request once and it renders one code tab per configured client — `curl`, `javascript`, `ruby`, `python` by default — wrapped in a `DocsUI::Example`, so the reader's sticky global language choice persists across every endpoint on the site. ```ruby render DocsUI::RequestExample.new( method: :post, path: "/v1/customers", body: { email: "ada@example.com", name: "Ada Lovelace" } ) ``` Under the hood each tab is fed a `DocsKit::ApiRequest` — an immutable value object carrying `method`/`path`/`url`/`query`/`headers`/`body` with display helpers (`#http_method`, `#body?`, `#pretty_body_json`, `#url_with_query`). Every shipped template guards its payload lines on `#body?`, so a body-less GET emits no dangling `-d` / `json=` / `request.body =` line. | Option | Type | Default | Description | | --- | --- | --- | --- | | `method: / path:` | Symbol/String, String | — | The verb and path; path is appended to c.api_base_url. | | `body:` | Hash, String, nil | nil | Payload — deep-stringified into each snippet. Omit for a GET. | | `query:` | Hash | {} | Query params, URL-encoded onto every snippet's URL. | | `headers:` | Hash | {} | Extra headers, merged over the config auth header (explicit wins). | | `clients:` | Array<Symbol>, nil | all configured | Filter AND order the tabs, e.g. %i[curl ruby]. | > **Warning:** A **lone** client renders no tabs — `DocsUI::Example` needs at least two. When demoing one custom client, pair it (e.g. `clients: %i[cli curl]`). An unknown token in `clients:` is silently skipped — a typo yields fewer tabs, never a raise. ## Configure the base URL & auth Two knobs point every snippet at your real host with a real auth line. `RequestExample` reads three config knobs. Set them once in the initializer (see [Configuration](https://docs-kit.zoolutions.llc/docs/configuration)) and every endpoint inherits them. - `c.api_base_url` — prefixed onto every `path:`. Defaults to the neutral `https://api.example.com`. - `c.api_auth_header` — an example `Authorization` line merged into every snippet. Defaults to `nil` → no auth line (clean snippets for a no-auth API). It's split on the first colon into a `{ name => value }` header, merged **under** any explicit `headers:` you pass (so your explicit header wins). - `c.api_clients` — override or extend the tab set (next section). ```ruby DocsKit.configure do |c| c.api_base_url = "https://api.acme.com" c.api_auth_header = "Authorization: Bearer sk_live_..." end ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `c.api_base_url` | String | "https://api.example.com" | Host prefixed onto every RequestExample path. | | `c.api_auth_header` | String, nil | nil | Example Authorization line merged into every snippet. | | `c.api_clients` | Hash | {} | Overrides/extensions merged over the four defaults (writer only). | ## Custom clients — SDK-flavored tabs Replace a default tab with an SDK snippet, or append your own (a cli tab). The gem ships **generic HTTP** snippets because it can't know your SDK. A `DocsKit::ApiClient` describes one tab — a `label`, a Rouge `lexer`, an optional `filename` (a String or a `(request) -> String` proc), and a `template`: a `(DocsKit::ApiRequest) -> String` callable that renders the snippet. Set `c.api_clients` to a `{ token => ApiClient }` Hash. It merges **over** the four defaults (`curl`, `javascript`, `ruby`, `python`): reusing a default token **replaces** that tab with an SDK-flavored one; a new token **appends** a tab. Hash merge preserves order — reused tokens keep their slot, new ones append in declaration order. ```ruby DocsKit.configure do |c| c.api_clients = { # Replace the generic Ruby tab with the SDK flavour: ruby: DocsKit::ApiClient.new( label: "Ruby", lexer: :ruby, filename: "acme.rb", template: ->(req) { %(Acme.#{req.http_method.downcase}("#{req.path}")) } ), # Append a brand-new CLI tab: cli: DocsKit::ApiClient.new( label: "Acme CLI", lexer: :shell, filename: "cli.sh", template: ->(req) { "acme #{req.http_method.downcase} #{req.path}" } ) } end ``` > **Note:** Read the effective map via `DocsKit.configuration.api_clients` (which merges over the defaults), never the raw ivar. Because it merges, setting one `cli:` client yields FIVE tabs, not one. Config can't **remove** a default tab — use `RequestExample`'s `clients:` to select a subset. ## A full endpoint, live `POST` `/v1/customers` Everything above, composed — an `Endpoint` description, a `FieldTable`, an `ErrorTable`, a `RequestExample` (four real client tabs, on THIS site's config), and a `JsonResponse`. This is the real kit rendering, not a screenshot: | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | ✓ | The customer's email address. | | `name` | string | — | The customer's full name. | | `metadata` | object | — | Up to 50 key/value pairs, e.g. `plan: pro`. | | Scenario | Status | Type | Param | | --- | --- | --- | --- | | Missing or invalid API key | 401 | `authentication_error` | — | | Email already registered | 422 | `validation_error` | `email` | Try it — one declaration renders every client tab: cURL JavaScript Ruby Python ```console curl -X POST 'https://api.example.com/v1/customers' \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "name": "Ada Lovelace", "metadata": { "plan": "pro" } }' ``` ```javascript const response = await fetch("https://api.example.com/v1/customers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({"email":"ada@example.com","name":"Ada Lovelace","metadata":{"plan":"pro"}}), }); const data = await response.json(); ``` ```ruby require "net/http" require "json" uri = URI("https://api.example.com/v1/customers") request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = { "email": "ada@example.com", "name": "Ada Lovelace", "metadata": { "plan": "pro" } }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) end ``` ```python import requests response = requests.post( "https://api.example.com/v1/customers", headers={"Content-Type": "application/json"}, json={ "email": "ada@example.com", "name": "Ada Lovelace", "metadata": { "plan": "pro" } }, ) data = response.json() ``` A successful response: ```json { "id": "cus_1a2b3c", "object": "customer", "email": "ada@example.com", "name": "Ada Lovelace", "metadata": { "plan": "pro" }, "created": 1720000000 } ``` The calls that produced the block above: ```ruby DocsUI::Section("Create a customer", description: DocsUI::Endpoint.new(:post, "/v1/customers")) do render DocsUI::FieldTable.new([ { name: "email", type: "string", required: true, description: "The customer's email address." } ]) render DocsUI::ErrorTable.new([ { scenario: "Email already registered", status: "422", type: "validation_error", param: "email" } ]) render DocsUI::RequestExample.new( method: :post, path: "/v1/customers", body: { email: "ada@example.com", name: "Ada Lovelace" } ) render DocsUI::JsonResponse.new( { id: "cus_1a2b3c", object: "customer", email: "ada@example.com" } ) end ``` > **Tip:** See the [Components](https://docs-kit.zoolutions.llc/docs/components) reference for every arg of every kit component, and [Markdown authoring](https://docs-kit.zoolutions.llc/docs/markdown) for the `md` prose used throughout this page. --- # OpenAPI bridge Authoring # OpenAPI bridge If you already maintain an OpenAPI spec, don't restate it. Point c.openapi at the file and one line renders the whole endpoint — method, path, fields, errors, request tabs, and response — derived from the spec. ## Zero hand-restatement One operation call → a full endpoint reference, composed from the kit. An API-reference page normally restates every method, path, field, and response that your `openapi.yaml` already describes — and a field rename means editing both. The **OpenAPI bridge** closes that gap: a single `operation "operationId"` reads the spec and renders the whole endpoint through the same kit the [API reference](https://docs-kit.zoolutions.llc/docs/api) page composes by hand — a `DocsUI::Endpoint` badge, `FieldTable`s, an `ErrorTable`, a `RequestExample` (or your `x-codeSamples`), and a `JsonResponse`. Because it's composed from the kit, the `.md` twin, `llms.txt`, search, and MCP surfaces all derive from it for free. ## Point c.openapi at your spec A path (.json ⇒ JSON, else YAML) or an already-parsed Hash. nil by default. ```ruby DocsKit.configure do |c| c.openapi = Rails.root.join("openapi.yaml") end ``` Then, in any page's `#content`, render an operation by its `operationId`: ```ruby def content operation "createInvoice" end ``` > **Tip:** The document is memoized and reloads when the file's mtime changes, so editing `openapi.yaml` in development shows up without a server restart. ## What one operation expands to Each part of the operation maps to one kit component. | Spec source | Renders as | | --- | --- | | operationId + summary + method/path | the Section title + a `DocsUI::Endpoint` badge | | description | Markdown prose | | parameters (query/path) | a `DocsUI::FieldTable` | | requestBody schema ($ref, allOf, nested) | a `FieldTable` (nested names dotted: `customer.id`) | | 4xx / 5xx responses | a `DocsUI::ErrorTable` (error `type` from a response example) | | x-codeSamples | `DocsUI::Example` tabs (a lone sample → a plain `Code`) | | no code samples | a generated `DocsUI::RequestExample` | | first 2xx example | a `DocsUI::JsonResponse` | ## Live: createInvoice Rendered from docs/openapi.yaml — request body, errors, tabs, and response. The section below is produced by a single call — `operation "createInvoice"`. Nothing here is hand-written: ## Create an invoice `POST` `/v1/invoices` Creates a new invoice for a customer and returns it. | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | — | The unique invoice identifier. | | `amount` | integer | ✓ | Amount due, in the smallest currency unit (e.g. cents). | | `currency` | usd \| eur \| gbp | ✓ | Three-letter ISO currency code. | | `customer` | object | — | The customer the invoice is billed to. | | `customer.id` | string | — | | | `customer.email` | string | — | | | `created` | integer | — | Unix timestamp of creation. | | Scenario | Status | Type | | --- | --- | --- | | Missing or invalid API key. | 401 | `authentication_error` | | The invoice failed validation. | 422 | `validation_error` | cURL JavaScript Ruby Python ```console curl -X POST 'https://api.example.com/v1/invoices' \ -H "Content-Type: application/json" \ -d '{ "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 }' ``` ```javascript const response = await fetch("https://api.example.com/v1/invoices", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({"id":"inv_1a2b3c","amount":4200,"currency":"usd","customer":{"id":"cus_9f8e7d","email":"ada@example.com"},"created":1720000000}), }); const data = await response.json(); ``` ```ruby require "net/http" require "json" uri = URI("https://api.example.com/v1/invoices") request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = { "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) end ``` ```python import requests response = requests.post( "https://api.example.com/v1/invoices", headers={"Content-Type": "application/json"}, json={ "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 }, ) data = response.json() ``` ```json { "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 } ``` ## Live: getInvoice (with x-codeSamples) This operation ships x-codeSamples, so they replace the generated tabs. `getInvoice` carries `x-codeSamples` (a Ruby SDK tab and a CLI tab) in the spec, so the bridge renders those instead of the generic curl/JS/Ruby/Python snippets — and substitutes the `id` parameter's example into the path: ## Retrieve an invoice `GET` `/v1/invoices/{id}` Fetches a single invoice by its ID. | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | ✓ | The invoice ID. | | Scenario | Status | Type | | --- | --- | --- | | Missing or invalid API key. | 401 | `authentication_error` | Ruby SDK CLI ```ruby Billing::Invoice.retrieve("inv_1a2b3c") ``` ```shell billing invoices retrieve inv_1a2b3c ``` ```json { "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 } ``` ## Live: listInvoices (filter the tabs) A GET with query parameters; here we keep just the curl and Ruby tabs. Pass `clients:` to filter and order the generated client tabs — here `clients: %i[curl ruby]`: ## List invoices `GET` `/v1/invoices` Returns a paginated list of invoices, most recent first. | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | — | Maximum number of invoices to return (1–100). | | `status` | open \| paid \| void | — | Filter by invoice status. | cURL Ruby ```console curl -X GET 'https://api.example.com/v1/invoices?limit=20' ``` ```ruby require "net/http" require "json" uri = URI("https://api.example.com/v1/invoices?limit=20") request = Net::HTTP::Get.new(uri) request["Content-Type"] = "application/json" response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) end ``` ```json [ { "id": "inv_1a2b3c", "amount": 4200, "currency": "usd", "customer": { "id": "cus_9f8e7d", "email": "ada@example.com" }, "created": 1720000000 } ] ``` ## Lookup, prose, and errors By id or verb+path; append prose with a block; unknown ids raise. ```ruby operation :delete, "/v1/invoices/{id}" # verb + path (id-less specs) operation "createInvoice", clients: %i[curl ruby] # only these tabs operation "createInvoice" do |op| # append prose in the section op.md("Idempotency keys are honored for 24 hours.") end ``` > **Warning:** An unknown `operationId` raises `DocsKit::OpenApi::OperationNotFound` (naming the available ids); an external/remote `$ref` raises `DocsKit::OpenApi::UnsupportedRef`. Authoring or validating the spec itself is out of scope — bring your own. --- # Components Reference # Components Every DocsUI component — shown live where it makes sense, with its calling code and args. ## Shell The whole HTML document you're looking at right now. `DocsUI::Shell` is the top-level page: the topbar (brand + `ThemeSwitcher`), the `Sidebar`, the content column, and the auto TOC. It yields the page body. `Page` renders it for you — you rarely construct it directly. ```ruby DocsUI::Shell(title: "My guide", on_page: :panel) do # page body end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `title` | String, nil | nil | Document + topbar title. Falls back to the site brand. | | `on_page` | Symbol, false | false | TOC placement — :panel / :toggle / :sidebar / false. | ## Page The base class you subclass for every docs page — including this one. Subclass `DocsUI::Page`, set `title`/`eyebrow` with the class-level DSL, and implement `#content` (and optionally `#lead`). The page wraps your content in the `Shell` with a `Header` masthead and auto TOC. ```ruby class Views::Docs::Pages::Guide < DocsUI::Page title "My guide" eyebrow "Reference" on_page :toggle # :panel | :toggle | :sidebar | false def lead = "One-sentence summary." def content = DocsUI::Section("Hello") { prose { p { "..." } } } end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `title` | String (class DSL) | — | Sets the document + masthead title. | | `eyebrow` | String (class DSL) | nil | Small kicker above the h1 (e.g. the group). | | `on_page` | Symbol (class DSL) | config default | TOC placement — :panel / :toggle / :sidebar / false. | | `#lead` | instance method | nil | Muted summary paragraph under the h1. | | `#content` | instance method | — | The page body — call kit components here. | ## Header The masthead: eyebrow + h1 + optional lead. The block at the top of this page — kicker, heading, summary — is a `DocsUI::Header`. `Page` builds it from your `title`/`eyebrow`/`#lead`, so you seldom render it yourself. ```ruby DocsUI::Header("My guide", eyebrow: "Reference") do plain "An optional lead paragraph." end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `title` | String (positional) | — | The h1 text. Legacy title: kwarg still accepted. | | `eyebrow` | String, nil | nil | Small kicker above the h1. | | `block` | Phlex block | nil | Optional lead paragraph rendered under the h1. | ## Section An anchored section wrapper — this description is its description: arg. Every block on this page is a `DocsUI::Section`. It renders a `<section id>` with an `<h2>`, an optional muted `description:` lead, then your block. The `id` auto-slugs from the title (feeding the TOC), or pass `id:` to override it. ```ruby DocsUI::Section("Getting started", id: "start", description: "Read me first.") do prose { p { "Section body." } } end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `title` | String | — | The h2 text; auto-slugs into the anchor id. | | `id` | String, nil | slug of title | Override the section anchor. | | `description` | String, callable, nil | nil | Muted lead paragraph under the h2. | ## Prose A typographic wrapper for hand-authored HTML. Prose gives hand-authored text a consistent reading rhythm without a typography plugin. - lists, - inline code, - links — all styled. The call that produced the block above: ```ruby prose do p { "Prose gives hand-authored text a consistent reading rhythm." } ul { li { "lists," }; li { "inline code," }; li { "links." } } end ``` > **Tip:** On a `DocsUI::Page` use the lowercase `prose do … end` helper — a method call, no parens needed. The kit form `DocsUI::Prose() do … end` also works, but bare `DocsUI::Prose do` is a SyntaxError, so it needs the empty `()`. | Arg | Type | Default | Description | | --- | --- | --- | --- | | `block` | Phlex block | — | Hand-authored HTML — p, ul/li, code, strong, a, plain text. | ## Code A Rouge-highlighted code block with an optional filename bar. ```ruby class User < ApplicationRecord has_many :posts end ``` The call that produced the block above: ```ruby DocsUI::Code(source, lexer: :ruby, filename: "app/models/user.rb") ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `source` | String | — | The code to highlight. | | `lexer` | Symbol | inferred | Any Rouge language — :shell, :yaml, :erb, :python, :go, etc. Overrides the filename guess; ruby when neither is given. | | `filename` | String, nil | nil | Optional filename bar above the block. Also selects the language (*.yml → yaml, Dockerfile → docker, *.sh → shell). | ## Example Multi-language tabbed code with a sticky, global language choice. Ruby Python ```ruby Anthropic::Client.new.messages.create(model: "claude-opus-4-8", messages: msgs) ``` ```python anthropic.Anthropic().messages.create(model="claude-opus-4-8", messages=msgs) ``` The call that produced the tabs above: ```ruby example do |ex| ex.code(:ruby, filename: "client.rb") { ruby_source } ex.code(:python, filename: "client.py") { python_source } end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `block` | Phlex block | — | Yields an object with #code — one call per language. | | `ex.code lang` | Symbol | — | The Rouge language for this tab. | | `ex.code filename:` | String, nil | nil | Optional filename bar for this tab. | | `ex.code lexer:` | Symbol | lang | Override the Rouge lexer if it differs from the tab label. | ## Table & PropTable Reference tables — generic headers+rows, and a name/type/default/description preset. `DocsUI::Table` renders headers + rows in the kit's daisyUI look. A cell is a `String` (plain, escaped), a `[:code, "x"]` pair (inline `<code>`), or a `[:md, "…"]` pair (inline Markdown). `DocsUI::PropTable` is the preset every args table on this page uses — the same shape, first column auto code-styled, default `Option/Type/Default/Description` headers. | Cell | Renders as | | --- | --- | | brand | plain, escaped text | | `%w[dark light]` | inline code | | a **bold** note | inline markdown | The call that produced the table above: ```ruby DocsUI::Table( [ "Cell", "Renders as" ], [ [ "brand", "plain, escaped text" ], [ [ :code, "%w[dark light]" ], "inline code" ], [ [ :md, "a **bold** note" ], "inline markdown" ] ] ) ``` > **Tip:** Every args table on this page is a `DocsUI::PropTable` — pass just the rows; the headers default to `Option/Type/Default/Description` (override with `headers:`). | Option | Type | Default | Description | | --- | --- | --- | --- | | `DocsUI::Table headers` | Array | — | Header labels — one per column. | | `DocsUI::Table rows` | Array | — | Rows; each a cell array (String / [:code, x] / [:md, …]). | | `DocsUI::PropTable rows` | Array | — | Rows; the first cell is auto-wrapped in <code>. | | `DocsUI::PropTable headers:` | Array | Option/Type/Default/Description | Override the header labels. | ## Endpoint, FieldTable & ErrorTable The API-reference kit — a method+path line, a fields table, and an error table. `DocsUI::Endpoint` renders an HTTP method badge (coloured per verb) plus a monospace path, inline — so it drops straight into a `Section` description. `FieldTable` and `ErrorTable` are keyword-schema presets over `Table` for an object's fields and an endpoint's errors. ## Create a webhook endpoint `POST` `/api/webhook_endpoints` Registers a destination URL for outbound event notifications. | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | ✓ | HTTPS destination URL. | | `description` | string | — | Optional internal label. | | `events` | array | ✓ | Event types, e.g. `payment_link.paid`. | | Scenario | Status | Type | Param | | --- | --- | --- | --- | | Missing or invalid API key | 401 | `authentication_error` | — | | Non-HTTPS URL | 422 | `validation_error` | `url` | | Unknown event name | 422 | `validation_error` | `events` | The calls that produced the block above: ```ruby DocsUI::Section("Create a webhook endpoint", description: DocsUI::Endpoint.new(:post, "/api/webhook_endpoints")) do render DocsUI::FieldTable.new([ { name: "url", type: "string", required: true, description: "HTTPS destination URL." }, { name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`."] } ]) render DocsUI::ErrorTable.new([ { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" } ]) end ``` > **Tip:** Verb → colour is a frozen Hash of literal badge classes (`GET` → success, `POST` → primary, `PATCH/PUT` → warning, `DELETE` → error). An unknown verb renders a neutral badge — no raise. | Call | Type | Default | Description | | --- | --- | --- | --- | | `DocsUI::Endpoint.new(method, path)` | Symbol/String, String | — | Method badge + monospace path; renders inline. | | `DocsUI::FieldTable.new(fields)` | Array<Hash> | — | Each: { name:, type:, required: false, description: }. | | `DocsUI::ErrorTable.new(errors)` | Array<Hash> | — | Each: { scenario:, status:, type:, param: nil }; Param column auto-hidden. | | `Section(description:)` | String, proc, or component | nil | Now also accepts a Phlex component instance. | ## RequestExample & JsonResponse The API-docs kit — declare a request once, get every client tab; render a Ruby hash as a JSON response. `DocsUI::RequestExample` turns one structured request declaration (`method:`/`path:`/`body:`) into a `DocsUI::Example` with one tab per configured client — `curl`, `javascript`, `ruby`, `python` by default (a site adds its own, e.g. a `cli` tab). `DocsUI::JsonResponse` renders a Ruby Hash as pretty-printed JSON — no hand-rolled `deep_stringify`. ## Create a payment link `POST` `/v1/payment_links` Creates a shareable payment link for a fixed amount. | Name | Type | Required | Description | | --- | --- | --- | --- | | `amount` | integer | ✓ | Amount in the smallest currency unit. | | `currency` | string | ✓ | ISO 4217 currency code. | | `description` | string | — | Shown to the payer at checkout. | cURL JavaScript Ruby Python ```console curl -X POST 'https://api.example.com/v1/payment_links' \ -H "Content-Type: application/json" \ -d '{ "amount": 4900, "currency": "usd", "description": "Pro plan" }' ``` ```javascript const response = await fetch("https://api.example.com/v1/payment_links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({"amount":4900,"currency":"usd","description":"Pro plan"}), }); const data = await response.json(); ``` ```ruby require "net/http" require "json" uri = URI("https://api.example.com/v1/payment_links") request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = { "amount": 4900, "currency": "usd", "description": "Pro plan" }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) end ``` ```python import requests response = requests.post( "https://api.example.com/v1/payment_links", headers={"Content-Type": "application/json"}, json={ "amount": 4900, "currency": "usd", "description": "Pro plan" }, ) data = response.json() ``` A successful response: ```json { "id": "plink_1a2b3c", "object": "payment_link", "amount": 4900, "currency": "usd", "url": "https://pay.example.com/plink_1a2b3c", "active": true } ``` The calls that produced the block above: ```ruby render DocsUI::RequestExample.new( method: :post, path: "/v1/payment_links", body: { amount: 4900, currency: "usd", description: "Pro plan" } ) render DocsUI::JsonResponse.new( { id: "plink_1a2b3c", object: "payment_link", amount: 4900, currency: "usd", url: "https://pay.example.com/plink_1a2b3c", active: true } ) ``` > **Tip:** The client set, base URL, and example auth header are config: `c.api_clients` (defaults + your overrides), `c.api_base_url`, and `c.api_auth_header`. Override a default token to swap in an SDK-flavored snippet; add a new token (e.g. `cli`) to append a tab. | Call | Type | Default | Description | | --- | --- | --- | --- | | `RequestExample method:/path:` | Symbol/String, String | — | The HTTP verb and path (path is appended to c.api_base_url). | | `RequestExample body:` | Hash, nil | nil | Request payload; deep-stringified into each snippet. Omit for a GET. | | `RequestExample query:/headers:` | Hash | {} | Query params and extra headers merged into every snippet. | | `RequestExample clients:` | Array<Symbol>, nil | all configured | Filter/order the tabs (e.g. [:curl, :ruby]). | | `JsonResponse.new(body)` | Hash or String | — | Hash → pretty JSON with string keys; String → passed through. | | `JsonResponse filename:` | String | "response.json" | The code block's title-bar filename. | ## Callout note / tip / warning — a daisyUI alert with a lucide icon. > **Note:** This is a note callout. > **Tip:** A tip callout — for handy asides. > **Warning:** A warning callout — for gotchas. The calls that produced the boxes above: ```ruby DocsUI::Callout(:note) { "This is a note callout." } DocsUI::Callout(:tip) { "A tip callout." } DocsUI::Callout(:warning) { "A warning callout." } ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `level` | Symbol | :note | Alert style — :note / :tip / :warning. | | `title` | String, nil | nil | Optional heading above the body. | | `block` | Phlex block | — | The callout body. | ## Icon A lucide icon by name; extra attributes pass through. The calls that produced the icons above: ```ruby DocsUI::Icon("rocket", class: "size-6") DocsUI::Icon("book-open", class: "size-6") DocsUI::Icon("paintbrush", class: "size-6") ``` > **Note:** Icons no-op gracefully if `rails_icons` isn't configured — nothing renders, no error. | Arg | Type | Default | Description | | --- | --- | --- | --- | | `name` | String | — | The lucide icon name, e.g. "rocket". | | `**attributes` | Hash | {} | Extra HTML attributes (class:, etc.) passed to the icon. | ## OnThisPage The auto-TOC — the panel Shell renders from your on_page setting. The TOC is built from the page's `Section` anchors. You don't construct it — `Shell` renders it based on the page's `on_page` setting. Set the mode per page or as a config default. ```ruby class Views::Docs::Pages::Api < DocsUI::Page on_page :toggle # :panel | :toggle | :sidebar | false end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `mode` | Symbol | :panel | Placement — :panel (aside) / :toggle (button) / :sidebar. | | `title` | String | "On this page" | The TOC heading. | ## Sidebar The left nav — built from your config, rendered by Shell. The sidebar is driven entirely by `DocsKit.configuration.nav` — a callable returning grouped `DocsKit::NavItem`s. `Shell` renders it, so you configure it rather than construct it. ```ruby DocsKit.configure do |c| c.nav = -> { { "Docs" => Doc.grouped } } end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `(none)` | — | — | No args — reads DocsKit.configuration.nav. | ## ThemeSwitcher The theme dropdown — built from your config, rendered by Shell. The dropdown in the topbar is a `DocsUI::ThemeSwitcher`. It lists `DocsKit.configuration.themes` — which must match the daisyUI `@plugin` block in your Tailwind entry. `Shell` renders it for you. ```ruby DocsKit.configure do |c| c.themes = %w[dark light synthwave dracula night] end ``` | Arg | Type | Default | Description | | --- | --- | --- | --- | | `(none)` | — | — | No args — reads DocsKit.configuration.themes. | --- # On this page Reference # On this page docs-kit builds a scroll-spy table of contents from your Section headings — no config required. ## Automatic TOC docs-kit builds an **On this page** table of contents from your page's `DocsUI::Section` headings automatically — no config needed. As you scroll, scroll-spy highlights the section you're currently reading. This very page has one — look to the right. ## Three placements The TOC renders in one of three spots, or not at all. | Mode | Placement | | --- | --- | | `:panel` | A sticky card top-right of the content column (default). | | `:toggle` | A floating button top-right that opens a dropdown. | | `:sidebar` | Nested under the active nav item in the left sidebar. | | `false` | No auto-TOC. | ## Setting the default ```ruby DocsKit.configure do |c| c.on_page_default = :panel end ``` Sets the placement for **every** page unless a page overrides it. ## Per-page override ```ruby class Views::Docs::Pages::Deploy < DocsUI::Page title "Deploy" on_page :toggle # :panel | :sidebar | :toggle | false def content # ... end end ``` Declare `on_page` in a `DocsUI::Page` subclass to override the default for that page only. Accepts `:panel`, `:toggle`, `:sidebar`, or `false`. ## How it works The TOC is pure client-side. The docs-nav Stimulus controller reads `section[id]`, `h2[id]`, and `h3[id]` from the DOM, then an `IntersectionObserver` drives the scroll-spy highlight as sections enter the viewport. Short pages — fewer than the minimum headings — hide the TOC automatically. > **Tip:** Headings come from `DocsUI::Section` ids, so structure your page with Sections to get a good TOC. --- # AI & agents AI & tooling # AI & agents Every page is machine-readable for free — a Markdown twin, an llms.txt index, and a read-only MCP endpoint, all built from the same render your readers see. ## Machine-readable, for free Four surfaces, one render — the .md twin, llms.txt, llms-full.txt, and MCP all read from the page you already wrote. docs-kit derives its agent-facing surfaces from the **same render** the HTML pages use — so they never drift and you author nothing extra: - **The `.md` twin** — every page has a GFM Markdown copy at `GET /docs/x.md`, converted post-render from the page's own HTML. This page's twin is [/docs/ai.md](https://docs-kit.zoolutions.llc/docs/ai.md). - **The "Markdown" action** — the masthead button that copies (or opens) the current page's twin. - **[/llms.txt](https://docs-kit.zoolutions.llc/llms.txt) + [/llms-full.txt](https://docs-kit.zoolutions.llc/llms-full.txt)** — the llmstxt.org index and the full-text concatenation, built straight from the registry. - **The MCP endpoint** — an optional read-only `POST /mcp` server exposing `list_pages` / `get_page` / `search_docs`. None of it is a second source of truth. The `.md` twin is a conversion of the rendered `#docs-content`; llms.txt is the registry; MCP reads the same twins and the same [search](https://docs-kit.zoolutions.llc/docs/search) index. ## The .md twin GET /docs/x.md returns faithful Markdown of exactly what /docs/x shows. A controller that includes `DocsKit::Controller` gets the twin automatically: `render_page(view)` serves the page's GFM Markdown instead of HTML when the request format is `.md` (or `.text` as an alias). Same page class, same render, `text/markdown` body — you write nothing extra. ```ruby class DocsController < ApplicationController include DocsKit::Controller def show render_page(Views::Docs::Pages.const_get(params[:page].classify).new) end end ``` The conversion is `DocsKit::MarkdownExport`: it renders the page to HTML, extracts the `#docs-content` subtree that `DocsUI::Shell` stamps, strips `[data-md-skip]` / `<script>` / `<style>`, and walks the remaining DOM to GFM. Because it runs **after** the render, authoring style is irrelevant — Phlex components, `md` islands, and raw tags in `prose` all convert identically. A page with no `#docs-content` region yields an empty body (`200` with `""`), never a 404, and the HTML route is untouched. > **Note:** The host must route the `.md`/`.text` format (a format-aware or catch-all route). `.text` is accepted only so hosts whose routes permit the built-in `:text` format still get the twin. The page chrome never leaks into the twin: `DocsUI::Page` stamps `data-md-skip` on its top nav (the "← Home" link and the "Markdown" action), so the export drops it. Anything you want kept out of the `.md` can opt out the same way — wrap it in `data: { md_skip: true }`. | Surface | Kind | Default | Description | | --- | --- | --- | --- | | `GET /docs/x.md` | route | — | The page's GFM twin (.text is an alias). | | `DocsKit::Controller#render_page` | method | — | Serves the twin on a .md/.text request, HTML otherwise. | | `DocsKit::MarkdownExport.new(view).to_md` | class | — | The HTML→GFM converter over #docs-content. | | `data: { md_skip: true }` | attribute | — | Opt any wrapper out of the exported Markdown. | ## The "Markdown" masthead action The button at the top of this page — copy the page as Markdown, or open the raw twin with JS off. `DocsUI::MarkdownAction` renders the small "Markdown" button in the masthead (a clipboard icon + label), pointing at the current page's `.md` twin. `DocsUI::Page` renders it automatically when `DocsKit.configuration.page_markdown_action` is true (the default) — look at the top-right of this page. With **JS off** it simply opens the raw Markdown — a working fallback, never a dead end. With **JS on**, the one `docs-nav` Stimulus controller upgrades the click: it fetches the same `.md` URL with `Accept: text/markdown`, writes the body to the clipboard, and flashes the label to "Copied!" for 1500ms. Any failure (no clipboard API on an insecure context, a non-ok fetch) falls back to normal navigation to the raw `.md`. ```ruby # Rendered for you by DocsUI::Page; the href is request.path + ".md": render DocsUI::MarkdownAction.new(request.path) ``` The href is built from the request path and is idempotent about the query string: `/x → /x.md`, `/x?q=1 → /x.md?q=1`, and an existing `/x.md` is left as-is. The affordance lives inside the page's `data-md-skip` nav, so it never appears in the exported twin. > **Tip:** Hide the button site-wide with `c.page_markdown_action = false` — the `.md` route itself keeps serving the twin regardless; the knob only controls the UI. | Surface | Type | Default | Description | | --- | --- | --- | --- | | `DocsUI::MarkdownAction.new(path)` | String | request.path | The page whose .md twin the button targets. | | `docs-nav#copyMarkdown` | action | — | Fetches + copies the twin; falls back to opening it. | | `c.page_markdown_action` | Boolean | true | Show the masthead button (the .md route ignores this). | ## /llms.txt and /llms-full.txt The llmstxt.org index and full-text dump — built straight from the registry, zero authoring. docs-kit's engine ships **no routes** — it is glue-only, so a site keeps full control over path, auth, and omission. The install generator draws the two llms routes for you: ```ruby get "/llms.txt" => "docs_kit/llms#index", as: :llms get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full ``` **[/llms.txt](https://docs-kit.zoolutions.llc/llms.txt)** is the llmstxt.org index, built by `DocsKit::LlmsText.index`: - an `# {brand}` H1 (`c.brand`, default `"Docs"` — always present), - a `> {tagline}` blockquote (`c.tagline`) right under it — this site sets it to the shell's one-line summary; nil or empty omits the line, - one `## {group}` section per nav group, a tight bullet list of each authored page's absolute `.md` link, in registry order, - a trailing `## MCP` block *only when the MCP endpoint is live*. **[/llms-full.txt](https://docs-kit.zoolutions.llc/llms-full.txt)** concatenates every authored page's Markdown twin — each as `# {title}` + its rendered Markdown, separated by a `---` rule. Both include **only pages with a resolvable `view_class`** — an unwritten registry entry is excluded from the links and the concatenation, so neither ever references a page that doesn't exist yet. Links are absolutized against `request.base_url`, so agent tooling fetches a portable URL. > **Note:** Both endpoints are HTTP-cached: they revalidate on the rendered body plus `DocsKit::VERSION` as the etag salt, so any registry, config, or page change busts the cache while an unchanged site serves a `304 Not Modified`. | Surface | Type | Default | Description | | --- | --- | --- | --- | | `c.brand` | String | "Docs" | The # H1 of the index (always emitted). | | `c.tagline` | String, nil | nil | The > blockquote under the H1; nil/empty omits it. | | `c.nav_registries` | Hash | {} | Registries → the ## group sections and their .md links. | | `DocsKit::LlmsText.index / .full` | class | — | The pure builders the LlmsController threads request.base_url into. | ## The read-only MCP server POST /mcp — list_pages / get_page / search_docs over JSON-RPC, gated on the optional mcp gem. docs-kit ships a built-in **read-only** MCP server so an agent can connect over the protocol instead of scraping. It exposes three tools over `POST /mcp` (JSON-RPC), all reading the same registry, `.md` twins, and [search](https://docs-kit.zoolutions.llc/docs/search) index the docs render from: - **`list_pages`** — every authored page as `{slug, title, group, url}`, - **`get_page`** — one page's GFM twin by slug (unknown slugs return the list of valid ones, so an agent self-corrects), - **`search_docs`** — ranked full-text search returning `{page_title, section_title, url, snippet}`. > **Warning:** The endpoint is live only when BOTH the optional `mcp` gem is loadable AND `c.mcp` is true (the default). Off in either case → the controller 404s and the site is byte-identical to before the feature. A fresh site has `c.mcp = true` but no live endpoint until you add the gem and uncomment the routes. Enabling it is two steps. Add the gem: ```ruby gem "mcp" # optional — powers the built-in POST /mcp server ``` …then uncomment the routes the install generator drew for you (commented out, because the gem is optional). `POST` speaks JSON-RPC; `GET`/`DELETE` return `405` — the server is stateless and read-only, so there is no SSE session to open or terminate: ```ruby match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete] post "/mcp" => "docs_kit/mcp#create" ``` The controller delegates the whole protocol to the official MCP SDK: `DocsKit::McpServer.build` constructs the `MCP::Server` (named from `c.brand`, versioned from `DocsKit::VERSION`) and registers the three tools; `server.handle_json(request.body.read)` parses, dispatches, and serializes the JSON-RPC response. All three tools' logic lives in `DocsKit::McpTools` as pure plain-Ruby functions with zero gem and zero JSON-RPC dependency — so the whole consumption story is unit-testable without booting Rails or the SDK. Once live, `/llms.txt` grows its trailing `## MCP` block advertising the endpoint, so an agent reading the index discovers it can also connect over the protocol. | Surface | Type | Default | Description | | --- | --- | --- | --- | | `c.mcp` | Boolean | true | Toggle; the endpoint needs this AND the mcp gem present. | | `c.mcp_enabled?` | method | — | !!c.mcp && the gem loadable — the gate the controller + llms.txt read. | | `POST /mcp` | route | — | JSON-RPC; GET/DELETE 405 (stateless, read-only). | | `list_pages / get_page / search_docs` | tools | — | The three read-only tools, over DocsKit::McpTools. | ## AGENTS.md + the write-docs-page skill The install generator scaffolds an authoring contract every agent can read. So an agent (or a teammate) can *author* pages the docs-kit way, the install generator scaffolds two files into the consuming site: - **`AGENTS.md`** at the repo root — the cross-tool authoring contract. The generator owns a delimited block inside it (between `<!-- BEGIN docs-kit -->` / `<!-- END docs-kit -->`), so a re-run updates only that block and leaves the rest of your `AGENTS.md` alone. A fresh site gets the whole file. - **`.claude/skills/write-docs-page/SKILL.md`** — a Claude Code skill that scaffolds with `rails g docs_kit:page`, writes Markdown-first `#content`, and runs the verification gates. Written unless the site already has one. Both point at the same recipe: one `DocsUI::Section` per part of the page (Sections own structure and the TOC — never a Markdown `##`), prose via a single-quoted `md <<~'MD'` heredoc, and reference material via `DocsUI::PropTable` / `DocsUI::FieldTable` / `DocsUI::RequestExample`. See [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring) for the same contract written for humans. > **Tip:** The skill's recipe is exactly what the `docs_kit:page` generator produces — see [Authoring pages](https://docs-kit.zoolutions.llc/docs/authoring) and [Components](https://docs-kit.zoolutions.llc/docs/components) for the full kit. --- # Search AI & tooling # Search One index, two front-ends: a plain GET form that works with JavaScript off, and a ⌘K palette that enhances it. Both read the same twins that feed /llms-full.txt, so search can never drift from the pages. ## How search fits together Zero authoring, no external service, no build step — the pages ARE the index. docs-kit search has three moving parts and no second registry: - **`DocsKit::SearchIndex`** — an in-memory index built per request from each page's Markdown twin (the same twins [/llms-full.txt](https://docs-kit.zoolutions.llc/docs/ai) serves), split on its `## ` headings into searchable sections. - **`DocsKit::SearchController`** — one gem controller answering **both** formats off that index: HTML for the JS-off results page, JSON for the palette. - **`DocsUI::SearchBox`** — the topbar form the `docs-nav` controller enhances into a keyboard palette. Everything is driven by `DocsKit.configuration` — `search`, `search_path`, and `search_shortcuts` — so a site tunes it without touching a component. > **Note:** The route is **not** added by the engine — the install generator draws `get "/docs/search" => "docs_kit/search#index"` so a site can remount search elsewhere via `config.search_path`. ## Works with JavaScript off The GET form is the whole search UX; the palette is a pure enhancement over the same controller. The topbar affordance is a real `GET` form pointed at `config.search_path`. Press **Enter** and the browser lands on the server-rendered results page — no JavaScript required. `docs-nav` enhances that same form into a debounced palette; if JS never loads (or dies mid-typing) the form still submits normally. The results page IS `DocsUI::SearchResults` wrapped in `DocsUI::Shell` — a full working page, not a JSON blob. It echoes the query, groups hits by page (best-scoring page first), links each hit to its section anchor, and shows a pre-highlighted snippet. The JS-off results body, rendered live for a query that hits this very page: # Search 2 results for “search index” ## Search Overview The docs search index is built from each page's Markdown twin. The search index DocsKit::SearchIndex splits a twin on its headings into sections. The call that produced the block above (the controller does this for you): ```ruby render DocsUI::SearchResults.new( query: params[:q], hits: index.search(params[:q]) ) ``` > **Tip:** A blank query prompts the reader; a query with **no** hits renders guidance (“Try fewer or more general words”) instead of an empty list. A page-intro hit (no section) is labeled `Overview`. ## The in-memory index DocsKit::SearchIndex over the registry — pure Ruby, unit-testable with no Rails. The controller renders each registry page through `DocsKit::MarkdownExport` and hands `SearchIndex` a list of triples — `[page_title, page_href, markdown]`. The index splits every twin on its `## ` (level-2 ATX) headings into one entry per section, plus a **page-intro** entry for the text before the first heading. ```ruby index = DocsKit::SearchIndex.new( [["Overview", "/docs/overview", overview_markdown_twin], ["Search", "/docs/search", search_markdown_twin]] ) index.search("theme switcher") # => [DocsKit::SearchHit, ...] ``` **Scoring** is weighted AND-token ranking: every whitespace-split query token must match somewhere in an entry, and each token scores the heaviest field it hit — title beats heading beats body (`100 > 10 > 1`). The entry's score is the per-token sum, so a section matching more tokens in heavier fields floats up. Matching is plain case-insensitive `String#include?` (so `gen` matches `Generators`) — no fuzzy matching, no stemming, by design. Results cap at 20. The page **title** is a searchable field only on the page-intro entry — never on every section — so a pure title match surfaces once rather than flooding the results with every section of that page. | API | Type | Default | Description | | --- | --- | --- | --- | | `SearchIndex.new(triples)` | Array | [] | [[page_title, page_href, markdown], …] — the twins. | | `#search(query)` | String | — | Array<SearchHit>, best first, capped at 20. Blank → []. | | `#entries` | — | — | The indexed Entry list (one per section + page intro). | | `TITLE/HEADING/BODY_WEIGHT` | Integer | 100 / 10 / 1 | Field weights a token scores against. | | `MAX_RESULTS` | Integer | 20 | Hard cap on returned hits. | Each hit is a `DocsKit::SearchHit` — an immutable value object with `page_title`, `section_title` (nil for a page-intro hit), `href` (the `page_href#anchor`), a pre-highlighted HTML-safe `snippet`, and a `score`. Its `#label` reads `"Page → Section"` (or just the page title), and `#as_json` is the `{ label, href, snippet }` shape the palette fetches (score is dropped — rank only matters server-side). The snippet is a `~80`-char window centered on the first match with the query terms wrapped in `<mark>`; everything else is HTML-escaped **first**, so a source angle bracket can never inject markup. That's why `SearchResults` can render it via `raw(safe(…))` — it's trusted gem-produced markup, the same idiom `DocsUI::Code` uses. > **Warning:** The section anchor is recomputed as `page_href#slug` using the SAME `slugify` rule `DocsUI::Section` stamps on its `<section id>`. Section splitting is code-fence aware, but a `## ` with no space (`##Nospace`) is not treated as a heading. ## One endpoint, HTML + JSON DocsKit::SearchController answers both formats off the same lazily-built index. The host draws the route (the engine adds none); the controller reads `params[:q]` and responds by format: - **HTML** — the JS-off path: `DocsUI::SearchResults` inside `DocsUI::Shell`, rendered `layout: false` (the Shell IS the whole document). - **JSON** — the enhancement path: `{ query, results: [...] }`, where each result is a `SearchHit#as_json`. The palette fetches this debounced as you type. The palette hits the `.json` variant of the same path: ```json { "query": "theme", "results": [ { "label": "Components → ThemeSwitcher", "href": "/docs/components#themeswitcher", "snippet": "…the <mark>theme</mark> dropdown in the topbar…" } ] } ``` The index is rebuilt on **every** request (no caching) — fine for a tens-of-pages site, but `O(pages × markdown render)` per query. Because it renders through the controller's own view context, url helpers and CSRF resolve, and hrefs are absolutized against `request.base_url` — exactly as the [/llms-full.txt](https://docs-kit.zoolutions.llc/docs/ai) endpoint renders each twin. > **Note:** The controller reads config via `#docs_config`, never `#config` — shadowing `ActionController::Base#config` would break `csrf_meta_tags` when the Shell renders. ## The topbar SearchBox & ⌘K palette The GET form docs-nav enhances — one <kbd> hint per configured shortcut, bound from JSON. `DocsUI::SearchBox` is the affordance in the topbar — `DocsUI::Shell` renders it whenever `DocsKit.configuration.search_enabled?`. It's a plain `GET` form to `config.search_path` with a `q` input, plus the hooks `docs-nav` needs to turn it into a palette: - one `<kbd>` badge per `config.search_shortcuts`, labeled by the parsed `DocsKit::Shortcut#label` (`/`, `Ctrl K`, …), - the parsed shortcut list emitted as JSON on the scope (`data-docs-nav-shortcuts-value`), so the visible badges and the key bindings share ONE source and can't drift, - an empty, hidden results dropdown `docs-nav` fills as you type. The real component (it's the same one in this site's topbar): / Ctrl K Render it yourself with: ```ruby render DocsUI::SearchBox.new # Shell renders it automatically when config.search_enabled? ``` Shortcuts are platform-agnostic strings. `mod` is the **platform modifier** — ⌘ on mac, Ctrl elsewhere — kept abstract server-side and resolved in the browser by `docs-nav`, which swaps only the badge label (never the binding). Modifier aliases: `mod`, `ctrl`/`control`, `shift`, `alt`/`option`, `cmd`/`command`/`meta`. | Shortcut string | <kbd> label | Meaning | | --- | --- | --- | | `mod+k` | Ctrl K | Platform command chord — ⌘K on mac, Ctrl K elsewhere. | | `/` | / | A bare key — shown exactly as authored. | | `s` | s | A bare single char — not uppercased. | | `ctrl+shift+f` | Ctrl Shift F | An explicit physical chord (no platform abstraction). | > **Tip:** A modifier-only or empty string (`mod+`, `""`, `nil`) is unparseable — `Shortcut.parse_list` silently drops it. With `search_shortcuts` empty the form still works; it just renders no `<kbd>` badges. ## Configuration Three knobs — the affordance, where it submits, and the shortcuts. ```ruby DocsKit.configure do |c| c.search = true # toggle the affordance (default true) c.search_path = "/docs/search" # where the form GETs; palette fetches .json here c.search_shortcuts = %w[/ mod+k] # chord strings (default ["/", "mod+k"]) end ``` | Knob / reader | Type | Default | Description | | --- | --- | --- | --- | | `c.search` | Boolean | true | Toggles the topbar affordance + palette markup site-wide. | | `c.search_path` | String | "/docs/search" | Where the form GETs and the base the palette fetches .json from. | | `c.search_shortcuts` | Array<String> | ["/", "mod+k"] | Chord strings that open the palette. | | `config.search_enabled?` | Boolean | — | search == true AND a non-blank search_path. | | `config.search_shortcuts` | Array<Shortcut> | — | The PARSED list (reader maps to Shortcut, drops unparseable). | `search_shortcuts` is asymmetric by design: you set raw **strings**, but `config.search_shortcuts` reads them back as parsed `DocsKit::Shortcut` objects (dropping anything unparseable) — read the parsed reader, never `@search_shortcuts`. Blanking `search_path` disables the affordance even with `search == true`: `search_enabled?` is false because there'd be nothing to submit to. That lets a site kill search without touching `c.search`. > **Note:** See [Configuration](https://docs-kit.zoolutions.llc/docs/configuration) for the full config surface, [AI & agents](https://docs-kit.zoolutions.llc/docs/ai) for the twins search reads, and [Components](https://docs-kit.zoolutions.llc/docs/components) for SearchBox / SearchResults alongside the rest of the kit. --- # Deploy Reference # Deploy One reusable workflow deploys every docs-kit site to dash + GHCR. ## Scaffolded for you The CLI writes the whole deploy: `config/deploy.yml`, `.dash/secrets`, a `Dockerfile`, and a `.github/workflows/deploy-docs.yml` that calls the shared reusable workflow. Point it at your repo and you have a deployable app: ```shell docs-kit new my-docs --image OWNER/REPO --service my-repo ``` ## The Docker image Lean, multi-stage, and upgradable. The scaffolded `Dockerfile` is a multi-stage build: a throwaway `build` stage carries the toolchain (build-essential, git, bun) and compiles the gems + assets, and the final stage copies **only** the installed bundle and the app — no compilers, no `node_modules`. A shipped `.dockerignore` keeps the build context small (no `.git`, `node_modules`, logs, specs, or coverage). When the site bundles `thruster` (a Rails 8 default), `bin/thrust` fronts Puma with HTTP caching, compression, and X-Sendfile — Thruster listens on the routed port (3000) and proxies to Puma. The `.dockerignore` is gem-owned — every `docs_kit:install` run refreshes it. The `Dockerfile` is yours to tune, so the generator never clobbers it; it stamps a version marker (`# docs-kit Dockerfile vX.Y.Z`) so `--sync` warns you when a newer, leaner template ships. Diff and adopt: ```shell bin/rails g docs_kit:install --sync # warns if your Dockerfile is stale diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templates/Dockerfile.tt" ``` ## dash-proxy, switched on The scaffolded deploy.yml uses the proxy, not just the router. Every site deploys with [dash](https://github.com/zoolutions/dash) 4 (`minimum_version: 4.0.7`) and turns on the per-app dash-proxy features a docs site benefits from — no per-site tuning, the template writes them: - `compress: true` — zstd / brotli / gzip negotiated at the edge; Thruster-encoded responses pass through. - `cache: { enabled: true, max_ttl: 300 }` — an RFC 9111 shared cache. It stores only responses marked `Cache-Control: public` (Propshaft assets, `/llms.txt`); HTML carrying a session cookie is refused by design. `dash proxy cache stats` shows what it holds. - `headers` — `X-Content-Type-Options` / `Referrer-Policy` set once at the proxy; `Server` and `X-Powered-By` stripped. - `intercept_errors: [502, 503, 504]` + `error_pages_path: public` — the site's own status pages during a container swap, not a bare "Bad Gateway". - `exclude_metrics_paths: [/up]` — the health probe stays out of the request histograms. Deliberately left alone: `proxy.run` is host-wide (every site on the shared host boots the same proxy; a differing `run:` block reboots it on each alternate deploy), and `rate_limit` / `deny_ips` need `client_ip.trusted_proxies` pinned to the tunnel's address to key on visitors rather than on cloudflared. `dash docs proxy` is the always-current reference. The first dash 4 deploy on a host renames the proxy (`kamal-proxy` → `dash-proxy`) and copies its config volume — one short outage on that host while ports 80/443 change hands, paid once by whichever site deploys first. ## The reusable workflow Build and deploy live **once** in `zoolutions/docs-kit/.github/workflows/deploy.yml`. Each site's `.github/workflows/deploy-docs.yml` is a thin caller — no build logic is copied per site. ```yaml on: release: { types: [published] } workflow_dispatch: permissions: contents: read packages: write jobs: deploy: uses: zoolutions/docs-kit/.github/workflows/deploy.yml@main with: image: OWNER/REPO service: my-repo secrets: inherit ``` ## Naming Use the repo name. Set `image` and `service` to the repo's `OWNER/REPO`. The pushed GHCR package then auto-links to the repo, so `GITHUB_TOKEN` can push **and** pull it — no PAT required. > **Warning:** A name that doesn't match the repo becomes an unlinked package that `GITHUB_TOKEN` can't pull — the deploy fails when dash tries to fetch the image. Before deploying, the workflow runs `dash doctor`, a pre-flight of host, registry, proxy, ports and readiness gates that fails the job early with one report instead of one failure at a time. ## Secrets | Secret | Purpose | | --- | --- | | `SSH_PRIVATE_KEY` | Deploy key for the dash SSH user. | | `DEPLOY_HOST` | The deploy host (IP or DNS). | | `DEPLOY_DOMAIN` | The public host dash-proxy routes. | Add these to a `docs` GitHub Environment. The registry password is the auto-provided `GITHUB_TOKEN`, so `secrets: inherit` passes everything the reusable workflow needs. ## Requirements the caller must set > **Warning:** The caller workflow MUST grant `permissions: packages: write` itself — a reusable workflow can't escalate its caller's permissions. Without it the deploy fails at startup, before any dash step runs.