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
INITIALIZER =

The config initializer — the one file every site has, so it carries the last-synced version stamp. See stamp_synced_version / run_migrations.

"config/initializers/docs_kit.rb"
SYNCED_STAMP_RE =

The inert comment recording which docs-kit version last synced this site. A future --sync reads it to run only the ordered migrations between that version and the gem's current one. Absent → the site predates the stamp (treated as the earliest version, so every migration applies).

/^#\s*docs-kit synced:\s*v(\d+\.\d+\.\d+)\s*$/
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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.synced_stamp(version = DocsKit::VERSION) ⇒ Object



58
# File 'lib/generators/docs_kit/install/install_generator.rb', line 58

def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}"

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.



172
173
174
175
176
177
178
179
180
# File 'lib/generators/docs_kit/install/install_generator.rb', line 172

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



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/generators/docs_kit/install/install_generator.rb', line 139

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.



263
264
265
266
# File 'lib/generators/docs_kit/install/install_generator.rb', line 263

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).



185
186
187
188
189
190
191
192
193
# File 'lib/generators/docs_kit/install/install_generator.rb', line 185

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.)



211
212
213
214
215
216
217
218
219
# File 'lib/generators/docs_kit/install/install_generator.rb', line 211

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.



225
226
227
# File 'lib/generators/docs_kit/install/install_generator.rb', line 225

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.)



104
105
106
107
108
109
110
111
112
# File 'lib/generators/docs_kit/install/install_generator.rb', line 104

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.



201
202
203
# File 'lib/generators/docs_kit/install/install_generator.rb', line 201

def create_og_task
  template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake"
end

#create_phlex_initializerObject



81
82
83
84
85
86
87
88
89
90
91
# File 'lib/generators/docs_kit/install/install_generator.rb', line 81

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



93
94
95
96
97
98
# File 'lib/generators/docs_kit/install/install_generator.rb', line 93

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.



129
130
131
132
133
134
135
136
137
# File 'lib/generators/docs_kit/install/install_generator.rb', line 129

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.



235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/generators/docs_kit/install/install_generator.rb', line 235

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



114
115
116
117
118
119
120
121
122
123
124
# File 'lib/generators/docs_kit/install/install_generator.rb', line 114

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



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/generators/docs_kit/install/install_generator.rb', line 287

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.



316
317
318
319
320
321
322
# File 'lib/generators/docs_kit/install/install_generator.rb', line 316

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

#run_migrationsObject

Run the ordered release-to-release migrations between the site's last-synced version and the gem's current one — the payoff of the version stamp. --sync only (a full install is a fresh scaffold, not an upgrade), and BEFORE stamp_synced_version restamps, so it reads the OLD version. An un-stamped site is treated as the earliest version (every migration runs). Migrations are warn-only-safe: what they can't automate they hand back as a checklist, printed like the drift report. The registry ships EMPTY at 1.0.x, so today this is a no-op that establishes the upgrade path.



332
333
334
335
336
337
338
339
340
# File 'lib/generators/docs_kit/install/install_generator.rb', line 332

def run_migrations
  return unless options[:sync]

  warnings = MigrationRegistry.default.migrate!(synced_version, destination_root, self)
  return if warnings.empty?

  say_status :warn, "migration steps to apply by hand:", :yellow
  warnings.each { |item| say "#{item}" }
end

#show_post_installObject



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/generators/docs_kit/install/install_generator.rb', line 361

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

#stamp_synced_versionObject

Record which docs-kit version this site is now synced at, so the NEXT --sync can run only the migrations after it. Injected as an inert comment at the top of the initializer (the one file every site has) — which create_initializer never rewrites, so stamping is its own step. Idempotent: updates a stale stamp in place, adds one when absent, and is a no-op when already current.



348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/generators/docs_kit/install/install_generator.rb', line 348

def stamp_synced_version
  path = File.join(destination_root, INITIALIZER)
  return unless File.exist?(path)

  current = self.class.synced_stamp
  source = File.read(path)
  updated = source.match?(SYNCED_STAMP_RE) ? source.sub(SYNCED_STAMP_RE, current) : "#{current}\n#{source}"
  return if updated == source

  File.write(path, updated)
  say_status :update, "#{INITIALIZER} (synced v#{DocsKit::VERSION})", :green
end

#wire_assets_and_package_jsonObject



248
249
250
251
252
253
254
255
# File 'lib/generators/docs_kit/install/install_generator.rb', line 248

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.


275
276
277
278
279
280
281
282
283
284
285
# File 'lib/generators/docs_kit/install/install_generator.rb', line 275

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