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