docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Wire an existing Rails app into docs-kit: the config initializer, the
3
+ controller render helper, a Doc registry + a sample guide page + routes, the
4
+ Bun/Tailwind CSS build (bin/build-css + application.tailwind.css), and the
5
+ Stimulus/importmap registration for the docs-nav controller.
6
+
7
+ Idempotent — re-running skips files that already exist.
8
+
9
+ Example:
10
+ rails generate docs_kit:install
11
+
12
+ After running:
13
+ bun install && bun run build:css
14
+ # edit config/initializers/docs_kit.rb, add pages under app/views/docs/pages/
15
+ bin/dev
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "yaml"
5
+ require "rails/generators/base"
6
+ require_relative "sync_report"
7
+
8
+ module DocsKit
9
+ module Generators
10
+ # `rails g docs_kit:install`
11
+ #
12
+ # Wires an existing Rails app into docs-kit: the config initializer, the
13
+ # controller render helper, a Doc registry + a sample guide page + route, the
14
+ # Bun/Tailwind CSS build, and the Stimulus + importmap registration. Run it in
15
+ # a fresh `rails new` app (the new-site template does exactly this), or on top
16
+ # of an existing app to add a docs section.
17
+ #
18
+ # Fully idempotent — safe on a fresh app AND a years-old site, which makes
19
+ # re-running it the sanctioned upgrade path. Every step guards a re-run: the
20
+ # config initializer is skipped (never clobbered); routes are skipped even
21
+ # when the site wrote them in its own style (single quotes, `to:` vs `=>`);
22
+ # file creations skip what already exists. `--sync` runs ONLY the additive
23
+ # wiring (routes, initializer hint, importmap/stimulus, AGENTS.md, .rubocop)
24
+ # and prints a checklist of manual drift it can't safely automate.
25
+ class InstallGenerator < ::Rails::Generators::Base
26
+ source_root File.expand_path("templates", __dir__)
27
+
28
+ # `--sync`: the upgrade path for an existing site. Runs the wiring steps
29
+ # (idempotent) and skips scaffolding site-owned content (the doc registry,
30
+ # sample pages, the CSS build) — those already exist and are the site's.
31
+ class_option :sync, type: :boolean, default: false,
32
+ desc: "Upgrade an existing site: re-run wiring only, report drift, scaffold no content"
33
+
34
+ # eagerLoadControllersFrom (NOT lazy) — the default controllers/index.js only
35
+ # imports eagerLoadControllersFrom; injecting a lazyLoadControllersFrom call
36
+ # without its import throws a ReferenceError that aborts the whole module, so
37
+ # NO controllers register. Eager-loading a few docs controllers is fine.
38
+ REGISTER_LINE = 'eagerLoadControllersFrom("docs_kit/controllers", application)'
39
+
40
+ # The delimiters bounding the gem-owned block inside AGENTS.md. Everything
41
+ # outside them is the user's; a re-run only rewrites what's between them.
42
+ AGENTS_BEGIN = "<!-- BEGIN docs-kit -->"
43
+ AGENTS_END = "<!-- END docs-kit -->"
44
+ AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m
45
+
46
+ # The RuboCop wiring docs-kit injects. REQUIRE loads the cops;
47
+ # INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml).
48
+ RUBOCOP_REQUIRE = "docs_kit/rubocop"
49
+ RUBOCOP_INHERIT_GEM = "docs-kit"
50
+ RUBOCOP_INHERIT_PATH = "config/rubocop/docs_kit.yml"
51
+
52
+ # The .rubocop.yml written when a site has none yet.
53
+ RUBOCOP_STARTER = <<~YAML.freeze
54
+ # docs-kit ships its custom cops from the gem — load + enable them here.
55
+ # (RuboCop is a development-time dependency; add `gem "rubocop"` to your
56
+ # Gemfile if it isn't there.)
57
+ require:
58
+ - #{RUBOCOP_REQUIRE}
59
+
60
+ inherit_gem:
61
+ #{RUBOCOP_INHERIT_GEM}: #{RUBOCOP_INHERIT_PATH}
62
+
63
+ AllCops:
64
+ NewCops: enable
65
+ YAML
66
+
67
+ def create_phlex_initializer
68
+ # Phlex autoload namespaces (Views:: / Components::). Skip if the app
69
+ # already configures phlex-rails so we don't clobber a bespoke setup.
70
+ existing = Dir[File.join(destination_root, "config/initializers/*.rb")]
71
+ .any? { |f| File.read(f).include?("push_dir") && File.read(f).match?(/namespace:\s*Views/) }
72
+ return say_status(:skip, "phlex namespaces already configured", :blue) if existing
73
+
74
+ empty_directory "app/components"
75
+ create_file "app/components/.keep", "" unless File.exist?(File.join(destination_root, "app/components/.keep"))
76
+ template "phlex.rb.erb", "config/initializers/phlex.rb"
77
+ end
78
+
79
+ def create_rails_icons_initializer
80
+ icons = "config/initializers/rails_icons.rb"
81
+ return say_status(:skip, icons, :blue) if File.exist?(File.join(destination_root, icons))
82
+
83
+ template "rails_icons.rb.erb", icons
84
+ end
85
+
86
+ # The site's config (brand, themes, nav) lives here and is heavily edited,
87
+ # so a re-run must NEVER clobber it. Skip when present and point an upgrader
88
+ # at the current template for a manual diff. (create_rails_icons_initializer
89
+ # and create_phlex_initializer already follow this skip-if-exists pattern.)
90
+ def create_initializer
91
+ initializer = "config/initializers/docs_kit.rb"
92
+ if File.exist?(File.join(destination_root, initializer))
93
+ template_path = File.join(self.class.source_root, "docs_kit.rb.erb")
94
+ return say_status(:skip, "#{initializer} exists — compare with #{template_path} if upgrading", :blue)
95
+ end
96
+
97
+ template "docs_kit.rb.erb", initializer
98
+ end
99
+
100
+ def include_controller_helper
101
+ controller = "app/controllers/application_controller.rb"
102
+ return say_status(:skip, "#{controller} not found — include DocsKit::Controller manually", :yellow) \
103
+ unless File.exist?(File.join(destination_root, controller))
104
+
105
+ if File.read(File.join(destination_root, controller)).include?("DocsKit::Controller")
106
+ return say_status(:identical, controller, :blue)
107
+ end
108
+
109
+ inject_into_class controller, "ApplicationController", " include DocsKit::Controller\n"
110
+ end
111
+
112
+ # Site-owned content — the doc registry, its controllers, the sample guide
113
+ # page, the landing. A `--sync` upgrade never scaffolds these: they exist
114
+ # and are the site's to edit.
115
+ def create_registry_and_pages
116
+ return say_status(:skip, "site content (--sync: registry/pages are yours)", :blue) if options[:sync]
117
+
118
+ template "doc.rb.erb", "app/models/doc.rb"
119
+ template "docs_controller.rb.erb", "app/controllers/docs_controller.rb"
120
+ template "landings_controller.rb.erb", "app/controllers/landings_controller.rb"
121
+ template "installation_page.rb.erb", "app/views/docs/pages/installation.rb"
122
+ template "landing.rb.erb", "app/views/landings/show.rb"
123
+ end
124
+
125
+ def add_routes
126
+ # The `(.:format)` segment enables the Markdown twin: GET /docs/x.md hits
127
+ # docs#show with request.format.md?, so DocsKit::Controller#render_page
128
+ # returns the page's GFM. No `defaults: { format: "html" }` — that would
129
+ # pin html and defeat the .md route.
130
+ route_once %(get "docs/:doc(.:format)" => "docs#show", as: :doc)
131
+ # Docs search, served from the registry by the gem's DocsKit::SearchController
132
+ # (matches the default c.search_path). Thor's `route` PREPENDS, so this call
133
+ # — after the docs route above — lands ABOVE `docs/:doc` in the file, where
134
+ # it must be: otherwise `docs/:doc` would swallow /docs/search as :doc.
135
+ route_once %(get "/docs/search" => "docs_kit/search#index", as: :docs_search)
136
+ route_once %(root "landings#show")
137
+
138
+ # AI-readable docs (llmstxt.org), served from the registry by the gem's
139
+ # DocsKit::LlmsController — zero authoring. /llms.txt is the index;
140
+ # /llms-full.txt concatenates every page's Markdown twin.
141
+ route_once %(get "/llms.txt" => "docs_kit/llms#index", as: :llms)
142
+ route_once %(get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full)
143
+
144
+ add_mcp_route
145
+ end
146
+
147
+ # The read-only MCP endpoint (DocsKit::McpController), drawn COMMENTED OUT
148
+ # because it needs the OPTIONAL `mcp` gem — the generator can't assume it's
149
+ # bundled. A site opts in by adding `gem "mcp"` and uncommenting these. POST
150
+ # speaks JSON-RPC; GET/DELETE 405 (read-only, stateless — no SSE session).
151
+ # `route` prepends, so drawing `match` before `post` leaves `post` on top.
152
+ def add_mcp_route
153
+ route %(# match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete])
154
+ route %(# post "/mcp" => "docs_kit/mcp#create")
155
+ route %(# Add your docs to an agent over MCP (needs `gem "mcp"`):)
156
+ end
157
+
158
+ # The CSS build — its stylesheet carries the site's theme @plugin block, so
159
+ # a `--sync` upgrade leaves it alone (an existing site has already built +
160
+ # customized it).
161
+ def create_css_build
162
+ return say_status(:skip, "CSS build (--sync: application.tailwind.css is yours)", :blue) if options[:sync]
163
+
164
+ template "application.tailwind.css.erb", "app/assets/stylesheets/application.tailwind.css"
165
+ template "build-css", "bin/build-css"
166
+ chmod "bin/build-css", 0o755
167
+ template "build_css.rake", "lib/tasks/build_css.rake"
168
+ create_file "app/assets/builds/.keep", ""
169
+ end
170
+
171
+ def wire_assets_and_package_json
172
+ # Serve the bun-built CSS from app/assets/builds.
173
+ inject_into_file "config/initializers/assets.rb",
174
+ %(\nRails.application.config.assets.paths << Rails.root.join("app", "assets", "builds")\n),
175
+ after: /Rails.application.config.assets.version.*\n/, verbose: false
176
+
177
+ add_package_json_scripts
178
+ end
179
+
180
+ # AI-authoring scaffold: an AGENTS.md (the cross-tool authoring contract)
181
+ # and a Claude Code skill (.claude/skills/write-docs-page/SKILL.md). Both
182
+ # encode how to write a docs-kit page so "document this" works out of the
183
+ # box. Idempotent: a fresh AGENTS.md is created whole; an existing one gets
184
+ # only its delimited docs-kit block replaced (the user's own content is
185
+ # never touched); the skill file is skipped if it already exists.
186
+ def create_agent_docs
187
+ write_agents_md
188
+ write_write_docs_page_skill
189
+ end
190
+
191
+ # Wire docs-kit's shipped RuboCop cops into the site's .rubocop.yml: a
192
+ # `require: docs_kit/rubocop` entry (loads the cops) plus an
193
+ # `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }` entry (enables
194
+ # + scopes them). RuboCop is a dev-time dependency the host already has —
195
+ # docs-kit never requires it at runtime. Created minimal when absent,
196
+ # MERGED into an existing config (a `rails new` app ships an omakase
197
+ # inherit_gem we must not drop), and idempotent on re-run.
198
+ def wire_rubocop_cops
199
+ path = File.join(destination_root, ".rubocop.yml")
200
+ return create_file(".rubocop.yml", RUBOCOP_STARTER) unless File.exist?(path)
201
+
202
+ existing = File.read(path)
203
+ merged = merge_rubocop_config(existing)
204
+ return say_status(:identical, ".rubocop.yml", :blue) if merged == existing
205
+
206
+ File.write(path, merged)
207
+ say_status(:update, ".rubocop.yml (docs-kit cops)", :green)
208
+ end
209
+
210
+ def register_stimulus_controller
211
+ index = stimulus_index_path
212
+ return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index
213
+ # Skip if the docs_kit path is already registered via EITHER loader — a
214
+ # site that wired it lazily is valid (the engine auto-pins it); injecting
215
+ # our eager line would duplicate the registration. Quote-style tolerant.
216
+ return say_status(:identical, relative(index), :blue) if stimulus_registered?(index)
217
+
218
+ inject_into_file index, after: /eagerLoadControllersFrom\([^\n]*\n/ do
219
+ "#{REGISTER_LINE}\n"
220
+ end
221
+ return if stimulus_registered?(index) # inject handled it
222
+
223
+ # No eager anchor to inject after: only append the eager line if the file
224
+ # actually imports eagerLoadControllersFrom — appending it to a lazy-only
225
+ # index.js writes a call with no import, a ReferenceError that aborts the
226
+ # module and registers ZERO controllers (the failure REGISTER_LINE warns
227
+ # of). A lazy-only file is valid, so warn instead of breaking it.
228
+ unless File.read(index).match?(/import\s*\{[^}]*eagerLoadControllersFrom/)
229
+ return say_status(:skip, "#{relative(index)} doesn't eager-load — add: #{REGISTER_LINE}", :yellow)
230
+ end
231
+
232
+ append_to_file(index, "\n#{REGISTER_LINE}\n")
233
+ end
234
+
235
+ # Detect + print manual drift the generator can't safely automate (a
236
+ # hand-written render_page, a dead IconHelper). String-level and
237
+ # conservative — it warns, never deletes, and never fails the run. Runs on
238
+ # every invocation; it's the headline deliverable of a `--sync` upgrade.
239
+ def report_drift
240
+ report = SyncReport.new(destination_root)
241
+ return if report.clean?
242
+
243
+ say_status :warn, "manual cleanup needed (docs-kit now provides these):", :yellow
244
+ report.items.each { |item| say " • #{item}" }
245
+ end
246
+
247
+ def show_post_install
248
+ return show_sync_summary if options[:sync]
249
+
250
+ say_status :info, "docs-kit installed.", :green
251
+ say <<~MSG
252
+
253
+ Next:
254
+ 1. bin/rails g rails_icons:sync --library=lucide # sync the lucide icon set
255
+ 2. bun install && bun run build:css # build the daisyUI/Tailwind CSS
256
+ 3. Edit config/initializers/docs_kit.rb # brand, themes, nav
257
+ 4. Add pages under app/views/docs/pages/ # subclass DocsUI::Page
258
+ 5. bin/dev (or bin/rails server)
259
+
260
+ Requires importmap-rails (the shell loads assets via javascript_importmap_tags)
261
+ and a Stimulus controllers/index.js — the new-site template sets both up.
262
+ MSG
263
+ end
264
+
265
+ private
266
+
267
+ def show_sync_summary
268
+ say_status :info, "docs-kit synced.", :green
269
+ say <<~MSG
270
+
271
+ Next:
272
+ 1. Act on any drift warnings above (delete the flagged files).
273
+ 2. bun run build:css # pick up any new emitted classes
274
+ 3. bundle exec rspec # confirm the site still boots + renders
275
+ MSG
276
+ end
277
+
278
+ # Create AGENTS.md whole when absent; otherwise replace only the delimited
279
+ # docs-kit block (or append it if the file predates docs-kit), leaving the
280
+ # user's own content intact.
281
+ def write_agents_md
282
+ path = File.join(destination_root, "AGENTS.md")
283
+ rendered = render_template("agents_md.erb")
284
+
285
+ return create_file("AGENTS.md", rendered) unless File.exist?(path)
286
+
287
+ existing = File.read(path)
288
+ block = extract_agents_block(rendered)
289
+ updated = merge_agents_block(existing, block)
290
+ return say_status(:identical, "AGENTS.md", :blue) if updated == existing
291
+
292
+ File.write(path, updated)
293
+ say_status(:update, "AGENTS.md (docs-kit block)", :green)
294
+ end
295
+
296
+ # The BEGIN…END docs-kit block (inclusive) sliced out of the rendered
297
+ # template — the unit injected into a pre-existing AGENTS.md.
298
+ def extract_agents_block(rendered)
299
+ rendered[AGENTS_BLOCK_RE]
300
+ end
301
+
302
+ # Swap the existing delimited block for the fresh one, or append it when the
303
+ # file has none yet. Idempotent: same block in → same file out.
304
+ def merge_agents_block(existing, block)
305
+ if existing.include?(AGENTS_BEGIN) && existing.include?(AGENTS_END)
306
+ existing.sub(AGENTS_BLOCK_RE, block)
307
+ else
308
+ "#{existing.rstrip}\n\n#{block}\n"
309
+ end
310
+ end
311
+
312
+ # Write the write-docs-page Claude Code skill, unless the site already has
313
+ # one (a hand-customized skill is never clobbered).
314
+ def write_write_docs_page_skill
315
+ skill = ".claude/skills/write-docs-page/SKILL.md"
316
+ return say_status(:skip, skill, :blue) if File.exist?(File.join(destination_root, skill))
317
+
318
+ create_file skill, render_template("skill.md.erb")
319
+ end
320
+
321
+ # Merge docs-kit's require + inherit_gem entries into an existing
322
+ # .rubocop.yml, preserving everything else. Idempotent: entries already
323
+ # present are left untouched, so re-running yields byte-identical output.
324
+ # Returns the (possibly unchanged) YAML string.
325
+ def merge_rubocop_config(existing)
326
+ config = YAML.safe_load(existing) || {}
327
+ config = {} unless config.is_a?(Hash)
328
+
329
+ config["require"] = ensure_in_list(config["require"], RUBOCOP_REQUIRE)
330
+
331
+ inherit_gem = config["inherit_gem"].is_a?(Hash) ? config["inherit_gem"] : {}
332
+ inherit_gem[RUBOCOP_INHERIT_GEM] = ensure_in_list(inherit_gem[RUBOCOP_INHERIT_GEM], RUBOCOP_INHERIT_PATH)
333
+ config["inherit_gem"] = inherit_gem
334
+
335
+ # Round-trip through the same load the merge started from: if nothing
336
+ # changed, return the original text verbatim (so :identical is reported
337
+ # and re-runs don't churn formatting).
338
+ YAML.safe_load(existing) == config ? existing : YAML.dump(config)
339
+ end
340
+
341
+ # Normalise a RuboCop scalar-or-list field to an array and append `value`
342
+ # unless already present. `nil` (absent key) becomes `[value]`; a bare
343
+ # string is promoted to a list so we never drop the site's own entry.
344
+ def ensure_in_list(current, value)
345
+ list = Array(current)
346
+ list.include?(value) ? list : list + [value]
347
+ end
348
+
349
+ # Render an ERB template from source_root against the generator binding, so
350
+ # helpers like app_brand resolve — used where we need the rendered string in
351
+ # memory (block extraction/merge) rather than Thor's file-to-file `template`.
352
+ def render_template(name)
353
+ source = File.read(File.join(self.class.source_root, name))
354
+ ERB.new(source, trim_mode: "-").result(binding)
355
+ end
356
+
357
+ # Draw a route unless the site already has one for the same endpoint —
358
+ # tolerant of the site's own style (single vs double quotes, `to:` vs `=>`,
359
+ # extra whitespace). Thor's `route` only skips a BYTE-IDENTICAL line, so a
360
+ # years-old hand-written routes.rb would get a duplicate; this guard makes
361
+ # re-running a genuine no-op. We never rewrite the site's line — drift is
362
+ # warned, not auto-edited.
363
+ def route_once(routing_code)
364
+ return route(routing_code) unless route_present?(routing_code)
365
+
366
+ say_status(:identical, "route #{route_endpoint(routing_code)} (already drawn)", :blue)
367
+ end
368
+
369
+ # True if config/routes.rb already draws this route's endpoint. Matches the
370
+ # `controller#action` string in any quote style, or — for `root` — the bare
371
+ # `root` keyword (a file has at most one).
372
+ def route_present?(routing_code)
373
+ path = File.join(destination_root, "config/routes.rb")
374
+ return false unless File.exist?(path)
375
+
376
+ endpoint = route_endpoint(routing_code)
377
+ routes = File.read(path)
378
+ return routes.match?(/^\s*root\b/) if endpoint == :root
379
+
380
+ routes.match?(/["']#{Regexp.escape(endpoint)}["']/)
381
+ end
382
+
383
+ # The endpoint a route targets: `:root` for a root route, else its
384
+ # `controller#action` string (e.g. "docs#show", "docs_kit/search#index").
385
+ def route_endpoint(routing_code)
386
+ return :root if routing_code.match?(/\Aroot\b/)
387
+
388
+ routing_code[%r{["']([\w/]+#\w+)["']}, 1]
389
+ end
390
+
391
+ def add_package_json_scripts
392
+ pkg = File.join(destination_root, "package.json")
393
+ return create_file("package.json", package_json_stub) unless File.exist?(pkg)
394
+
395
+ json = File.read(pkg)
396
+ return if json.include?('"build:css"')
397
+
398
+ say_status :info, "add these scripts to package.json:\n#{package_json_scripts}", :yellow
399
+ end
400
+
401
+ def package_json_scripts
402
+ <<~JSON.strip
403
+ "build:css": "bin/build-css --minify",
404
+ "watch:css": "bin/build-css --watch"
405
+ JSON
406
+ end
407
+
408
+ def package_json_stub
409
+ <<~JSON
410
+ {
411
+ "private": true,
412
+ "scripts": {
413
+ "build:css": "bin/build-css --minify",
414
+ "watch:css": "bin/build-css --watch"
415
+ },
416
+ "devDependencies": {
417
+ "@tailwindcss/cli": "^4.1.18",
418
+ "daisyui": "^5.6.0",
419
+ "tailwindcss": "^4.1.18"
420
+ }
421
+ }
422
+ JSON
423
+ end
424
+
425
+ def stimulus_index_path
426
+ %w[app/javascript/controllers/index.js]
427
+ .map { |rel| File.join(destination_root, rel) }.find { |p| File.exist?(p) }
428
+ end
429
+
430
+ # True if the index already registers the docs_kit controllers path — via
431
+ # eager OR lazy loading, any quote style. A site that wired it lazily is
432
+ # valid; we must not inject a second (eager) registration on top.
433
+ def stimulus_registered?(index)
434
+ File.read(index).match?(%r{(?:eager|lazy)LoadControllersFrom\(\s*["']docs_kit/controllers["']})
435
+ end
436
+
437
+ def relative(path) = path.sub("#{destination_root}/", "")
438
+
439
+ # The brand shown in the shell — the app's name, humanized (e.g. "my_gem_docs"
440
+ # → "My gem docs"). Used in templates via <%= app_brand %>.
441
+ def app_brand
442
+ name = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.class&.module_parent_name
443
+ (name || File.basename(destination_root)).to_s.underscore.humanize
444
+ end
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ module Generators
5
+ # Detects manual drift in an existing docs site that the install generator
6
+ # can't safely automate away — things docs-kit now provides, so the site's
7
+ # own copy is dead weight. String-level and CONSERVATIVE by design: it reads
8
+ # a few known files, reports what it finds, and never touches a byte. The
9
+ # generator prints these as a checklist during a `--sync` upgrade; the site
10
+ # owner deletes the flagged code by hand.
11
+ #
12
+ # Two drift items, both from the consumer audits:
13
+ # - ApplicationController hand-defines `render_page` — DocsKit::Controller
14
+ # (included by the generator for months) already provides it.
15
+ # - a dead IconHelper copy — the gem renders icons via rails_icons.
16
+ class SyncReport
17
+ APPLICATION_CONTROLLER = "app/controllers/application_controller.rb"
18
+ ICON_HELPER = "app/helpers/icon_helper.rb"
19
+
20
+ def initialize(destination_root)
21
+ @root = destination_root
22
+ end
23
+
24
+ # The drift messages, in the order a site should act on them. Empty when
25
+ # the site is clean.
26
+ def items
27
+ [render_page_drift, icon_helper_drift].compact
28
+ end
29
+
30
+ def clean?
31
+ items.empty?
32
+ end
33
+
34
+ private
35
+
36
+ # ApplicationController defines its own `render_page` — DocsKit::Controller
37
+ # already provides it, so the hand-rolled method shadows the gem's and
38
+ # fossilizes whatever `layout:`/render call the site copied years ago.
39
+ def render_page_drift
40
+ source = read(APPLICATION_CONTROLLER)
41
+ return unless source&.match?(/def\s+render_page\b/)
42
+
43
+ "#{APPLICATION_CONTROLLER} defines its own render_page — delete it; " \
44
+ "DocsKit::Controller#render_page is included."
45
+ end
46
+
47
+ # A leftover IconHelper — docs-kit renders icons through rails_icons
48
+ # (DocsUI::Icon), so a hand-written helper is dead code.
49
+ def icon_helper_drift
50
+ return unless exist?(ICON_HELPER)
51
+
52
+ "#{ICON_HELPER} (IconHelper) is dead — docs-kit renders icons via " \
53
+ "rails_icons (DocsUI::Icon); delete it."
54
+ end
55
+
56
+ def read(rel)
57
+ path = File.join(@root, rel)
58
+ File.exist?(path) ? File.read(path) : nil
59
+ end
60
+
61
+ def exist?(rel) = File.exist?(File.join(@root, rel))
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,105 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI coding agents working in this repository. `AGENTS.md` is the
4
+ cross-tool convention (Claude Code, Cursor, Copilot, Aider, …); Claude Code also
5
+ reads it through the bundled `write-docs-page` skill. Edit freely — a
6
+ `docs_kit:install` re-run only touches the delimited block below.
7
+
8
+ <!-- BEGIN docs-kit -->
9
+ ## Writing docs pages (docs-kit)
10
+
11
+ <%= app_brand %> is a [docs-kit](https://github.com/mhenrixon/docs-kit) site: a
12
+ Phlex/daisyUI chrome where **every page is a `DocsUI::Page` subclass** and the
13
+ sidebar, TOC, search, and Markdown twin come free. To document something, you
14
+ scaffold a page, then write its `#content`. Never hand-write HTML or daisyUI
15
+ markup — compose the kit's `DocsUI::` helpers.
16
+
17
+ ### 1. Scaffold the page (one command)
18
+
19
+ ```bash
20
+ rails g docs_kit:page "Getting Started" --group=Guide
21
+ ```
22
+
23
+ That writes `app/views/docs/pages/getting_started.rb` **and** injects
24
+ `page "Getting Started", group: "Guide"` into the `Doc` registry — so the page is
25
+ routed and in the sidebar the moment you fill in `#content`. Overrides:
26
+ `--slug=auth`, `--view=OauthGuide`, `--eyebrow="Advanced"`, `--registry=Guide`.
27
+ Re-running is idempotent.
28
+
29
+ > The registry line is **required** — a page with no `page "…"` line in
30
+ > `app/models/doc.rb` is not routed and not in the nav. The generator adds it;
31
+ > if you hand-write a page, add the line yourself.
32
+
33
+ ### 2. Write `#content` — Markdown first
34
+
35
+ Prose is `md` with a **single-quoted** heredoc (`<<~'MD'`) so `#{…}` stays
36
+ literal (Phlex escapes author text — never `html_safe` or interpolate):
37
+
38
+ ```ruby
39
+ class Views::Docs::Pages::Guide < DocsUI::Page
40
+ title "Guide"
41
+ eyebrow "Getting started"
42
+
43
+ def lead = "One sentence under the page title."
44
+
45
+ def content
46
+ DocsUI::Section("First steps", description: "What this covers.") do
47
+ md <<~'MD'
48
+ Prose as **Markdown** — lists, `inline code`, links, GFM tables, and
49
+ fenced ```ruby``` blocks all render styled. Use Markdown `###` only for
50
+ sub-headings *inside* a Section.
51
+ MD
52
+
53
+ DocsUI::Code(<<~RUBY, filename: "config/routes.rb")
54
+ Rails.application.routes.draw { mount DocsKit::Engine, at: "/docs" }
55
+ RUBY
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ ### The authoring contract
62
+
63
+ - **`DocsUI::Section` owns page structure and the "On this page" TOC.** One
64
+ Section per part of the page; each heading becomes a TOC entry. **Never** use a
65
+ Markdown `##` for page structure — only for sub-headings inside a Section.
66
+ - **The primary argument is positional; modifiers are keywords.**
67
+ `Section("Title", description:)`, `Code(source, filename:)`,
68
+ `Header("Title", eyebrow:)`.
69
+ - **Wrappers that take no positional arg use lowercase page helpers** so a block
70
+ needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`,
71
+ `operation "operationId"`. (A bare `DocsUI::Prose do` is a Ruby SyntaxError; the
72
+ helpers sidestep it.)
73
+ - **Reference material has dedicated helpers** — reach for these before prose:
74
+ `DocsUI::PropTable`, `DocsUI::FieldTable`, `DocsUI::RequestExample`,
75
+ `DocsUI::Callout(:note | :tip | :warning)`.
76
+ - **OpenAPI-backed endpoints** (when `c.openapi` is set): `operation "createInvoice"`
77
+ renders a whole endpoint from the spec — badge, field/error tables, request tabs,
78
+ response — no hand-restatement. Append prose with a block; filter tabs with
79
+ `clients:`.
80
+
81
+ ### Invariants — do not break
82
+
83
+ - **The registry line is required** (see above) — no line, no page.
84
+ - **The page must work with JavaScript off.** The server renders it fully;
85
+ the one `docs-nav` controller only *enhances*. Never require JS to read a page.
86
+ - **Themes offered must exist in the CSS build** — `c.themes` in
87
+ `config/initializers/docs_kit.rb` must match the `@plugin "daisyui" { themes: … }`
88
+ block in `app/assets/stylesheets/application.tailwind.css`. Don't add one
89
+ without the other.
90
+ - **No inline `rubocop:disable`** to force layout — write idiomatic Ruby the
91
+ site's cops accept.
92
+
93
+ ### 3. Verify before you finish
94
+
95
+ ```bash
96
+ bundle exec rspec && bundle exec rubocop # tests + lint must pass
97
+ bun run build:css # if you added classes the CSS scans
98
+ ```
99
+
100
+ Then render the page locally (`bin/dev`, open `/docs/<slug>`) and confirm it
101
+ reads correctly — with JavaScript off, too.
102
+
103
+ **Depth:** the live [Authoring pages](/docs/authoring) doc is the full,
104
+ always-current version of this contract. When in doubt, read it.
105
+ <!-- END docs-kit -->
@@ -0,0 +1,39 @@
1
+ @import "tailwindcss";
2
+
3
+ /* daisyUI — the theme list MUST match DocsKit.configuration.themes. */
4
+ @plugin "daisyui" {
5
+ themes: dark --default, light --prefersdark, synthwave, retro, cyberpunk,
6
+ dracula, night, nord, sunset;
7
+ }
8
+
9
+ /* Tailwind scans this app's views + the daisyui/docs-kit GEMS (their install dir
10
+ varies by environment, so bin/build-css resolves them into the imported file). */
11
+ @source "../../../app/views/**/*.{rb,erb,haml,html,slim}";
12
+ @source "../../../app/components/**/*.rb";
13
+ @source "../../../app/helpers/**/*.rb";
14
+ @source "../../../app/javascript/**/*.js";
15
+ @source "../../../public/*.html";
16
+ @import "./tailwind.sources.css";
17
+
18
+ /* daisyUI Drawer classes are emitted at render time (never literal) — force them
19
+ so the shell isn't tree-shaken. */
20
+ @source inline("drawer drawer-content drawer-side drawer-toggle drawer-overlay {lg:}drawer-open drawer-end");
21
+
22
+ /* Search palette classes the docs-nav controller applies at RUN TIME (JS only —
23
+ bin/build-css scans the gems' .rb, not their .js), so force them here. The
24
+ dropdown/menu/input structure is literal in DocsUI::SearchBox and scanned; only
25
+ these JS-toggled classes need forcing. */
26
+ @source inline("menu-title menu-active");
27
+
28
+ /* Responsive drawer-open — sidebar always visible on desktop. */
29
+ @media (width >= 1024px) {
30
+ .lg\:drawer-open { display: grid !important; grid-auto-columns: max-content auto !important; }
31
+ .lg\:drawer-open > .drawer-toggle { display: none !important; }
32
+ .lg\:drawer-open > .drawer-toggle ~ .drawer-side,
33
+ .lg\:drawer-open > .drawer-side {
34
+ pointer-events: auto !important; visibility: visible !important; position: sticky !important;
35
+ top: 0 !important; display: block !important; width: auto !important;
36
+ height: 100dvh !important; overflow-y: auto !important; opacity: 1 !important;
37
+ }
38
+ .lg\:drawer-open > .drawer-side > .drawer-overlay { background-color: transparent !important; }
39
+ }