Class: DocsKit::Generators::InstallGenerator
- Inherits:
-
Rails::Generators::Base
- Object
- Rails::Generators::Base
- DocsKit::Generators::InstallGenerator
- Defined in:
- lib/generators/docs_kit/install/install_generator.rb
Overview
rails g docs_kit:install
Wires an existing Rails app into docs-kit: the config initializer, the
controller render helper, a Doc registry + a sample guide page + route, the
Bun/Tailwind CSS build, and the Stimulus + importmap registration. Run it in
a fresh rails new app (the new-site template does exactly this), or on top
of an existing app to add a docs section.
Fully idempotent — safe on a fresh app AND a years-old site, which makes
re-running it the sanctioned upgrade path. Every step guards a re-run: the
config initializer is skipped (never clobbered); routes are skipped even
when the site wrote them in its own style (single quotes, to: vs =>);
file creations skip what already exists. --sync runs ONLY the additive
wiring (routes, initializer hint, importmap/stimulus, AGENTS.md, .rubocop)
and prints a checklist of manual drift it can't safely automate.
Constant Summary collapse
- REGISTER_LINE =
eagerLoadControllersFrom (NOT lazy) — the default controllers/index.js only imports eagerLoadControllersFrom; injecting a lazyLoadControllersFrom call without its import throws a ReferenceError that aborts the whole module, so NO controllers register. Eager-loading a few docs controllers is fine.
'eagerLoadControllersFrom("docs_kit/controllers", application)'- AGENTS_BEGIN =
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.
"<!-- BEGIN docs-kit -->"- AGENTS_END =
"<!-- END docs-kit -->"- AGENTS_BLOCK_RE =
/#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m- RUBOCOP_REQUIRE =
The RuboCop wiring docs-kit injects. REQUIRE loads the cops; INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml).
"docs_kit/rubocop"- RUBOCOP_INHERIT_GEM =
"docs-kit"- RUBOCOP_INHERIT_PATH =
"config/rubocop/docs_kit.yml"- RUBOCOP_STARTER =
The .rubocop.yml written when a site has none yet.
<<~YAML.freeze # docs-kit ships its custom cops from the gem — load + enable them here. # (RuboCop is a development-time dependency; add `gem "rubocop"` to your # Gemfile if it isn't there.) require: - #{RUBOCOP_REQUIRE} inherit_gem: #{RUBOCOP_INHERIT_GEM}: #{RUBOCOP_INHERIT_PATH} AllCops: NewCops: enable YAML
Instance Method Summary collapse
-
#add_mcp_route ⇒ Object
The read-only MCP endpoint (DocsKit::McpController), drawn COMMENTED OUT because it needs the OPTIONAL
mcpgem — the generator can't assume it's bundled. - #add_routes ⇒ Object
-
#create_agent_docs ⇒ Object
AI-authoring scaffold: an AGENTS.md (the cross-tool authoring contract) and a Claude Code skill (.claude/skills/write-docs-page/SKILL.md).
-
#create_css_build ⇒ Object
The CSS build — its stylesheet carries the site's theme @plugin block, so a
--syncupgrade leaves it alone (an existing site has already built + customized it). -
#create_dockerfile ⇒ Object
The production Dockerfile — a lean multi-stage build for a standalone docs site.
-
#create_dockerignore ⇒ Object
The .dockerignore — gem-owned build-context trimming (node_modules, .git, logs, specs, coverage).
-
#create_initializer ⇒ Object
The site's config (brand, themes, nav) lives here and is heavily edited, so a re-run must NEVER clobber it.
-
#create_og_task ⇒ Object
Install the
docs_kit:ograke task — gem-owned wiring, refreshed on every run so a site picks up task fixes. - #create_phlex_initializer ⇒ Object
- #create_rails_icons_initializer ⇒ Object
-
#create_registry_and_pages ⇒ Object
Site-owned content — the doc registry, its controllers, the sample guide page, the landing.
-
#create_thrust_binstub ⇒ Object
When the site bundles thruster but lacks the binstub, create it — the Dockerfile's exec-form
CMD ["./bin/thrust", ...]needs the file to EXIST in the image (bundle installinstalls the gem, not app binstubs, andCOPY . .can't ship a file the repo doesn't have). - #include_controller_helper ⇒ Object
- #register_stimulus_controller ⇒ Object
-
#report_drift ⇒ Object
Detect + print manual drift the generator can't safely automate (a hand-written render_page, a dead IconHelper).
- #show_post_install ⇒ Object
- #wire_assets_and_package_json ⇒ Object
-
#wire_rubocop_cops ⇒ Object
Wire docs-kit's shipped RuboCop cops into the site's .rubocop.yml: a
require: docs_kit/rubocopentry (loads the cops) plus aninherit_gem: { docs-kit: config/rubocop/docs_kit.yml }entry (enables + scopes them).
Instance Method Details
#add_mcp_route ⇒ Object
The read-only MCP endpoint (DocsKit::McpController), drawn COMMENTED OUT
because it needs the OPTIONAL mcp gem — the generator can't assume it's
bundled. A site opts in by adding gem "mcp" and uncommenting these. POST
speaks JSON-RPC; GET/DELETE 405 (read-only, stateless — no SSE session).
route prepends, so drawing match before post leaves post on top.
Guarded on the endpoint (route_present?), NOT on Thor's byte-identical
skip: a site that opted in has LIVE routes in its own style (an
, as: :mcp suffix, single quotes) which never byte-match the commented
template — plain route would re-inject the scaffold on every --sync.
Found dogfooding 1.0.3 into pgbus + phlex-reactive.
159 160 161 162 163 164 165 166 167 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 159 def add_mcp_route if route_present?(%(post "/mcp" => "docs_kit/mcp#create")) return say_status(:identical, "route /mcp (already drawn or scaffolded)", :blue) end route %(# match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete]) route %(# post "/mcp" => "docs_kit/mcp#create") route %(# Add your docs to an agent over MCP (needs `gem "mcp"`):) end |
#add_routes ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 126 def add_routes # The `(.:format)` segment enables the Markdown twin: GET /docs/x.md hits # docs#show with request.format.md?, so DocsKit::Controller#render_page # returns the page's GFM. No `defaults: { format: "html" }` — that would # pin html and defeat the .md route. route_once %(get "docs/:doc(.:format)" => "docs#show", as: :doc) # Docs search, served from the registry by the gem's DocsKit::SearchController # (matches the default c.search_path). Thor's `route` PREPENDS, so this call # — after the docs route above — lands ABOVE `docs/:doc` in the file, where # it must be: otherwise `docs/:doc` would swallow /docs/search as :doc. route_once %(get "/docs/search" => "docs_kit/search#index", as: :docs_search) route_once %(root "landings#show") # AI-readable docs (llmstxt.org), served from the registry by the gem's # DocsKit::LlmsController — zero authoring. /llms.txt is the index; # /llms-full.txt concatenates every page's Markdown twin. route_once %(get "/llms.txt" => "docs_kit/llms#index", as: :llms) route_once %(get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full) add_mcp_route end |
#create_agent_docs ⇒ Object
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.
250 251 252 253 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 250 def create_agent_docs write_agents_md write_write_docs_page_skill end |
#create_css_build ⇒ Object
The CSS build — its stylesheet carries the site's theme @plugin block, so
a --sync upgrade leaves it alone (an existing site has already built +
customized it).
172 173 174 175 176 177 178 179 180 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 172 def create_css_build return say_status(:skip, "CSS build (--sync: application.tailwind.css is yours)", :blue) if [:sync] template "application.tailwind.css.erb", "app/assets/stylesheets/application.tailwind.css" template "build-css", "bin/build-css" chmod "bin/build-css", 0o755 template "build_css.rake", "lib/tasks/build_css.rake" create_file "app/assets/builds/.keep", "" end |
#create_dockerfile ⇒ Object
The production Dockerfile — a lean multi-stage build for a standalone
docs site. Site-customizable (a site tunes packages/CMD), so skip when it
exists and point an upgrader at the current template for a manual diff.
The template stamps a # docs-kit Dockerfile vX.Y.Z marker so a --sync
upgrade (SyncReport) can flag a stale copy. (create_initializer follows
this same skip-if-exists + template-hint pattern.)
198 199 200 201 202 203 204 205 206 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 198 def create_dockerfile dockerfile = "Dockerfile" if File.exist?(File.join(destination_root, dockerfile)) template_path = File.join(self.class.source_root, "Dockerfile.tt") return say_status(:skip, "#{dockerfile} exists — compare with #{template_path} if upgrading", :blue) end template "Dockerfile.tt", dockerfile end |
#create_dockerignore ⇒ Object
The .dockerignore — gem-owned build-context trimming (node_modules, .git, logs, specs, coverage). Refreshed on every run (like create_og_task): it carries no site-specific content, so a re-run always ships the current excludes rather than fossilizing an old list.
212 213 214 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 212 def create_dockerignore copy_file "dockerignore", ".dockerignore", force: true end |
#create_initializer ⇒ Object
The site's config (brand, themes, nav) lives here and is heavily edited, so a re-run must NEVER clobber it. Skip when present and point an upgrader at the current template for a manual diff. (create_rails_icons_initializer and create_phlex_initializer already follow this skip-if-exists pattern.)
91 92 93 94 95 96 97 98 99 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 91 def create_initializer initializer = "config/initializers/docs_kit.rb" if File.exist?(File.join(destination_root, initializer)) template_path = File.join(self.class.source_root, "docs_kit.rb.erb") return say_status(:skip, "#{initializer} exists — compare with #{template_path} if upgrading", :blue) end template "docs_kit.rb.erb", initializer end |
#create_og_task ⇒ Object
Install the docs_kit:og rake task — gem-owned wiring, refreshed on every
run so a site picks up task fixes. It does NOT ship an OG image: the
social-share image is SITE content, generated into the site's OWN
app/assets/images/ by bin/rails docs_kit:og. Until a site runs it (and
sets c.seo.og_image), no og:image tag is emitted — a valid card, never a
404 for an image the gem can't provide.
188 189 190 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 188 def create_og_task template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake" end |
#create_phlex_initializer ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 68 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. existing = Dir[File.join(destination_root, "config/initializers/*.rb")] .any? { |f| File.read(f).include?("push_dir") && File.read(f).match?(/namespace:\s*Views/) } return say_status(:skip, "phlex namespaces already configured", :blue) if existing empty_directory "app/components" create_file "app/components/.keep", "" unless File.exist?(File.join(destination_root, "app/components/.keep")) template "phlex.rb.erb", "config/initializers/phlex.rb" end |
#create_rails_icons_initializer ⇒ Object
80 81 82 83 84 85 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 80 def create_rails_icons_initializer icons = "config/initializers/rails_icons.rb" return say_status(:skip, icons, :blue) if File.exist?(File.join(destination_root, icons)) template "rails_icons.rb.erb", icons end |
#create_registry_and_pages ⇒ Object
Site-owned content — the doc registry, its controllers, the sample guide
page, the landing. A --sync upgrade never scaffolds these: they exist
and are the site's to edit.
116 117 118 119 120 121 122 123 124 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 116 def create_registry_and_pages return say_status(:skip, "site content (--sync: registry/pages are yours)", :blue) if [:sync] template "doc.rb.erb", "app/models/doc.rb" template "docs_controller.rb.erb", "app/controllers/docs_controller.rb" template "landings_controller.rb.erb", "app/controllers/landings_controller.rb" template "installation_page.rb.erb", "app/views/docs/pages/installation.rb" template "landing.rb.erb", "app/views/landings/show.rb" end |
#create_thrust_binstub ⇒ Object
When the site bundles thruster but lacks the binstub, create it — the
Dockerfile's exec-form CMD ["./bin/thrust", ...] needs the file to EXIST
in the image (bundle install installs the gem, not app binstubs, and
COPY . . can't ship a file the repo doesn't have). Without this, the
image builds green and the container crashes at boot. Skip-if-exists: a
hand-tuned binstub is never touched.
222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 222 def create_thrust_binstub return unless gemfile_bundles_thruster? && !thrust_binstub? create_file "bin/thrust", <<~RUBY #!/usr/bin/env ruby require "rubygems" require "bundler/setup" load Gem.bin_path("thruster", "thrust") RUBY chmod "bin/thrust", 0o755 end |
#include_controller_helper ⇒ Object
101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 101 def include_controller_helper controller = "app/controllers/application_controller.rb" return say_status(:skip, "#{controller} not found — include DocsKit::Controller manually", :yellow) \ unless File.exist?(File.join(destination_root, controller)) if File.read(File.join(destination_root, controller)).include?("DocsKit::Controller") return say_status(:identical, controller, :blue) end inject_into_class controller, "ApplicationController", " include DocsKit::Controller\n" end |
#register_stimulus_controller ⇒ Object
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 274 def register_stimulus_controller index = stimulus_index_path return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index # Skip if the docs_kit path is already registered via EITHER loader — a # site that wired it lazily is valid (the engine auto-pins it); injecting # our eager line would duplicate the registration. Quote-style tolerant. return say_status(:identical, relative(index), :blue) if stimulus_registered?(index) inject_into_file index, after: /eagerLoadControllersFrom\([^\n]*\n/ do "#{REGISTER_LINE}\n" end return if stimulus_registered?(index) # inject handled it # No eager anchor to inject after: only append the eager line if the file # actually imports eagerLoadControllersFrom — appending it to a lazy-only # index.js writes a call with no import, a ReferenceError that aborts the # module and registers ZERO controllers (the failure REGISTER_LINE warns # of). A lazy-only file is valid, so warn instead of breaking it. unless File.read(index).match?(/import\s*\{[^}]*eagerLoadControllersFrom/) return say_status(:skip, "#{relative(index)} doesn't eager-load — add: #{REGISTER_LINE}", :yellow) end append_to_file(index, "\n#{REGISTER_LINE}\n") end |
#report_drift ⇒ Object
Detect + print manual drift the generator can't safely automate (a
hand-written render_page, a dead IconHelper). String-level and
conservative — it warns, never deletes, and never fails the run. Runs on
every invocation; it's the headline deliverable of a --sync upgrade.
303 304 305 306 307 308 309 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 303 def report_drift report = SyncReport.new(destination_root) return if report.clean? say_status :warn, "manual cleanup needed (docs-kit now provides these):", :yellow report.items.each { |item| say " • #{item}" } end |
#show_post_install ⇒ Object
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 311 def show_post_install return show_sync_summary if [:sync] say_status :info, "docs-kit installed.", :green say <<~MSG Next: 1. bin/rails g rails_icons:sync --library=lucide # sync the lucide icon set 2. bun install && bun run build:css # build the daisyUI/Tailwind CSS 3. Edit config/initializers/docs_kit.rb # brand, themes, nav 4. Add pages under app/views/docs/pages/ # subclass DocsUI::Page 5. bin/dev (or bin/rails server) Requires importmap-rails (the shell loads assets via javascript_importmap_tags) and a Stimulus controllers/index.js — the new-site template sets both up. MSG end |
#wire_assets_and_package_json ⇒ Object
235 236 237 238 239 240 241 242 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 235 def wire_assets_and_package_json # Serve the bun-built CSS from app/assets/builds. inject_into_file "config/initializers/assets.rb", %(\nRails.application.config.assets.paths << Rails.root.join("app", "assets", "builds")\n), after: /Rails.application.config.assets.version.*\n/, verbose: false add_package_json_scripts end |
#wire_rubocop_cops ⇒ Object
Wire docs-kit's shipped RuboCop cops into the site's .rubocop.yml: a
require: docs_kit/rubocop entry (loads the cops) plus an
inherit_gem: { docs-kit: config/rubocop/docs_kit.yml } entry (enables
- scopes them). RuboCop is a dev-time dependency the host already has —
docs-kit never requires it at runtime. Created minimal when absent,
MERGED into an existing config (a
rails newapp ships an omakase inherit_gem we must not drop), and idempotent on re-run.
262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/generators/docs_kit/install/install_generator.rb', line 262 def wire_rubocop_cops path = File.join(destination_root, ".rubocop.yml") return create_file(".rubocop.yml", RUBOCOP_STARTER) unless File.exist?(path) existing = File.read(path) merged = merge_rubocop_config(existing) return say_status(:identical, ".rubocop.yml", :blue) if merged == existing File.write(path, merged) say_status(:update, ".rubocop.yml (docs-kit cops)", :green) end |