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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
class MarkdownExport
|
|
5
|
+
# The Nokogiri → GFM visitor. Splits into two passes over the bounded kit
|
|
6
|
+
# vocabulary: block-level elements (this class) produce Markdown lines
|
|
7
|
+
# separated by blank lines; inline elements (Inline) produce a single run of
|
|
8
|
+
# text. Unknown block elements recurse into their children (text survives, the
|
|
9
|
+
# wrapper is dropped), matching the issue's "recurse into children" rule.
|
|
10
|
+
class Blocks
|
|
11
|
+
HEADINGS = { "h1" => "#", "h2" => "##", "h3" => "###", "h4" => "####" }.freeze
|
|
12
|
+
LIST_TAGS = %w[ul ol].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(export)
|
|
15
|
+
@export = export
|
|
16
|
+
@inline = Inline.new(export)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Render a container's block children, joined by blank lines. Blocks that
|
|
20
|
+
# yield nothing (whitespace-only text nodes) are dropped so no stray blank
|
|
21
|
+
# lines accumulate.
|
|
22
|
+
def render(node)
|
|
23
|
+
node.children.filter_map { |child| block(child) }
|
|
24
|
+
.reject(&:empty?)
|
|
25
|
+
.join("\n\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Dispatch one node to its block rendering. A bare text node contributes its
|
|
31
|
+
# text only when non-blank (structural whitespace between block tags is
|
|
32
|
+
# dropped). An unknown element recurses.
|
|
33
|
+
def block(node)
|
|
34
|
+
return node.text.strip.empty? ? nil : @inline.render_node(node) if node.text?
|
|
35
|
+
return nil unless node.element?
|
|
36
|
+
|
|
37
|
+
name = node.name
|
|
38
|
+
return heading(node, HEADINGS[name]) if HEADINGS.key?(name)
|
|
39
|
+
|
|
40
|
+
dispatch(node, name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dispatch(node, name)
|
|
44
|
+
# A DocsUI::Callout (div[data-md-callout]) becomes a labelled blockquote —
|
|
45
|
+
# checked BEFORE the structural-div recurse so it isn't flattened away.
|
|
46
|
+
level = node["data-md-callout"]
|
|
47
|
+
return callout(node, level) if level
|
|
48
|
+
|
|
49
|
+
block_element(node, name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def block_element(node, name)
|
|
53
|
+
case name
|
|
54
|
+
when "p" then paragraph(node)
|
|
55
|
+
when "pre" then code_fence(node)
|
|
56
|
+
when "ul", "ol" then list(node, ordered: name == "ol")
|
|
57
|
+
when "table" then Table.new(@export).render(node)
|
|
58
|
+
when "blockquote" then blockquote(node)
|
|
59
|
+
else leaf(node, name) # hr/img, else recurse into an unknown wrapper
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The self-contained leaf blocks; anything else is a structural wrapper
|
|
64
|
+
# (section/header/div/…) whose children carry the content, so recurse.
|
|
65
|
+
def leaf(node, name)
|
|
66
|
+
case name
|
|
67
|
+
when "hr" then "---"
|
|
68
|
+
when "img" then @inline.image(node)
|
|
69
|
+
else render(node)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A heading's text. DocsUI::Section wraps its title in a self-referencing
|
|
74
|
+
# anchor (a deep-link affordance) with a decorative "#" span; both are
|
|
75
|
+
# chrome, so render the heading's inner TEXT rather than a `[Title](#id)`
|
|
76
|
+
# link. The Inline pass already drops the "#" span.
|
|
77
|
+
def heading(node, hashes)
|
|
78
|
+
"#{hashes} #{@inline.heading_text(node)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def paragraph(node)
|
|
82
|
+
@inline.render(node)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A DocsUI::Code block is pre inside div[data-md-lang]; the fence carries
|
|
86
|
+
# that resolved language (blank for plaintext → a language-less fence). The
|
|
87
|
+
# source is the <pre>'s text, un-escaped (Nokogiri already decoded entities).
|
|
88
|
+
def code_fence(node)
|
|
89
|
+
lang = fence_language(node)
|
|
90
|
+
source = node.text.delete_suffix("\n")
|
|
91
|
+
"```#{lang}\n#{source}\n```"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The language is on the DocsUI::Code wrapper (div[data-md-lang]) around the
|
|
95
|
+
# <pre>. "plaintext" (or absent) means no language token on the fence.
|
|
96
|
+
def fence_language(node)
|
|
97
|
+
lang = node.ancestors("[data-md-lang]").first&.[]("data-md-lang").to_s
|
|
98
|
+
lang == "plaintext" ? "" : lang
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A blockquote's block children, each line prefixed "> ".
|
|
102
|
+
def blockquote(node)
|
|
103
|
+
quote(render(node))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A callout → `> **Label:** body` as a blockquote. Callout stamps a
|
|
107
|
+
# `div.font-semibold` title (present only when title: is given) and a
|
|
108
|
+
# `div.text-sm` body. Read them separately: the author's title is the label
|
|
109
|
+
# when present (else the level label), and only the body div is rendered so
|
|
110
|
+
# the title never fuses into the body text.
|
|
111
|
+
def callout(node, level)
|
|
112
|
+
title = node.at_css(".font-semibold")
|
|
113
|
+
body_node = node.at_css(".text-sm") || node
|
|
114
|
+
label = title ? @inline.render(title).strip : CALLOUT_LABELS.fetch(level, "Note")
|
|
115
|
+
body = @inline.render(body_node).strip
|
|
116
|
+
quote("**#{label}:** #{body}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def quote(text)
|
|
120
|
+
text.split("\n").map { |line| line.empty? ? ">" : "> #{line}" }.join("\n")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A list, rendered at the given indent depth. Each item is a marker (- or
|
|
124
|
+
# 1./2./…) plus its inline text; nested lists indent two spaces per level.
|
|
125
|
+
def list(node, ordered:, depth: 0)
|
|
126
|
+
index = 0
|
|
127
|
+
items(node).map do |li|
|
|
128
|
+
index += 1
|
|
129
|
+
marker = ordered ? "#{index}. " : "- "
|
|
130
|
+
"#{' ' * depth}#{marker}#{item(li, depth)}"
|
|
131
|
+
end.join("\n")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def items(node)
|
|
135
|
+
node.children.select { |child| child.element? && child.name == "li" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An <li>'s content: its inline text, plus any nested list indented one level
|
|
139
|
+
# deeper on following lines.
|
|
140
|
+
def item(node, depth)
|
|
141
|
+
text = @inline.render(inline_children(node)).strip
|
|
142
|
+
nested = node.children.filter_map do |child|
|
|
143
|
+
next unless list?(child)
|
|
144
|
+
|
|
145
|
+
"\n#{list(child, ordered: child.name == 'ol', depth: depth + 1)}"
|
|
146
|
+
end
|
|
147
|
+
"#{text}#{nested.join}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The li's children minus nested lists (those render on their own lines).
|
|
151
|
+
def inline_children(node)
|
|
152
|
+
node.children.reject { |child| list?(child) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def list?(node)
|
|
156
|
+
node.element? && LIST_TAGS.include?(node.name)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
class MarkdownExport
|
|
5
|
+
# The inline pass: a node's descendants flattened to a single run of Markdown
|
|
6
|
+
# text (bold/italic/code/links). Block elements never reach here; an unknown
|
|
7
|
+
# inline element recurses into its children so its text survives.
|
|
8
|
+
class Inline
|
|
9
|
+
# Emphasis tags whose Markdown just wraps the inner text in a delimiter.
|
|
10
|
+
# Keeps #render_node a flat dispatch instead of a branch per tag.
|
|
11
|
+
WRAP = {
|
|
12
|
+
"strong" => "**", "b" => "**",
|
|
13
|
+
"em" => "*", "i" => "*",
|
|
14
|
+
"del" => "~~", "s" => "~~"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(export)
|
|
18
|
+
@export = export
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Inline-render a node OR a Nokogiri node-set (an <li>'s inline children).
|
|
22
|
+
def render(node_or_set)
|
|
23
|
+
nodes = node_or_set.respond_to?(:children) ? node_or_set.children : node_or_set
|
|
24
|
+
nodes.map { |child| render_node(child) }.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Dispatch one node. Text is returned verbatim (Nokogiri decoded entities,
|
|
28
|
+
# so `<`/`&` are literal — GFM keeps them). Elements map to their inline
|
|
29
|
+
# Markdown; the DocsUI::Section hover-anchor "#" span is dropped.
|
|
30
|
+
def render_node(node)
|
|
31
|
+
return node.text if node.text?
|
|
32
|
+
return "" unless node.element?
|
|
33
|
+
|
|
34
|
+
name = node.name
|
|
35
|
+
wrap = WRAP[name]
|
|
36
|
+
return "#{wrap}#{render(node)}#{wrap}" if wrap
|
|
37
|
+
|
|
38
|
+
element(node, name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The non-emphasis inline elements. `code` keeps its text verbatim; `a`
|
|
42
|
+
# becomes a link; a decorative "#" span (Section's hover anchor) is dropped;
|
|
43
|
+
# any other wrapper recurses so its text survives.
|
|
44
|
+
def element(node, name)
|
|
45
|
+
case name
|
|
46
|
+
when "code" then code_span(node.text)
|
|
47
|
+
when "a" then link(node)
|
|
48
|
+
when "img" then image(node)
|
|
49
|
+
when "br" then " \n"
|
|
50
|
+
when "span" then anchor_decoration?(node) ? "" : render(node)
|
|
51
|
+
else render(node)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def image(node)
|
|
56
|
+
"![#{node['alt']}](#{@export.absolutize(node['src'])})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A heading's inline text, treating a self-referencing anchor (href="#id",
|
|
60
|
+
# DocsUI::Section's deep-link wrapper) as transparent — its children render
|
|
61
|
+
# directly, so a heading is `## Title`, never `## [Title](#id)`.
|
|
62
|
+
def heading_text(node)
|
|
63
|
+
node.children.map do |child|
|
|
64
|
+
self_anchor?(child) ? render(child) : render_node(child)
|
|
65
|
+
end.join.strip
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# A GFM-correct inline code span. The fence is a backtick run one longer
|
|
71
|
+
# than the longest run inside the text, so an interior backtick can never
|
|
72
|
+
# close the span; a space pads content that starts or ends with a backtick.
|
|
73
|
+
def code_span(text)
|
|
74
|
+
fence = "`" * ((text.scan(/`+/).map(&:length).max || 0) + 1)
|
|
75
|
+
pad = text.start_with?("`") || text.end_with?("`") ? " " : ""
|
|
76
|
+
"#{fence}#{pad}#{text}#{pad}#{fence}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self_anchor?(node)
|
|
80
|
+
node.element? && node.name == "a" && node["href"].to_s.start_with?("#")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def link(node)
|
|
84
|
+
"[#{render(node)}](#{@export.absolutize(node['href'])})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# DocsUI::Section renders a decorative "#" span (a hover deep-link glyph)
|
|
88
|
+
# inside its heading anchor. It's chrome, not content — drop it so a heading
|
|
89
|
+
# doesn't export as "Title #".
|
|
90
|
+
def anchor_decoration?(node)
|
|
91
|
+
node.text.strip == "#" && node.parent&.name == "a"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
class MarkdownExport
|
|
5
|
+
# A GFM pipe table. The first row (thead, or the first row if there's no
|
|
6
|
+
# thead) is the header; a separator row follows; the rest are body rows. Cell
|
|
7
|
+
# content is inline Markdown with pipes escaped so they don't break the table.
|
|
8
|
+
class Table
|
|
9
|
+
def initialize(export)
|
|
10
|
+
@inline = Inline.new(export)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(node)
|
|
14
|
+
rows = rows(node)
|
|
15
|
+
return "" if rows.empty?
|
|
16
|
+
|
|
17
|
+
width = rows.map(&:length).max
|
|
18
|
+
header, *body = pad(rows, width)
|
|
19
|
+
lines = [row(header), separator(width)]
|
|
20
|
+
lines.concat(body.map { |cells| row(cells) })
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Pad every row out to +width+ with empty cells so the header, separator,
|
|
27
|
+
# and all body rows declare the same column count (a rectangular GFM table).
|
|
28
|
+
def pad(rows, width)
|
|
29
|
+
rows.map { |cells| cells + Array.new(width - cells.length, "") }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# All rows as arrays of cell strings, header row first. A <thead> row leads;
|
|
33
|
+
# <tbody>/bare <tr> rows follow.
|
|
34
|
+
def rows(node)
|
|
35
|
+
node.css("tr").map do |tr|
|
|
36
|
+
tr.css("th, td").map { |cell| cell_text(cell) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cell_text(cell)
|
|
41
|
+
@inline.render(cell).strip.gsub("|", "\\|").tr("\n", " ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def row(cells)
|
|
45
|
+
"| #{cells.join(' | ')} |"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def separator(count)
|
|
49
|
+
"| #{Array.new(count, '---').join(' | ')} |"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
# Derives a faithful GFM Markdown twin of a docs page FROM the page's own
|
|
7
|
+
# rendered HTML. The gem emits the HTML, so the tag vocabulary is bounded and a
|
|
8
|
+
# small hand-rolled Nokogiri visitor converts it exactly — no dependence on
|
|
9
|
+
# authoring style (Phlex, `md` islands, and raw tags in Prose all convert
|
|
10
|
+
# identically because conversion happens post-render).
|
|
11
|
+
#
|
|
12
|
+
# DocsKit::MarkdownExport.new(page_view, view_context:, base_url: req.base_url).to_md
|
|
13
|
+
#
|
|
14
|
+
# The page is rendered, the #docs-content region (DocsUI::Shell's anchor) is
|
|
15
|
+
# extracted, [data-md-skip] chrome is stripped, and the remaining subtree is
|
|
16
|
+
# walked to Markdown. Data hints the kit stamps at render time drive the tricky
|
|
17
|
+
# cases: data-md-lang (DocsUI::Code) → a ```lang fence; data-md-callout
|
|
18
|
+
# (DocsUI::Callout) → a `> **Tip:**` blockquote.
|
|
19
|
+
class MarkdownExport
|
|
20
|
+
# The extraction anchor DocsUI::Shell stamps on its content column.
|
|
21
|
+
CONTENT_SELECTOR = "#docs-content"
|
|
22
|
+
|
|
23
|
+
# Elements dropped whole — chrome and non-content that must never reach the
|
|
24
|
+
# Markdown twin. [data-md-skip] is authored (Page's "← Home" nav); script and
|
|
25
|
+
# style carry no readable content.
|
|
26
|
+
DROP_SELECTOR = "[data-md-skip], script, style"
|
|
27
|
+
|
|
28
|
+
# Callout level → the bold label opening its blockquote.
|
|
29
|
+
CALLOUT_LABELS = { "note" => "Note", "tip" => "Tip", "warning" => "Warning" }.freeze
|
|
30
|
+
|
|
31
|
+
# The parameter kinds that count as a keyword arg (required or optional) when
|
|
32
|
+
# sniffing whether a view's #call accepts a view_context: kwarg.
|
|
33
|
+
KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
|
|
34
|
+
|
|
35
|
+
# view: a renderable page (a Phlex component in production). view_context: the
|
|
36
|
+
# Rails view context to render it through (CSRF, url helpers). base_url: the
|
|
37
|
+
# request's base URL, used to absolutize relative link/image hrefs so the
|
|
38
|
+
# exported Markdown is portable.
|
|
39
|
+
def initialize(view, view_context: nil, base_url: nil)
|
|
40
|
+
@view = view
|
|
41
|
+
@view_context = view_context
|
|
42
|
+
@base_url = base_url
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The page's content as GFM Markdown, or "" when there is no #docs-content
|
|
46
|
+
# region (a page that isn't the docs chrome — the HTML route is untouched).
|
|
47
|
+
def to_md
|
|
48
|
+
content = extract_content
|
|
49
|
+
return "" unless content
|
|
50
|
+
|
|
51
|
+
content.search(DROP_SELECTOR).each(&:remove)
|
|
52
|
+
Blocks.new(self).render(content).strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Absolutize a relative href/src against the base URL; leave absolute and
|
|
56
|
+
# anchor/mailto links untouched. No base URL → return as-is.
|
|
57
|
+
def absolutize(url)
|
|
58
|
+
return url if url.nil? || url.empty?
|
|
59
|
+
return url unless @base_url
|
|
60
|
+
return url if url.match?(%r{\A(?:[a-z][a-z0-9+.-]*:|//|#)}i)
|
|
61
|
+
|
|
62
|
+
"#{@base_url.chomp('/')}/#{url.delete_prefix('/')}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Render the page to an HTML document and grab the #docs-content subtree.
|
|
68
|
+
def extract_content
|
|
69
|
+
Nokogiri::HTML5.fragment(render_html).at_css(CONTENT_SELECTOR)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Render the page to an HTML string. Production renders a Phlex component
|
|
73
|
+
# through the Rails view context (so url helpers/CSRF work); in isolation the
|
|
74
|
+
# view is rendered via #call. The seam: a view whose #call accepts a
|
|
75
|
+
# view_context: kwarg gets it; a Phlex component with a view_context is
|
|
76
|
+
# rendered through Rails; otherwise a bare #call.
|
|
77
|
+
def render_html
|
|
78
|
+
if call_accepts_view_context?
|
|
79
|
+
@view.call(view_context: @view_context)
|
|
80
|
+
elsif @view_context && @view.respond_to?(:render_in)
|
|
81
|
+
@view_context.render(@view)
|
|
82
|
+
else
|
|
83
|
+
@view.call
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def call_accepts_view_context?
|
|
88
|
+
@view.respond_to?(:call) &&
|
|
89
|
+
@view.method(:call).parameters.any? { |type, name| name == :view_context && KEYWORD_PARAM_TYPES.include?(type) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
# Builds the read-only MCP::Server a docs-kit site exposes at POST /mcp — a
|
|
7
|
+
# stateless JSON-RPC skin over DocsKit::McpTools, so an agent (Claude Code,
|
|
8
|
+
# Claude.ai, Cursor) adds one URL and the docs become first-class tools:
|
|
9
|
+
#
|
|
10
|
+
# server = DocsKit::McpServer.build(DocsKit.configuration, base_url:, view_context:)
|
|
11
|
+
# server.handle_json(request.body.read) # → the JSON-RPC response string
|
|
12
|
+
#
|
|
13
|
+
# The `mcp` gem is OPTIONAL and runtime-detected (docs-kit depends on it in no
|
|
14
|
+
# gemspec list). .build returns nil when the gem is absent, and the controller
|
|
15
|
+
# only reaches here when DocsKit.configuration#mcp_enabled? — so a site without
|
|
16
|
+
# the gem, or with c.mcp = false, is byte-identical to before this feature.
|
|
17
|
+
#
|
|
18
|
+
# base_url + view_context ride in the server_context (the SDK threads it to
|
|
19
|
+
# every tool block), so the tools render each page's Markdown twin through the
|
|
20
|
+
# Rails view context and absolutize URLs — the same seam LlmsController#full
|
|
21
|
+
# uses. The three tools mirror DocsKit::McpTools one-to-one.
|
|
22
|
+
module McpServer
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The MCP::Server for this config, or nil when the `mcp` gem isn't loadable.
|
|
26
|
+
# base_url/view_context flow to the tools via server_context.
|
|
27
|
+
def build(config, base_url: nil, view_context: nil)
|
|
28
|
+
return unless mcp_available?
|
|
29
|
+
|
|
30
|
+
server = MCP::Server.new(
|
|
31
|
+
name: config.brand.to_s,
|
|
32
|
+
version: DocsKit::VERSION,
|
|
33
|
+
instructions: instructions_for(config, base_url),
|
|
34
|
+
server_context: { config:, base_url:, view_context: }
|
|
35
|
+
)
|
|
36
|
+
define_tools(server)
|
|
37
|
+
server
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether the official MCP SDK is loadable — the runtime-detection gate. Kept
|
|
41
|
+
# as a seam so specs can force the gem-absent branch without unloading it.
|
|
42
|
+
def mcp_available?
|
|
43
|
+
require "mcp"
|
|
44
|
+
defined?(::MCP::Server) ? true : false
|
|
45
|
+
rescue LoadError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Server instructions: the site's tagline (when set) plus a pointer to
|
|
50
|
+
# /llms.txt, so an agent knows what these docs cover and where the full index
|
|
51
|
+
# lives. base_url absolutizes the /llms.txt hint when available.
|
|
52
|
+
def instructions_for(config, base_url)
|
|
53
|
+
llms = base_url ? "#{base_url.chomp('/')}/llms.txt" : "/llms.txt"
|
|
54
|
+
lines = []
|
|
55
|
+
tagline = config.tagline
|
|
56
|
+
lines << tagline.to_s if tagline && !tagline.to_s.empty?
|
|
57
|
+
lines << "Read-only documentation tools for #{config.brand}. " \
|
|
58
|
+
"Use search_docs to find sections, get_page to read a page's Markdown, " \
|
|
59
|
+
"and list_pages to enumerate the docs. Full index: #{llms}."
|
|
60
|
+
lines.join(" ")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Register the three read-only tools. Each block pulls config + render context
|
|
64
|
+
# from server_context, calls the matching DocsKit::McpTools function, and
|
|
65
|
+
# returns the result as pretty JSON text (agents consume structured data).
|
|
66
|
+
def define_tools(server)
|
|
67
|
+
define_list_pages(server)
|
|
68
|
+
define_get_page(server)
|
|
69
|
+
define_search_docs(server)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def define_list_pages(server)
|
|
73
|
+
server.define_tool(
|
|
74
|
+
name: "list_pages",
|
|
75
|
+
description: "List every documentation page: its slug, title, group, and URL. " \
|
|
76
|
+
"Use the slug with get_page.",
|
|
77
|
+
input_schema: { type: "object", properties: {}, required: [] }
|
|
78
|
+
) do |server_context:|
|
|
79
|
+
cfg = server_context[:config]
|
|
80
|
+
McpServer.json_response(McpTools.list_pages(cfg, base_url: server_context[:base_url]))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def define_get_page(server)
|
|
85
|
+
server.define_tool(
|
|
86
|
+
name: "get_page",
|
|
87
|
+
description: "Fetch one documentation page as Markdown, by its slug (see list_pages). " \
|
|
88
|
+
"Returns the page's full Markdown twin.",
|
|
89
|
+
input_schema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: { slug: { type: "string", description: "The page slug, e.g. \"installation\"." } },
|
|
92
|
+
required: ["slug"]
|
|
93
|
+
}
|
|
94
|
+
) do |slug:, server_context:|
|
|
95
|
+
cfg = server_context[:config]
|
|
96
|
+
McpServer.json_response(
|
|
97
|
+
McpTools.get_page(cfg, slug:, base_url: server_context[:base_url],
|
|
98
|
+
view_context: server_context[:view_context])
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def define_search_docs(server)
|
|
104
|
+
server.define_tool(
|
|
105
|
+
name: "search_docs",
|
|
106
|
+
description: "Full-text search across all documentation pages. " \
|
|
107
|
+
"Returns ranked hits with the page, section, URL, and a snippet.",
|
|
108
|
+
input_schema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: { query: { type: "string", description: "The search terms." } },
|
|
111
|
+
required: ["query"]
|
|
112
|
+
}
|
|
113
|
+
) do |query:, server_context:|
|
|
114
|
+
cfg = server_context[:config]
|
|
115
|
+
McpServer.json_response(
|
|
116
|
+
McpTools.search_docs(cfg, query:, base_url: server_context[:base_url],
|
|
117
|
+
view_context: server_context[:view_context])
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Wrap a Ruby data payload as a single-text MCP tool response, the data as
|
|
123
|
+
# pretty JSON so an agent parses structured fields (not prose).
|
|
124
|
+
def json_response(payload)
|
|
125
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
# The pure, HTTP-free core the built-in MCP server exposes — three plain-Ruby
|
|
7
|
+
# functions over the SAME registry, Markdown twins, and search index the docs
|
|
8
|
+
# already render from, so an agent queries live docs, never a stale scrape:
|
|
9
|
+
#
|
|
10
|
+
# list_pages(config) → [{ slug, title, group, url }] authored pages only
|
|
11
|
+
# get_page(config, slug:) → { found:, title:, url:, markdown: } | { found: false, message: }
|
|
12
|
+
# search_docs(config, query:)→ [{ page_title, section_title, url, snippet }] ranked
|
|
13
|
+
#
|
|
14
|
+
# Zero `mcp`-gem dependency and zero JSON-RPC: DocsKit::McpServer wraps these
|
|
15
|
+
# into MCP tools, and the controller only threads the Rails view context (for
|
|
16
|
+
# url helpers/CSRF, the same seam DocsKit::LlmsController#full uses). So the
|
|
17
|
+
# whole consumption story is unit-testable without booting Rails or the SDK.
|
|
18
|
+
#
|
|
19
|
+
# "Authored" means a resolvable #view_class — DocsKit::LlmsText.pages already
|
|
20
|
+
# flattens every registry to just those, so an unwritten page is never listed,
|
|
21
|
+
# fetched, or indexed (no dead links, no 404s over the protocol).
|
|
22
|
+
module McpTools
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The authored pages across every registry, in config/registry order, as a
|
|
26
|
+
# flat list of { slug, title, group, url } — url absolutized against base_url
|
|
27
|
+
# when given (agents fetch a portable URL), else the root-relative href.
|
|
28
|
+
def list_pages(config, base_url: nil)
|
|
29
|
+
LlmsText.pages(config).map do |page|
|
|
30
|
+
{
|
|
31
|
+
slug: page.slug,
|
|
32
|
+
title: page.title,
|
|
33
|
+
group: page.group,
|
|
34
|
+
url: absolutize(page.href, base_url)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A single page's Markdown twin by slug. Renders the page's #view_class
|
|
40
|
+
# through view_context (nil off-Rails) exactly as LlmsController#full does. An
|
|
41
|
+
# unknown or unwritten slug returns { found: false } with a message listing
|
|
42
|
+
# the valid slugs, so an agent can correct itself instead of hitting an error.
|
|
43
|
+
def get_page(config, slug:, base_url: nil, view_context: nil)
|
|
44
|
+
page = find_page(config, slug)
|
|
45
|
+
return not_found(config, slug) unless page
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
found: true,
|
|
49
|
+
slug: page.slug,
|
|
50
|
+
title: page.title,
|
|
51
|
+
url: absolutize(page.href, base_url),
|
|
52
|
+
markdown: render_markdown(page, base_url:, view_context:)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The top DocsKit::SearchIndex hits for query, as { page_title, section_title,
|
|
57
|
+
# url, snippet } — url absolutized, snippet reduced to plain text (the index's
|
|
58
|
+
# HTML <mark> highlight stripped, since MCP delivers text, not HTML). Blank
|
|
59
|
+
# query → []. Builds the index from the same twins search + llms-full serve.
|
|
60
|
+
def search_docs(config, query:, base_url: nil, view_context: nil)
|
|
61
|
+
index_for(config, base_url:, view_context:).search(query).map do |hit|
|
|
62
|
+
{
|
|
63
|
+
page_title: hit.page_title,
|
|
64
|
+
section_title: hit.section_title,
|
|
65
|
+
url: absolutize(hit.href, base_url),
|
|
66
|
+
snippet: strip_html(hit.snippet)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The authored page with this slug across every registry, or nil (an unwritten
|
|
72
|
+
# page has no resolvable view_class, so it's absent from LlmsText.pages).
|
|
73
|
+
def find_page(config, slug)
|
|
74
|
+
LlmsText.pages(config).find { |page| page.slug.to_s == slug.to_s }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A { found: false } result whose message names every valid slug, so an agent
|
|
78
|
+
# that guessed wrong can retry with a real one.
|
|
79
|
+
def not_found(config, slug)
|
|
80
|
+
valid = list_pages(config).map { |page| page[:slug] }
|
|
81
|
+
{
|
|
82
|
+
found: false,
|
|
83
|
+
slug: slug,
|
|
84
|
+
message: "No page with slug #{slug.inspect}. Valid slugs: #{valid.join(', ')}."
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A page's GFM Markdown twin, rendered through the view context so url helpers
|
|
89
|
+
# and relative-link absolutization resolve — the LlmsController#full seam.
|
|
90
|
+
def render_markdown(page, base_url:, view_context:)
|
|
91
|
+
MarkdownExport.new(page.view_class.new, view_context:, base_url:).to_md
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A DocsKit::SearchIndex over every authored page's twin — the same triples
|
|
95
|
+
# DocsKit::SearchController builds, so MCP search can't drift from the pages.
|
|
96
|
+
def index_for(config, base_url:, view_context:)
|
|
97
|
+
triples = LlmsText.pages(config).map do |page|
|
|
98
|
+
[page.title, page.href, render_markdown(page, base_url:, view_context:)]
|
|
99
|
+
end
|
|
100
|
+
SearchIndex.new(triples)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# href absolutized against base_url (no .md suffix — MCP serves the page URL,
|
|
104
|
+
# not the twin). Relative href when base_url is nil. Mirrors LlmsText.md_url.
|
|
105
|
+
def absolutize(href, base_url)
|
|
106
|
+
return href unless base_url
|
|
107
|
+
|
|
108
|
+
"#{base_url.chomp('/')}#{href}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reduce the search index's HTML-safe snippet (term wrapped in <mark>, rest
|
|
112
|
+
# CGI-escaped) to plain text: drop the <mark> tags and unescape entities, so
|
|
113
|
+
# the MCP snippet is human/agent-readable text rather than HTML.
|
|
114
|
+
def strip_html(snippet)
|
|
115
|
+
CGI.unescapeHTML(snippet.to_s.gsub(%r{</?mark>}, ""))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# A single sidebar link. Sites map their own registries (Doc, Demo,
|
|
5
|
+
# ComponentDoc, ...) to NavItems in the nav callable, so the Sidebar stays
|
|
6
|
+
# registry-agnostic:
|
|
7
|
+
#
|
|
8
|
+
# c.nav = -> {
|
|
9
|
+
# {
|
|
10
|
+
# "Demos" => Demo.grouped.transform_values { |demos|
|
|
11
|
+
# demos.map { |d| DocsKit::NavItem.new(href: demo_path(d.slug), label: d.title, icon: d.icon) }
|
|
12
|
+
# },
|
|
13
|
+
# }
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# The Sidebar renders #label at #href, with an optional lucide #icon.
|
|
17
|
+
NavItem = Data.define(:href, :label, :icon) do
|
|
18
|
+
def initialize(href:, label:, icon: nil)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|