docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Per-site configuration for the shared docs chrome. Everything that differs
5
+ # between two otherwise-identical docs sites lives here, so the Phlex shell
6
+ # (DocsUI::Shell, DocsUI::Sidebar, DocsUI::ThemeSwitcher) is byte-identical across
7
+ # sites and only the config changes.
8
+ #
9
+ # DocsKit.configure do |c|
10
+ # c.brand = "phlex-reactive"
11
+ # c.title_suffix = "phlex-reactive"
12
+ # c.themes = %w[dark light synthwave ...]
13
+ # c.nav = -> { { "Demos" => Demo.grouped, "Docs" => Doc.grouped } }
14
+ # end
15
+ class Configuration
16
+ # The brand text shown in the topbar and sidebar header.
17
+ attr_accessor :brand
18
+
19
+ # A one-line site summary, rendered as the llms.txt blockquote
20
+ # (`> {tagline}`) under the H1. Defaults to nil → the blockquote line is
21
+ # omitted, so a site that never sets it still gets a valid llms.txt. Purely
22
+ # for the AI-readable index (DocsKit::LlmsText); the chrome never shows it.
23
+ attr_accessor :tagline
24
+
25
+ # The href the topbar brand link points at. Defaults to "/" (site root). A
26
+ # site whose docs live under a subpath sets its own (e.g. "/docs") so the
27
+ # brand link is a one-line config change, not a Shell subclass.
28
+ attr_accessor :brand_href
29
+
30
+ # Appended to a page's <title> (e.g. "Installation · phlex-reactive").
31
+ # Defaults to #brand when unset.
32
+ attr_writer :title_suffix
33
+
34
+ # The themes offered by the ThemeSwitcher. The first is the page default
35
+ # unless #default_theme is set. Must match the themes enabled in the
36
+ # site's Tailwind @plugin "daisyui" { themes: ... } block.
37
+ attr_accessor :themes
38
+
39
+ # The data-theme applied to <html> on first paint. Defaults to the first
40
+ # entry of #themes.
41
+ attr_writer :default_theme
42
+
43
+ # A callable returning the sidebar nav as an ordered Hash of
44
+ # { "Heading" => { "Subgroup" => [items] } }. Each item must respond to
45
+ # the duck type the Sidebar renders (see DocsUI::Sidebar#nav_link): #href,
46
+ # #label, and optional #icon. Defaults to an empty nav.
47
+ #
48
+ # Prefer #nav_registries for the common case — an explicit #nav lambda is
49
+ # only needed for bespoke nav (multiple registries interleaved, custom
50
+ # subgroups). When #nav is left at its default, the sidebar derives from
51
+ # #nav_registries instead.
52
+ attr_reader :nav
53
+
54
+ # Assigning #nav marks it explicit, so #nav_groups uses it verbatim rather
55
+ # than deriving from #nav_registries — tracked by a flag, not object
56
+ # identity, so ANY assigned lambda wins (even one that resolves to {}).
57
+ def nav=(value)
58
+ @nav_explicit = true
59
+ @nav = value
60
+ end
61
+
62
+ # An ordered { "Heading" => registry_class } map. Each registry responds to
63
+ # .nav_items (Registry v2) → { group => [NavItem] } for its authored pages.
64
+ # #nav_groups derives the whole sidebar from this with zero site code, so a
65
+ # site never hand-writes the nav lambda. Defaults to {}. An explicit #nav
66
+ # lambda still wins (full backwards compatibility).
67
+ attr_accessor :nav_registries
68
+
69
+ # Optional callable returning a short version-badge string for the sidebar
70
+ # header (e.g. -> { "v#{DaisyUI::VERSION}" }). nil renders no badge.
71
+ attr_accessor :version_badge
72
+
73
+ # The stylesheet logical names linked in <head>, in order. Defaults to
74
+ # ["application"] (the Bun/Tailwind-compiled build). A site that ships extra
75
+ # stylesheets (e.g. a separate rouge theme) lists them here.
76
+ attr_accessor :stylesheets
77
+
78
+ # The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
79
+ # This is the BASE (light) theme, emitted un-scoped so it applies to every
80
+ # theme unless a dark override wins (see #code_theme_dark).
81
+ attr_accessor :code_theme
82
+
83
+ # An optional second Rouge theme (String name or Class) used for the site's
84
+ # DARK daisyUI themes. Default nil → single-theme behavior, fully backwards
85
+ # compatible. When set, DocsUI::Code additionally emits this theme's CSS scoped
86
+ # under [data-theme=X] .code-highlight for each shipped dark theme (see
87
+ # #dark_themes), so code blocks stay readable when the switcher flips to a
88
+ # dark theme — CSS-only, no JS, no flash.
89
+ attr_accessor :code_theme_dark
90
+
91
+ # The theme names treated as DARK for #code_theme_dark scoping. Defaults to
92
+ # the built-in daisyUI dark themes (DEFAULT_DARK_THEMES). Intersected with
93
+ # #themes at render time (see #dark_themes_shipped) so only shipped themes
94
+ # generate CSS. Override to name custom dark themes (e.g. %w[zazu-dark]).
95
+ attr_accessor :dark_themes
96
+
97
+ # The lucide icon name used for a nav group with no explicit icon.
98
+ attr_accessor :default_group_icon
99
+
100
+ # The RailsIcons library docs-kit renders its OWN chrome icons from (the
101
+ # sidebar carets, search glyph, theme toggle, etc.). Defaults to "lucide" to
102
+ # match the lucide icon names docs-kit ships. This is independent of the host
103
+ # app's RailsIcons.configuration.default_library — a host whose default is
104
+ # phosphor/heroicons can leave this at "lucide" so the chrome keeps rendering,
105
+ # without flipping its global default. Set to nil to defer to the host's
106
+ # default_library.
107
+ attr_accessor :icon_library
108
+
109
+ # Namespaces the sidebar's localStorage keys (collapse state) so two docs
110
+ # sites on the same origin don't clobber each other. Defaults to a slug of
111
+ # the brand.
112
+ attr_writer :nav_storage_key
113
+
114
+ # The default "On this page" (auto-TOC) placement, used by DocsUI::Page when a
115
+ # page doesn't pass its own on_page:. One of the ON_PAGE_MODES, or false to
116
+ # render no auto-TOC by default.
117
+ attr_writer :on_page_default
118
+
119
+ # Friendly-name → Rouge lexer aliases for code blocks, merged over the
120
+ # built-in defaults. Any language Rouge knows (~200) already works by its own
121
+ # name/alias; use this only to add or override (e.g. { curl: "console",
122
+ # dockerfile: "docker" }). Value is anything Rouge::Lexer.find accepts.
123
+ attr_accessor :code_lexer_aliases
124
+
125
+ # The lexer used when a requested language can't be resolved. Default
126
+ # "plaintext" (no highlighting, never raises).
127
+ attr_accessor :code_lexer_fallback
128
+
129
+ # Human labels for language tabs in DocsUI::Example, merged over the built-ins
130
+ # (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize.
131
+ attr_accessor :code_language_labels
132
+
133
+ # Whether DocsUI::Page shows the "Markdown" masthead action — a link to the
134
+ # page's `.md` twin that docs-nav enhances into copy-to-clipboard. Defaults to
135
+ # true; set false to hide the affordance site-wide (the `.md` route still
136
+ # works). See DocsKit::MarkdownExport / DocsKit::Controller#render_page.
137
+ attr_accessor :page_markdown_action
138
+
139
+ # Whether the built-in read-only MCP endpoint (DocsKit::McpController, a
140
+ # POST /mcp JSON-RPC server exposing list_pages / get_page / search_docs over
141
+ # the same registry the docs render from) is active. Defaults to true, but the
142
+ # endpoint only turns on when the optional `mcp` gem is ALSO present and the
143
+ # host draws the route — #mcp_enabled? gates on both. Set false to keep the
144
+ # endpoint off even on a site that bundles the gem. See DocsKit::McpServer.
145
+ attr_accessor :mcp
146
+
147
+ # Whether the topbar renders the docs-search form (and the docs-nav palette
148
+ # markup). Defaults to true. Set false to hide search site-wide — the route
149
+ # can stay drawn, but no affordance points at it. Gated together with a
150
+ # present #search_path by #search_enabled?, which the Shell reads.
151
+ attr_accessor :search
152
+
153
+ # The path the topbar search form submits to (GET ?q=), and the base the
154
+ # palette fetches `.json` from. Defaults to "/docs/search" — the route the
155
+ # install generator draws. A site that mounts search elsewhere sets its own;
156
+ # blank it to disable the affordance without touching #search.
157
+ attr_accessor :search_path
158
+
159
+ # The keyboard shortcut STRINGS that open the search palette, e.g.
160
+ # %w[/ mod+k s]. Defaults to DEFAULT_SEARCH_SHORTCUTS (["/", "mod+k"] — the
161
+ # keys shipped before this was configurable, so existing sites are unchanged).
162
+ # "mod" is the platform modifier (⌘ on mac, Ctrl elsewhere), so one entry
163
+ # works on every OS. Read the parsed form via #search_shortcuts (which maps to
164
+ # DocsKit::Shortcut and drops anything unparseable), never @search_shortcuts.
165
+ attr_writer :search_shortcuts
166
+
167
+ # The API base URL prefixed onto a DocsUI::RequestExample path so copy-pasted
168
+ # snippets point at a real host. Defaults to a neutral example host; a site
169
+ # sets its own (e.g. "https://api.acme.com").
170
+ attr_accessor :api_base_url
171
+
172
+ # An example Authorization header line merged into every generated request
173
+ # snippet (e.g. "Authorization: Bearer sk_live_..."). Defaults to nil → no
174
+ # auth line, so a site with no auth example renders clean snippets.
175
+ attr_accessor :api_auth_header
176
+
177
+ # Site overrides/extensions for the DocsUI::RequestExample client tabs — an
178
+ # ordered { token => DocsKit::ApiClient } Hash merged OVER the four shipped
179
+ # defaults (curl, javascript, ruby, python). Reusing a default token replaces
180
+ # that client (SDK-flavored snippet); a new token appends a tab (e.g. a `cli`).
181
+ # Read the effective map via #api_clients (which merges), never @api_clients.
182
+ attr_writer :api_clients
183
+
184
+ # External links rendered in the topbar next to the theme switcher — a repo
185
+ # link, a chat invite, a social profile. Each entry is a Hash
186
+ # ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links
187
+ # normalizes them into TopbarLink value objects. Defaults to [] → the topbar
188
+ # is byte-identical to before, so a site that sets nothing is unchanged.
189
+ # `icon` is a shipped brand mark (:github, :discord, …) or a lucide icon
190
+ # name. Read the normalized list via #topbar_links, never @topbar_links.
191
+ attr_writer :topbar_links
192
+
193
+ # The OpenAPI spec the `operation` page helper / DocsUI::OpenApiOperation read
194
+ # from: a String/Pathname path (`.json` parsed as JSON, else YAML) or an
195
+ # already-parsed Hash. Defaults to nil → the bridge is off and a site that
196
+ # doesn't set it is byte-identical to before. Read the loaded model via
197
+ # #openapi_document (which memoizes + reloads on file change), never @openapi.
198
+ attr_accessor :openapi
199
+
200
+ # The sentinel "no explicit nav" lambda. #nav_groups compares against this
201
+ # identity to decide whether to derive the sidebar from #nav_registries.
202
+ DEFAULT_NAV = -> { {} }
203
+
204
+ # The search-palette shortcuts before this was configurable — "/" and the
205
+ # platform command chord — so a site that never sets #search_shortcuts keeps
206
+ # exactly the previous behavior.
207
+ DEFAULT_SEARCH_SHORTCUTS = ["/", "mod+k"].freeze
208
+
209
+ # The built-in daisyUI theme names that are dark. #dark_themes defaults to
210
+ # this; #dark_themes_shipped intersects it with the site's #themes so only
211
+ # shipped themes ever generate dark code CSS. A site with custom dark themes
212
+ # overrides #dark_themes (docs-kit can't see the compiled daisyUI CSS to
213
+ # detect darkness at render time, so an honest static list + override wins).
214
+ DEFAULT_DARK_THEMES = %w[
215
+ dark synthwave halloween forest black luxury dracula
216
+ business night coffee dim sunset abyss
217
+ ].freeze
218
+
219
+ # Built-in friendly aliases (kept small — Rouge resolves most names itself).
220
+ DEFAULT_LEXER_ALIASES = { curl: "console", console: "console" }.freeze
221
+
222
+ # Built-in tab labels for the common languages that don't just capitalize.
223
+ DEFAULT_LANGUAGE_LABELS = {
224
+ javascript: "JavaScript", typescript: "TypeScript", php: "PHP",
225
+ curl: "cURL", json: "JSON", yaml: "YAML", html: "HTML", css: "CSS",
226
+ erb: "ERB", jsx: "JSX", tsx: "TSX", sql: "SQL", graphql: "GraphQL"
227
+ }.freeze
228
+
229
+ def initialize
230
+ @brand = "Docs"
231
+ @tagline = nil
232
+ @brand_href = "/"
233
+ @title_suffix = nil
234
+ @themes = %w[dark light]
235
+ @default_theme = nil
236
+ # The default nav lambda. Until a site assigns #nav (which sets
237
+ # @nav_explicit), #nav_groups treats nav as "unset" and derives the sidebar
238
+ # from #nav_registries instead; an explicit c.nav (any lambda) then wins.
239
+ @nav = DEFAULT_NAV
240
+ @nav_explicit = false
241
+ @nav_registries = {}
242
+ @version_badge = nil
243
+ @stylesheets = %w[application]
244
+ @code_theme = "Rouge::Themes::Monokai"
245
+ @code_theme_dark = nil
246
+ @dark_themes = DEFAULT_DARK_THEMES
247
+ @default_group_icon = "file-text"
248
+ @icon_library = "lucide"
249
+ @nav_storage_key = nil
250
+ @on_page_default = :panel
251
+ @code_lexer_aliases = {}
252
+ @code_lexer_fallback = "plaintext"
253
+ @code_language_labels = {}
254
+ @page_markdown_action = true
255
+ @mcp = true
256
+ @search = true
257
+ @search_path = "/docs/search"
258
+ @search_shortcuts = DEFAULT_SEARCH_SHORTCUTS
259
+ @api_base_url = "https://api.example.com"
260
+ @api_auth_header = nil
261
+ @api_clients = {}
262
+ @topbar_links = []
263
+ @openapi = nil
264
+ end
265
+
266
+ # The normalized topbar links (DocsKit::TopbarLink list), in declaration
267
+ # order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so
268
+ # the Shell only ever sees value objects. Blank/nil config yields [].
269
+ def topbar_links
270
+ Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
271
+ end
272
+
273
+ # The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
274
+ # is a file path, the memo is invalidated on an mtime change so editing the
275
+ # spec in development is picked up without a server restart. Raises a
276
+ # DocsKit::Error naming the knob when read while #openapi is unset — a missing
277
+ # spec has nothing useful to degrade to.
278
+ def openapi_document
279
+ raise DocsKit::Error, "no OpenAPI spec configured — set c.openapi to a path or Hash" if @openapi.nil?
280
+
281
+ mtime = openapi_source_mtime
282
+ return @openapi_document if defined?(@openapi_document) && @openapi_document_mtime == mtime
283
+
284
+ @openapi_document_mtime = mtime
285
+ @openapi_document = DocsKit::OpenApi.load(@openapi)
286
+ end
287
+
288
+ # The effective client map for DocsUI::RequestExample: the four shipped
289
+ # defaults with site overrides/extensions merged over them. Hash#merge keeps
290
+ # a reused token in its original position and appends new tokens in
291
+ # declaration order, so tab order is stable and predictable.
292
+ def api_clients
293
+ DocsKit::ApiClient::DEFAULTS.merge(@api_clients || {})
294
+ end
295
+
296
+ # The effective alias map (built-ins + site overrides), symbol-keyed.
297
+ def lexer_aliases
298
+ DEFAULT_LEXER_ALIASES.merge((@code_lexer_aliases || {}).transform_keys(&:to_sym))
299
+ end
300
+
301
+ # The effective label map (built-ins + site overrides), symbol-keyed.
302
+ def language_labels
303
+ DEFAULT_LANGUAGE_LABELS.merge((@code_language_labels || {}).transform_keys(&:to_sym))
304
+ end
305
+
306
+ # The auto-TOC placements, all driven by the same docs-nav Stimulus
307
+ # controller (it reads the page's headings from the DOM):
308
+ # :panel — a sticky card in the top-right of the content column
309
+ # :toggle — a sticky floating button (top-right) opening a dropdown
310
+ # :sidebar — nested under the active nav item in the left sidebar
311
+ ON_PAGE_MODES = %i[panel toggle sidebar].freeze
312
+
313
+ # The resolved default placement (a mode symbol or false). A bare `true`
314
+ # default means :panel (the canonical default), never a self-reference.
315
+ def on_page_default
316
+ raw = @on_page_default
317
+ raw = :panel if raw == true
318
+ coerce_on_page_mode(raw)
319
+ end
320
+
321
+ # Coerce a per-page on_page: value to a valid mode symbol or false. `true`
322
+ # means "use the configured default".
323
+ def normalize_on_page(value)
324
+ return on_page_default if value == true
325
+
326
+ coerce_on_page_mode(value)
327
+ end
328
+
329
+ private
330
+
331
+ # True when the optional `mcp` gem can be loaded. Memoized across both
332
+ # outcomes so a site without the gem doesn't pay a failed require per request.
333
+ # We attempt the require lazily (rather than only checking defined?(MCP)) so a
334
+ # bundled-but-not-yet-required gem still counts as present — the same
335
+ # degrade-gracefully-on-a-missing-optional-gem posture as DocsUI::Icon's
336
+ # rails_icons guard.
337
+ def mcp_gem_present?
338
+ return @mcp_gem_present if defined?(@mcp_gem_present)
339
+
340
+ @mcp_gem_present =
341
+ begin
342
+ require "mcp"
343
+ defined?(::MCP::Server) ? true : false
344
+ rescue LoadError
345
+ false
346
+ end
347
+ end
348
+
349
+ # The mtime of the #openapi source when it's a readable file path, else nil
350
+ # (a Hash spec, or a path that isn't a file). Drives #openapi_document's
351
+ # reload-on-change memoization; a Hash spec loads once and never invalidates.
352
+ def openapi_source_mtime
353
+ return if @openapi.is_a?(Hash)
354
+
355
+ path = Pathname.new(@openapi)
356
+ path.file? ? path.mtime : nil
357
+ rescue StandardError
358
+ nil
359
+ end
360
+
361
+ # { heading => registry.nav_items }, dropping headings with no authored
362
+ # pages so the sidebar never shows an empty group.
363
+ def nav_groups_from_registries
364
+ @nav_registries.each_with_object({}) do |(heading, registry), acc|
365
+ items = registry.nav_items
366
+ acc[heading] = items unless items.empty?
367
+ end
368
+ end
369
+
370
+ def coerce_on_page_mode(value)
371
+ case value
372
+ when false, nil then false
373
+ when *ON_PAGE_MODES then value.to_sym
374
+ else
375
+ raise ArgumentError,
376
+ "on_page must be one of #{ON_PAGE_MODES.inspect}, true, or false (got #{value.inspect})"
377
+ end
378
+ end
379
+
380
+ public
381
+
382
+ def nav_storage_key
383
+ @nav_storage_key || @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
384
+ end
385
+
386
+ def title_suffix
387
+ @title_suffix || @brand
388
+ end
389
+
390
+ # Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
391
+ # optional `mcp` gem is loadable — the same "toggle AND capability" shape as
392
+ # #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
393
+ # and DocsKit::McpController (to 404 when off), so a site without the gem, or
394
+ # one that set c.mcp = false, is byte-identical to before this feature.
395
+ def mcp_enabled?
396
+ !!@mcp && mcp_gem_present?
397
+ end
398
+
399
+ # Whether the Shell renders the search affordance: search is on AND a path is
400
+ # set to submit to. A site with @search_path blanked (or nil) gets no form
401
+ # even if @search is true — there'd be nothing to submit to.
402
+ def search_enabled?
403
+ !!@search && !@search_path.to_s.empty?
404
+ end
405
+
406
+ # The parsed search-palette shortcuts (DocsKit::Shortcut list), with anything
407
+ # unparseable dropped. The topbar renders one <kbd> per entry and docs-nav
408
+ # binds each; an empty list means no keyboard shortcut (the form still works).
409
+ def search_shortcuts
410
+ DocsKit::Shortcut.parse_list(@search_shortcuts)
411
+ end
412
+
413
+ def default_theme
414
+ @default_theme || Array(@themes).first
415
+ end
416
+
417
+ # The resolved nav Hash for this request. Always returns a Hash.
418
+ #
419
+ # An explicit #nav lambda wins. Otherwise the sidebar derives from
420
+ # #nav_registries: each heading maps to its registry's .nav_items, and a
421
+ # heading whose pages are all unauthored (empty nav_items) is dropped so no
422
+ # empty group renders.
423
+ def nav_groups
424
+ return nav_groups_from_registries unless @nav_explicit
425
+
426
+ result = @nav.respond_to?(:call) ? @nav.call : @nav
427
+ result || {}
428
+ end
429
+
430
+ # The resolved version badge string, or nil.
431
+ # The rendered version badge. A callable is invoked; a plain String (or any
432
+ # non-nil value) is coerced to its string form — so `c.version_badge = "v1.2"`
433
+ # renders, not only a lambda.
434
+ def version_badge_text
435
+ return if @version_badge.nil?
436
+ return @version_badge.call if @version_badge.respond_to?(:call)
437
+
438
+ @version_badge.to_s
439
+ end
440
+
441
+ # The default Rouge theme both #code_theme_class and #code_theme_dark_class
442
+ # fall back to when a configured theme name can't be resolved — so a typo'd
443
+ # theme name degrades gracefully instead of raising on every code block.
444
+ DEFAULT_CODE_THEME = "Rouge::Themes::Monokai"
445
+
446
+ # The Rouge theme class resolved from #code_theme (String or class). A String
447
+ # name that doesn't resolve degrades to the default theme rather than raising
448
+ # NameError on every DocsUI::Code render.
449
+ def code_theme_class
450
+ return @code_theme if @code_theme.is_a?(Class)
451
+
452
+ resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
453
+ end
454
+
455
+ # The dark Rouge theme class resolved from #code_theme_dark (String or
456
+ # class), or nil when unset — mirrors #code_theme_class. DocsUI::Code emits
457
+ # dark code CSS only when this is non-nil, so an unresolvable name degrades to
458
+ # nil (no dark restyle) rather than raising.
459
+ def code_theme_dark_class
460
+ return if @code_theme_dark.nil?
461
+ return @code_theme_dark if @code_theme_dark.is_a?(Class)
462
+
463
+ resolve_theme(@code_theme_dark)
464
+ end
465
+
466
+ # The dark themes the site actually ships: #dark_themes intersected with
467
+ # #themes, in #themes declaration order. DocsUI::Code scopes the dark theme's
468
+ # CSS under [data-theme=X] for each of these, so a dark theme that isn't in
469
+ # the Tailwind build never emits dead CSS.
470
+ def dark_themes_shipped
471
+ Array(@themes) & Array(@dark_themes)
472
+ end
473
+
474
+ private
475
+
476
+ # Resolve a Rouge theme constant from its String name, returning nil (not
477
+ # raising) when the name doesn't resolve — a typo'd or unloaded theme must
478
+ # not crash every code block on the page.
479
+ def resolve_theme(name)
480
+ Object.const_get(name.to_s)
481
+ rescue NameError
482
+ nil
483
+ end
484
+ end
485
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Controller glue for a docs site. Include in ApplicationController to get the
5
+ # one shared render helper.
6
+ #
7
+ # class ApplicationController < ActionController::Base
8
+ # include DocsKit::Controller
9
+ # def show = render_page(Views::Landings::Show.new)
10
+ # end
11
+ module Controller
12
+ # Render a Phlex page that is itself a full HTML document (it composes
13
+ # Docs::Shell, which emits <html>/<head>/<body>). `layout: false` prevents the
14
+ # Rails ERB application layout from double-nesting <html>. phlex-rails still
15
+ # renders through a real view context, so CSRF, dom_id, url helpers, and the
16
+ # phlex-reactive token signer all work inside components.
17
+ #
18
+ # A `.md`/`.text` request instead returns the page's Markdown twin, derived
19
+ # from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So
20
+ # `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the
21
+ # author writes nothing extra, and the two never drift.
22
+ def render_page(view)
23
+ return render_markdown(view) if markdown_request?
24
+
25
+ render view, layout: false
26
+ end
27
+
28
+ private
29
+
30
+ # True for a `.md` or `.text` request. `.text` is accepted as an alias so a
31
+ # host whose routes only allow the built-in `:text` format still gets the
32
+ # twin.
33
+ def markdown_request?
34
+ request.format.md? || request.format.text?
35
+ end
36
+
37
+ # The Markdown twin as text/markdown. Rendered through the controller's view
38
+ # context (so url helpers/CSRF resolve) and with the request base URL so
39
+ # relative links in the export are absolutized to portable URLs.
40
+ def render_markdown(view)
41
+ markdown = DocsKit::MarkdownExport.new(
42
+ view, view_context:, base_url: request.base_url
43
+ ).to_md
44
+ render plain: markdown, content_type: "text/markdown"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module DocsKit
6
+ # An asset/glue engine (no isolate_namespace, no routes/models). It makes the
7
+ # gem's Phlex components autoloadable in the host app and exposes the controller
8
+ # helper, so a docs site only adds the gem + its DocsKit.configure block.
9
+ class Engine < ::Rails::Engine
10
+ # The gem's OWN zeitwerk loader (in lib/docs_kit.rb) is the single owner of
11
+ # the Docs::* components under app/components/docs. Rails would otherwise also
12
+ # add an engine's app/* dirs to the host autoload paths and double-manage
13
+ # those constants, so opt this engine's app/ out of Rails autoloading.
14
+ config.autoload_paths = []
15
+ config.eager_load_paths = []
16
+ config.paths["app"].skip_eager_load!
17
+
18
+ JAVASCRIPT_PATH = root.join("app/javascript")
19
+
20
+ initializer "docs_kit.controller_helper" do
21
+ ActiveSupport.on_load(:action_controller_base) do
22
+ include DocsKit::Controller
23
+ end
24
+ end
25
+
26
+ # Register the :md format (text/markdown) so a `.md` request routes and
27
+ # `request.format.md?` is true — the trigger for the page's Markdown twin
28
+ # (DocsKit::Controller#render_page). Guarded so re-registration (a host that
29
+ # already declared :md) is a no-op rather than a duplicate-type error.
30
+ initializer "docs_kit.mime_types" do
31
+ Mime::Type.register("text/markdown", :md) unless Mime::Type.lookup_by_extension(:md)
32
+ end
33
+
34
+ # Serve the bundled Stimulus controller (docs_nav) as an asset.
35
+ initializer "docs_kit.assets" do |app|
36
+ app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
37
+ end
38
+
39
+ # Auto-pin the controller for importmap-rails consumers, so a host app gets
40
+ # `docs_kit/controllers/docs_nav_controller` with no manual pin.
41
+ initializer "docs_kit.importmap", before: "importmap" do |app|
42
+ next unless app.config.respond_to?(:importmap)
43
+
44
+ importmap = app.config.importmap
45
+ importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
46
+ importmap.cache_sweepers << JAVASCRIPT_PATH if importmap.respond_to?(:cache_sweepers)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Builds the two AI-readable artifacts a docs-kit site serves, straight from
5
+ # the registry — zero authoring:
6
+ #
7
+ # /llms.txt — the llmstxt.org index: H1 brand, `> tagline` blockquote,
8
+ # one `## {group}` section per nav group, and a
9
+ # `- [title](abs .md url)` line per authored page.
10
+ # /llms-full.txt — every page's Markdown twin concatenated, `# {title}` +
11
+ # body, separated by `---`.
12
+ #
13
+ # It's a pure text builder: given a DocsKit::Configuration and (for the full
14
+ # form) already-rendered `[title, markdown]` pairs, it produces strings with no
15
+ # Rails. The controller owns the Rails view context — it renders each page to
16
+ # Markdown (DocsKit::MarkdownExport) and hands the pairs to .full — so all the
17
+ # shaping is unit-testable without booting Rails.
18
+ #
19
+ # The enumeration source is DocsKit::Registry v2: each registry in
20
+ # `config.nav_registries` responds to #nav_items ({ group => [NavItem] },
21
+ # authored pages only) for the index and #all (entries with #view_class) for
22
+ # the authored page list. An unwritten page (no resolvable view_class) is
23
+ # excluded from both, so neither artifact ever links or concatenates a page
24
+ # that doesn't exist yet.
25
+ module LlmsText
26
+ module_function
27
+
28
+ # The llms.txt index string. base_url absolutizes each page's `.md` href so
29
+ # agent tooling fetches a portable URL; omit it for relative links.
30
+ #
31
+ # Blocks (H1, the tagline blockquote, and one per section) are separated by a
32
+ # blank line; within a section the `## heading` and its `- [..]` bullets are a
33
+ # single tight list (no blank lines between bullets), per the llmstxt.org
34
+ # convention.
35
+ def index(config, base_url: nil)
36
+ blocks = ["# #{config.brand}"]
37
+ tagline = config.tagline
38
+ blocks << "> #{tagline}" if tagline && !tagline.to_s.empty?
39
+ blocks.concat(section_blocks(config, base_url))
40
+
41
+ # Advertise the built-in MCP endpoint last, so an agent that reads llms.txt
42
+ # discovers it can also connect over the protocol (native tools vs fetching
43
+ # text). Only when the endpoint is actually live (gem present + c.mcp on).
44
+ blocks << mcp_block(base_url) if config.mcp_enabled?
45
+
46
+ blocks.join("\n\n")
47
+ end
48
+
49
+ # One `## {group}` block per nav group, each a tight bullet list of its
50
+ # authored pages' `.md` links, in registry order.
51
+ def section_blocks(config, base_url)
52
+ groups(config).map do |group, links|
53
+ ["## #{group}", *links.map { |link| link_line(link, base_url) }].join("\n")
54
+ end
55
+ end
56
+
57
+ # The authored pages across every registry, in config/registry order — each
58
+ # responds to #title / #href / #view_class. The controller renders these to
59
+ # Markdown for .full.
60
+ def pages(config)
61
+ config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) }
62
+ end
63
+
64
+ # The llms-full.txt body: each [title, markdown] pair as `# {title}` + body,
65
+ # separated by a `---` rule. Empty pairs → "".
66
+ def full(_config, title_markdown_pairs)
67
+ title_markdown_pairs.map { |title, markdown| "# #{title}\n\n#{markdown}" }.join("\n\n---\n\n")
68
+ end
69
+
70
+ # { group => [links] } across every registry's #nav_items, in config order.
71
+ # A registry with no authored pages contributes nothing, so no empty section
72
+ # is ever emitted.
73
+ def groups(config)
74
+ config.nav_registries.values.each_with_object({}) do |registry, acc|
75
+ registry.nav_items.each { |group, links| (acc[group] ||= []).concat(links) }
76
+ end
77
+ end
78
+
79
+ # The `## MCP` section pointing an agent at the read-only MCP endpoint. The
80
+ # `/mcp` URL is absolutized against base_url when available (agents connect to
81
+ # a portable URL); relative otherwise.
82
+ def mcp_block(base_url)
83
+ url = base_url ? "#{base_url.chomp('/')}/mcp" : "/mcp"
84
+ "## MCP\n" \
85
+ "This documentation is also available over the Model Context Protocol " \
86
+ "(search, page retrieval) at #{url} — add it to an MCP client " \
87
+ "(Claude Code, Claude.ai, Cursor) to query these docs as tools."
88
+ end
89
+
90
+ # `- [label](absolute .md url)`. The `.md` suffix targets the page's Markdown
91
+ # twin (DocsKit::Controller#render_page).
92
+ def link_line(link, base_url)
93
+ "- [#{link.label}](#{md_url(link.href, base_url)})"
94
+ end
95
+
96
+ # href + ".md", absolutized against base_url when given. base_url has no
97
+ # trailing slash concerns here (hrefs are root-relative like "/docs/x").
98
+ def md_url(href, base_url)
99
+ path = "#{href}.md"
100
+ return path unless base_url
101
+
102
+ "#{base_url.chomp('/')}#{path}"
103
+ end
104
+ end
105
+ end