From eb6f466ab822487aa2edec743dbe88b74c9d6a86 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 15:53:26 +0200 Subject: [PATCH] docs(dogfood): rewrite the docs-kit docs site to cover the whole gem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dogfood site had drifted badly behind the gem (epic #8 shipped Markdown islands, the API-docs kit, the .md twin, llms.txt, search, MCP, --sync, and more — none of it documented). Rewrite + extend so the site documents AND demonstrates every capability, each page dogfooding the gem. Registry + nav (app/models/doc.rb) - Regrouped into 5 headings: Getting started / Authoring / Reference / AI & tooling / Deploy. nav_registries stays the canonical nav path. New pages - Markdown authoring (/docs/markdown) — the `md` island, the single-quoted heredoc convention, the GFM vocabulary rendered LIVE, how it flows into the .md twin. - API reference (/docs/api) — renders a live DocsUI::Endpoint + FieldTable + ErrorTable + RequestExample (4 client tabs) + JsonResponse, plus the api_base_url/api_auth_header/api_clients config. - AI & agents (/docs/ai) — the .md twin, the Markdown masthead action, /llms.txt + /llms-full.txt, the built-in MCP server, and the AGENTS.md + write-docs-page skill the generator scaffolds. - Search (/docs/search) — server-rendered search + the ⌘K palette, the search_/search_path/search_shortcuts config, progressive enhancement. Rewrites - Configuration — the "All options" table is now COMPLETE (28/28 knobs with defaults, was 13), grouped by concern; notes version_badge accepts a String or callable; nav_registries canonical, c.nav the escape hatch. - Overview — current "What you get" covering the whole surface, each feature linking to its page. - Installation — documents the --sync additive upgrade path. - Styling & CSS — adds c.code_theme / code_theme_dark / dark_themes (the light/dark Rouge highlight mechanism), corrected to describe the now-graceful theme-name degradation (not the old NameError crash). - Authoring — expanded building-blocks section links to the dedicated pages. Every page dogfoods the kit (md islands, DocsUI::Section/Code, live component renders) and renders 200; the .md twin, /llms.txt, /llms-full.txt, and search all render clean. Gem suite stays green (538 examples). Refs #8 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- docs/app/models/doc.rb | 31 +- docs/app/views/docs/pages/ai.rb | 335 +++++++++++++++++++ docs/app/views/docs/pages/api.rb | 353 +++++++++++++++++++++ docs/app/views/docs/pages/authoring.rb | 127 ++++---- docs/app/views/docs/pages/configuration.rb | 324 ++++++++++++++----- docs/app/views/docs/pages/installation.rb | 101 +++++- docs/app/views/docs/pages/markdown.rb | 308 ++++++++++++++++++ docs/app/views/docs/pages/overview.rb | 173 +++++----- docs/app/views/docs/pages/search.rb | 347 ++++++++++++++++++++ docs/app/views/docs/pages/styling.rb | 271 +++++++++++----- docs/config/initializers/docs_kit.rb | 6 +- 11 files changed, 2054 insertions(+), 322 deletions(-) create mode 100644 docs/app/views/docs/pages/ai.rb create mode 100644 docs/app/views/docs/pages/api.rb create mode 100644 docs/app/views/docs/pages/markdown.rb create mode 100644 docs/app/views/docs/pages/search.rb 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