From 2fd06a813ad197f26b596a2933a396939f0d8b9d Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 13:20:04 +0200 Subject: [PATCH] feat(generator): scaffold AGENTS.md + a write-docs-page skill into consuming sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary An agent dropped into a consuming site today has to reverse-engineer the kit's authoring idioms. This wires the docs-kit authoring contract into every site (and every `docs-kit new` site) in a machine-readable form, maintained in ONE place — the gem's templates — so "document this endpoint" works out of the box. The install generator gains `create_agent_docs`, which writes: - `AGENTS.md` (site root) — the cross-tool authoring contract (the one-command page flow, the `md <<~'MD'` prose idiom, that `DocsUI::Section` owns structure and the TOC, the reference helpers, and the invariants an agent must not break). Idempotent: a fresh file is created whole; an existing one gets only its `` block replaced (the user's own content is never touched); a re-run rewrites only what's between the markers. - `.claude/skills/write-docs-page/SKILL.md` — a Claude Code skill: the task recipe (gather subject → `rails g docs_kit:page` → write sections md-first → self-review → run the gates). Skipped if the site already has one. Frontmatter targets "write / add / update a documentation page". `docs-kit new` inherits both automatically (it runs the install generator). The gem's own repo gets an adapted `AGENTS.md` (dogfooding for agents contributing to docs-kit's docs site). ## Test Coverage - fresh app → AGENTS.md + skill created, brand substituted, core idioms present - skill frontmatter targets "document" + names the page generator - existing AGENTS.md → docs-kit block injected between delimiters, user content preserved - re-run → no duplicate block; a hand-edited AGENTS.md's user sections stay intact - existing skill file → not clobbered ## Verification - [x] bundle exec rspec (459 examples, 0 failures) - [x] bundle exec rubocop (94 files, no offenses) - [x] end-to-end smoke: generated AGENTS.md + skill render with brand substituted Refs #21 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- AGENTS.md | 73 +++++++++++ README.md | 26 ++++ .../docs_kit/install/install_generator.rb | 69 +++++++++++ .../docs_kit/install/templates/agents_md.erb | 100 +++++++++++++++ .../docs_kit/install/templates/skill.md.erb | 81 ++++++++++++ spec/generators/install_generator_spec.rb | 117 ++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 AGENTS.md create mode 100644 lib/generators/docs_kit/install/templates/agents_md.erb create mode 100644 lib/generators/docs_kit/install/templates/skill.md.erb diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..200147c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md + +Guidance for AI coding agents working in the **docs-kit** repository. This is the +cross-tool convention file (Claude Code, Cursor, Copilot, Aider, …). Claude Code +users also have `.claude/commands/` and `.claude/rules/`; see `CLAUDE.md` for the +full project brief. This file is the fast orientation. + +docs-kit is a Rails engine that ships the shared Phlex/daisyUI **chrome** for +documentation sites — the shell, sidebar, code blocks, theme switcher, page kit — +so many docs sites look identical and are maintained in one place. The gem also +**dogfoods itself**: its own docs site lives under `docs/`. + +## The two things you'll be asked to do + +### A. Change the gem (a component, config knob, generator, the engine) + +Read `CLAUDE.md` first — it has the layer map and the critical rules. The +non-negotiables: + +- **Compose from `DocsUI::` Phlex components** — never hand-write raw daisyUI markup. +- **Site-specific values come from `DocsKit.configuration`**, never hardcoded in a + component. A new knob lands on `DocsKit::Configuration` with a sensible default + (backwards compatible). +- **The page works with JavaScript off.** The server renders it fully; the one + `docs-nav` Stimulus controller only *enhances* (collapse persistence, auto-TOC). +- **Themes offered must exist in the CSS build** — `config.themes` must match the + `@plugin "daisyui" { themes: … }` block. +- **Required setup wires into BOTH** the install generator + (`lib/generators/docs_kit/install/`) **and** the `docs-kit new` template + (`lib/docs_kit/templates/new_site.rb`) — never the README alone. +- **TDD**: write the failing spec first (`spec/docs_kit`, `spec/docs_ui`, + `spec/generators`), then the minimum code. Assert on semantics, not HTML snapshots. + +### B. Write a docs page for docs-kit's own docs site (under `docs/`) + +The dogfood site is itself a docs-kit site. Its registry is +`docs/app/models/doc.rb`; its pages are `docs/app/views/docs/pages/`. To document +a feature of the gem: + +**1. Scaffold** (from the `docs/` app): + +```bash +cd docs && bin/rails g docs_kit:page "Getting Started" --group=Guide +``` + +That writes `docs/app/views/docs/pages/getting_started.rb` **and** injects the +required `page "Getting Started", group: "Guide"` line into `Doc`. Overrides: +`--slug`, `--view`, `--eyebrow`, `--registry`. The registry line is **required** — +no line, no page. + +**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). `DocsUI::Section` owns page structure and the TOC; +never use a Markdown `##` for structure. The primary arg is positional, modifiers +are keywords: `Section("Title", description:)`, `Code(source, filename:)`. For no +positional arg use the lowercase helpers `md` / `prose` / `example`. Reference +material has dedicated helpers: `DocsUI::PropTable`, `DocsUI::FieldTable`, +`DocsUI::RequestExample`, `DocsUI::Callout(:note | :tip | :warning)`. + +The always-current, worked example of the whole contract is +`docs/app/views/docs/pages/authoring.rb` (rendered at `/docs/authoring`). Read it +before writing a page. + +## Verify before you finish (every change) + +```bash +bundle exec rspec # the suite (SimpleCov enforces 80% minimum) +bundle exec rubocop # lint — no offenses (rubocop -A to autocorrect) +bundle exec rake # both, together +``` + +For CSS-affecting changes (a new emitted class), rebuild in the `docs/` app: +`bun run build:css`. Never `gem push` by hand — release via `rake release[X.Y.Z]`. diff --git a/README.md b/README.md index 0f81e0c..2b894d1 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,32 @@ DocsKit.configure { |c| c.page_markdown_action = false } to your `get "docs/:doc"` route) to enable the `.md` URLs. Sites that don't re-run simply have no `.md` route match — HTML rendering is untouched. +## AI-assisted authoring + +The install generator scaffolds the authoring contract in a **machine-readable** +form, so "document this endpoint" works out of the box — an agent doesn't have to +reverse-engineer the kit's idioms. Two files, both brand-substituted and +maintained in one place (the gem's templates): + +- **`AGENTS.md`** (site root) — the cross-tool convention file (Claude Code, + Cursor, Copilot, Aider, …). A terse, example-first authoring contract: the + one-command page flow (`rails g docs_kit:page`), the `md <<~'MD'` prose idiom, + that `DocsUI::Section` owns structure and the TOC, the reference-material + helpers, and the invariants an agent must not break (the registry line is + required, JS-off must work, themes ↔ CSS build). It links the live + [Authoring pages](#the-authoring-convention) doc for depth. +- **`.claude/skills/write-docs-page/SKILL.md`** — a Claude Code skill: the recipe + for the task (gather the subject → `rails g docs_kit:page` → write sections + md-first → self-review against a checklist → run the gates). Its frontmatter + targets "write / add / update a documentation page," so Claude Code reaches for + it automatically. + +If a site already has an `AGENTS.md`, the generator injects the docs-kit block +between `` / `` markers — your own +content is preserved, and a re-run only rewrites what's between the markers. An +existing skill file is never clobbered. `docs-kit new` inherits both files +automatically (it runs the install generator). + ## Search Every site gets search from the gem — no external service, no build step, no diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 4cee63f..0f55e08 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "erb" require "rails/generators/base" module DocsKit @@ -22,6 +23,12 @@ class InstallGenerator < ::Rails::Generators::Base # NO controllers register. Eager-loading a few docs controllers is fine. REGISTER_LINE = 'eagerLoadControllersFrom("docs_kit/controllers", application)' + # The delimiters bounding the gem-owned block inside AGENTS.md. Everything + # outside them is the user's; a re-run only rewrites what's between them. + AGENTS_BEGIN = "" + AGENTS_END = "" + AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m + def create_phlex_initializer # Phlex autoload namespaces (Views:: / Components::). Skip if the app # already configures phlex-rails so we don't clobber a bespoke setup. @@ -116,6 +123,17 @@ def wire_assets_and_package_json add_package_json_scripts end + # AI-authoring scaffold: an AGENTS.md (the cross-tool authoring contract) + # and a Claude Code skill (.claude/skills/write-docs-page/SKILL.md). Both + # encode how to write a docs-kit page so "document this" works out of the + # box. Idempotent: a fresh AGENTS.md is created whole; an existing one gets + # only its delimited docs-kit block replaced (the user's own content is + # never touched); the skill file is skipped if it already exists. + def create_agent_docs + write_agents_md + write_write_docs_page_skill + end + def register_stimulus_controller index = stimulus_index_path return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index @@ -145,6 +163,57 @@ def show_post_install private + # Create AGENTS.md whole when absent; otherwise replace only the delimited + # docs-kit block (or append it if the file predates docs-kit), leaving the + # user's own content intact. + def write_agents_md + path = File.join(destination_root, "AGENTS.md") + rendered = render_template("agents_md.erb") + + return create_file("AGENTS.md", rendered) unless File.exist?(path) + + existing = File.read(path) + block = extract_agents_block(rendered) + updated = merge_agents_block(existing, block) + return say_status(:identical, "AGENTS.md", :blue) if updated == existing + + File.write(path, updated) + say_status(:update, "AGENTS.md (docs-kit block)", :green) + end + + # The BEGIN…END docs-kit block (inclusive) sliced out of the rendered + # template — the unit injected into a pre-existing AGENTS.md. + def extract_agents_block(rendered) + rendered[AGENTS_BLOCK_RE] + end + + # Swap the existing delimited block for the fresh one, or append it when the + # file has none yet. Idempotent: same block in → same file out. + def merge_agents_block(existing, block) + if existing.include?(AGENTS_BEGIN) && existing.include?(AGENTS_END) + existing.sub(AGENTS_BLOCK_RE, block) + else + "#{existing.rstrip}\n\n#{block}\n" + end + end + + # Write the write-docs-page Claude Code skill, unless the site already has + # one (a hand-customized skill is never clobbered). + def write_write_docs_page_skill + skill = ".claude/skills/write-docs-page/SKILL.md" + return say_status(:skip, skill, :blue) if File.exist?(File.join(destination_root, skill)) + + create_file skill, render_template("skill.md.erb") + end + + # Render an ERB template from source_root against the generator binding, so + # helpers like app_brand resolve — used where we need the rendered string in + # memory (block extraction/merge) rather than Thor's file-to-file `template`. + def render_template(name) + source = File.read(File.join(self.class.source_root, name)) + ERB.new(source, trim_mode: "-").result(binding) + end + def add_package_json_scripts pkg = File.join(destination_root, "package.json") return create_file("package.json", package_json_stub) unless File.exist?(pkg) diff --git a/lib/generators/docs_kit/install/templates/agents_md.erb b/lib/generators/docs_kit/install/templates/agents_md.erb new file mode 100644 index 0000000..2df068c --- /dev/null +++ b/lib/generators/docs_kit/install/templates/agents_md.erb @@ -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) + +<%= app_brand %> 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/lib/generators/docs_kit/install/templates/skill.md.erb b/lib/generators/docs_kit/install/templates/skill.md.erb new file mode 100644 index 0000000..982143c --- /dev/null +++ b/lib/generators/docs_kit/install/templates/skill.md.erb @@ -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 (<%= app_brand %>). +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/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 004a261..0d2db7d 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -262,4 +262,121 @@ def silence_stream expect(mode).to eq(0o755) end end + + # The AI-authoring scaffold: an AGENTS.md (the cross-tool authoring contract) + # and a Claude Code skill (.claude/skills/write-docs-page/SKILL.md). Both are + # brand-substituted; AGENTS.md is injected between delimiters when one already + # exists (user content preserved); the skill file is skipped when present. + describe "AI-authoring scaffold (create_agent_docs)" do + # The delimiters bounding the injected block in a pre-existing AGENTS.md. + let(:begin_marker) { "" } + let(:end_marker) { "" } + + context "when neither file exists" do + before do + build_skeleton + run_generator + end + + it "creates AGENTS.md at the site root" do + expect(exist?("AGENTS.md")).to be(true) + end + + it "creates the write-docs-page Claude Code skill" do + expect(exist?(".claude/skills/write-docs-page/SKILL.md")).to be(true) + end + + it "substitutes the humanized app brand into AGENTS.md" do + expect(read("AGENTS.md")).to include("My app docs") + end + + it "encodes the core authoring idioms in AGENTS.md" do + agents = read("AGENTS.md") + + # The one-command page flow and the md-first prose idiom — the two + # things an agent must know before it writes a page. + expect(agents).to include("rails g docs_kit:page") + expect(agents).to include("md <<~'MD'") + # The invariant an agent must not break. + expect(agents).to include("DocsUI::Section") + end + + it "wraps the AGENTS.md body in the docs-kit delimiters (so a re-run can find it)" do + agents = read("AGENTS.md") + + expect(agents).to include(begin_marker) + expect(agents).to include(end_marker) + end + + it "targets write/add/update documentation in the skill frontmatter" do + skill = read(".claude/skills/write-docs-page/SKILL.md") + + expect(skill).to match(/^---$/) # YAML frontmatter present + expect(skill).to match(/description:.*document/i) + expect(skill).to include("rails g docs_kit:page") + end + end + + context "when AGENTS.md already exists with user content" do + let(:user_content) { "# My project\n\nHand-written guidance the user owns.\n" } + + before do + build_skeleton + write("AGENTS.md", user_content) + run_generator + end + + it "preserves the user's existing content" do + expect(read("AGENTS.md")).to include("Hand-written guidance the user owns.") + end + + it "injects the docs-kit block between delimiters" do + agents = read("AGENTS.md") + + expect(agents).to include(begin_marker) + expect(agents).to include(end_marker) + expect(agents).to include("rails g docs_kit:page") + end + end + + context "when re-run (idempotence)" do + before do + build_skeleton + run_generator + run_generator # second invocation against the same destination + end + + it "does not duplicate the docs-kit block in AGENTS.md" do + agents = read("AGENTS.md") + + expect(agents.scan(begin_marker).size).to eq(1) + expect(agents.scan(end_marker).size).to eq(1) + end + + it "leaves a hand-edited AGENTS.md's user sections intact" do + # Simulate a user editing OUTSIDE the delimited block after install. + agents = read("AGENTS.md") + edited = "#{agents}\n## My own section\n\nDo not clobber me.\n" + write("AGENTS.md", edited) + + run_generator + + result = read("AGENTS.md") + expect(result).to include("Do not clobber me.") + expect(result.scan(begin_marker).size).to eq(1) + end + end + + context "when the skill file already exists" do + before do + build_skeleton + write(".claude/skills/write-docs-page/SKILL.md", "# custom skill, do not clobber\n") + run_generator + end + + it "does not overwrite the existing skill" do + expect(read(".claude/skills/write-docs-page/SKILL.md")).to eq("# custom skill, do not clobber\n") + end + end + end end