docs-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +939 -0
  5. data/app/components/docs_ui/brand_mark.rb +88 -0
  6. data/app/components/docs_ui/callout.rb +37 -0
  7. data/app/components/docs_ui/code.rb +123 -0
  8. data/app/components/docs_ui/endpoint.rb +44 -0
  9. data/app/components/docs_ui/error_table.rb +72 -0
  10. data/app/components/docs_ui/example.rb +102 -0
  11. data/app/components/docs_ui/field_table.rb +46 -0
  12. data/app/components/docs_ui/header.rb +30 -0
  13. data/app/components/docs_ui/icon.rb +65 -0
  14. data/app/components/docs_ui/json_response.rb +46 -0
  15. data/app/components/docs_ui/markdown.rb +187 -0
  16. data/app/components/docs_ui/markdown_action.rb +45 -0
  17. data/app/components/docs_ui/on_this_page.rb +104 -0
  18. data/app/components/docs_ui/open_api_operation.rb +126 -0
  19. data/app/components/docs_ui/page.rb +83 -0
  20. data/app/components/docs_ui/page_helpers.rb +52 -0
  21. data/app/components/docs_ui/prop_table.rb +43 -0
  22. data/app/components/docs_ui/prose.rb +30 -0
  23. data/app/components/docs_ui/request_example.rb +85 -0
  24. data/app/components/docs_ui/search_box.rb +106 -0
  25. data/app/components/docs_ui/search_results.rb +95 -0
  26. data/app/components/docs_ui/section.rb +94 -0
  27. data/app/components/docs_ui/shell.rb +161 -0
  28. data/app/components/docs_ui/sidebar.rb +106 -0
  29. data/app/components/docs_ui/table.rb +64 -0
  30. data/app/components/docs_ui/theme_switcher.rb +46 -0
  31. data/app/components/docs_ui/topbar_links.rb +42 -0
  32. data/app/controllers/docs_kit/llms_controller.rb +76 -0
  33. data/app/controllers/docs_kit/mcp_controller.rb +60 -0
  34. data/app/controllers/docs_kit/search_controller.rb +72 -0
  35. data/app/javascript/docs_kit/controllers/docs_nav_controller.js +619 -0
  36. data/config/importmap.rb +15 -0
  37. data/config/rubocop/docs_kit.yml +24 -0
  38. data/exe/docs-kit +80 -0
  39. data/lib/docs-kit.rb +5 -0
  40. data/lib/docs_kit/api_client.rb +52 -0
  41. data/lib/docs_kit/api_request.rb +66 -0
  42. data/lib/docs_kit/api_templates.rb +92 -0
  43. data/lib/docs_kit/configuration.rb +485 -0
  44. data/lib/docs_kit/controller.rb +47 -0
  45. data/lib/docs_kit/engine.rb +49 -0
  46. data/lib/docs_kit/llms_text.rb +105 -0
  47. data/lib/docs_kit/markdown_export/blocks.rb +160 -0
  48. data/lib/docs_kit/markdown_export/inline.rb +95 -0
  49. data/lib/docs_kit/markdown_export/table.rb +53 -0
  50. data/lib/docs_kit/markdown_export.rb +92 -0
  51. data/lib/docs_kit/mcp_server.rb +128 -0
  52. data/lib/docs_kit/mcp_tools.rb +118 -0
  53. data/lib/docs_kit/nav_item.rb +22 -0
  54. data/lib/docs_kit/open_api/document.rb +91 -0
  55. data/lib/docs_kit/open_api/operation.rb +213 -0
  56. data/lib/docs_kit/open_api/schema.rb +178 -0
  57. data/lib/docs_kit/open_api.rb +55 -0
  58. data/lib/docs_kit/registry.rb +152 -0
  59. data/lib/docs_kit/rubocop.rb +19 -0
  60. data/lib/docs_kit/search_hit.rb +28 -0
  61. data/lib/docs_kit/search_index/snippet.rb +65 -0
  62. data/lib/docs_kit/search_index.rb +169 -0
  63. data/lib/docs_kit/shortcut.rb +99 -0
  64. data/lib/docs_kit/templates/new_site.rb +175 -0
  65. data/lib/docs_kit/topbar_link.rb +39 -0
  66. data/lib/docs_kit/version.rb +5 -0
  67. data/lib/docs_kit.rb +72 -0
  68. data/lib/generators/docs_kit/install/USAGE +15 -0
  69. data/lib/generators/docs_kit/install/install_generator.rb +447 -0
  70. data/lib/generators/docs_kit/install/sync_report.rb +64 -0
  71. data/lib/generators/docs_kit/install/templates/agents_md.erb +105 -0
  72. data/lib/generators/docs_kit/install/templates/application.tailwind.css.erb +39 -0
  73. data/lib/generators/docs_kit/install/templates/build-css +34 -0
  74. data/lib/generators/docs_kit/install/templates/build_css.rake +13 -0
  75. data/lib/generators/docs_kit/install/templates/doc.rb.erb +17 -0
  76. data/lib/generators/docs_kit/install/templates/docs_controller.rb.erb +14 -0
  77. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +91 -0
  78. data/lib/generators/docs_kit/install/templates/installation_page.rb.erb +37 -0
  79. data/lib/generators/docs_kit/install/templates/landing.rb.erb +25 -0
  80. data/lib/generators/docs_kit/install/templates/landings_controller.rb.erb +7 -0
  81. data/lib/generators/docs_kit/install/templates/phlex.rb.erb +14 -0
  82. data/lib/generators/docs_kit/install/templates/rails_icons.rb.erb +12 -0
  83. data/lib/generators/docs_kit/install/templates/skill.md.erb +88 -0
  84. data/lib/generators/docs_kit/page/USAGE +26 -0
  85. data/lib/generators/docs_kit/page/page_generator.rb +127 -0
  86. data/lib/generators/docs_kit/page/templates/page.rb.erb +21 -0
  87. data/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb +119 -0
  88. data/lib/rubocop/cop/docs_kit/render_component_preferred.rb +123 -0
  89. metadata +253 -0
@@ -0,0 +1,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