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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +939 -0
- data/app/components/docs_ui/brand_mark.rb +88 -0
- data/app/components/docs_ui/callout.rb +37 -0
- data/app/components/docs_ui/code.rb +123 -0
- data/app/components/docs_ui/endpoint.rb +44 -0
- data/app/components/docs_ui/error_table.rb +72 -0
- data/app/components/docs_ui/example.rb +102 -0
- data/app/components/docs_ui/field_table.rb +46 -0
- data/app/components/docs_ui/header.rb +30 -0
- data/app/components/docs_ui/icon.rb +65 -0
- data/app/components/docs_ui/json_response.rb +46 -0
- data/app/components/docs_ui/markdown.rb +187 -0
- data/app/components/docs_ui/markdown_action.rb +45 -0
- data/app/components/docs_ui/on_this_page.rb +104 -0
- data/app/components/docs_ui/open_api_operation.rb +126 -0
- data/app/components/docs_ui/page.rb +83 -0
- data/app/components/docs_ui/page_helpers.rb +52 -0
- data/app/components/docs_ui/prop_table.rb +43 -0
- data/app/components/docs_ui/prose.rb +30 -0
- data/app/components/docs_ui/request_example.rb +85 -0
- data/app/components/docs_ui/search_box.rb +106 -0
- data/app/components/docs_ui/search_results.rb +95 -0
- data/app/components/docs_ui/section.rb +94 -0
- data/app/components/docs_ui/shell.rb +161 -0
- data/app/components/docs_ui/sidebar.rb +106 -0
- data/app/components/docs_ui/table.rb +64 -0
- data/app/components/docs_ui/theme_switcher.rb +46 -0
- data/app/components/docs_ui/topbar_links.rb +42 -0
- data/app/controllers/docs_kit/llms_controller.rb +76 -0
- data/app/controllers/docs_kit/mcp_controller.rb +60 -0
- data/app/controllers/docs_kit/search_controller.rb +72 -0
- data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
- data/config/importmap.rb +15 -0
- data/config/rubocop/docs_kit.yml +24 -0
- data/exe/docs-kit +80 -0
- data/lib/docs-kit.rb +5 -0
- data/lib/docs_kit/api_client.rb +52 -0
- data/lib/docs_kit/api_request.rb +66 -0
- data/lib/docs_kit/api_templates.rb +92 -0
- data/lib/docs_kit/configuration.rb +485 -0
- data/lib/docs_kit/controller.rb +47 -0
- data/lib/docs_kit/engine.rb +49 -0
- data/lib/docs_kit/llms_text.rb +105 -0
- data/lib/docs_kit/markdown_export/blocks.rb +160 -0
- data/lib/docs_kit/markdown_export/inline.rb +95 -0
- data/lib/docs_kit/markdown_export/table.rb +53 -0
- data/lib/docs_kit/markdown_export.rb +92 -0
- data/lib/docs_kit/mcp_server.rb +128 -0
- data/lib/docs_kit/mcp_tools.rb +118 -0
- data/lib/docs_kit/nav_item.rb +22 -0
- data/lib/docs_kit/open_api/document.rb +91 -0
- data/lib/docs_kit/open_api/operation.rb +213 -0
- data/lib/docs_kit/open_api/schema.rb +178 -0
- data/lib/docs_kit/open_api.rb +55 -0
- data/lib/docs_kit/registry.rb +152 -0
- data/lib/docs_kit/rubocop.rb +19 -0
- data/lib/docs_kit/search_hit.rb +28 -0
- data/lib/docs_kit/search_index/snippet.rb +65 -0
- data/lib/docs_kit/search_index.rb +169 -0
- data/lib/docs_kit/shortcut.rb +99 -0
- data/lib/docs_kit/templates/new_site.rb +175 -0
- data/lib/docs_kit/topbar_link.rb +39 -0
- data/lib/docs_kit/version.rb +5 -0
- data/lib/docs_kit.rb +72 -0
- data/lib/generators/docs_kit/install/USAGE +15 -0
- data/lib/generators/docs_kit/install/install_generator.rb +447 -0
- data/lib/generators/docs_kit/install/sync_report.rb +64 -0
- data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
- data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
- data/lib/generators/docs_kit/install/templates/build-css +34 -0
- data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
- data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
- data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
- data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
- data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
- data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
- data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
- data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
- data/lib/generators/docs_kit/page/USAGE +26 -0
- data/lib/generators/docs_kit/page/page_generator.rb +127 -0
- data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
- data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
- data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
- 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
|
+
}
|