docs-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +939 -0
- data/app/components/docs_ui/brand_mark.rb +88 -0
- data/app/components/docs_ui/callout.rb +37 -0
- data/app/components/docs_ui/code.rb +123 -0
- data/app/components/docs_ui/endpoint.rb +44 -0
- data/app/components/docs_ui/error_table.rb +72 -0
- data/app/components/docs_ui/example.rb +102 -0
- data/app/components/docs_ui/field_table.rb +46 -0
- data/app/components/docs_ui/header.rb +30 -0
- data/app/components/docs_ui/icon.rb +65 -0
- data/app/components/docs_ui/json_response.rb +46 -0
- data/app/components/docs_ui/markdown.rb +187 -0
- data/app/components/docs_ui/markdown_action.rb +45 -0
- data/app/components/docs_ui/on_this_page.rb +104 -0
- data/app/components/docs_ui/open_api_operation.rb +126 -0
- data/app/components/docs_ui/page.rb +83 -0
- data/app/components/docs_ui/page_helpers.rb +52 -0
- data/app/components/docs_ui/prop_table.rb +43 -0
- data/app/components/docs_ui/prose.rb +30 -0
- data/app/components/docs_ui/request_example.rb +85 -0
- data/app/components/docs_ui/search_box.rb +106 -0
- data/app/components/docs_ui/search_results.rb +95 -0
- data/app/components/docs_ui/section.rb +94 -0
- data/app/components/docs_ui/shell.rb +161 -0
- data/app/components/docs_ui/sidebar.rb +106 -0
- data/app/components/docs_ui/table.rb +64 -0
- data/app/components/docs_ui/theme_switcher.rb +46 -0
- data/app/components/docs_ui/topbar_links.rb +42 -0
- data/app/controllers/docs_kit/llms_controller.rb +76 -0
- data/app/controllers/docs_kit/mcp_controller.rb +60 -0
- data/app/controllers/docs_kit/search_controller.rb +72 -0
- data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
- data/config/importmap.rb +15 -0
- data/config/rubocop/docs_kit.yml +24 -0
- data/exe/docs-kit +80 -0
- data/lib/docs-kit.rb +5 -0
- data/lib/docs_kit/api_client.rb +52 -0
- data/lib/docs_kit/api_request.rb +66 -0
- data/lib/docs_kit/api_templates.rb +92 -0
- data/lib/docs_kit/configuration.rb +485 -0
- data/lib/docs_kit/controller.rb +47 -0
- data/lib/docs_kit/engine.rb +49 -0
- data/lib/docs_kit/llms_text.rb +105 -0
- data/lib/docs_kit/markdown_export/blocks.rb +160 -0
- data/lib/docs_kit/markdown_export/inline.rb +95 -0
- data/lib/docs_kit/markdown_export/table.rb +53 -0
- data/lib/docs_kit/markdown_export.rb +92 -0
- data/lib/docs_kit/mcp_server.rb +128 -0
- data/lib/docs_kit/mcp_tools.rb +118 -0
- data/lib/docs_kit/nav_item.rb +22 -0
- data/lib/docs_kit/open_api/document.rb +91 -0
- data/lib/docs_kit/open_api/operation.rb +213 -0
- data/lib/docs_kit/open_api/schema.rb +178 -0
- data/lib/docs_kit/open_api.rb +55 -0
- data/lib/docs_kit/registry.rb +152 -0
- data/lib/docs_kit/rubocop.rb +19 -0
- data/lib/docs_kit/search_hit.rb +28 -0
- data/lib/docs_kit/search_index/snippet.rb +65 -0
- data/lib/docs_kit/search_index.rb +169 -0
- data/lib/docs_kit/shortcut.rb +99 -0
- data/lib/docs_kit/templates/new_site.rb +175 -0
- data/lib/docs_kit/topbar_link.rb +39 -0
- data/lib/docs_kit/version.rb +5 -0
- data/lib/docs_kit.rb +72 -0
- data/lib/generators/docs_kit/install/USAGE +15 -0
- data/lib/generators/docs_kit/install/install_generator.rb +447 -0
- data/lib/generators/docs_kit/install/sync_report.rb +64 -0
- data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
- data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
- data/lib/generators/docs_kit/install/templates/build-css +34 -0
- data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
- data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
- data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
- data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
- data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
- data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
- data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
- data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
- data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
- data/lib/generators/docs_kit/page/USAGE +26 -0
- data/lib/generators/docs_kit/page/page_generator.rb +127 -0
- data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
- data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
- data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
- metadata +253 -0
|
@@ -0,0 +1,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
|