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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # One structured request declaration → one code tab per configured API client.
5
+ # Declare method/path/body once and every client (curl, javascript, ruby,
6
+ # python by default; plus whatever a site adds) renders its own snippet, in a
7
+ # DocsUI::Example so the sticky global language preference keeps working.
8
+ #
9
+ # render DocsUI::RequestExample.new(
10
+ # method: :post, path: "/v1/webhook_endpoints",
11
+ # body: { url: "https://example.com/hook", events: ["payment.paid"] }
12
+ # )
13
+ #
14
+ # # only some tabs, in a chosen order:
15
+ # render DocsUI::RequestExample.new(method: :get, path: "/v1/things", clients: %i[curl ruby])
16
+ #
17
+ # The base URL and an example auth header come from config
18
+ # (DocsKit.configuration.api_base_url / #api_auth_header); the client set comes
19
+ # from #api_clients (defaults + site overrides). This replaces the per-client
20
+ # heredoc a docs page used to hand-write once per endpoint per language.
21
+ class RequestExample < Phlex::HTML
22
+ def initialize(method:, path:, body: nil, query: nil, headers: {}, clients: nil)
23
+ @method = method
24
+ @path = path
25
+ @body = body
26
+ @query = query || {}
27
+ @headers = headers || {}
28
+ @clients = clients
29
+ end
30
+
31
+ def view_template
32
+ request = build_request
33
+ selected = selected_clients
34
+
35
+ render DocsUI::Example.new do |ex|
36
+ selected.each do |token, client|
37
+ ex.code(
38
+ token,
39
+ lexer: client.lexer,
40
+ label: client.label,
41
+ filename: client.filename_for(request)
42
+ ) { client.render(request) }
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # The request struct handed to every client template: config base URL + path,
50
+ # config auth header merged into the headers.
51
+ def build_request
52
+ config = DocsKit.configuration
53
+ DocsKit::ApiRequest.new(
54
+ method: @method,
55
+ path: @path,
56
+ url: "#{config.api_base_url}#{@path}",
57
+ query: @query,
58
+ headers: merged_headers(config.api_auth_header),
59
+ body: @body
60
+ )
61
+ end
62
+
63
+ # The site's example Authorization header (if any) merged into the per-request
64
+ # headers. The header line is "Name: value"; split it into a { name => value }
65
+ # entry so templates can format it per language.
66
+ def merged_headers(auth_header)
67
+ return @headers if auth_header.nil? || auth_header.strip.empty?
68
+
69
+ name, value = auth_header.split(":", 2).map(&:strip)
70
+ return @headers if value.nil? || value.empty?
71
+
72
+ { name => value }.merge(@headers)
73
+ end
74
+
75
+ # The { token => ApiClient } tabs to render, in order: the configured full map,
76
+ # or just the requested `clients:` tokens (in the given order), skipping any
77
+ # unknown token so a typo degrades to fewer tabs rather than raising.
78
+ def selected_clients
79
+ configured = DocsKit.configuration.api_clients
80
+ return configured if @clients.nil?
81
+
82
+ @clients.filter_map { |token| [token.to_sym, configured[token.to_sym]] if configured.key?(token.to_sym) }.to_h
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The topbar docs-search affordance: a plain GET form to config.search_path (the
5
+ # JS-off path — Enter lands on the server-rendered results page) that the ONE
6
+ # docs-nav controller enhances into a keyboard-shortcut palette. The shortcuts
7
+ # come from config.search_shortcuts (default "/" and "mod+k"): this component
8
+ # renders one <kbd> hint per shortcut AND emits the parsed list as JSON on the
9
+ # scope, so the badges and the key bindings share one source and can't drift.
10
+ # The results dropdown is server-rendered here EMPTY and hidden; docs-nav fills
11
+ # it from `search.json?q=` as the reader types and toggles it. The form still
12
+ # submits normally if JS dies mid-typing, so search never depends on JavaScript.
13
+ #
14
+ # Rendered by DocsUI::Shell only when DocsKit.configuration.search_enabled?.
15
+ #
16
+ # The dropdown/menu/hidden classes are render-time LITERALS (never
17
+ # interpolated), so Tailwind's scan of this file keeps them; the CSS also
18
+ # @source inline()s them belt-and-suspenders, since they only appear at render
19
+ # time (like the Drawer classes).
20
+ class SearchBox < Phlex::HTML
21
+ def view_template
22
+ # data-docs-nav-target="searchScope" roots the palette so the shortcut keys
23
+ # can focus the input, and the results dropdown is a sibling.
24
+ # data-docs-nav-shortcuts-value carries the parsed shortcut list as JSON so
25
+ # docs-nav binds each configured key without hardcoding any.
26
+ # min-w-0 (not flex-none) so the box SHRINKS on a narrow topbar instead of
27
+ # pushing the theme switcher off-screen — on a 390px phone the brand +
28
+ # search + switcher otherwise overflow. The input carries daisyUI's default
29
+ # min-width, so it too needs min-w-0 + a modest responsive max-width.
30
+ div(
31
+ class: "dropdown min-w-0",
32
+ data: { docs_nav_target: "searchScope", docs_nav_shortcuts_value: shortcuts_json }
33
+ ) do
34
+ form(
35
+ action: config.search_path, method: "get", role: "search",
36
+ class: "flex min-w-0 items-center", data: { action: "submit->docs-nav#submitSearch" }
37
+ ) do
38
+ label(class: "input input-sm flex min-w-0 max-w-[40vw] items-center gap-2 sm:max-w-none") do
39
+ render DocsUI::Icon.new("search", class: "size-4 opacity-60")
40
+ search_input
41
+ shortcut_hint
42
+ end
43
+ end
44
+ results_dropdown
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def config = DocsKit.configuration
51
+
52
+ def search_input
53
+ input(
54
+ type: "search", name: "q", placeholder: "Search…", autocomplete: "off",
55
+ aria_label: "Search docs", class: "grow bg-transparent",
56
+ data: {
57
+ docs_nav_target: "searchInput",
58
+ action: "input->docs-nav#performSearch keydown->docs-nav#navigateResults"
59
+ }
60
+ )
61
+ end
62
+
63
+ # The configured shortcuts (DocsKit::Shortcut list) — drives both the visible
64
+ # <kbd> badges and the JSON docs-nav binds against, so they can never drift.
65
+ def shortcuts = config.search_shortcuts
66
+
67
+ # The shortcut list as JSON for docs-nav's Value API (data-docs-nav-shortcuts-
68
+ # value). Each entry is { key, mod, ctrl, shift, alt, meta } — everything the
69
+ # controller needs to match a keydown without hardcoding any key.
70
+ def shortcuts_json = shortcuts.map(&:to_h).to_json
71
+
72
+ # The keyboard-shortcut hint the reader SEES — one <kbd> badge per configured
73
+ # shortcut, rendered from DocsKit.configuration.search_shortcuts. A badge's
74
+ # label is the parsed Shortcut#label ("/", "Ctrl K", "S", …); a mod-chord
75
+ # badge is tagged data-hint=modifier so docs-nav swaps just its label to ⌘ on
76
+ # mac — it never changes the key BINDING. Nothing renders when the site
77
+ # configures no shortcuts. aria-hidden: the badges are decorative (the input
78
+ # has aria-label).
79
+ #
80
+ # The class strings are render-time LITERALS so Tailwind's file scan keeps
81
+ # them (kbd/kbd-sm are also @source inline'd in the CSS, belt-and-suspenders).
82
+ def shortcut_hint
83
+ return if shortcuts.empty?
84
+
85
+ span(class: "ml-1 hidden items-center gap-1 sm:flex", aria_hidden: "true") do
86
+ shortcuts.each { |shortcut| shortcut_badge(shortcut) }
87
+ end
88
+ end
89
+
90
+ def shortcut_badge(shortcut)
91
+ kbd(
92
+ class: "kbd kbd-sm opacity-60",
93
+ data: { docs_nav_target: "shortcutHint", hint: (shortcut.mod? ? "modifier" : "static") }
94
+ ) { shortcut.label }
95
+ end
96
+
97
+ # The palette results list — server-rendered EMPTY + hidden; docs-nav fills it.
98
+ def results_dropdown
99
+ ul(
100
+ class: "dropdown-content menu bg-base-200 rounded-box z-40 mt-1 hidden max-h-96 " \
101
+ "w-80 flex-nowrap overflow-y-auto p-2 shadow-2xl",
102
+ data: { docs_nav_target: "searchResults" }
103
+ )
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The server-rendered search results — the JS-off path. A plain page body (the
5
+ # host renders it inside DocsUI::Shell) that echoes the query, lists the hits
6
+ # grouped by page, and links each result to its section anchor. With JavaScript
7
+ # off this IS the search UX; the docs-nav palette is a progressive enhancement
8
+ # over the same DocsKit::SearchController that renders this.
9
+ #
10
+ # render DocsUI::SearchResults.new(query: params[:q], hits: index.search(params[:q]))
11
+ #
12
+ # hits are DocsKit::SearchHit value objects (already ranked, snippet pre-marked
13
+ # and HTML-safe). A blank query prompts the reader; a query with no hits renders
14
+ # guidance instead of an empty list.
15
+ class SearchResults < Phlex::HTML
16
+ def initialize(query:, hits:)
17
+ @query = query.to_s
18
+ @hits = hits
19
+ end
20
+
21
+ def view_template
22
+ div(class: "mx-auto max-w-3xl") do
23
+ header
24
+ if @query.strip.empty?
25
+ prompt
26
+ elsif @hits.empty?
27
+ empty_state
28
+ else
29
+ results
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def header
37
+ h1(class: "mb-2 text-3xl font-bold tracking-tight") { "Search" }
38
+ return if @query.strip.empty?
39
+
40
+ p(class: "mb-8 text-base-content/60") do
41
+ plain "#{result_count} for "
42
+ span(class: "font-semibold text-base-content") { "“#{@query}”" }
43
+ end
44
+ end
45
+
46
+ def result_count
47
+ n = @hits.size
48
+ "#{n} result#{'s' unless n == 1}"
49
+ end
50
+
51
+ # Blank query — the bare /docs/search page. Tell the reader what to do.
52
+ def prompt
53
+ p(class: "text-base-content/60") { "Type a query above to search the docs." }
54
+ end
55
+
56
+ # A query that matched nothing — guidance, not a dead end.
57
+ def empty_state
58
+ div(class: "rounded-box border border-base-300 bg-base-200 p-6 text-center") do
59
+ p(class: "mb-1 font-medium") { "No results for “#{@query}”." }
60
+ p(class: "text-sm text-base-content/60") { "Try fewer or more general words." }
61
+ end
62
+ end
63
+
64
+ # Hits grouped by page: one heading per page, each hit a linked card with its
65
+ # section label + highlighted snippet. group_by preserves first-seen (rank)
66
+ # order, so the best-scoring page leads.
67
+ def results
68
+ div(class: "space-y-8") do
69
+ @hits.group_by(&:page_title).each do |page_title, page_hits|
70
+ section(class: "space-y-2") do
71
+ h2(class: "text-sm font-semibold uppercase tracking-wider text-base-content/60") { page_title }
72
+ page_hits.each { |hit| result_row(hit) }
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def result_row(hit)
79
+ a(
80
+ href: hit.href,
81
+ class: "block rounded-box border border-base-300 bg-base-100 p-4 transition " \
82
+ "hover:border-primary hover:bg-base-200"
83
+ ) do
84
+ # A page-intro hit (no section) is the page overview; label it so as not
85
+ # to duplicate the page-group heading above it.
86
+ span(class: "block font-medium text-primary") { hit.section_title || "Overview" }
87
+ # The snippet is a gem-produced, pre-escaped HTML string (the matched term
88
+ # wrapped in <mark>, everything else escaped by SearchIndex::Snippet), so
89
+ # it's trusted markup — raw(safe) is the same idiom DocsUI::Code uses for
90
+ # its highlighted output. NEVER pass user/config free text here unescaped.
91
+ p(class: "mt-1 text-sm text-base-content/70") { raw(safe(hit.snippet)) }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A titled doc section with an anchor (so the heading is linkable) and an
5
+ # optional description rendered under the title, before the body. Use inside a
6
+ # Page; compose with Prose for the text.
7
+ #
8
+ # # plain section
9
+ # render DocsUI::Section.new("Add the gem") { render DocsUI::Prose.new { … } }
10
+ #
11
+ # # with a one-line description (a muted lead under the title)
12
+ # render DocsUI::Section.new("Overview", description: "What this endpoint does.") do
13
+ # render DocsUI::Prose.new { … }
14
+ # end
15
+ #
16
+ # # richer description (e.g. an API endpoint) — pass a block as `description:`
17
+ # render DocsUI::Section.new("Create a message", description: -> {
18
+ # code(class: "badge badge-sm") { "POST" }; plain " /v1/messages"
19
+ # }) { render DocsUI::Prose.new { … } }
20
+ #
21
+ # # or pass a renderable Phlex component directly (e.g. DocsUI::Endpoint)
22
+ # render DocsUI::Section.new("Create a message",
23
+ # description: DocsUI::Endpoint.new(:post, "/v1/messages")) { … }
24
+ #
25
+ # The description is rendered only when present, so plain sections are unchanged.
26
+ class Section < Phlex::HTML
27
+ def initialize(title, id: nil, description: nil)
28
+ @title = title
29
+ @explicit_id = id
30
+ @description = description
31
+ end
32
+
33
+ def view_template(&)
34
+ # Resolve the anchor id at render time so it can be de-duplicated against
35
+ # sibling sections sharing this page's render context (see #resolve_id).
36
+ @id = @explicit_id || unique_id(slugify(@title))
37
+ section(id: @id, class: "mb-10 scroll-mt-20") do
38
+ heading
39
+ description
40
+ yield
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def heading
47
+ h2(class: "group mb-2 text-2xl font-semibold tracking-tight") do
48
+ a(href: "##{@id}", class: "no-underline") do
49
+ plain @title
50
+ span(class: "ml-2 text-base-content/30 opacity-0 transition group-hover:opacity-100") { "#" }
51
+ end
52
+ end
53
+ end
54
+
55
+ # The optional description, rendered under the title. Three accepted forms:
56
+ # * a Phlex component instance (e.g. DocsUI::Endpoint) → rendered in place;
57
+ # * a proc/lambda → instance_exec'd so it can emit rich Phlex markup;
58
+ # * a String → plain, Phlex-escaped text.
59
+ # A Phlex component also responds to #call, so it MUST be matched before the
60
+ # callable branch (else it would be instance_exec'd, not rendered).
61
+ def description
62
+ return unless @description
63
+
64
+ p(class: "mb-4 text-base leading-relaxed text-base-content/70") do
65
+ case @description
66
+ when Phlex::SGML then render @description
67
+ else @description.respond_to?(:call) ? instance_exec(&@description) : plain(@description)
68
+ end
69
+ end
70
+ end
71
+
72
+ # ActiveSupport's #parameterize when available (Rails host), else a minimal
73
+ # ASCII slug so the component works in isolated Phlex tests too.
74
+ def slugify(text)
75
+ return text.parameterize if text.respond_to?(:parameterize)
76
+
77
+ text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
78
+ end
79
+
80
+ # De-duplicate the anchor id across every Section on the page. Phlex's render
81
+ # `context` is a Hash shared by the whole render tree, so sibling sections see
82
+ # the same used-id counter without any shared parent state. A title that
83
+ # slugifies to "" (e.g. "C++" → "c" is fine, but "+++" → "") falls back to
84
+ # "section"; colliding bases get a "-1", "-2", … sequence suffix so in-page
85
+ # anchors and the auto-TOC/scroll-spy resolve to distinct headings.
86
+ def unique_id(base)
87
+ base = "section" if base.empty?
88
+ used = (context[:__docs_ui_section_ids__] ||= Hash.new(0))
89
+ n = used[base]
90
+ used[base] += 1
91
+ n.zero? ? base : "#{base}-#{n}"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The site shell — the full HTML document. A Phlex layout built on the daisyUI
5
+ # Drawer: a sticky topbar, a sidebar always visible on desktop (lg:drawer-open)
6
+ # that toggles as an overlay on mobile, and the page content in a scrollable
7
+ # main. Loads Turbo + (when present) the phlex-reactive Stimulus controller via
8
+ # the importmap tags, and the daisyUI-compiled Tailwind build.
9
+ #
10
+ # Shell IS the full <html> document, so controllers render the page view with
11
+ # `layout: false` (see DocsKit::Controller#render_page) to avoid double <html>
12
+ # nesting. phlex-rails still renders through a real view context, so CSRF,
13
+ # dom_id, url helpers, and the reactive token signer all work inside components.
14
+ #
15
+ # render DocsUI::Shell.new(title: "Installation") { page_body }
16
+ class Shell < Phlex::HTML
17
+ include Phlex::Rails::Helpers::CSRFMetaTags
18
+ include Phlex::Rails::Helpers::CSPMetaTag
19
+ include Phlex::Rails::Helpers::ContentSecurityPolicyNonce
20
+ include Phlex::Rails::Helpers::StylesheetLinkTag
21
+ include Phlex::Rails::Helpers::JavaScriptImportmapTags
22
+ include DaisyUI
23
+
24
+ DRAWER_ID = "site-drawer"
25
+
26
+ # on_page: the auto-TOC placement for this page (:panel/:toggle/:sidebar or
27
+ # false). Threaded to the sidebar's docs-nav controller; :panel/:toggle also
28
+ # render an "On this page" slot inside the content column.
29
+ def initialize(title: nil, on_page: false)
30
+ @title = title
31
+ @on_page = DocsKit.configuration.normalize_on_page(on_page)
32
+ end
33
+
34
+ def view_template(&)
35
+ doctype
36
+ html(lang: "en", data: { theme: config.default_theme }) do
37
+ render_head
38
+ # The docs-nav controller lives on <body> — the shared ancestor of BOTH
39
+ # the sidebar (collapse persistence, :sidebar TOC injection) and the
40
+ # content column (:panel/:toggle TOC slot + scroll-spy). Scoping it to the
41
+ # sidebar alone would put the content-column TOC out of its reach.
42
+ body(
43
+ class: "bg-base-100 text-base-content",
44
+ data: {
45
+ controller: "docs-nav",
46
+ docs_nav_storage_key_value: config.nav_storage_key,
47
+ docs_nav_on_page_value: (@on_page || "").to_s
48
+ }
49
+ ) do
50
+ shell(&)
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def config = DocsKit.configuration
58
+
59
+ # The request's CSP nonce, or nil when there's no Rails view context (an
60
+ # isolated Phlex render, or a host that doesn't nonce script-src). The
61
+ # phlex-rails value helper delegates to view_context, which raises without
62
+ # one, so guard on its presence — a nil result makes Phlex omit the
63
+ # attribute, keeping the un-nonced markup unchanged.
64
+ def csp_nonce = view_context && content_security_policy_nonce
65
+
66
+ # panel/toggle render their TOC in the content column; sidebar mode is
67
+ # injected by the controller under the active nav link (no content slot).
68
+ def content_toc? = %i[panel toggle].include?(@on_page)
69
+
70
+ def render_head
71
+ head do
72
+ title { [@title, config.title_suffix].compact.join(" · ") }
73
+ meta(charset: "utf-8")
74
+ meta(name: "viewport", content: "width=device-width,initial-scale=1")
75
+ csrf_meta_tags
76
+ csp_meta_tag
77
+ # Turbo morphs page-level navigations so a re-render preserves scroll and
78
+ # focus, matching the in-place feel of reactive components.
79
+ meta(name: "turbo-refresh-method", content: "morph")
80
+ meta(name: "turbo-refresh-scroll", content: "preserve")
81
+ config.stylesheets.each { |sheet| stylesheet_link_tag(sheet, data: { turbo_track: "reload" }) }
82
+ theme_restore_script
83
+ javascript_importmap_tags
84
+ end
85
+ end
86
+
87
+ # Restore the persisted theme BEFORE first paint so there's no flash of the
88
+ # server default before the docs-nav controller runs. Reads the same
89
+ # localStorage key the controller writes (docs-kit:<site>:theme). Runs on
90
+ # initial load and on Turbo page renders (turbo:load).
91
+ #
92
+ # Carries the request's CSP nonce so the inline script is allowed under a
93
+ # nonce-based script-src (Rails' default when script-src is in
94
+ # content_security_policy_nonce_directives). Off a request there is no nonce
95
+ # (see #csp_nonce) and Phlex omits a nil-valued attribute, so the no-nonce
96
+ # markup is byte-identical to before.
97
+ def theme_restore_script
98
+ key = "docs-kit:#{config.nav_storage_key}:theme"
99
+ script(nonce: csp_nonce) do
100
+ raw(safe(<<~JS))
101
+ (function(){
102
+ function apply(){
103
+ try{
104
+ var t = localStorage.getItem(#{key.to_json});
105
+ if (t) document.documentElement.setAttribute("data-theme", t);
106
+ }catch(e){}
107
+ }
108
+ apply();
109
+ document.addEventListener("turbo:load", apply);
110
+ })();
111
+ JS
112
+ end
113
+ end
114
+
115
+ # The daisyUI Drawer app-shell. Desktop: sidebar always open (lg:drawer-open).
116
+ # Mobile: sidebar hidden, toggled by the hamburger in the topbar.
117
+ def shell(&block)
118
+ Drawer(id: DRAWER_ID, class: "lg:drawer-open min-h-screen") do |drawer|
119
+ drawer.toggle
120
+
121
+ drawer.content(class: "flex flex-col min-h-screen") do
122
+ topbar
123
+ main(class: "flex-1 overflow-auto px-4 py-8 md:px-8") do
124
+ # The :panel/:toggle "On this page" is position:fixed to the viewport's
125
+ # top-right (below the topbar), so it never overlaps the prose. Render
126
+ # it here so it's inside the docs-nav controller scope; the controller
127
+ # fills it from the page headings.
128
+ render DocsUI::OnThisPage.new(mode: @on_page) if content_toc?
129
+ # id="docs-content" is the stable extraction anchor for the Markdown
130
+ # export (DocsKit::MarkdownExport walks this subtree). The topbar,
131
+ # sidebar, and TOC live OUTSIDE it, so they never bleed into the .md.
132
+ div(id: "docs-content", class: "mx-auto max-w-4xl", &block)
133
+ end
134
+ end
135
+
136
+ drawer.side(class: "z-40") do
137
+ drawer.overlay
138
+ render DocsUI::Sidebar.new
139
+ end
140
+ end
141
+ end
142
+
143
+ # Sticky topbar: hamburger (mobile only), brand, search, theme switcher.
144
+ def topbar
145
+ div(class: "navbar bg-base-200 border-b border-base-300 sticky top-0 z-30 px-4") do
146
+ div(class: "flex-1 items-center gap-2") do
147
+ label(for: DRAWER_ID, class: "btn btn-square btn-ghost btn-sm lg:hidden",
148
+ aria_label: "Open menu") { render DocsUI::Icon.new("menu", class: "size-5") }
149
+ a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
150
+ end
151
+ render DocsUI::SearchBox.new if config.search_enabled?
152
+ div(class: "flex-none items-center") do
153
+ # Config-driven repo/social links (config.topbar_links) render as
154
+ # icon-only ghost buttons BEFORE the switcher; nothing when unset.
155
+ render DocsUI::TopbarLinks.new
156
+ render DocsUI::ThemeSwitcher.new
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The drawer sidebar: a brand header (with an optional version badge) over the
5
+ # nav, driven entirely by DocsKit.configuration.nav_groups. Renders inside the
6
+ # daisyUI drawer-side (DocsUI::Shell owns responsive visibility), so this is just
7
+ # the panel content.
8
+ #
9
+ # nav_groups is an ordered Hash:
10
+ # { "Heading" => { "Subgroup" => [DocsKit::NavItem, ...] } }
11
+ class Sidebar < Phlex::HTML
12
+ include Phlex::Rails::Helpers::Request
13
+ include DaisyUI
14
+
15
+ # Suppress the browser's native <details> disclosure triangle on nav summaries.
16
+ # daisyUI draws its OWN rotating chevron (li>details>summary::after), so the
17
+ # native marker would render a SECOND, non-rotating caret stacked next to it.
18
+ # daisyUI only hides the old ::-webkit-details-marker; modern browsers (and
19
+ # Safari) also need list-none + the standard ::marker reset. Literal classes —
20
+ # Tailwind tree-shakes interpolated ones.
21
+ MARKER_RESET = "list-none [&::-webkit-details-marker]:hidden [&::marker]:content-none"
22
+
23
+ def view_template
24
+ # The docs-nav Stimulus controller lives on <body> (DocsUI::Shell) so it spans
25
+ # this sidebar AND the content column. Here it drives collapse persistence
26
+ # and, in :sidebar mode, hosts the injected page TOC. With JS off the server
27
+ # renders every <details open>, so the sidebar is simply fully expanded.
28
+ div(class: "bg-base-200 flex min-h-full w-72 flex-col") do
29
+ header_section
30
+ div(class: "flex-1 overflow-y-auto px-2 pb-6") do
31
+ Menu(class: "w-full gap-1") do
32
+ nav_groups.each { |heading, grouped| nav_group(heading, grouped) }
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def config = DocsKit.configuration
41
+ def nav_groups = config.nav_groups
42
+
43
+ def header_section
44
+ div(class: "flex min-h-16 items-center gap-2 px-4") do
45
+ a(href: config.brand_href, class: "text-lg font-bold text-base-content") { config.brand }
46
+ badge = config.version_badge_text
47
+ span(class: "badge badge-sm badge-ghost") { badge } if badge
48
+ end
49
+ end
50
+
51
+ # A top-level collapsible group (e.g. "Docs") holding collapsible sub-groups
52
+ # (e.g. "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash.
53
+ def nav_group(heading, grouped)
54
+ return if grouped.nil? || grouped.empty?
55
+
56
+ li do
57
+ details(open: true) do
58
+ summary(class: "text-xs font-semibold uppercase tracking-wider text-base-content/50 #{MARKER_RESET}") do
59
+ heading
60
+ end
61
+ ul do
62
+ grouped.each { |subgroup, items| nav_subgroup(subgroup, items) }
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # A collapsible sub-group: its title is a <summary> so the whole section folds
69
+ # away. daisyUI's Menu renders nested li>details as an accordion natively.
70
+ #
71
+ # NOTE: a collapsible summary must NOT carry `.menu-title` — daisyUI reserves
72
+ # that for a STATIC (non-<details>) heading and its layout rule for the
73
+ # rotating caret is `summary:not(.menu-title)`. A `.menu-title` summary loses
74
+ # the grid layout, so its chevron drops below-left of the label instead of
75
+ # right-aligning like the top-level group's. Style it as a plain summary
76
+ # (matching daisyUI's own collapsible-submenu example) so both carets align.
77
+ def nav_subgroup(subgroup, items)
78
+ li do
79
+ details(open: true) do
80
+ summary(class: "text-xs font-medium text-base-content/60 #{MARKER_RESET}") { subgroup }
81
+ ul do
82
+ items.each { |item| li { nav_link(item) } }
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def nav_link(item)
89
+ a(href: item.href, class: link_classes(item.href)) do
90
+ render DocsUI::Icon.new(item.icon, class: "size-4 shrink-0") if item.icon
91
+ span(class: "truncate") { item.label }
92
+ end
93
+ end
94
+
95
+ def link_classes(href)
96
+ active = current_path == href
97
+ ["flex items-center gap-3", (active ? "menu-active font-medium" : nil)].compact
98
+ end
99
+
100
+ def current_path
101
+ request&.path
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+ end