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,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
|