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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ VERSION = "0.1.0"
5
+ 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)