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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point for docs-kit's custom RuboCop cops. A consuming site loads them
|
|
4
|
+
# with a single line in its `.rubocop.yml`:
|
|
5
|
+
#
|
|
6
|
+
# require:
|
|
7
|
+
# - docs_kit/rubocop
|
|
8
|
+
# inherit_gem:
|
|
9
|
+
# docs-kit: config/rubocop/docs_kit.yml
|
|
10
|
+
#
|
|
11
|
+
# RuboCop is required LAZILY here — it is a development-time dependency of the
|
|
12
|
+
# HOST app (every generated docs site has `rubocop` in its Gemfile), never a
|
|
13
|
+
# runtime dependency of docs-kit itself. Requiring this file outside a RuboCop
|
|
14
|
+
# run (e.g. if a stray `require` reaches it) still works: it pulls in rubocop on
|
|
15
|
+
# demand rather than assuming it is already loaded.
|
|
16
|
+
require "rubocop"
|
|
17
|
+
|
|
18
|
+
require_relative "../rubocop/cop/docs_kit/render_component_preferred"
|
|
19
|
+
require_relative "../rubocop/cop/docs_kit/escaped_interpolation_in_heredoc"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# One ranked search result. Built by DocsKit::SearchIndex#search and rendered by
|
|
5
|
+
# DocsUI::SearchResults (html) / serialized to JSON for the docs-nav palette.
|
|
6
|
+
#
|
|
7
|
+
# page_title — the page the hit lives on (results group by this)
|
|
8
|
+
# section_title — the `## ` section, or nil for a page-intro hit
|
|
9
|
+
# href — the page href + "#anchor" (nil section → bare page href)
|
|
10
|
+
# snippet — an HTML-safe excerpt around the match, the term in <mark>
|
|
11
|
+
# score — the rank weight (title > heading > body); higher wins
|
|
12
|
+
SearchHit = Data.define(:page_title, :section_title, :href, :snippet, :score) do
|
|
13
|
+
def initialize(page_title:, href:, snippet:, score:, section_title: nil)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The label a result row shows: "Page → Section", or just the page title for
|
|
18
|
+
# a page-intro hit.
|
|
19
|
+
def label
|
|
20
|
+
section_title ? "#{page_title} → #{section_title}" : page_title
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# JSON shape the palette fetches (matches #label / #href / #snippet).
|
|
24
|
+
def as_json(*)
|
|
25
|
+
{ "label" => label, "href" => href, "snippet" => snippet }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
class SearchIndex
|
|
7
|
+
# Builds the HTML-safe excerpt shown under a search result: a short window of
|
|
8
|
+
# text centered on the first query-token match, with every token wrapped in
|
|
9
|
+
# <mark>. Surrounding text is HTML-escaped so an angle bracket in the source
|
|
10
|
+
# can never inject markup — the returned String is safe to render.
|
|
11
|
+
class Snippet
|
|
12
|
+
# Characters of context on either side of the first match.
|
|
13
|
+
RADIUS = 80
|
|
14
|
+
|
|
15
|
+
def self.build(text, tokens)
|
|
16
|
+
new(text, tokens).build
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(text, tokens)
|
|
20
|
+
@flat = text.to_s.gsub(/\s+/, " ").strip
|
|
21
|
+
@tokens = tokens
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build
|
|
25
|
+
highlight(window)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# A ~RADIUS-on-each-side slice around the first token match, with leading/
|
|
31
|
+
# trailing ellipses when the window is cut from a longer body. No match (the
|
|
32
|
+
# hit was title-only) → the head of the text.
|
|
33
|
+
def window
|
|
34
|
+
idx = first_match_index
|
|
35
|
+
return head if idx.nil?
|
|
36
|
+
|
|
37
|
+
start = [idx - RADIUS, 0].max
|
|
38
|
+
finish = [idx + RADIUS, @flat.length].min
|
|
39
|
+
"#{'…' if start.positive?}#{@flat[start...finish].strip}#{'…' if finish < @flat.length}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def head
|
|
43
|
+
slice = @flat[0, RADIUS * 2].to_s.strip
|
|
44
|
+
@flat.length > RADIUS * 2 ? "#{slice}…" : slice
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def first_match_index
|
|
48
|
+
down = @flat.downcase
|
|
49
|
+
@tokens.filter_map { |token| down.index(token) }.min
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Escape the window, then wrap each token's (case-insensitive) occurrences in
|
|
53
|
+
# <mark>. Tokens are escaped before matching so the search runs against the
|
|
54
|
+
# same escaped text and no token can smuggle in HTML.
|
|
55
|
+
def highlight(window)
|
|
56
|
+
escaped = CGI.escapeHTML(window)
|
|
57
|
+
@tokens.each do |token|
|
|
58
|
+
pattern = Regexp.new(Regexp.escape(CGI.escapeHTML(token)), Regexp::IGNORECASE)
|
|
59
|
+
escaped = escaped.gsub(pattern) { |match| "<mark>#{match}</mark>" }
|
|
60
|
+
end
|
|
61
|
+
escaped
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module DocsKit
|
|
6
|
+
# An in-memory docs search index, built straight from the pages' Markdown twins
|
|
7
|
+
# — zero authoring, no external service, no build step. This is the structural
|
|
8
|
+
# replacement for the hand-maintained "second registry" + regex-parsed text a
|
|
9
|
+
# site used to keep: the twin already IS the page's content, split on its `## `
|
|
10
|
+
# headings into searchable sections.
|
|
11
|
+
#
|
|
12
|
+
# DocsKit::SearchIndex.new(triples).search("theme switcher")
|
|
13
|
+
#
|
|
14
|
+
# `triples` is [[page_title, page_href, markdown], ...] — the controller renders
|
|
15
|
+
# each registry page through DocsKit::MarkdownExport and hands the triples in,
|
|
16
|
+
# exactly as DocsKit::LlmsText separates pure shaping from the controller's
|
|
17
|
+
# rendering. So the whole index + scorer is unit-testable with no Rails.
|
|
18
|
+
#
|
|
19
|
+
# One entry per section (plus a page-intro entry for the text before the first
|
|
20
|
+
# `## `). Scoring is plain Ruby: case-insensitive token match, all tokens must
|
|
21
|
+
# hit (AND), a title hit outranks a heading hit outranks a body hit. Results cap
|
|
22
|
+
# at MAX_RESULTS with an HTML-safe snippet around the match (the term in
|
|
23
|
+
# <mark>). No dependencies, no fuzzy matching (revisit if usage demands it).
|
|
24
|
+
class SearchIndex
|
|
25
|
+
# Field weights — a match in the page title beats a section heading beats body
|
|
26
|
+
# text, so the most on-topic section floats up.
|
|
27
|
+
TITLE_WEIGHT = 100
|
|
28
|
+
HEADING_WEIGHT = 10
|
|
29
|
+
BODY_WEIGHT = 1
|
|
30
|
+
|
|
31
|
+
# Never return more than this — a docs site is tens of pages, and a reader
|
|
32
|
+
# scans the top matches, not a hundred.
|
|
33
|
+
MAX_RESULTS = 20
|
|
34
|
+
|
|
35
|
+
# An indexed section (or page intro). `haystacks` holds the lowercased text of
|
|
36
|
+
# each weighted field so scoring is a simple include? per token.
|
|
37
|
+
#
|
|
38
|
+
# The page title is searchable ONLY on the page-intro entry (section_title
|
|
39
|
+
# nil), not on every section: a title token matches all sections of a page
|
|
40
|
+
# equally, so weighting each section by the title would flood the results with
|
|
41
|
+
# near-identical rows from one page. A pure title match therefore surfaces
|
|
42
|
+
# once (the intro), while a section still ranks on its own heading/body.
|
|
43
|
+
Entry = Struct.new(:page_title, :section_title, :href, :body, keyword_init: true) do
|
|
44
|
+
# { weight => lowercased searchable text } for this entry.
|
|
45
|
+
def haystacks
|
|
46
|
+
@haystacks ||= begin
|
|
47
|
+
fields = {
|
|
48
|
+
HEADING_WEIGHT => section_title.to_s.downcase,
|
|
49
|
+
BODY_WEIGHT => body.to_s.downcase
|
|
50
|
+
}
|
|
51
|
+
fields[TITLE_WEIGHT] = page_title.to_s.downcase if section_title.nil?
|
|
52
|
+
fields
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# triples: [[page_title, page_href, markdown], ...].
|
|
58
|
+
def initialize(triples = [])
|
|
59
|
+
@entries = triples.flat_map { |title, href, markdown| entries_for(title, href, markdown) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :entries
|
|
63
|
+
|
|
64
|
+
# The top MAX_RESULTS SearchHits for `query`, best first. Blank query → [].
|
|
65
|
+
# Every whitespace-split token must match the entry somewhere (AND); the
|
|
66
|
+
# entry's score is the sum, per token, of the best field it matched.
|
|
67
|
+
def search(query)
|
|
68
|
+
tokens = tokenize(query)
|
|
69
|
+
return [] if tokens.empty?
|
|
70
|
+
|
|
71
|
+
scored = @entries.filter_map { |entry| score_entry(entry, tokens) }
|
|
72
|
+
scored.sort_by { |hit| [-hit.score, hit.page_title, hit.section_title.to_s] }
|
|
73
|
+
.first(MAX_RESULTS)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Split a page's Markdown twin into entries: the intro text (before the first
|
|
79
|
+
# `## `) becomes a page-level entry; each `## Heading` starts a section entry
|
|
80
|
+
# whose href carries the recomputed anchor.
|
|
81
|
+
def entries_for(page_title, page_href, markdown)
|
|
82
|
+
intro, sections = split_sections(markdown.to_s)
|
|
83
|
+
built = []
|
|
84
|
+
built << build_entry(page_title, nil, page_href, intro) unless intro.strip.empty?
|
|
85
|
+
sections.each do |heading, body|
|
|
86
|
+
anchor = "#{page_href}##{slugify(heading)}"
|
|
87
|
+
built << build_entry(page_title, heading, anchor, body)
|
|
88
|
+
end
|
|
89
|
+
# A page with no intro and no sections (empty twin) still gets one entry, so
|
|
90
|
+
# its title is searchable.
|
|
91
|
+
built << build_entry(page_title, nil, page_href, "") if built.empty?
|
|
92
|
+
built
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_entry(page_title, section_title, href, body)
|
|
96
|
+
Entry.new(page_title: page_title, section_title: section_title, href: href, body: body.strip)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# → [intro_text, [[heading, body], ...]]. Splits on lines that are exactly a
|
|
100
|
+
# level-2 ATX heading (`## Foo`), matching MarkdownExport's twin output. Scans
|
|
101
|
+
# line-by-line and toggles an in-fence flag on ``` / ~~~ fences so a `## `
|
|
102
|
+
# inside a code block stays body text — the rendered page never ids it, so a
|
|
103
|
+
# section entry there would carry a dead anchor.
|
|
104
|
+
def split_sections(markdown)
|
|
105
|
+
intro = +""
|
|
106
|
+
sections = []
|
|
107
|
+
in_fence = false
|
|
108
|
+
markdown.each_line do |line|
|
|
109
|
+
in_fence = !in_fence if line.match?(/^[ \t]*(```|~~~)/)
|
|
110
|
+
heading = line.match(/^\#\#[ \t]+(.+?)[ \t]*$/) unless in_fence
|
|
111
|
+
if heading
|
|
112
|
+
sections << [heading[1].strip, +""]
|
|
113
|
+
elsif sections.empty?
|
|
114
|
+
intro << line
|
|
115
|
+
else
|
|
116
|
+
sections.last[1] << line
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
[intro, sections]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The section anchor the twin dropped: the same slug DocsUI::Section stamps on
|
|
123
|
+
# its <section id> (ActiveSupport #parameterize when available, else a minimal
|
|
124
|
+
# ASCII slug so the index works off-Rails too).
|
|
125
|
+
def slugify(text)
|
|
126
|
+
return text.parameterize if text.respond_to?(:parameterize)
|
|
127
|
+
|
|
128
|
+
text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def tokenize(query)
|
|
132
|
+
query.to_s.downcase.split(/\s+/).reject(&:empty?)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A SearchHit if EVERY token matched somewhere in the entry (AND), else nil.
|
|
136
|
+
# Each token scores the heaviest field it appears in; the entry score sums
|
|
137
|
+
# those, so a section matching more tokens (and in heavier fields) ranks higher.
|
|
138
|
+
def score_entry(entry, tokens)
|
|
139
|
+
total = 0
|
|
140
|
+
tokens.each do |token|
|
|
141
|
+
best = best_field_weight(entry, token)
|
|
142
|
+
return nil unless best # this token matched nothing → entry is out (AND)
|
|
143
|
+
|
|
144
|
+
total += best
|
|
145
|
+
end
|
|
146
|
+
SearchHit.new(
|
|
147
|
+
page_title: entry.page_title, section_title: entry.section_title,
|
|
148
|
+
href: entry.href, snippet: snippet_for(entry, tokens), score: total
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The heaviest field weight whose text contains `token`, or nil if none do.
|
|
153
|
+
def best_field_weight(entry, token)
|
|
154
|
+
entry.haystacks.select { |_weight, text| text.include?(token) }.keys.max
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# An HTML-safe snippet around the match. Prefer the body; if the match is
|
|
158
|
+
# title-only (empty body), fall back to the section or page title so the row
|
|
159
|
+
# still has context. Snippet windowing + <mark> highlighting + escaping live
|
|
160
|
+
# in SearchIndex::Snippet.
|
|
161
|
+
def snippet_for(entry, tokens)
|
|
162
|
+
source = entry.body.to_s
|
|
163
|
+
if source.strip.empty?
|
|
164
|
+
source = entry.section_title.to_s.empty? ? entry.page_title.to_s : entry.section_title.to_s
|
|
165
|
+
end
|
|
166
|
+
Snippet.build(source, tokens)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# A parsed keyboard shortcut for the docs-search palette — one entry of
|
|
5
|
+
# DocsKit.configuration.search_shortcuts. A site writes shortcut STRINGS
|
|
6
|
+
# ("mod+k", "/", "s", "ctrl+shift+f") and this turns each into a key + modifier
|
|
7
|
+
# set that three places share: the config surface, the server-rendered <kbd>
|
|
8
|
+
# hint (#label), and the docs-nav matcher (#to_h, serialized to JSON).
|
|
9
|
+
#
|
|
10
|
+
# DocsKit::Shortcut.parse("mod+k").label # => "Ctrl K" (JS swaps to "⌘K" on mac)
|
|
11
|
+
# DocsKit::Shortcut.parse("mod+k").to_h # => { "key" => "k", "mod" => true, ... }
|
|
12
|
+
#
|
|
13
|
+
# "mod" is the PLATFORM modifier — ⌘ on mac, Ctrl elsewhere — left abstract here
|
|
14
|
+
# (the server can't know the OS) and resolved in the browser by docs-nav. Use
|
|
15
|
+
# "mod" for the "primary command" chord so one config works on every platform;
|
|
16
|
+
# use explicit "ctrl"/"meta" only when you truly mean that physical key.
|
|
17
|
+
#
|
|
18
|
+
# Modifier tokens (case-insensitive): mod, ctrl/control, shift, alt/option,
|
|
19
|
+
# cmd/command/meta. The final token is the key (single char or a named key like
|
|
20
|
+
# "escape"); a string with no key (e.g. "mod+") is unparseable and yields nil.
|
|
21
|
+
class Shortcut
|
|
22
|
+
# Canonical modifier token → the flag it sets.
|
|
23
|
+
MODIFIER_ALIASES = {
|
|
24
|
+
"mod" => :mod,
|
|
25
|
+
"ctrl" => :ctrl, "control" => :ctrl,
|
|
26
|
+
"shift" => :shift,
|
|
27
|
+
"alt" => :alt, "option" => :alt,
|
|
28
|
+
"cmd" => :meta, "command" => :meta, "meta" => :meta
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# The order modifiers appear in a #label (matches the common convention).
|
|
32
|
+
LABEL_ORDER = %i[mod ctrl meta alt shift].freeze
|
|
33
|
+
|
|
34
|
+
# Human labels for the modifier flags in a #label. "mod" renders as the
|
|
35
|
+
# majority default "Ctrl"; docs-nav swaps it to "⌘" on mac.
|
|
36
|
+
MODIFIER_LABELS = { mod: "Ctrl", ctrl: "Ctrl", meta: "Meta", alt: "Alt", shift: "Shift" }.freeze
|
|
37
|
+
|
|
38
|
+
# Parse one shortcut string → a Shortcut, or nil when there's no key to bind.
|
|
39
|
+
def self.parse(string)
|
|
40
|
+
tokens = string.to_s.downcase.split("+").map(&:strip).reject(&:empty?)
|
|
41
|
+
key = tokens.pop
|
|
42
|
+
return if key.nil? || MODIFIER_ALIASES.key?(key)
|
|
43
|
+
|
|
44
|
+
mods = tokens.filter_map { |token| MODIFIER_ALIASES[token] }.to_set
|
|
45
|
+
new(key, mods)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parse a list of shortcut strings, dropping any that don't parse.
|
|
49
|
+
def self.parse_list(strings)
|
|
50
|
+
Array(strings).filter_map { |string| parse(string) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :key
|
|
54
|
+
|
|
55
|
+
# key: the final key token (lowercased). mods: a Set of modifier flag symbols.
|
|
56
|
+
def initialize(key, mods)
|
|
57
|
+
@key = key
|
|
58
|
+
@mods = mods
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mod? = @mods.include?(:mod)
|
|
63
|
+
def ctrl? = @mods.include?(:ctrl)
|
|
64
|
+
def shift? = @mods.include?(:shift)
|
|
65
|
+
def alt? = @mods.include?(:alt)
|
|
66
|
+
def meta? = @mods.include?(:meta)
|
|
67
|
+
|
|
68
|
+
# The <kbd> badge text: modifiers (in LABEL_ORDER) then the key. In a CHORD
|
|
69
|
+
# (with a modifier) a single-char key is uppercased for legibility ("mod+k" →
|
|
70
|
+
# "Ctrl K"); a BARE key is shown exactly as authored ("/", "s"). A named key
|
|
71
|
+
# (e.g. "escape") is left as-is either way.
|
|
72
|
+
def label
|
|
73
|
+
mods = LABEL_ORDER.select { |flag| @mods.include?(flag) }.map { |flag| MODIFIER_LABELS[flag] }.uniq
|
|
74
|
+
(mods << key_label(chord: !mods.empty?)).join(" ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The shape docs-nav matches a keydown against (booleans always present so the
|
|
78
|
+
# JSON is uniform). String keys → clean JSON without symbol quoting.
|
|
79
|
+
def to_h
|
|
80
|
+
{
|
|
81
|
+
"key" => key, "mod" => mod?, "ctrl" => ctrl?,
|
|
82
|
+
"shift" => shift?, "alt" => alt?, "meta" => meta?
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
alias as_json to_h
|
|
86
|
+
|
|
87
|
+
def ==(other)
|
|
88
|
+
other.is_a?(Shortcut) && to_h == other.to_h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# The key as it appears in the badge — uppercased only in a chord, and only for
|
|
94
|
+
# a single char; a named key (length > 1) is always left as authored.
|
|
95
|
+
def key_label(chord:)
|
|
96
|
+
chord && key.length == 1 ? key.upcase : key
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
# Rails application template for a docs-kit docs site. Run via:
|
|
6
|
+
#
|
|
7
|
+
# rails new my-docs --minimal -a propshaft -j importmap --skip-... -m new_site.rb
|
|
8
|
+
#
|
|
9
|
+
# or, more simply, via the `docs-kit new` CLI (exe/docs-kit) which supplies the
|
|
10
|
+
# right `rails new` flags. It:
|
|
11
|
+
# * adds docs-kit + its runtime deps to the Gemfile,
|
|
12
|
+
# * runs `docs_kit:install` (all the Ruby/CSS/Stimulus wiring),
|
|
13
|
+
# * syncs the lucide icon set and builds the CSS,
|
|
14
|
+
# * scaffolds a deployable Kamal setup that calls docs-kit's reusable workflow.
|
|
15
|
+
#
|
|
16
|
+
# The generated app is a complete, deployable standalone docs site.
|
|
17
|
+
|
|
18
|
+
# --- config the template reads ------------------------------------------------
|
|
19
|
+
# DOCS_KIT_GEM_SOURCE lets the dogfood site (docs-kit/docs) depend on the gem via
|
|
20
|
+
# path: ".." while a real new site depends on the released gem. Default: rubygems.
|
|
21
|
+
gem_source = ENV.fetch("DOCS_KIT_GEM_SOURCE", "") # e.g. 'path: "..", ' or 'github: "mhenrixon/docs-kit", '
|
|
22
|
+
# The GHCR image/service = the OWNER/REPO the site will live in (repo-linked pkg).
|
|
23
|
+
image = ENV.fetch("DOCS_KIT_IMAGE", "mhenrixon/#{app_name}")
|
|
24
|
+
service = ENV.fetch("DOCS_KIT_SERVICE", app_name)
|
|
25
|
+
|
|
26
|
+
# --- gems ---------------------------------------------------------------------
|
|
27
|
+
gem_line = "gem \"docs-kit\"#{", #{gem_source}" unless gem_source.empty?}"
|
|
28
|
+
inject_into_file "Gemfile", after: %r{source ["']https://rubygems\.org["']\n} do
|
|
29
|
+
<<~RUBY
|
|
30
|
+
|
|
31
|
+
# docs-kit shared docs chrome + its runtime deps
|
|
32
|
+
#{gem_line}
|
|
33
|
+
gem "daisyui", require: "daisy_ui"
|
|
34
|
+
gem "phlex-rails"
|
|
35
|
+
gem "rails_icons", "~> 1.1"
|
|
36
|
+
gem "rouge"
|
|
37
|
+
|
|
38
|
+
# Optional: expose these docs to AI agents over MCP (a read-only /mcp endpoint).
|
|
39
|
+
# Uncomment this and the /mcp route in config/routes.rb. See the docs-kit README.
|
|
40
|
+
# gem "mcp"
|
|
41
|
+
RUBY
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
after_bundle do
|
|
45
|
+
# The whole docs-kit wiring in one call (idempotent).
|
|
46
|
+
generate "docs_kit:install"
|
|
47
|
+
|
|
48
|
+
# Sync the lucide icons the chrome renders, then build the CSS.
|
|
49
|
+
run "bin/rails g rails_icons:sync --library=lucide --force --quiet"
|
|
50
|
+
run "bun install --silent" if system("command -v bun >/dev/null 2>&1")
|
|
51
|
+
run "bun run build:css" if system("command -v bun >/dev/null 2>&1")
|
|
52
|
+
|
|
53
|
+
# --- deploy scaffolding (Kamal + the reusable workflow) ---------------------
|
|
54
|
+
create_file "config/deploy.yml", <<~YAML
|
|
55
|
+
# Kamal deploy → the oss-infrastructure server (Cloudflare Tunnel + kamal-proxy).
|
|
56
|
+
# service/image = the repo OWNER/REPO so the ghcr package auto-links to the
|
|
57
|
+
# repo and GITHUB_TOKEN can push + pull it (no PAT). See docs-kit's README.
|
|
58
|
+
service: #{service}
|
|
59
|
+
image: #{image}
|
|
60
|
+
|
|
61
|
+
servers:
|
|
62
|
+
web:
|
|
63
|
+
hosts:
|
|
64
|
+
- <%= ENV["DEPLOY_HOST"] %>
|
|
65
|
+
|
|
66
|
+
ssh:
|
|
67
|
+
user: <%= ENV.fetch("DEPLOY_SSH_USER", "oss") %>
|
|
68
|
+
|
|
69
|
+
proxy:
|
|
70
|
+
host: <%= ENV["DEPLOY_DOMAIN"] %>
|
|
71
|
+
app_port: 3000
|
|
72
|
+
ssl: false
|
|
73
|
+
healthcheck:
|
|
74
|
+
path: /up
|
|
75
|
+
interval: 5
|
|
76
|
+
timeout: 30
|
|
77
|
+
|
|
78
|
+
registry:
|
|
79
|
+
server: ghcr.io
|
|
80
|
+
username: mhenrixon
|
|
81
|
+
password:
|
|
82
|
+
- KAMAL_REGISTRY_PASSWORD
|
|
83
|
+
|
|
84
|
+
builder:
|
|
85
|
+
arch: amd64
|
|
86
|
+
context: .
|
|
87
|
+
dockerfile: Dockerfile
|
|
88
|
+
|
|
89
|
+
env:
|
|
90
|
+
clear:
|
|
91
|
+
RAILS_SERVE_STATIC_FILES: "true"
|
|
92
|
+
RAILS_LOG_TO_STDOUT: "true"
|
|
93
|
+
# A stateless docs site: SECRET_KEY_BASE only signs cookies, so it's
|
|
94
|
+
# inlined (no user data at risk). Rotate with `bin/rails secret`.
|
|
95
|
+
SECRET_KEY_BASE: "#{SecureRandom.hex(64)}"
|
|
96
|
+
YAML
|
|
97
|
+
|
|
98
|
+
create_file ".kamal/secrets", <<~SH
|
|
99
|
+
# In CI the deploy workflow sets this to the job's GITHUB_TOKEN. Locally,
|
|
100
|
+
# export it (e.g. KAMAL_REGISTRY_PASSWORD=$(gh auth token)).
|
|
101
|
+
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
|
|
102
|
+
SH
|
|
103
|
+
|
|
104
|
+
create_file "Dockerfile", <<~DOCKER
|
|
105
|
+
# syntax = docker/dockerfile:1
|
|
106
|
+
ARG RUBY_VERSION=3.4.2
|
|
107
|
+
FROM ruby:$RUBY_VERSION-slim AS base
|
|
108
|
+
|
|
109
|
+
ARG BUN_VERSION=1.3.2
|
|
110
|
+
ENV BUN_INSTALL="/usr/local/bun"
|
|
111
|
+
ENV PATH="/usr/local/bun/bin:$PATH"
|
|
112
|
+
WORKDIR /rails
|
|
113
|
+
ENV BUNDLE_WITHOUT="development:test" RAILS_ENV="production"
|
|
114
|
+
|
|
115
|
+
RUN apt-get update -qq && \\
|
|
116
|
+
apt-get install --no-install-recommends -y curl libjemalloc2 && \\
|
|
117
|
+
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
|
118
|
+
RUN gem update --system --no-document && gem install -N bundler
|
|
119
|
+
|
|
120
|
+
FROM base AS build
|
|
121
|
+
RUN apt-get update -qq && \\
|
|
122
|
+
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip
|
|
123
|
+
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
|
|
124
|
+
COPY Gemfile Gemfile.lock ./
|
|
125
|
+
RUN bundle install && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
|
|
126
|
+
COPY . .
|
|
127
|
+
RUN bun install --frozen-lockfile
|
|
128
|
+
# assets:precompile runs bun run build:css via the css:build rake enhance.
|
|
129
|
+
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
|
130
|
+
|
|
131
|
+
FROM base
|
|
132
|
+
# Kamal verifies this label on --skip-push deploy; must equal `service:`.
|
|
133
|
+
LABEL service="#{service}"
|
|
134
|
+
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
|
135
|
+
COPY --from=build /rails /rails
|
|
136
|
+
RUN groupadd --system --gid 1000 rails && \\
|
|
137
|
+
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \\
|
|
138
|
+
chown -R 1000:1000 /rails/log /rails/tmp
|
|
139
|
+
USER 1000:1000
|
|
140
|
+
EXPOSE 3000
|
|
141
|
+
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
|
|
142
|
+
DOCKER
|
|
143
|
+
|
|
144
|
+
create_file ".github/workflows/deploy-docs.yml", <<~YAML
|
|
145
|
+
name: Deploy docs
|
|
146
|
+
# Build + deploy via docs-kit's reusable workflow (defined once for all sites).
|
|
147
|
+
on:
|
|
148
|
+
release: { types: [published] }
|
|
149
|
+
workflow_dispatch:
|
|
150
|
+
jobs:
|
|
151
|
+
deploy:
|
|
152
|
+
uses: mhenrixon/docs-kit/.github/workflows/deploy.yml@main
|
|
153
|
+
with:
|
|
154
|
+
image: #{image}
|
|
155
|
+
service: #{service}
|
|
156
|
+
build_context: "."
|
|
157
|
+
dockerfile: "Dockerfile"
|
|
158
|
+
working_directory: "."
|
|
159
|
+
secrets: inherit
|
|
160
|
+
YAML
|
|
161
|
+
|
|
162
|
+
create_file "Procfile.dev", <<~PROC
|
|
163
|
+
web: bin/rails server
|
|
164
|
+
css: bun run watch:css
|
|
165
|
+
PROC
|
|
166
|
+
|
|
167
|
+
say "\n✅ docs-kit docs site scaffolded.", :green
|
|
168
|
+
say <<~MSG
|
|
169
|
+
Next:
|
|
170
|
+
cd #{app_name}
|
|
171
|
+
bin/dev # or bin/rails server
|
|
172
|
+
Deploy: push, create a GitHub Release (or run the Deploy docs workflow).
|
|
173
|
+
Set repo secrets: SSH_PRIVATE_KEY, DEPLOY_HOST, DEPLOY_DOMAIN (env: docs).
|
|
174
|
+
MSG
|
|
175
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
# A single external link rendered in the topbar next to the theme switcher —
|
|
5
|
+
# a repo link, a chat invite, a social profile. Sites declare these in config
|
|
6
|
+
# as plain Hashes; #topbar_links normalizes each into a TopbarLink so the Shell
|
|
7
|
+
# stays value-object-driven (like DocsKit::NavItem for the sidebar):
|
|
8
|
+
#
|
|
9
|
+
# c.topbar_links = [
|
|
10
|
+
# { href: "https://github.com/me/repo", label: "GitHub", icon: :github },
|
|
11
|
+
# { href: "https://discord.gg/abc", label: "Discord", icon: :discord },
|
|
12
|
+
# ]
|
|
13
|
+
#
|
|
14
|
+
# The Shell renders each as an icon-only ghost button; #label is the accessible
|
|
15
|
+
# name (aria-label + title). #icon is a token DocsUI::BrandMark resolves — a
|
|
16
|
+
# shipped brand mark (:github, :discord, …) or, failing that, a lucide icon
|
|
17
|
+
# name (any string DocsUI::Icon knows). nil #icon renders the label as text.
|
|
18
|
+
TopbarLink = Data.define(:href, :label, :icon) do
|
|
19
|
+
def initialize(href:, label:, icon: nil)
|
|
20
|
+
super(href:, label:, icon: icon&.to_sym)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a TopbarLink from a Hash (symbol- OR string-keyed, so a YAML/JSON
|
|
24
|
+
# config loads cleanly) or pass an existing TopbarLink through unchanged.
|
|
25
|
+
def self.from(link)
|
|
26
|
+
return link if link.is_a?(self)
|
|
27
|
+
|
|
28
|
+
attrs = link.to_h.transform_keys(&:to_sym)
|
|
29
|
+
new(href: attrs[:href], label: attrs[:label], icon: attrs[:icon])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the href points off-site (absolute http/https). The Shell adds
|
|
33
|
+
# target=_blank + rel=noopener only for external links; a site-relative link
|
|
34
|
+
# (e.g. "/changelog") opens in place like any nav link.
|
|
35
|
+
def external?
|
|
36
|
+
href.to_s.match?(%r{\Ahttps?://}i)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/docs_kit.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "phlex"
|
|
4
|
+
require "rouge"
|
|
5
|
+
require "zeitwerk"
|
|
6
|
+
|
|
7
|
+
require_relative "docs_kit/version"
|
|
8
|
+
require_relative "docs_kit/configuration"
|
|
9
|
+
|
|
10
|
+
# DocsKit — a reusable docs-site component library: the shared Phlex chrome
|
|
11
|
+
# (shell, sidebar, code block, theme switcher, page kit) that lets several docs
|
|
12
|
+
# sites look identical while differing only in configuration.
|
|
13
|
+
#
|
|
14
|
+
# Components live under the `Docs::` namespace and are exposed as a Phlex::Kit,
|
|
15
|
+
# so a host base view can `include Docs` and call `Shell(...)`, `Page(...)`,
|
|
16
|
+
# `Code(...)`, etc., or always use the namespaced `render Docs::Shell.new(...)`.
|
|
17
|
+
module DocsKit
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# The current configuration, memoized. Reset with DocsKit.reset_configuration!.
|
|
22
|
+
def configuration
|
|
23
|
+
@configuration ||= Configuration.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def configure
|
|
27
|
+
yield(configuration)
|
|
28
|
+
configuration
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_configuration!
|
|
32
|
+
@configuration = Configuration.new
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The DocsUI namespace is a Phlex::Kit so `include DocsUI` exposes every
|
|
38
|
+
# component constant as a bare callable method (DocsUI::Shell(), DocsUI::Code(),
|
|
39
|
+
# ...). Named DocsUI (not Docs) so it never collides with a host app's own
|
|
40
|
+
# `Views::Docs` page namespace, and to match the UI/AdminUI/DocsUI kit convention.
|
|
41
|
+
# Defined before loader.setup so zeitwerk autoloads its children into this
|
|
42
|
+
# already-extended module.
|
|
43
|
+
module DocsUI
|
|
44
|
+
extend Phlex::Kit
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
loader = Zeitwerk::Loader.new
|
|
48
|
+
loader.tag = "docs_kit"
|
|
49
|
+
loader.inflector.inflect("docs_kit" => "DocsKit", "docs_ui" => "DocsUI")
|
|
50
|
+
# DocsKit::* support code (registry mixin, engine helpers) under lib/docs_kit/,
|
|
51
|
+
# excluding the eagerly-required files below.
|
|
52
|
+
loader.push_dir(File.expand_path("docs_kit", __dir__), namespace: DocsKit)
|
|
53
|
+
# The shippable Phlex components under app/components/docs_ui/ → DocsUI::*.
|
|
54
|
+
loader.push_dir(File.expand_path("../app/components/docs_ui", __dir__), namespace: DocsUI)
|
|
55
|
+
loader.ignore(File.expand_path("docs_kit/version.rb", __dir__))
|
|
56
|
+
loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
|
|
57
|
+
# docs_kit/rubocop.rb is the RuboCop-cop entry point: it defines cops under
|
|
58
|
+
# RuboCop::Cop::DocsKit::*, not a DocsKit::Rubocop constant, so zeitwerk must not
|
|
59
|
+
# manage it. It (and the cops under lib/rubocop/, which are outside the loader's
|
|
60
|
+
# push_dirs entirely) load only when a `.rubocop.yml` requires "docs_kit/rubocop".
|
|
61
|
+
loader.ignore(File.expand_path("docs_kit/rubocop.rb", __dir__))
|
|
62
|
+
# engine.rb is required explicitly below only under Rails, so zeitwerk never
|
|
63
|
+
# manages it (it would otherwise expect a DocsKit::Engine constant outside Rails).
|
|
64
|
+
loader.ignore(File.expand_path("docs_kit/engine.rb", __dir__))
|
|
65
|
+
# The Rails application template (docs_kit/templates) is executed inside
|
|
66
|
+
# `rails new`, not autoloadable Ruby — ignore it so eager_load! doesn't try to
|
|
67
|
+
# load it (it references template-DSL locals like `app_name`). Generators are
|
|
68
|
+
# loaded by Rails' generator system, not this loader.
|
|
69
|
+
loader.ignore(File.expand_path("docs_kit/templates", __dir__))
|
|
70
|
+
loader.setup
|
|
71
|
+
|
|
72
|
+
require_relative "docs_kit/engine" if defined?(Rails::Engine)
|