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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # A generic reference table — headers + rows — in the kit's daisyUI look (a
5
+ # `table table-sm table-zebra` inside a `rounded-box` border, `not-prose` so the
6
+ # surrounding Prose typography doesn't restyle it). This is the piece every docs
7
+ # site was hand-rolling; compose it, don't write raw `table`/`tr`/`td` markup.
8
+ #
9
+ # render DocsUI::Table.new(
10
+ # ["Option", "Type", "Default", "Description"],
11
+ # [
12
+ # ["brand", "String", '"Docs"', "Topbar + sidebar heading."],
13
+ # ["themes", [:code, "%w[dark light]"], "—", "ThemeSwitcher options."],
14
+ # ]
15
+ # )
16
+ #
17
+ # Cell values (the same convention the dogfood PropTable proved):
18
+ #
19
+ # * String → plain text (Phlex-escaped; HTML in it is inert, never live)
20
+ # * [:code, "x"] → inline <code> (for a type, a default literal, an identifier)
21
+ # * [:md, "…"] → inline GFM through DocsUI::Markdown (bold/links/inline code),
22
+ # opt-in so a plain String that merely *looks* like markdown
23
+ # is never surprise-parsed.
24
+ #
25
+ # PropTable is a thin preset over this (name/type/default/description, first
26
+ # column auto-code-styled).
27
+ class Table < Phlex::HTML
28
+ WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300"
29
+ TABLE = "table table-sm table-zebra"
30
+
31
+ def initialize(headers, rows)
32
+ @headers = headers
33
+ @rows = rows
34
+ end
35
+
36
+ def view_template
37
+ div(class: WRAPPER) do
38
+ table(class: TABLE) do
39
+ thead do
40
+ tr { @headers.each { |header| th(class: "whitespace-nowrap") { plain header.to_s } } }
41
+ end
42
+ tbody do
43
+ @rows.each { |cells| tr { cells.each { |cell| td { render_cell(cell) } } } }
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Dispatch a cell by its shape. A [type, value] pair selects an inline
52
+ # renderer; anything else is plain, Phlex-escaped text.
53
+ def render_cell(cell)
54
+ case cell
55
+ in [:code, value] then code(class: "text-sm") { plain value.to_s }
56
+ in [:md, value] then render DocsUI::Markdown.inline(value.to_s)
57
+ in [Symbol => _tag, *]
58
+ raise ArgumentError,
59
+ "DocsUI::Table: unknown or malformed typed cell #{cell.inspect}; use [:code, value] or [:md, value]"
60
+ else plain cell.to_s
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # daisyUI theme switcher — a dropdown of radio inputs with the `theme-controller`
5
+ # class. daisyUI swaps the page theme (data-theme on :root) via a CSS :has()
6
+ # selector; the docs-nav Stimulus controller additionally PERSISTS the choice to
7
+ # localStorage (change->docs-nav#selectTheme) and re-applies it on connect, and
8
+ # the anti-flash <head> script (DocsUI::Shell) restores it before first paint —
9
+ # so the theme survives navigation without a flicker.
10
+ #
11
+ # The offered themes come from DocsKit.configuration.themes and MUST match the
12
+ # themes enabled in the site's Tailwind @plugin "daisyui" { themes: ... } block.
13
+ class ThemeSwitcher < Phlex::HTML
14
+ def view_template
15
+ div(class: "dropdown dropdown-end") do
16
+ div(tabindex: "0", role: "button", class: "btn btn-sm btn-ghost gap-1") do
17
+ render DocsUI::Icon.new("palette", class: "size-4")
18
+ plain "Theme"
19
+ end
20
+ ul(tabindex: "0",
21
+ class: "dropdown-content bg-base-300 rounded-box z-10 w-44 p-2 shadow-2xl max-h-96 overflow-y-auto") do
22
+ themes.each { |theme| theme_option(theme) }
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def themes
30
+ DocsKit.configuration.themes
31
+ end
32
+
33
+ def theme_option(theme)
34
+ li do
35
+ input(
36
+ type: "radio",
37
+ name: "theme-dropdown",
38
+ value: theme,
39
+ class: "theme-controller btn btn-sm btn-block btn-ghost justify-start",
40
+ aria_label: theme.capitalize,
41
+ data: { testid: "theme-#{theme}", action: "change->docs-nav#selectTheme" }
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The config-driven repo/social links in the topbar, next to the theme switcher
5
+ # (DocsKit.configuration.topbar_links). Each link renders as an icon-only ghost
6
+ # button — a DocsUI::BrandMark (a shipped brand glyph like GitHub/Discord, or a
7
+ # lucide fallback) whose accessible name is the link's #label. External links
8
+ # open in a new tab with rel=noopener; a site-relative link opens in place.
9
+ #
10
+ # Renders nothing when the site configures no links, so a site that sets
11
+ # c.topbar_links = [] (the default) has a byte-identical topbar.
12
+ class TopbarLinks < Phlex::HTML
13
+ def view_template
14
+ links = DocsKit.configuration.topbar_links
15
+ return if links.empty?
16
+
17
+ links.each { |link| topbar_link(link) }
18
+ end
19
+
20
+ private
21
+
22
+ def topbar_link(link)
23
+ # An icon-only button when the link names an icon (square), else a text
24
+ # button showing the label — so an icon-less link is never a blank button.
25
+ icon = link.icon
26
+ a(
27
+ href: link.href,
28
+ class: "btn btn-sm btn-ghost #{'btn-square' if icon}",
29
+ aria_label: link.label,
30
+ title: link.label,
31
+ target: (link.external? ? "_blank" : nil),
32
+ rel: (link.external? ? "noopener noreferrer" : nil)
33
+ ) do
34
+ if icon
35
+ render DocsUI::BrandMark.new(icon, label: link.label, class: "size-5")
36
+ else
37
+ plain link.label
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Serves the two AI-readable artifacts (llmstxt.org) from the registry, with
5
+ # zero authoring — the host app wires the routes (the engine is glue-only, no
6
+ # routes of its own), so a site keeps full control over path, auth, and
7
+ # omission:
8
+ #
9
+ # # config/routes.rb
10
+ # get "/llms.txt" => "docs_kit/llms#index"
11
+ # get "/llms-full.txt" => "docs_kit/llms#full"
12
+ #
13
+ # #index → the llms.txt index (brand, tagline, nav-grouped links to each page's
14
+ # `.md` twin). #full → llms-full.txt (every page's Markdown concatenated). Both
15
+ # are text/plain and HTTP-cached: the response revalidates on the registry's
16
+ # own content plus DocsKit::VERSION, so a page/gem change busts the cache while
17
+ # an unchanged registry serves a 304.
18
+ #
19
+ # All the text shaping lives in DocsKit::LlmsText (pure, Rails-free). This
20
+ # controller only threads the Rails view context: #full renders each page to
21
+ # Markdown via DocsKit::MarkdownExport (which needs url helpers/CSRF), then
22
+ # hands the [title, markdown] pairs to LlmsText.full.
23
+ class LlmsController < ActionController::Base
24
+ # #full renders each page's full HTML through this controller's view context
25
+ # (DocsKit::MarkdownExport), and DocsUI::Shell's <head> calls csrf_meta_tags —
26
+ # which needs protect_against_forgery? registered as a view helper. A gem's
27
+ # bare ActionController::Base subclass doesn't inherit the host app's
28
+ # default_protect_from_forgery, so declare it here. :null_session fits these
29
+ # GET-only, sessionless, public text endpoints (no token to verify).
30
+ protect_from_forgery with: :null_session
31
+
32
+ def index
33
+ body = DocsKit::LlmsText.index(docs_config, base_url: request.base_url)
34
+ render_text(body) if stale_llms?(body)
35
+ end
36
+
37
+ def full
38
+ pairs = DocsKit::LlmsText.pages(docs_config).map do |page|
39
+ [page.title, render_page_markdown(page)]
40
+ end
41
+ body = DocsKit::LlmsText.full(docs_config, pairs)
42
+ render_text(body) if stale_llms?(body)
43
+ end
44
+
45
+ private
46
+
47
+ # NOT named #config — ActionController::Base#config is the Rails config
48
+ # object, and RequestForgeryProtection delegates allow_forgery_protection/
49
+ # csrf_token_storage_strategy to it (`delegate ..., to: :config`). Shadowing
50
+ # #config with DocsKit.configuration would route those to the wrong object and
51
+ # blow up csrf_meta_tags when #full renders a page's <head>.
52
+ def docs_config = DocsKit.configuration
53
+
54
+ # text/plain (llms.txt is plain text, not markdown — agent tooling fetches it
55
+ # as-is). UTF-8 because page titles/taglines may carry non-ASCII.
56
+ def render_text(body)
57
+ render plain: body, content_type: "text/plain; charset=utf-8"
58
+ end
59
+
60
+ # Revalidate on the rendered body itself (so any registry/config/page change
61
+ # busts it) plus the gem version as the etag salt. In development, always
62
+ # re-render; production sites deploy immutably so the version etag is stable.
63
+ def stale_llms?(body)
64
+ stale?(etag: [DocsKit::VERSION, body], public: true)
65
+ end
66
+
67
+ # A page's Markdown twin, rendered through this controller's view context so
68
+ # url helpers/CSRF resolve and relative links absolutize to portable URLs —
69
+ # the same path DocsKit::Controller#render_page takes for a `.md` request.
70
+ def render_page_markdown(page)
71
+ DocsKit::MarkdownExport.new(
72
+ page.view_class.new, view_context:, base_url: request.base_url
73
+ ).to_md
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The built-in read-only MCP endpoint — one gem controller, host-drawn route
5
+ # (same shape as DocsKit::LlmsController/SearchController; the engine adds no
6
+ # routes):
7
+ #
8
+ # # config/routes.rb
9
+ # post "/mcp" => "docs_kit/mcp#create"
10
+ # match "/mcp" => "docs_kit/mcp#method_not_allowed", via: %i[get delete]
11
+ #
12
+ # A user adds `https://docs.example.com/mcp` to Claude Code / Claude.ai / Cursor
13
+ # once and the docs become first-class agent tools (list_pages / get_page /
14
+ # search_docs) over the SAME registry the site renders from. See
15
+ # DocsKit::McpServer / DocsKit::McpTools.
16
+ #
17
+ # Stateless JSON-RPC: each POST is independent (no SSE session), so it works
18
+ # behind the existing Kamal/Cloudflare deploy unchanged. #create delegates the
19
+ # whole protocol to DocsKit::McpServer#handle_json — the SDK parses the request,
20
+ # dispatches the tool, and serializes the response (including JSON-RPC errors),
21
+ # so the controller never hand-rolls the protocol.
22
+ #
23
+ # OFF unless BOTH the optional `mcp` gem is present AND the site left c.mcp on
24
+ # (DocsKit.configuration#mcp_enabled?). A site without the gem, or with
25
+ # c.mcp = false, gets a 404 here and is byte-identical to before this feature.
26
+ class McpController < ActionController::Base
27
+ # A JSON-RPC POST carries no CSRF token to verify (there's no form, no
28
+ # session — an agent posts a raw JSON body). Unlike the GET-only text
29
+ # endpoints (which use :null_session so csrf_meta_tags resolves in a rendered
30
+ # <head>), this action renders JSON only and never a Shell, so drop forgery
31
+ # protection outright.
32
+ skip_forgery_protection
33
+
34
+ def create
35
+ return head(:not_found) unless docs_config.mcp_enabled?
36
+
37
+ server = DocsKit::McpServer.build(docs_config, base_url: request.base_url, view_context:)
38
+ return head(:not_found) unless server
39
+
40
+ # #handle_json returns an already-serialized JSON string, so render it as the
41
+ # raw body with the JSON content type — `render json:` would re-encode the
42
+ # string (wrapping it in quotes), corrupting the JSON-RPC envelope.
43
+ render body: server.handle_json(request.body.read), content_type: "application/json"
44
+ end
45
+
46
+ # Read-only + stateless: the endpoint speaks JSON-RPC over POST only. There is
47
+ # no standalone SSE stream (GET) and no session to terminate (DELETE), so both
48
+ # are 405 rather than the SDK's session machinery.
49
+ def method_not_allowed
50
+ head :method_not_allowed
51
+ end
52
+
53
+ private
54
+
55
+ # NOT named #config — ActionController::Base#config is the Rails config object
56
+ # and RequestForgeryProtection delegates to it; shadowing it breaks forgery
57
+ # handling (see LlmsController). The DocsKit config reader is #docs_config.
58
+ def docs_config = DocsKit.configuration
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Serves the docs search — one gem controller, host-drawn route (same shape as
5
+ # DocsKit::LlmsController; the engine is glue-only and adds no routes):
6
+ #
7
+ # # config/routes.rb
8
+ # get "/docs/search" => "docs_kit/search#index"
9
+ #
10
+ # #index answers BOTH formats off the same index:
11
+ #
12
+ # * html — the JS-off path: renders DocsUI::SearchResults inside DocsUI::Shell,
13
+ # a full working results page. The topbar form (GET ?q=) lands here.
14
+ # * json — the enhancement path: the docs-nav palette fetches `search.json?q=`
15
+ # debounced and renders the hits client-side. The form still submits to the
16
+ # html path if JS dies mid-typing.
17
+ #
18
+ # The index is built lazily per request from DocsKit::SearchIndex, whose entries
19
+ # come from each registry page's Markdown twin (DocsKit::MarkdownExport) split on
20
+ # its `## ` headings — the SAME twins llms-full.txt serves, so search can never
21
+ # drift from the pages. Sites are tens of pages; there's no external index, no
22
+ # build step, no second registry.
23
+ class SearchController < ActionController::Base
24
+ # Like LlmsController: a bare ActionController::Base subclass doesn't inherit
25
+ # the host's default_protect_from_forgery, and #index renders DocsUI::Shell,
26
+ # whose <head> calls csrf_meta_tags (which needs protect_against_forgery?
27
+ # registered as a view helper). :null_session fits this GET-only, sessionless,
28
+ # public endpoint.
29
+ protect_from_forgery with: :null_session
30
+
31
+ def index
32
+ hits = search_index.search(query)
33
+
34
+ respond_to do |format|
35
+ format.html { render_results_page(hits) }
36
+ format.json { render json: { "query" => query, "results" => hits.map(&:as_json) } }
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # NOT named #config — ActionController::Base#config is the Rails config object
43
+ # and RequestForgeryProtection delegates to it; shadowing it breaks
44
+ # csrf_meta_tags when the Shell renders (see LlmsController).
45
+ def docs_config = DocsKit.configuration
46
+
47
+ def query = params[:q].to_s
48
+
49
+ # The index built from every authored registry page's Markdown twin. Each page
50
+ # is rendered through THIS controller's view context (url helpers/CSRF resolve)
51
+ # and absolutized against the request base URL, exactly as LlmsController#full
52
+ # renders each twin.
53
+ def search_index
54
+ triples = DocsKit::LlmsText.pages(docs_config).map do |page|
55
+ markdown = DocsKit::MarkdownExport.new(
56
+ page.view_class.new, view_context:, base_url: request.base_url
57
+ ).to_md
58
+ [page.title, page.href, markdown]
59
+ end
60
+ DocsKit::SearchIndex.new(triples)
61
+ end
62
+
63
+ # The full chrome results page. DocsUI::Shell IS the whole document, so render
64
+ # with layout: false (the same contract as DocsKit::Controller#render_page).
65
+ def render_results_page(hits)
66
+ page = DocsUI::Shell.new(title: "Search") do
67
+ render DocsUI::SearchResults.new(query:, hits:)
68
+ end
69
+ render page, layout: false
70
+ end
71
+ end
72
+ end