diff --git a/docs/app/models/doc.rb b/docs/app/models/doc.rb
index 93b1fa7..a9f18ab 100644
--- a/docs/app/models/doc.rb
+++ b/docs/app/models/doc.rb
@@ -13,13 +13,26 @@ class Doc
path_prefix "/docs"
view_namespace "Views::Docs::Pages"
- page "Overview", group: "Getting started"
- page "Installation", group: "Getting started"
- page "Configuration", group: "Getting started"
- page "Authoring pages", group: "Getting started", slug: "authoring", view: "Authoring"
- page "Styling & CSS", group: "Getting started", slug: "styling", view: "Styling"
- page "Components", group: "Reference"
- page "Code languages", group: "Reference", slug: "languages", view: "Languages"
- page "On this page", group: "Reference"
- page "Deploy", group: "Reference"
+ # Getting started
+ page "Overview", group: "Getting started"
+ page "Installation", group: "Getting started"
+ page "Configuration", group: "Getting started"
+ page "Styling & CSS", group: "Getting started", slug: "styling", view: "Styling"
+
+ # Authoring
+ page "Authoring pages", group: "Authoring", slug: "authoring", view: "Authoring"
+ page "Markdown authoring", group: "Authoring", slug: "markdown", view: "Markdown"
+ page "Code languages", group: "Authoring", slug: "languages", view: "Languages"
+ page "API reference", group: "Authoring", slug: "api", view: "Api"
+
+ # Reference
+ page "Components", group: "Reference"
+ page "On this page", group: "Reference"
+
+ # AI & tooling
+ page "AI & agents", group: "AI & tooling", slug: "ai", view: "Ai"
+ page "Search", group: "AI & tooling"
+
+ # Deploy
+ page "Deploy", group: "Deploy"
end
diff --git a/docs/app/views/docs/pages/ai.rb b/docs/app/views/docs/pages/ai.rb
new file mode 100644
index 0000000..292b4bc
--- /dev/null
+++ b/docs/app/views/docs/pages/ai.rb
@@ -0,0 +1,335 @@
+# frozen_string_literal: true
+
+module Views
+ module Docs
+ module Pages
+ # Everything that makes a docs-kit site machine-readable: the automatic
+ # `.md` twin per page (+ the "Markdown" masthead action), /llms.txt and
+ # /llms-full.txt, and the built-in read-only MCP server. All derived from
+ # the SAME render the HTML pages use — the author writes nothing extra.
+ class Ai < DocsUI::Page
+ title "AI & agents"
+ eyebrow "AI & tooling"
+
+ def lead = "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."
+
+ def content
+ overview_section
+ md_twin_section
+ markdown_action_section
+ llms_section
+ mcp_section
+ agents_section
+ end
+
+ private
+
+ def overview_section
+ DocsUI::Section("Machine-readable, for free",
+ description: "Four surfaces, one render — the .md twin, llms.txt, llms-full.txt, and MCP all read from the page you already wrote.") do
+ md <<~'MD'
+ 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](/docs/ai.md).
+ - **The "Markdown" action** — the masthead button that copies (or
+ opens) the current page's twin.
+ - **[/llms.txt](/llms.txt) + [/llms-full.txt](/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](/docs/search) index.
+ MD
+ end
+ end
+
+ def md_twin_section
+ DocsUI::Section("The .md twin",
+ description: "GET /docs/x.md returns faithful Markdown of exactly what /docs/x shows.") do
+ md <<~'MD'
+ 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.
+ MD
+
+ DocsUI::Code(<<~RUBY, filename: "app/controllers/docs_controller.rb")
+ class DocsController < ApplicationController
+ include DocsKit::Controller
+
+ def show
+ render_page(Views::Docs::Pages.const_get(params[:page].classify).new)
+ end
+ end
+ RUBY
+
+ md <<~'MD'
+ The conversion is `DocsKit::MarkdownExport`: it renders the page to
+ HTML, extracts the `#docs-content` subtree that `DocsUI::Shell`
+ stamps, strips `[data-md-skip]` / `" }
+ plain " survives as a separate commonmarker text node — inert, Phlex-escaped prose that reads as the literal words "
+ code { "alert(1)" }
+ plain ". Never executable, but not erased either."
+ end
+
+ md <<~'MD'
+ 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.
+ MD
+ end
+ end
+
+ def twin_section
+ DocsUI::Section("Markdown flows into the .md twin",
+ description: "Every page has a raw-Markdown twin; the masthead links it.") do
+ md <<~'MD'
+ 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`](/docs/ai) controller intercepts the click, fetches the
+ `.md`, copies it to your clipboard, and prevents the navigation.
+ MD
+
+ DocsUI::Code(<<~'RUBY')
+ # DocsUI::Page renders this automatically when
+ # DocsKit.configuration.page_markdown_action is true (the default).
+ render DocsUI::MarkdownAction.new(request.path)
+ RUBY
+
+ md <<~'MD'
+ 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`.
+ MD
+
+ DocsUI::Callout(:note) do
+ plain "The twin href is idempotent and query-preserving: "
+ code { "/docs/markdown" }
+ plain " → "
+ code { "/docs/markdown.md" }
+ plain ", "
+ code { "/x?a=1" }
+ plain " → "
+ code { "/x.md?a=1" }
+ plain ", and a path already ending in "
+ code { ".md" }
+ plain " is left untouched."
+ end
+ end
+ end
+
+ def args_section
+ DocsUI::Section("DocsUI::Markdown args",
+ description: "The component behind the md helper.") do
+ render DocsUI::PropTable.new(
+ [
+ [ "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." ]
+ ],
+ headers: [ "Arg", "Type", "Default", "Description" ]
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/docs/app/views/docs/pages/overview.rb b/docs/app/views/docs/pages/overview.rb
index 589613a..4a7be69 100644
--- a/docs/app/views/docs/pages/overview.rb
+++ b/docs/app/views/docs/pages/overview.rb
@@ -3,7 +3,8 @@
module Views
module Docs
module Pages
- # The introduction page: what docs-kit is and the mental model behind it.
+ # The introduction page: what docs-kit is, the mental model behind it, and
+ # a scannable tour of the whole surface — each feature linking to its page.
class Overview < DocsUI::Page
title "Overview"
eyebrow "Getting started"
@@ -21,51 +22,34 @@ def content
def what_is_section
DocsUI::Section("What is docs-kit", description: "A gem, not a template.") do
- prose do
- p do
- strong { "docs-kit" }
- plain " is a Ruby gem that gives you the shared chrome for a Rails "
- plain "documentation site: the topbar, the responsive sidebar, the theme "
- plain "switcher, the content column, an automatic "
- plain %("On this page" TOC, and syntax-highlighted code blocks.)
- end
- p do
- plain "It's built on "
- code { "phlex-rails" }
- plain " and "
- code { "daisyUI" }
- plain ". You write page bodies as Phlex components — docs-kit renders everything around them."
- end
- end
+ md <<~'MD'
+ **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.
+ MD
end
end
def mental_model_section
DocsUI::Section("The mental model", description: "Configure the chrome; don't re-author it.") do
- prose do
- p do
- plain "The chrome — "
- code { "Shell" }
- plain ", "
- code { "Sidebar" }
- plain ", "
- code { "Page" }
- plain " — is byte-identical across every site that uses docs-kit. The only thing "
- plain "that differs is "
- code { "DocsKit.configure" }
- plain "."
- end
- p do
- plain "That's the whole point: two sites built with docs-kit look and behave "
- plain "consistently for free, because they share the same components. You change "
- plain "the brand, the themes, and the nav — never the layout code."
- end
- end
+ md <<~'MD'
+ 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.
+ MD
DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
DocsKit.configure do |c|
- c.brand = "My Project" # only this differs per site
- c.themes = %w[dark light] # the chrome itself is identical
+ 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
RUBY
@@ -77,60 +61,75 @@ def mental_model_section
end
def what_you_get_section
- DocsUI::Section("What you get", description: "Everything below ships in the box.") do
- prose do
- ul do
- li do
- strong { "Shared shell + responsive sidebar" }
- plain " — the same layout and navigation on every screen size."
- end
- li do
- strong { "A theme switcher" }
- plain " with sticky themes remembered in "
- code { "localStorage" }
- plain "."
- end
- li do
- strong { "Syntax highlighting for ~200 languages" }
- plain " via Rouge — no allowlist."
- end
- li do
- strong { "Multi-language code examples" }
- plain " with a sticky, global language choice."
- end
- li do
- plain "An automatic "
- strong { %("On this page" TOC) }
- plain " with three placement options."
- end
- li do
- strong { "A one-command generator" }
- plain " — "
- code { "docs-kit new" }
- plain " scaffolds a site."
- end
- li do
- strong { "A single reusable deploy workflow" }
- plain " — Kamal + GHCR."
- end
- end
- end
+ DocsUI::Section("What you get", description: "The whole surface, in the box — each row links to its page.") do
+ md <<~'MD'
+ #### The chrome
+
+ - **Shared shell + responsive sidebar + theme switcher** — the same
+ topbar, nav, and layout on every screen size, remembered in
+ `localStorage`. See [Components](/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](/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](/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](/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](/docs/components) and the [API reference](/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](/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](/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](/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](/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](/docs/installation).
+ - **`docs-kit new` + a single reusable deploy workflow** — scaffold a
+ whole site, then ship it with Kamal + GHCR. See [Deploy](/docs/deploy).
+ MD
end
end
def next_steps_section
DocsUI::Section("Next steps") do
- prose do
- p do
- plain "Start with "
- strong { "Installation" }
- plain " to add the gem and render your first page. Then read "
- strong { "Configuration" }
- plain " to set your brand, themes, and nav, and "
- strong { "Authoring" }
- plain " to learn the DocsUI kit — the building blocks for every page body."
- end
- end
+ md <<~'MD'
+ Start with [Installation](/docs/installation) to add the gem and
+ render your first page. Then read [Configuration](/docs/configuration)
+ to set your brand, themes, and nav, and [Authoring pages](/docs/authoring)
+ to learn the DocsUI kit — the building blocks for every page body.
+ MD
end
end
end
diff --git a/docs/app/views/docs/pages/search.rb b/docs/app/views/docs/pages/search.rb
new file mode 100644
index 0000000..c1b02ad
--- /dev/null
+++ b/docs/app/views/docs/pages/search.rb
@@ -0,0 +1,347 @@
+# frozen_string_literal: true
+
+module Views
+ module Docs
+ module Pages
+ # Server-rendered docs search + the ⌘K palette: a JS-off GET form, an
+ # in-memory index built from the pages' Markdown twins, and the .json
+ # endpoint the palette fetches. Renders the real SearchBox/SearchResults.
+ class Search < DocsUI::Page
+ title "Search"
+ eyebrow "AI & tooling"
+
+ def lead = "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."
+
+ def content
+ overview_section
+ progressive_section
+ index_section
+ endpoint_section
+ searchbox_section
+ config_section
+ end
+
+ private
+
+ def overview_section
+ DocsUI::Section("How search fits together",
+ description: "Zero authoring, no external service, no build step — the pages ARE the index.") do
+ md <<~'MD'
+ 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](/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.
+ MD
+
+ DocsUI::Callout(:note) do
+ plain "The route is "
+ strong { "not" }
+ plain " added by the engine — the install generator draws "
+ code { "get \"/docs/search\" => \"docs_kit/search#index\"" }
+ plain " so a site can remount search elsewhere via "
+ code { "config.search_path" }
+ plain "."
+ end
+ end
+ end
+
+ def progressive_section
+ DocsUI::Section("Works with JavaScript off",
+ description: "The GET form is the whole search UX; the palette is a pure enhancement over the same controller.") do
+ md <<~'MD'
+ 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.
+ MD
+
+ prose { p { "The JS-off results body, rendered live for a query that hits this very page:" } }
+ render DocsUI::SearchResults.new(
+ query: "search index",
+ hits: DocsKit::SearchIndex.new(
+ [
+ [
+ "Search", "/docs/search",
+ "The docs search index is built from each page's Markdown twin.\n\n" \
+ "## The search index\nDocsKit::SearchIndex splits a twin on its headings into sections."
+ ]
+ ]
+ ).search("search index")
+ )
+
+ prose { p { "The call that produced the block above (the controller does this for you):" } }
+ DocsUI::Code(<<~RUBY)
+ render DocsUI::SearchResults.new(
+ query: params[:q],
+ hits: index.search(params[:q])
+ )
+ RUBY
+
+ DocsUI::Callout(:tip) do
+ plain "A blank query prompts the reader; a query with "
+ strong { "no" }
+ plain " hits renders guidance (“Try fewer or more general words”) instead of an empty list. A page-intro hit (no section) is labeled "
+ code { "Overview" }
+ plain "."
+ end
+ end
+ end
+
+ def index_section
+ DocsUI::Section("The in-memory index",
+ description: "DocsKit::SearchIndex over the registry — pure Ruby, unit-testable with no Rails.") do
+ md <<~'MD'
+ 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.
+ MD
+
+ DocsUI::Code(<<~RUBY)
+ index = DocsKit::SearchIndex.new(
+ [["Overview", "/docs/overview", overview_markdown_twin],
+ ["Search", "/docs/search", search_markdown_twin]]
+ )
+ index.search("theme switcher") # => [DocsKit::SearchHit, ...]
+ RUBY
+
+ md <<~'MD'
+ **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.
+ MD
+
+ render DocsUI::PropTable.new(
+ [
+ [ "SearchIndex.new(triples)", "Array", "[]", "[[page_title, page_href, markdown], …] — the twins." ],
+ [ "#search(query)", "String", "—", "Array, 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." ]
+ ],
+ headers: [ "API", "Type", "Default", "Description" ]
+ )
+
+ md <<~'MD'
+ 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 ``; 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.
+ MD
+
+ DocsUI::Callout(:warning) do
+ plain "The section anchor is recomputed as "
+ code { "page_href#slug" }
+ plain " using the SAME "
+ code { "slugify" }
+ plain " rule "
+ code { "DocsUI::Section" }
+ plain " stamps on its "
+ code { "" }
+ plain ". Section splitting is code-fence aware, but a "
+ code { "## " }
+ plain " with no space ("
+ code { "##Nospace" }
+ plain ") is not treated as a heading."
+ end
+ end
+ end
+
+ def endpoint_section
+ DocsUI::Section("One endpoint, HTML + JSON",
+ description: "DocsKit::SearchController answers both formats off the same lazily-built index.") do
+ md <<~'MD'
+ 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:
+ MD
+
+ DocsUI::Code(<<~JSON, filename: "GET /docs/search.json?q=theme", lexer: :json)
+ {
+ "query": "theme",
+ "results": [
+ {
+ "label": "Components → ThemeSwitcher",
+ "href": "/docs/components#themeswitcher",
+ "snippet": "…the theme dropdown in the topbar…"
+ }
+ ]
+ }
+ JSON
+
+ md <<~'MD'
+ 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](/docs/ai)
+ endpoint renders each twin.
+ MD
+
+ DocsUI::Callout(:note) do
+ plain "The controller reads config via "
+ code { "#docs_config" }
+ plain ", never "
+ code { "#config" }
+ plain " — shadowing "
+ code { "ActionController::Base#config" }
+ plain " would break "
+ code { "csrf_meta_tags" }
+ plain " when the Shell renders."
+ end
+ end
+ end
+
+ def searchbox_section
+ DocsUI::Section("The topbar SearchBox & ⌘K palette",
+ description: "The GET form docs-nav enhances — one hint per configured shortcut, bound from JSON.") do
+ md <<~'MD'
+ `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 `` 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.
+ MD
+
+ prose { p { "The real component (it's the same one in this site's topbar):" } }
+ render DocsUI::SearchBox.new
+
+ prose { p { "Render it yourself with:" } }
+ DocsUI::Code(<<~RUBY)
+ render DocsUI::SearchBox.new
+ # Shell renders it automatically when config.search_enabled?
+ RUBY
+
+ md <<~'MD'
+ 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`.
+ MD
+
+ render DocsUI::PropTable.new(
+ [
+ [ "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)." ]
+ ],
+ headers: [ "Shortcut string", " label", "Meaning" ]
+ )
+
+ DocsUI::Callout(:tip) do
+ plain "A modifier-only or empty string ("
+ code { "mod+" }
+ plain ", "
+ code { "\"\"" }
+ plain ", "
+ code { "nil" }
+ plain ") is unparseable — "
+ code { "Shortcut.parse_list" }
+ plain " silently drops it. With "
+ code { "search_shortcuts" }
+ plain " empty the form still works; it just renders no "
+ code { "" }
+ plain " badges."
+ end
+ end
+ end
+
+ def config_section
+ DocsUI::Section("Configuration",
+ description: "Three knobs — the affordance, where it submits, and the shortcuts.") do
+ DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
+ 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
+ RUBY
+
+ render DocsUI::PropTable.new(
+ [
+ [ "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", '["/", "mod+k"]', "Chord strings that open the palette." ],
+ [ "config.search_enabled?", "Boolean", "—", "search == true AND a non-blank search_path." ],
+ [ "config.search_shortcuts", "Array", "—", "The PARSED list (reader maps to Shortcut, drops unparseable)." ]
+ ],
+ headers: [ "Knob / reader", "Type", "Default", "Description" ]
+ )
+
+ md <<~'MD'
+ `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`.
+ MD
+
+ DocsUI::Callout(:note) do
+ plain "See "
+ a(href: "/docs/configuration") { "Configuration" }
+ plain " for the full config surface, "
+ a(href: "/docs/ai") { "AI & agents" }
+ plain " for the twins search reads, and "
+ a(href: "/docs/components") { "Components" }
+ plain " for SearchBox / SearchResults alongside the rest of the kit."
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/docs/app/views/docs/pages/styling.rb b/docs/app/views/docs/pages/styling.rb
index 3d9f532..ff4e204 100644
--- a/docs/app/views/docs/pages/styling.rb
+++ b/docs/app/views/docs/pages/styling.rb
@@ -3,126 +3,231 @@
module Views
module Docs
module Pages
+ # How each site builds its own Tailwind + daisyUI stylesheet, keeps the
+ # theme list in sync with the CSS, and drives the light/dark code-highlight
+ # theme — all config, no per-site CSS surgery.
class Styling < DocsUI::Page
title "Styling & CSS"
eyebrow "Getting started"
- def lead = "Each site builds its own Tailwind + daisyUI stylesheet so the chrome is themed to match."
+ def lead = "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."
def content
- DocsUI::Section("The canonical build", description: "docs-kit ships no compiled CSS — you build it.") do
- prose do
- p do
- plain "docs-kit ships "
- strong { "no compiled CSS" }
- plain ". Each site builds its own with the Tailwind CLI (run via Bun), so the "
- code { "@source" }
- plain " globs can see "
- strong { "both" }
- plain " your app "
- strong { "and" }
- plain " the gem's Phlex components. Without the gem in scope, every class the shared chrome uses would be tree-shaken away."
- end
- p do
- plain "The bundled "
- code { "bin/build-css" }
- plain " resolves the docs-kit gem path and adds it as an extra "
- code { "@source" }
- plain ", so you never hand-write the gem's location."
- end
- end
+ canonical_build_section
+ entry_point_section
+ adding_a_theme_section
+ code_theme_section
+ custom_styles_section
+ end
+
+ private
+
+ def canonical_build_section
+ DocsUI::Section("The canonical build",
+ description: "docs-kit ships no compiled CSS — you build it.") do
+ md <<~'MD'
+ 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.
+ MD
+
DocsUI::Code(<<~SHELL, lexer: :shell)
bun run build:css # one-shot, for deploys
bun run watch:css # rebuild on change, for development
SHELL
end
+ end
+
+ def entry_point_section
+ DocsUI::Section("application.tailwind.css",
+ description: "Your Tailwind entry point wires up daisyUI, the themes, and the sources.") do
+ md <<~'MD'
+ 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.
+ MD
- DocsUI::Section("application.tailwind.css", description: "Your Tailwind entry point wires up daisyUI and the sources.") do
- prose do
- p do
- plain "The "
- code { "themes:" }
- plain " list here "
- strong { "must match" }
- plain " "
- code { "c.themes" }
- plain " in your initializer — the CSS build ships those themes and the ThemeSwitcher offers them."
- end
- end
DocsUI::Code(<<~CSS, filename: "app/assets/stylesheets/application.tailwind.css", lexer: :css)
@import "tailwindcss";
+ /* daisyUI — the theme list MUST match DocsKit.configuration.themes. */
@plugin "daisyui" {
- themes: dark --default, light, synthwave;
+ themes: dark --default, light --prefersdark, synthwave, retro,
+ cyberpunk, dracula, night, nord, sunset;
}
- /* Your app's templates + components */
- @source "../../../app/views/**/*.rb";
+ /* 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";
-
- /* The docs-kit gem's Phlex chrome (bin/build-css injects this path) */
- @source "../../../..//lib/**/*.rb";
+ @import "./tailwind.sources.css"; /* gem @source lines, generated */
CSS
- end
- DocsUI::Section("Adding a theme") do
- prose do
- p { "Themes come from daisyUI. To add one:" }
- ol do
- li do
- plain "Add it to the "
- code { "@plugin \"daisyui\" { themes: ... }" }
- plain " block in "
- code { "application.tailwind.css" }
- plain "."
- end
- li do
- plain "Add the same name to "
- code { "c.themes" }
- plain " in "
- code { "config/initializers/docs_kit.rb" }
- plain "."
- end
- li do
- plain "Rebuild the CSS ("
- code { "bun run build:css" }
- plain ")."
- end
- end
- end
+ md <<~'MD'
+ 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`.
+ MD
+
DocsUI::Callout(:warning) do
plain "Interpolated Tailwind class names get tree-shaken. Always write "
strong { "literal" }
- plain " class strings in components — e.g. "
+ plain " class strings — e.g. "
code { 'class: "badge badge-primary"' }
plain ", never "
code { 'class: "badge badge-\#{color}"' }
- plain ". The scanner can't see the built name, so the style never ships."
+ plain ". The scanner can't see the built name, so the style never ships. New render-time classes (like the Drawer) need an "
+ code { "@source inline(...)" }
+ plain " line."
end
end
+ end
+
+ def adding_a_theme_section
+ DocsUI::Section("Adding a theme",
+ description: "Two edits and a rebuild — CSS block, config, done.") do
+ md <<~'MD'
+ 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](/docs/configuration) for the
+ full theme surface.
+ MD
+
+ DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
+ DocsKit.configure do |c|
+ c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
+ end
+ RUBY
+ end
+ end
+
+ def code_theme_section
+ DocsUI::Section("Code highlighting: one light theme, one dark",
+ description: "Rouge highlights code; two config knobs make it follow the switcher.") do
+ md <<~'MD'
+ `DocsUI::Code` highlights with [Rouge](/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.
+ MD
- DocsUI::Section("Custom styles") do
- prose do
- p do
- plain "Add your own CSS below the imports in "
- code { "application.tailwind.css" }
- plain " — plain rules, "
- code { "@apply" }
- plain ", or "
- code { "@layer" }
- plain " all work."
+ md <<~'MD'
+ **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:
+ MD
+
+ DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
+ 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
- p do
- plain "To pull in additional stylesheets (loaded after the built one), list them via "
- code { "c.stylesheets" }
- plain " in your initializer."
+ RUBY
+
+ md <<~'MD'
+ Try it: switch the theme in the topbar and watch this next block
+ change palette. It's the same highlighter, two scoped stylesheets.
+ MD
+
+ DocsUI::Code(<<~RUBY, filename: "app/models/doc.rb")
+ 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
+ RUBY
+
+ md <<~'MD'
+ 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.
+ MD
+
+ render DocsUI::PropTable.new(
+ [
+ [ "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", "daisyUI dark set", "Which theme names count as dark; intersected with c.themes at render." ]
+ ]
+ )
+
+ DocsUI::Callout(:note) do
+ plain "A String theme name is resolved to its Rouge constant. A typo'd or "
+ plain "unloaded name "
+ strong { "degrades gracefully" }
+ plain " — the base theme falls back to the default and a bad "
+ code { "code_theme_dark" }
+ plain " simply emits no dark CSS, so a mistake never crashes a code block."
end
+ end
+ end
+
+ def custom_styles_section
+ DocsUI::Section("Custom styles",
+ description: "Plain CSS, @apply, @layer, or extra stylesheets.") do
+ md <<~'MD'
+ 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.
+ MD
+
DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb")
DocsKit.configure do |c|
- c.stylesheets = %w[custom announcements]
+ c.stylesheets = %w[application announcements]
end
RUBY
+
+ md <<~'MD'
+ Next: see [Languages](/docs/languages) for the Rouge lexer surface,
+ [Components](/docs/components) for the kit `Code` and `Example`
+ render live, and [Configuration](/docs/configuration) for every
+ config knob in one place.
+ MD
end
end
end
diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb
index 9786961..8dc4c5c 100644
--- a/docs/config/initializers/docs_kit.rb
+++ b/docs/config/initializers/docs_kit.rb
@@ -28,8 +28,10 @@
# The sidebar nav derives from the registry — one heading → one registry.
# Each registry's authored pages become NavItems automatically (an unwritten
- # page is skipped, so no dead links). For bespoke nav (interleaved
- # registries, custom subgroups) set a `c.nav` lambda instead; it wins.
+ # page is skipped, so no dead links); the page `group:` values render as the
+ # collapsible sub-groups (Getting started / Authoring / Reference / AI &
+ # tooling / Deploy). For bespoke nav (interleaved registries) set a `c.nav`
+ # lambda instead; it wins.
c.nav_registries = { "Docs" => Doc }
end
end