Class: DocsKit::Generators::InstallGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
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

Instance Method Details

#add_mcp_routeObject

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_routesObject



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_docsObject

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_buildObject

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 options[: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_dockerfileObject

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_dockerignoreObject

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_initializerObject

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_taskObject

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_initializerObject



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_initializerObject



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_pagesObject

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 options[: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_binstubObject

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_helperObject



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_controllerObject



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_driftObject

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_installObject



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 options[: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_jsonObject



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_copsObject

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 new app 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