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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "commonmarker"
4
+
5
+ module DocsUI
6
+ # A Markdown "island" for prose authoring inside a Phlex page. Parses GFM with
7
+ # commonmarker (v2, comrak) and walks the AST emitting Phlex nodes — it never
8
+ # `raw`s commonmarker's HTML. That buys three things:
9
+ #
10
+ # * Phlex-native escaping — author text is escaped by Phlex, so no html_safe
11
+ # on free text (Critical Rule 7) and #{} in prose renders literally.
12
+ # * Fenced code delegated to DocsUI::Code (Rouge, configured aliases,
13
+ # plaintext fallback) — highlighted identically to a hand-written Code block.
14
+ # * The exact DocsUI::Prose typography classes on the wrapper, so Markdown
15
+ # prose is visually identical to hand-authored Prose.
16
+ #
17
+ # render DocsUI::Markdown.new(<<~MD)
18
+ # Write **prose** as GFM. Fenced blocks are highlighted:
19
+ #
20
+ # ```ruby
21
+ # puts "hi"
22
+ # ```
23
+ # MD
24
+ #
25
+ # Raw HTML in the source is dropped (the AST's html_block/html_inline nodes are
26
+ # skipped) — there is no config to enable it. Headings render as styled h3/h4;
27
+ # document structure and the TOC stay with DocsUI::Section.
28
+ class Markdown < Phlex::HTML
29
+ # Reuse Prose's child-selector typography vocabulary verbatim so Markdown and
30
+ # hand-authored Prose read identically.
31
+ CLASSES = Prose::CLASSES
32
+
33
+ # The kit table wrapper + daisyUI table classes (matches DocsUI's table look).
34
+ TABLE_WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300"
35
+ TABLE_CLASSES = "table table-sm table-zebra"
36
+
37
+ # Render source as INLINE markdown: no Prose wrapper div, and a single
38
+ # top-level paragraph is unwrapped so its inline children (strong/em/code/
39
+ # link) sit directly in the surrounding element. Used for a [:md, "…"] table
40
+ # cell — the cell's <td> is the container, so a block <p>/typography div would
41
+ # be wrong there.
42
+ def self.inline(source) = new(source, inline: true)
43
+
44
+ def initialize(source, inline: false)
45
+ # commonmarker v2 raises unless the text is UTF-8. Author heredocs already
46
+ # are, but nil.to_s / a US-ASCII string would crash the render — normalize
47
+ # at the boundary so any input parses.
48
+ @source = source.to_s.encode(Encoding::UTF_8)
49
+ @inline = inline
50
+ end
51
+
52
+ def view_template
53
+ return visit_inline(document) if @inline
54
+
55
+ div(class: CLASSES) { visit(document) }
56
+ end
57
+
58
+ private
59
+
60
+ def document
61
+ Commonmarker.parse(@source)
62
+ end
63
+
64
+ # Inline render: unwrap each top-level paragraph to its inline children (no
65
+ # <p>), so `[:md, "a **note**"]` becomes `a <strong>note</strong>` inside the
66
+ # cell rather than a block paragraph. Non-paragraph blocks (a stray list) still
67
+ # render as themselves. Adjacent blocks get a joining space so unwrapping never
68
+ # fuses text ("one" + "two" → "one two", not "onetwo").
69
+ def visit_inline(node)
70
+ node.each_with_index do |child, i|
71
+ whitespace unless i.zero?
72
+ child.type == :paragraph ? visit_children(child) : visit(child)
73
+ end
74
+ end
75
+
76
+ # Emit each child of a node in order.
77
+ def visit_children(node)
78
+ node.each { |child| visit(child) }
79
+ end
80
+
81
+ # Dispatch a node to its handler (node_<type>). A node type with no handler
82
+ # just recurses into its children; html_block/html_inline have handlers that
83
+ # drop them (see below).
84
+ def visit(node)
85
+ handler = "node_#{node.type}"
86
+ respond_to?(handler, true) ? send(handler, node) : visit_children(node)
87
+ end
88
+
89
+ def node_document(node) = visit_children(node)
90
+
91
+ def node_paragraph(node)
92
+ p { visit_children(node) }
93
+ end
94
+
95
+ # Demote so Markdown headings never collide with the page masthead/section
96
+ # headings: h1→h3, h2→h4, anything deeper caps at h4.
97
+ def node_heading(node)
98
+ case node.header_level
99
+ when 1 then h3 { visit_children(node) }
100
+ else h4 { visit_children(node) }
101
+ end
102
+ end
103
+
104
+ def node_text(node) = plain(node.string_content)
105
+
106
+ def node_strong(node)
107
+ strong { visit_children(node) }
108
+ end
109
+
110
+ def node_emph(node)
111
+ em { visit_children(node) }
112
+ end
113
+
114
+ def node_strikethrough(node)
115
+ del { visit_children(node) }
116
+ end
117
+
118
+ def node_code(node)
119
+ code { node.string_content }
120
+ end
121
+
122
+ # Fenced code goes through DocsUI::Code so it is highlighted (Rouge) exactly
123
+ # like a hand-written block. No fence language falls back to plaintext.
124
+ def node_code_block(node)
125
+ lexer = node.fence_info.to_s.strip
126
+ lexer = "plaintext" if lexer.empty?
127
+ render DocsUI::Code.new(node.string_content, lexer:)
128
+ end
129
+
130
+ def node_link(node)
131
+ a(href: node.url) { visit_children(node) }
132
+ end
133
+
134
+ def node_list(node)
135
+ node.list_type == :ordered ? ol { visit_children(node) } : ul { visit_children(node) }
136
+ end
137
+
138
+ # In a tight list, GFM renders items WITHOUT a <p> wrapper (and Prose styles
139
+ # `li` directly). Unwrap the item's paragraph children when the parent list is
140
+ # tight; a loose list keeps the paragraphs (spacing between items).
141
+ def node_item(node)
142
+ tight = node.parent&.list_tight
143
+ li do
144
+ node.each do |child|
145
+ tight && child.type == :paragraph ? visit_children(child) : visit(child)
146
+ end
147
+ end
148
+ end
149
+
150
+ def node_block_quote(node)
151
+ blockquote { visit_children(node) }
152
+ end
153
+
154
+ def node_thematic_break(_node) = hr
155
+
156
+ def node_softbreak(_node) = whitespace
157
+
158
+ def node_linebreak(_node) = br
159
+
160
+ # A GFM table: the first row is the header (th), the rest are body cells (td).
161
+ def node_table(node)
162
+ rows = node.to_a
163
+ div(class: TABLE_WRAPPER) do
164
+ table(class: TABLE_CLASSES) do
165
+ thead { table_row(rows.first, header: true) } if rows.any?
166
+ tbody { rows.drop(1).each { |row| table_row(row) } } if rows.length > 1
167
+ end
168
+ end
169
+ end
170
+
171
+ def table_row(row, header: false)
172
+ tr do
173
+ row.each do |cell|
174
+ if header
175
+ th { visit_children(cell) }
176
+ else
177
+ td { visit_children(cell) }
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # Raw HTML is dropped — no live tags from author Markdown.
184
+ def node_html_block(_node) = nil
185
+ def node_html_inline(_node) = nil
186
+ end
187
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The "Markdown" masthead affordance: a link to the current page's `.md` twin.
5
+ # With JS off it simply opens the raw Markdown (a working, no-JS fallback); the
6
+ # docs-nav controller enhances the click into copy-to-clipboard (a new target +
7
+ # action on the ONE controller — the single-controller rule holds).
8
+ #
9
+ # render DocsUI::MarkdownAction.new(request.path)
10
+ #
11
+ # Rendered by DocsUI::Page when DocsKit.configuration.page_markdown_action is
12
+ # true (the default). The `.md` twin itself is produced by
13
+ # DocsKit::Controller#render_page → DocsKit::MarkdownExport.
14
+ class MarkdownAction < Phlex::HTML
15
+ LABEL = "Markdown"
16
+ CLASSES = "btn btn-ghost btn-xs gap-1 opacity-70 hover:opacity-100"
17
+
18
+ def initialize(path)
19
+ @path = path.to_s
20
+ end
21
+
22
+ def view_template
23
+ a(
24
+ href: md_href,
25
+ class: CLASSES,
26
+ # JS-ON: docs-nav intercepts the click, fetches the .md, copies it, and
27
+ # (because the browser default is prevented) never navigates away.
28
+ data: { docs_nav_target: "markdownLink", action: "docs-nav#copyMarkdown" }
29
+ ) do
30
+ render DocsUI::Icon.new("clipboard", class: "size-3.5")
31
+ plain LABEL
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # The `.md` twin URL: the request path with a `.md` extension, preserving any
38
+ # query string. Idempotent — a path already ending in `.md` is left as-is.
39
+ def md_href
40
+ path, query = @path.split("?", 2)
41
+ path = "#{path}.md" unless path.end_with?(".md")
42
+ query ? "#{path}?#{query}" : path
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # An "On this page" table of contents that auto-collects the current page's
5
+ # <h2>/<h3> and highlights the section you're reading (scroll-spy). It ships
6
+ # EMPTY — the docs-nav Stimulus controller fills it from the DOM, so a page gets
7
+ # a live, self-maintaining TOC with zero server-side knowledge of its headings.
8
+ #
9
+ # Three placements, same data, chosen by `mode:` (see
10
+ # DocsKit::Configuration::ON_PAGE_MODES):
11
+ #
12
+ # :panel — a fixed card pinned to the top-right of the viewport (below the
13
+ # topbar). On wide screens it's always visible; on narrower screens
14
+ # it collapses to a floating toggle button (docs-nav#toggleToc).
15
+ # :toggle — always the floating toggle button + dropdown (top-right).
16
+ # :sidebar — a slot the controller fills under the active left-nav item.
17
+ #
18
+ # The controller hides the whole thing when the page has too few headings, so
19
+ # short pages show nothing (data-docs-nav-target="tocRoot" + auto-hide).
20
+ #
21
+ # render DocsUI::OnThisPage.new(mode: :panel)
22
+ class OnThisPage < Phlex::HTML
23
+ def initialize(mode: :panel, title: "On this page")
24
+ @mode = mode
25
+ @title = title
26
+ end
27
+
28
+ def view_template
29
+ case @mode
30
+ when :toggle then toggle_only
31
+ when :sidebar then nil # the controller injects under the active nav item; no content slot
32
+ else panel
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # The empty list the controller fills. `tocRoot` is what auto-hides on short
39
+ # pages; the docs-nav controller populates the inner list.
40
+ def toc_list(**attrs)
41
+ nav(**mix({ aria_label: @title, data: { docs_nav_target: "toc" } }, attrs)) do
42
+ heading
43
+ end
44
+ end
45
+
46
+ def heading
47
+ div(class: "mb-2 text-xs font-semibold uppercase tracking-wider text-base-content/60") { @title }
48
+ end
49
+
50
+ # :panel — a card fixed to the top-right of the viewport, just below the
51
+ # sticky topbar. Solid bg-base-300 with a border + shadow so it stands out and
52
+ # stays out of the way (it never overlaps the prose). On < xl it's hidden and
53
+ # the compact toggle button takes over instead.
54
+ #
55
+ # data-docs-nav-target="tocRoot" wraps BOTH the card and the toggle so the
56
+ # controller hides the whole feature at once on short pages.
57
+ def panel
58
+ div(class: "not-prose", data: { docs_nav_target: "tocRoot" }) do
59
+ # Wide screens: the always-visible card.
60
+ toc_list(
61
+ class: "hidden xl:block fixed right-4 top-20 z-20 max-h-[70vh] w-64 overflow-y-auto " \
62
+ "rounded-box border border-base-300 bg-base-300 p-4 text-sm shadow-xl"
63
+ )
64
+ # Narrower screens: a floating toggle button that reveals the same card.
65
+ floating_toggle(hide_on_xl: true)
66
+ end
67
+ end
68
+
69
+ # :toggle — always the floating toggle button (no always-visible card).
70
+ def toggle_only
71
+ div(class: "not-prose", data: { docs_nav_target: "tocRoot" }) do
72
+ floating_toggle(hide_on_xl: false)
73
+ end
74
+ end
75
+
76
+ # A floating button pinned top-right that toggles a popover TOC card.
77
+ # hide_on_xl: true for :panel (hide the button at xl+, where the always-open
78
+ # card shows instead); false for :toggle (always show the button). The
79
+ # `xl:hidden` class is written as a LITERAL (never interpolated) so Tailwind's
80
+ # scanner keeps it — a computed "#{bp}:hidden" would be tree-shaken away.
81
+ def floating_toggle(hide_on_xl:)
82
+ wrapper = "fixed right-4 top-20 z-20"
83
+ wrapper += " xl:hidden" if hide_on_xl
84
+
85
+ div(class: wrapper, data: { docs_nav_target: "tocToggleWrap" }) do
86
+ button(
87
+ type: "button",
88
+ class: "btn btn-sm gap-1 border border-base-300 bg-base-300 shadow-lg",
89
+ aria_label: @title,
90
+ data: { action: "docs-nav#toggleToc", docs_nav_target: "tocToggleBtn" }
91
+ ) do
92
+ render DocsUI::Icon.new("list", class: "size-4")
93
+ span(class: "hidden sm:inline") { @title }
94
+ end
95
+ # The popover card, hidden until toggled.
96
+ toc_list(
97
+ class: "hidden mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-box " \
98
+ "border border-base-300 bg-base-300 p-4 text-sm shadow-xl",
99
+ data: { docs_nav_target: "tocPopover" }
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Renders one OpenAPI operation as a full endpoint reference, composed entirely
5
+ # from the existing kit — zero hand-restatement. Given a
6
+ # DocsKit::OpenApi::Operation it emits:
7
+ #
8
+ # * a DocsUI::Section titled with the operation summary (id: the operationId,
9
+ # so deep links + the auto-TOC resolve), described by a DocsUI::Endpoint badge;
10
+ # * the operation description as Markdown prose (when present);
11
+ # * a parameters DocsUI::FieldTable and a request-body DocsUI::FieldTable (each
12
+ # labelled only when BOTH are present, so a single table reads clean);
13
+ # * a DocsUI::ErrorTable from the 4xx/5xx responses (when any);
14
+ # * the request block — the operation's x-codeSamples (a DocsUI::Example when
15
+ # ≥2, a plain DocsUI::Code when exactly one), else a generated
16
+ # DocsUI::RequestExample (respecting a passed clients: filter);
17
+ # * a DocsUI::JsonResponse from the first 2xx example (when derivable);
18
+ # * finally the caller's block, so a page can append hand-authored prose.
19
+ #
20
+ # render DocsUI::OpenApiOperation.new(doc.operation("createInvoice"))
21
+ #
22
+ # The `operation` page helper is the friction-free front door; use this directly
23
+ # for bespoke composition. It reads config only through the components it composes
24
+ # (RequestExample pulls api_base_url/auth), never hardcodes a site value.
25
+ class OpenApiOperation < Phlex::HTML
26
+ def initialize(operation, clients: nil)
27
+ @operation = operation
28
+ @clients = clients
29
+ end
30
+
31
+ def view_template(&block)
32
+ render DocsUI::Section.new(@operation.title, id: @operation.operation_id, description: endpoint_badge) do
33
+ operation_description
34
+ field_tables
35
+ error_table
36
+ request_block
37
+ success_response
38
+ yield_content(&block)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def endpoint_badge
45
+ DocsUI::Endpoint.new(@operation.http_method, @operation.path)
46
+ end
47
+
48
+ def operation_description
49
+ description = @operation.description
50
+ render DocsUI::Markdown.new(description) if present?(description)
51
+ end
52
+
53
+ # Both FieldTables, each labelled only when the OTHER is also present (so a
54
+ # lone table reads clean without a redundant "Parameters"/"Body" heading).
55
+ def field_tables
56
+ params = @operation.parameter_rows
57
+ body = @operation.body_rows
58
+ both = !params.empty? && !body.empty?
59
+
60
+ field_table("Parameters", params, labelled: both)
61
+ field_table("Request body", body, labelled: both)
62
+ end
63
+
64
+ def field_table(label, rows, labelled:)
65
+ return if rows.empty?
66
+
67
+ # All utility classes here are already emitted elsewhere in the kit
68
+ # (prose.rb / header.rb / shell.rb), so no new Tailwind @source scan is
69
+ # needed — see Critical Rule 6.
70
+ h3(class: "mb-2 mt-8 text-lg font-semibold") { label } if labelled
71
+ render DocsUI::FieldTable.new(rows)
72
+ end
73
+
74
+ def error_table
75
+ rows = @operation.error_rows
76
+ render DocsUI::ErrorTable.new(rows) unless rows.empty?
77
+ end
78
+
79
+ # The request block: x-codeSamples win over the generated RequestExample. Two+
80
+ # samples → a DocsUI::Example (tabbed); exactly one → a plain DocsUI::Code
81
+ # (Example needs two tabs to render any); none → the generated RequestExample.
82
+ def request_block
83
+ samples = @operation.code_samples
84
+ case samples.length
85
+ when 0 then generated_request
86
+ when 1 then single_code_sample(samples.first)
87
+ else code_sample_tabs(samples)
88
+ end
89
+ end
90
+
91
+ def generated_request
92
+ render DocsUI::RequestExample.new(
93
+ method: @operation.http_method,
94
+ path: @operation.example_path,
95
+ body: @operation.example_body,
96
+ query: @operation.example_query,
97
+ clients: @clients
98
+ )
99
+ end
100
+
101
+ def single_code_sample(sample)
102
+ render DocsUI::Code.new(sample[:source], lexer: sample[:lang])
103
+ end
104
+
105
+ def code_sample_tabs(samples)
106
+ render DocsUI::Example.new do |ex|
107
+ samples.each do |sample|
108
+ ex.code(sample[:lang], label: sample[:label]) { sample[:source] }
109
+ end
110
+ end
111
+ end
112
+
113
+ def success_response
114
+ example = @operation.success_example
115
+ render DocsUI::JsonResponse.new(example) unless example.nil?
116
+ end
117
+
118
+ def yield_content(&block)
119
+ yield self if block
120
+ end
121
+
122
+ def present?(value)
123
+ !value.nil? && !value.to_s.strip.empty?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The base class for a hand-authored doc page. Subclasses set the title (and
5
+ # optional eyebrow/lead) and implement #content with the page body, composing
6
+ # the doc kit (Section/Prose/Code/Callout). Page renders it inside DocsUI::Shell
7
+ # with a consistent masthead.
8
+ #
9
+ # class Views::Docs::Pages::Installation < DocsUI::Page
10
+ # title "Installation"
11
+ # eyebrow "Guide"
12
+ # def lead = "Add the gem and render your first component."
13
+ # def content
14
+ # render DocsUI::Section.new("Add the gem") { … }
15
+ # end
16
+ # end
17
+ class Page < Phlex::HTML
18
+ include Phlex::Rails::Helpers::Routes
19
+ # #request drives the "Markdown" masthead action's href (request.path + .md).
20
+ include Phlex::Rails::Helpers::Request
21
+ # Authored pages subclass this, so include the kit here: a page body can call
22
+ # DocsUI::Section(...) / DocsUI::Code(...) directly, no render ... .new.
23
+ include DocsUI
24
+ # The lowercase, block-friendly authoring helpers (md/prose/example) — the
25
+ # friction-free path that never trips the parens-with-blocks gotcha.
26
+ include DocsUI::PageHelpers
27
+
28
+ class << self
29
+ def title(value = nil)
30
+ @title = value if value
31
+ @title
32
+ end
33
+
34
+ def eyebrow(value = nil)
35
+ @eyebrow = value if value
36
+ @eyebrow
37
+ end
38
+
39
+ # The "On this page" auto-TOC placement for this page. Defaults to the
40
+ # configured DocsKit.configuration.on_page_default; set false to opt out,
41
+ # or :panel/:toggle/:sidebar to override per page.
42
+ def on_page(value = :__unset__)
43
+ @on_page = value unless value == :__unset__
44
+ defined?(@on_page) ? @on_page : DocsKit.configuration.on_page_default
45
+ end
46
+ end
47
+
48
+ def view_template
49
+ render DocsUI::Shell.new(title: self.class.title, on_page: self.class.on_page) do
50
+ # data-md-skip drops this nav from the Markdown export — it's chrome, not
51
+ # page content (DocsKit::MarkdownExport strips [data-md-skip]). The
52
+ # "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
53
+ # inside the skipped nav and never appears in the .md twin.
54
+ nav(class: "mb-6 flex items-center justify-between gap-4", data: { md_skip: true }) do
55
+ a(href: root_path, class: "link link-hover text-sm opacity-70") { "← Home" }
56
+ render DocsUI::MarkdownAction.new(request.path) if markdown_action?
57
+ end
58
+
59
+ render DocsUI::Header.new(self.class.title, eyebrow: self.class.eyebrow) do
60
+ plain lead if lead
61
+ end
62
+
63
+ content
64
+ end
65
+ end
66
+
67
+ # Whether to show the "Markdown" masthead action — the config knob
68
+ # (DocsKit.configuration.page_markdown_action, default true).
69
+ def markdown_action? = DocsKit.configuration.page_markdown_action
70
+
71
+ # The lowercase authoring helpers md/prose/example come from DocsUI::PageHelpers
72
+ # (included above) — the parens-free path that never hits the constant-reference
73
+ # SyntaxError. The kit forms (DocsUI::Prose(), DocsUI::Example()) stay valid too.
74
+
75
+ # Override in subclasses for the lead paragraph (optional).
76
+ def lead = nil
77
+
78
+ # Override in subclasses with the page body.
79
+ def content
80
+ raise NotImplementedError, "#{self.class} must implement #content"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Lowercase, block-friendly authoring helpers mixed into DocsUI::Page. They
5
+ # exist so the everyday page body never trips the Ruby parens-with-blocks trap:
6
+ # a lowercase method call takes a block WITHOUT parens, so `prose do … end` is
7
+ # unambiguously a method call (the bare `DocsUI::Prose do … end` kit form parses
8
+ # as a constant reference — a SyntaxError). The kit forms stay valid; these are
9
+ # the friction-free path.
10
+ #
11
+ # Extracted from Page so they can be unit-tested against a bare Phlex host:
12
+ # Page itself includes Phlex::Rails::Helpers::Routes (a live Rails view context)
13
+ # and cannot load in the standalone suite.
14
+ module PageHelpers
15
+ # Render a block of GFM Markdown as Prose-styled prose (see DocsUI::Markdown).
16
+ # A lowercase method + heredoc sidesteps the parens-with-blocks gotcha:
17
+ # md <<~'MD'
18
+ # Write **prose** as Markdown. Single-quoted heredoc so #{} stays literal.
19
+ # MD
20
+ def md(source)
21
+ render DocsUI::Markdown.new(source)
22
+ end
23
+
24
+ # Render hand-authored prose in a DocsUI::Prose wrapper. Lowercase, so it
25
+ # takes the block without parens: `prose do p { "…" } end`.
26
+ def prose(&)
27
+ render DocsUI::Prose.new(&)
28
+ end
29
+
30
+ # Render a multi-language code group (DocsUI::Example). Lowercase, so it takes
31
+ # the block without parens: `example do |ex| ex.code(:ruby) { … } end`.
32
+ def example(&)
33
+ render DocsUI::Example.new(&)
34
+ end
35
+
36
+ # Render one OpenAPI operation from the configured spec as a full endpoint
37
+ # reference (see DocsUI::OpenApiOperation) — zero hand-restatement. The
38
+ # operation is looked up on DocsKit.configuration.openapi_document, so
39
+ # `c.openapi` must be set; an unknown id raises OperationNotFound.
40
+ #
41
+ # operation "createInvoice" # the whole endpoint block
42
+ # operation "createInvoice", clients: %i[curl ruby] # filter the client tabs
43
+ # operation "createInvoice" do |op| op.plain "…" end # append prose inside it
44
+ #
45
+ # `id_or_method`/`path` mirror Document#operation: pass an operationId, or a
46
+ # verb + path for a spec whose operations have no ids.
47
+ def operation(id_or_method, path = nil, clients: nil, &)
48
+ op = DocsKit.configuration.openapi_document.operation(id_or_method, path)
49
+ render DocsUI::OpenApiOperation.new(op, clients: clients), &
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A props/options/params reference table: name · type · default · description,
5
+ # with the first column (the name) auto code-styled. A thin preset over
6
+ # DocsUI::Table — same cell conventions, same markup, no duplication.
7
+ #
8
+ # render DocsUI::PropTable.new(
9
+ # [
10
+ # ["brand", "String", '"Docs"', "Topbar + sidebar heading."],
11
+ # ["themes", "Array", "%w[dark light]", "ThemeSwitcher options."],
12
+ # ]
13
+ # )
14
+ #
15
+ # The default headers are Option/Type/Default/Description; pass `headers:` to
16
+ # override (e.g. `%w[Arg Type Default Description]` for a component's args). Cell
17
+ # values follow DocsUI::Table's convention (String / [:code, "x"] / [:md, "…"]);
18
+ # the first cell of each row is wrapped in <code> automatically unless it's
19
+ # already a special-cell pair.
20
+ class PropTable < Phlex::HTML
21
+ DEFAULT_HEADERS = %w[Option Type Default Description].freeze
22
+
23
+ def initialize(rows, headers: DEFAULT_HEADERS)
24
+ @rows = rows.map { |cells| code_first_column(cells) }
25
+ @headers = headers
26
+ end
27
+
28
+ def view_template
29
+ render DocsUI::Table.new(@headers, @rows)
30
+ end
31
+
32
+ private
33
+
34
+ # Auto-code-style the name column. A plain String first cell becomes a
35
+ # [:code, …] cell; a cell that's already a typed pair ([:code, …]/[:md, …]) is
36
+ # left as the author wrote it.
37
+ def code_first_column(cells)
38
+ first, *rest = cells
39
+ first = [:code, first] unless first.nil? || first.is_a?(Array)
40
+ [first, *rest]
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A typographic wrapper for hand-authored doc prose. Gives consistent reading
5
+ # rhythm via Tailwind's arbitrary-variant child selectors (so it does not depend
6
+ # on a specific typography plugin config). Authoring a doc is just writing
7
+ # p/ul/h3/code inside.
8
+ #
9
+ # render DocsUI::Prose.new do
10
+ # p { "Components are plain Ruby classes." }
11
+ # ul { li { "…" } }
12
+ # end
13
+ class Prose < Phlex::HTML
14
+ CLASSES = [
15
+ "max-w-none text-base-content/80 leading-relaxed",
16
+ "[&_p]:my-4",
17
+ "[&_a]:text-primary [&_a]:underline [&_a:hover]:no-underline",
18
+ "[&_strong]:text-base-content [&_strong]:font-semibold",
19
+ "[&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-base-content",
20
+ "[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul>li]:my-1",
21
+ "[&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol>li]:my-1",
22
+ "[&_code]:rounded [&_code]:bg-base-300 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-sm",
23
+ "[&_:where(pre)_code]:bg-transparent [&_:where(pre)_code]:p-0"
24
+ ].join(" ").freeze
25
+
26
+ def view_template(&)
27
+ div(class: CLASSES, &)
28
+ end
29
+ end
30
+ end