diff --git a/docs/.claude/skills/write-docs-page/SKILL.md b/docs/.claude/skills/write-docs-page/SKILL.md new file mode 100644 index 0000000..e88b5bc --- /dev/null +++ b/docs/.claude/skills/write-docs-page/SKILL.md @@ -0,0 +1,81 @@ +--- +name: write-docs-page +description: "Write, add, or update a documentation page in this docs-kit site. Use when asked to document a feature, endpoint, class, or workflow, or to add or edit a page under app/views/docs/pages/. Scaffolds with `rails g docs_kit:page`, writes Markdown-first content, and runs the verification gates." +--- + +# Write a docs page + +This is a [docs-kit](https://github.com/mhenrixon/docs-kit) site (Phlexy ui docs). +Every page is a `DocsUI::Page` subclass; the shell, sidebar, "On this page" TOC, +search, and the `.md` twin all come free. Your job is to scaffold a page and +write its `#content` — never hand-write HTML or daisyUI markup. + +The full authoring contract is in the repo's `AGENTS.md`. This is the recipe. + +## 1. Gather the subject + +Identify exactly what to document (the code, endpoint, or workflow) and where it +belongs in the sidebar (the `--group`). Read the relevant source first — do not +invent behavior. If the subject is an HTTP endpoint, prefer `DocsUI::RequestExample` +/ `DocsUI::FieldTable`; if it's a Ruby API, prefer `DocsUI::PropTable`. + +## 2. Scaffold (one command) + +```bash +rails g docs_kit:page "Page Title" --group=Guide +``` + +This writes `app/views/docs/pages/.rb` **and** injects the required +`page "…"` registry line into `app/models/doc.rb`. Overrides: `--slug`, `--view`, +`--eyebrow`, `--registry`. If it reports a legacy `entries [...]` registry, add +the printed line by hand. + +## 3. Write `#content` — Markdown first + +- Set `title`, `eyebrow`, and a one-sentence `lead`. +- One `DocsUI::Section("…")` per part of the page — **Sections own structure and + the TOC.** Never use a Markdown `##` for page structure. +- Prose is `md <<~'MD' … MD` — a **single-quoted** heredoc (no escaping, no + interpolation; Phlex escapes author text). Markdown `###` is only for + sub-headings inside a Section. +- Reference material: `DocsUI::PropTable`, `DocsUI::FieldTable`, + `DocsUI::RequestExample`, `DocsUI::Code(source, filename:)`, + `DocsUI::Callout(:note | :tip | :warning)`. + +```ruby +class Views::Docs::Pages::PageTitle < DocsUI::Page + title "Page Title" + eyebrow "Guide" + + def lead = "One sentence describing the page." + + def content + DocsUI::Section("Overview", description: "What this covers.") do + md <<~'MD' + Prose as **Markdown**. Fenced ```ruby``` blocks highlight; `inline code`, + lists, links, and GFM tables all render styled. + MD + end + end +end +``` + +## 4. Self-review against the checklist + +- [ ] The `page "…"` registry line exists (the generator adds it). +- [ ] Structure is `DocsUI::Section`s, not Markdown `##` headings. +- [ ] Prose uses `md <<~'MD'` (single-quoted); no `html_safe`, no `raw`. +- [ ] No hand-written HTML/daisyUI markup, no per-feature Stimulus controller. +- [ ] Reads correctly with JavaScript off (the server renders it fully). +- [ ] Any new theme is in both `c.themes` and the Tailwind `@plugin` block. +- [ ] No inline `rubocop:disable` to force layout. + +## 5. Run the gates + +```bash +bundle exec rspec && bundle exec rubocop +bun run build:css # only if you added classes the CSS must scan +``` + +Then render locally (`bin/dev`, open `/docs/`) and confirm it reads well. +For depth on any idiom, read the live [Authoring pages](/docs/authoring) doc. diff --git a/docs/.rubocop.yml b/docs/.rubocop.yml index ac68e57..68254c5 100644 --- a/docs/.rubocop.yml +++ b/docs/.rubocop.yml @@ -1,156 +1,96 @@ +--- plugins: - - rubocop-performance - - rubocop-rails - - rubocop-rake - - rubocop-rspec - -# Local custom cop: enforce the DocsUI/DaisyUI Phlex kit form -# (DocsUI::Code(...)) over `render DocsUI::Code.new(...)`. +- rubocop-performance +- rubocop-rails +- rubocop-rake +- rubocop-rspec require: - - ./lib/rubocop/cop/docs_kit/render_component_preferred.rb - +- "./lib/rubocop/cop/docs_kit/render_component_preferred.rb" +- docs_kit/rubocop DocsKit/RenderComponentPreferred: Enabled: true Include: - - app/**/*.rb + - app/**/*.rb Exclude: - - lib/rubocop/cop/**/*.rb - + - lib/rubocop/cop/**/*.rb AllCops: TargetRubyVersion: 3.4 NewCops: enable SuggestExtensions: false Exclude: - - "bin/**/*" - - "db/schema.rb" - - "node_modules/**/*" - - "tmp/**/*" - - "vendor/**/*" - -#=============== -#=== LAYOUT === -#=============== - + - bin/**/* + - db/schema.rb + - node_modules/**/* + - tmp/**/* + - vendor/**/* Layout/BlockAlignment: EnforcedStyleAlignWith: start_of_block - Layout/LineLength: Enabled: false - -#=============== -#=== LINT === -#=============== - Lint/AmbiguousBlockAssociation: Exclude: - - "spec/**/*" - + - spec/**/* Lint/EmptyBlock: Exclude: - - "app/views/**/*" - + - app/views/**/* Lint/MissingSuper: Exclude: - - "app/views/**/*" - - "app/components/**/*" - + - app/views/**/* + - app/components/**/* Lint/UselessConstantScoping: Enabled: false - -#=============== -#=== METRICS === -#=============== - Metrics: Enabled: false - -#=============== -#=== NAMING === -#=============== - Naming/VariableNumber: Exclude: - - "app/views/**/*" - - "app/components/**/*" - -#=============== -#=== RAILS === -#=============== - + - app/views/**/* + - app/components/**/* Rails: Enabled: true - Rails/Exit: Exclude: - - "lib/generators/**/*" - + - lib/generators/**/* Rails/FindEach: Exclude: - - "app/views/**/*" - + - app/views/**/* Rails/FilePath: EnforcedStyle: arguments - Rails/I18nLocaleTexts: Enabled: false - Rails/OutputSafety: Enabled: false - Rails/SkipsModelValidations: Enabled: false - -#=============== -#=== RAKE === -#=============== - Rake/Desc: Exclude: - - "lib/tasks/annotate.rake" - - "lib/tasks/auto_annotate_models.rake" - -#=============== -#=== STYLE === -#=============== - + - lib/tasks/annotate.rake + - lib/tasks/auto_annotate_models.rake Style/ClassAndModuleChildren: Enabled: false - Style/Documentation: Enabled: false - Style/ClassVars: Exclude: - - "app/models/category.rb" - - "app/models/component.rb" - + - app/models/category.rb + - app/models/component.rb Style/Lambda: EnforcedStyle: literal - Style/OpenStructUse: Enabled: false - Style/StringLiterals: EnforcedStyle: double_quotes - Style/SymbolArray: EnforcedStyle: brackets - Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma - Style/TrailingCommaInArrayLiteral: EnforcedStyleForMultiline: comma - -#============= -#=== RSpec === -#============= - RSpec/ExampleLength: Enabled: false - RSpec/MultipleExpectations: Enabled: false - RSpec/NestedGroups: Enabled: false +inherit_gem: + docs-kit: + - config/rubocop/docs_kit.yml diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..7fca0ee --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. `AGENTS.md` is the +cross-tool convention (Claude Code, Cursor, Copilot, Aider, …); Claude Code also +reads it through the bundled `write-docs-page` skill. Edit freely — a +`docs_kit:install` re-run only touches the delimited block below. + + +## Writing docs pages (docs-kit) + +Phlexy ui docs is a [docs-kit](https://github.com/mhenrixon/docs-kit) site: a +Phlex/daisyUI chrome where **every page is a `DocsUI::Page` subclass** and the +sidebar, TOC, search, and Markdown twin come free. To document something, you +scaffold a page, then write its `#content`. Never hand-write HTML or daisyUI +markup — compose the kit's `DocsUI::` helpers. + +### 1. Scaffold the page (one command) + +```bash +rails g docs_kit:page "Getting Started" --group=Guide +``` + +That writes `app/views/docs/pages/getting_started.rb` **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`. Overrides: +`--slug=auth`, `--view=OauthGuide`, `--eyebrow="Advanced"`, `--registry=Guide`. +Re-running is idempotent. + +> The registry line is **required** — a page with no `page "…"` line in +> `app/models/doc.rb` is not routed and not in the nav. The generator adds it; +> if you hand-write a page, add the line yourself. + +### 2. Write `#content` — Markdown first + +Prose is `md` with a **single-quoted** heredoc (`<<~'MD'`) so `#{…}` stays +literal (Phlex escapes author text — never `html_safe` or interpolate): + +```ruby +class Views::Docs::Pages::Guide < DocsUI::Page + title "Guide" + eyebrow "Getting started" + + def lead = "One sentence under the page title." + + def content + DocsUI::Section("First steps", description: "What this covers.") do + md <<~'MD' + Prose as **Markdown** — lists, `inline code`, links, GFM tables, and + fenced ```ruby``` blocks all render styled. Use Markdown `###` only for + sub-headings *inside* a Section. + MD + + DocsUI::Code(<<~RUBY, filename: "config/routes.rb") + Rails.application.routes.draw { mount DocsKit::Engine, at: "/docs" } + RUBY + end + end +end +``` + +### The authoring contract + +- **`DocsUI::Section` owns page structure and the "On this page" TOC.** One + Section per part of the page; each heading becomes a TOC entry. **Never** use a + Markdown `##` for page structure — only for sub-headings inside a Section. +- **The primary argument is positional; modifiers are keywords.** + `Section("Title", description:)`, `Code(source, filename:)`, + `Header("Title", eyebrow:)`. +- **Wrappers that take no positional arg use lowercase page helpers** so a block + needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`. (A bare + `DocsUI::Prose do` is a Ruby SyntaxError; the helpers sidestep it.) +- **Reference material has dedicated helpers** — reach for these before prose: + `DocsUI::PropTable`, `DocsUI::FieldTable`, `DocsUI::RequestExample`, + `DocsUI::Callout(:note | :tip | :warning)`. + +### Invariants — do not break + +- **The registry line is required** (see above) — no line, no page. +- **The page must work with JavaScript off.** The server renders it fully; + the one `docs-nav` controller only *enhances*. Never require JS to read a page. +- **Themes offered must exist in the CSS build** — `c.themes` in + `config/initializers/docs_kit.rb` must match the `@plugin "daisyui" { themes: … }` + block in `app/assets/stylesheets/application.tailwind.css`. Don't add one + without the other. +- **No inline `rubocop:disable`** to force layout — write idiomatic Ruby the + site's cops accept. + +### 3. Verify before you finish + +```bash +bundle exec rspec && bundle exec rubocop # tests + lint must pass +bun run build:css # if you added classes the CSS scans +``` + +Then render the page locally (`bin/dev`, open `/docs/`) and confirm it +reads correctly — with JavaScript off, too. + +**Depth:** the live [Authoring pages](/docs/authoring) doc is the full, +always-current version of this contract. When in doubt, read it. + diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 431dd75..675c733 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,9 +1,11 @@ GIT remote: https://github.com/mhenrixon/docs-kit.git - revision: 73e52fd390a4236f460b984dc4c22b9ba3b2252f + revision: 82d76a761cf21f1380f71eccdbbae49fe8c1ad6f specs: docs-kit (0.1.0) + commonmarker (~> 2.0) daisyui (>= 1.2) + nokogiri (>= 1.15) phlex-rails (>= 2.0, < 3) rails_icons (~> 1.1) rouge (>= 4.0) @@ -133,6 +135,10 @@ GEM addressable capybara playwright-ruby-client (>= 1.16.0) + commonmarker (2.8.2-aarch64-linux) + commonmarker (2.8.2-arm64-darwin) + commonmarker (2.8.2-x86_64-darwin) + commonmarker (2.8.2-x86_64-linux) concurrent-ruby (1.3.7) connection_pool (3.0.2) crass (1.0.7) diff --git a/docs/app/controllers/application_controller.rb b/docs/app/controllers/application_controller.rb index fa99b76..58603db 100644 --- a/docs/app/controllers/application_controller.rb +++ b/docs/app/controllers/application_controller.rb @@ -1,21 +1,16 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base + include DocsKit::Controller + # Only allow modern browsers supporting webp, web push, badges, import maps, # CSS nesting, and CSS :has (required by the zero-JS theme switcher). allow_browser versions: :modern protect_from_forgery with: :exception - private - - # Render a Phlex page view. Docs::Shell IS the full HTML document (its own - # // + the daisyUI drawer shell), so it must NOT be wrapped - # in a Rails ERB application layout — `layout: false` prevents double - # nesting. phlex-rails still renders through a real view context, so the - # reactive token signer, dom_id, csrf, and url helpers all work inside the - # components on the page. - def render_page(view) - render view, layout: false - end + # render_page comes from DocsKit::Controller (included above): it renders the + # Phlex view with layout: false (DocsUI::Shell IS the whole document) AND + # serves the Markdown twin on a .md request. The site no longer defines its + # own; the gem's version supersedes it. end diff --git a/docs/app/helpers/icon_helper.rb b/docs/app/helpers/icon_helper.rb deleted file mode 100644 index c17d144..0000000 --- a/docs/app/helpers/icon_helper.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# Lucide icon rendering. `_lucide(name, **)` returns the inline SVG string for a -# synced lucide icon; a missing name falls back to a question-mark icon (in -# production) instead of raising. -# -# In Phlex views, render it with `raw(safe(_lucide("search", class: "size-4")))`, -# or use the Docs::Icon component (from docs-kit) which wraps rails_icons directly. -module IconHelper - MISSING_ICON = "circle-question-mark" - - def _lucide(name, **) - icon(name, library: "lucide", **) - end - - private - - def icon(name, library: RailsIcons.configuration.default_library, variant: nil, **arguments) - Icons::Icon.new(name: name.to_s.dasherize, library:, variant:, arguments:).svg - rescue Icons::IconNotFound - raise if Rails.env.local? - - Icons::Icon.new(name: MISSING_ICON, library:, variant:, arguments:).svg - end -end diff --git a/docs/app/models/component_doc.rb b/docs/app/models/component_doc.rb index f1ec4e6..b09e659 100644 --- a/docs/app/models/component_doc.rb +++ b/docs/app/models/component_doc.rb @@ -170,4 +170,35 @@ def examples def example_class examples.first end + + # --- docs-kit AI-surface adapter (Registry v2 contract) ------------------ + # /llms.txt, /llms-full.txt, and search consume config.nav_registries, calling + # #nav_items on the registry and #href / #view_class on each entry. A component + # page renders Views::Components::Show.new(component:), so #view_class returns a + # no-arg subclass with the component pre-bound — satisfying `view_class.new`. + + # { category => [DocsKit::NavItem] } for components that have at least one + # example (no dead entries), in the official category order. + def self.nav_items + items_by_category = grouped.transform_values do |components| + authored = components.select { |c| c.examples.any? } + authored.map { |c| DocsKit::NavItem.new(href: c.href, label: c.title) } + end + items_by_category.reject { |_cat, items| items.empty? } + end + + # The component page URL. + def href = "/components/#{slug}" + + # A no-arg Phlex class that renders this component's Show page — so docs-kit's + # AI surfaces (which call view_class.new) can export the component's Markdown + # twin. nil when the component has no examples yet (dropped from the index). + def view_class + return nil if examples.empty? + + component = self + Class.new(Views::Components::Show) do + define_method(:initialize) { super(component: component) } + end + end end diff --git a/docs/app/models/doc.rb b/docs/app/models/doc.rb index 02f07c5..90701e0 100644 --- a/docs/app/models/doc.rb +++ b/docs/app/models/doc.rb @@ -33,8 +33,21 @@ def self.grouped all.group_by(&:group) end + # { group => [DocsKit::NavItem] } for the authored guides — the Registry v2 + # shape docs-kit's AI surfaces (/llms.txt, /llms-full.txt, search) consume via + # config.nav_registries. Unwritten pages (no view_class) are dropped. + def self.nav_items + all.select(&:view_class).group_by(&:group).transform_values do |docs| + docs.map { |doc| DocsKit::NavItem.new(href: doc.href, label: doc.title) } + end + end + # The hand-authored Phlex page class for this doc (nil if not yet written). def view_class "Views::Docs::Pages::#{view_name}".safe_constantize end + + # The guide's URL path — consumed by docs-kit's search index and the .md-twin + # links in /llms.txt. + def href = "/docs/#{slug}" end diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb index bdf14c1..f1835be 100644 --- a/docs/config/initializers/docs_kit.rb +++ b/docs/config/initializers/docs_kit.rb @@ -11,13 +11,28 @@ DocsKit.configure do |c| c.brand = "DaisyUI Ruby" c.title_suffix = "DaisyUI Ruby" + # The one-line summary agents read first in /llms.txt (the llmstxt.org + # blockquote under the H1). + c.tagline = "daisyUI's components as first-class Phlex — 70+ Ruby " \ + "components you compose in views, no HTML class soup." c.themes = %w[ dark light cupcake synthwave retro cyberpunk valentine dracula night coffee nord sunset business emerald corporate ] - # Monokai, inlined by Docs::Code — same as the phlex-reactive docs site. - c.code_theme = "Rouge::Themes::Monokai" - c.version_badge = -> { "v#{DaisyUI::VERSION}" } - c.nav = -> { DocsNav.groups } + + # A light base with a dark override, so code stays readable when the switcher + # lands on a dark theme. CSS-only scoping ([data-theme=X]) — no JS, no flash. + c.code_theme = "Rouge::Themes::Github" + c.code_theme_dark = "Rouge::Themes::Monokai" + c.version_badge = -> { "v#{DaisyUI::VERSION}" } + + # The sidebar interleaves Components + Guides, so it stays a bespoke lambda. + c.nav = -> { DocsNav.groups } + + # nav_registries feeds the AI surfaces (/llms.txt, /llms-full.txt, search) + # from the registries — the custom c.nav above only drives the sidebar, so + # without this the AI index would be empty. Both registries expose the + # Registry v2 shape (#nav_items + #href + #view_class). + c.nav_registries = { "Components" => ComponentDoc, "Guides" => Doc } end end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 0c1f1b5..f0c9201 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true Rails.application.routes.draw do + # Add your docs to an agent over MCP (needs `gem "mcp"`): + # post "/mcp" => "docs_kit/mcp#create" + # match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete] + get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full + get "/llms.txt" => "docs_kit/llms#index", as: :llms + get "/docs/search" => "docs_kit/search#index", as: :docs_search mount RailsIcons::Engine, at: "/rails_icons" # Reveal health status on /up that returns 200 if the app boots with no