docs-kit 1.1.1 → 1.3.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 +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +109 -11
- data/app/components/docs_ui/code.rb +29 -10
- data/app/components/docs_ui/landing.rb +2 -2
- data/app/components/docs_ui/markdown.rb +8 -4
- data/app/components/docs_ui/markdown_images.rb +73 -0
- data/app/components/docs_ui/meta_tags.rb +2 -2
- data/app/components/docs_ui/page.rb +38 -11
- data/app/components/docs_ui/shell.rb +18 -4
- data/app/components/docs_ui/sidebar.rb +2 -2
- data/app/controllers/docs_kit/llms_controller.rb +5 -2
- data/lib/docs_kit/configuration.rb +67 -1
- data/lib/docs_kit/engine.rb +7 -0
- data/lib/docs_kit/llms_text.rb +1 -1
- data/lib/docs_kit/mcp_server.rb +2 -2
- data/lib/docs_kit/templates/new_site.rb +1 -1
- data/lib/docs_kit/version.rb +1 -1
- data/lib/docs_kit.rb +1 -1
- data/lib/generators/docs_kit/install/install_generator.rb +39 -4
- data/lib/generators/docs_kit/install/migration_registry.rb +12 -9
- data/lib/generators/docs_kit/install/migrations/v1_2_0.rb +120 -0
- data/lib/generators/docs_kit/install/sync_report.rb +87 -1
- data/lib/generators/docs_kit/install/templates/Dockerfile.tt +1 -1
- data/lib/generators/docs_kit/install/templates/agents_md.erb +3 -0
- data/lib/generators/docs_kit/page/page_generator.rb +10 -4
- metadata +6 -4
|
@@ -72,9 +72,13 @@ module DocsUI
|
|
|
72
72
|
# injected by the controller under the active nav link (no content slot).
|
|
73
73
|
def content_toc? = %i[panel toggle].include?(@on_page)
|
|
74
74
|
|
|
75
|
+
# "Page · Suffix", or just the suffix on a title-less page. The suffix is
|
|
76
|
+
# resolved per render (a callable title_suffix/brand → this request's value).
|
|
77
|
+
def document_title = [@title, config.title_suffix_text].compact.join(" · ")
|
|
78
|
+
|
|
75
79
|
def render_head
|
|
76
80
|
head do
|
|
77
|
-
title {
|
|
81
|
+
title { document_title }
|
|
78
82
|
meta(charset: "utf-8")
|
|
79
83
|
meta(name: "viewport", content: "width=device-width,initial-scale=1")
|
|
80
84
|
# SEO / social-share tags (description, Open Graph, Twitter Card,
|
|
@@ -90,10 +94,20 @@ module DocsUI
|
|
|
90
94
|
meta(name: "turbo-refresh-scroll", content: "preserve")
|
|
91
95
|
config.stylesheets.each { |sheet| stylesheet_link_tag(sheet, data: { turbo_track: "reload" }) }
|
|
92
96
|
theme_restore_script
|
|
93
|
-
|
|
97
|
+
importmap_tags
|
|
94
98
|
end
|
|
95
99
|
end
|
|
96
100
|
|
|
101
|
+
# The import map tags for config.importmap_entry_point, from config.importmap
|
|
102
|
+
# when one is set. With nothing configured this is the helper's own default
|
|
103
|
+
# call, so the head matches a Shell that predates the knobs.
|
|
104
|
+
def importmap_tags
|
|
105
|
+
map = config.resolved_importmap
|
|
106
|
+
return javascript_importmap_tags(config.importmap_entry_point) if map.nil?
|
|
107
|
+
|
|
108
|
+
javascript_importmap_tags(config.importmap_entry_point, importmap: map)
|
|
109
|
+
end
|
|
110
|
+
|
|
97
111
|
# Restore the persisted theme BEFORE first paint so there's no flash of the
|
|
98
112
|
# server default before the docs-nav controller runs. Reads the same
|
|
99
113
|
# localStorage key the controller writes (docs-kit:<site>:theme). Runs on
|
|
@@ -183,9 +197,9 @@ module DocsUI
|
|
|
183
197
|
# text brand stays the mark's accessible-name fallback.
|
|
184
198
|
def brand_mark
|
|
185
199
|
if (logo = config.brand_logo)
|
|
186
|
-
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.
|
|
200
|
+
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand_text)
|
|
187
201
|
else
|
|
188
|
-
plain config.
|
|
202
|
+
plain config.brand_text
|
|
189
203
|
end
|
|
190
204
|
end
|
|
191
205
|
|
|
@@ -59,9 +59,9 @@ module DocsUI
|
|
|
59
59
|
# Slightly taller than the topbar's h-6: this is the masthead.
|
|
60
60
|
def brand_mark
|
|
61
61
|
if (logo = config.brand_logo)
|
|
62
|
-
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.
|
|
62
|
+
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand_text)
|
|
63
63
|
else
|
|
64
|
-
plain config.
|
|
64
|
+
plain config.brand_text
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
@@ -71,9 +71,12 @@ module DocsKit
|
|
|
71
71
|
# Revalidate on the rendered body itself (so any registry/config/page change
|
|
72
72
|
# busts it) plus the gem version as the etag salt. In development, always
|
|
73
73
|
# re-render; production sites deploy immutably so the version etag is stable.
|
|
74
|
+
# A callable brand (#92) makes the body per-request, so the response is then
|
|
75
|
+
# `private`: a shared cache must not hand one tenant's llms.txt to another.
|
|
74
76
|
def stale_llms?(body)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
shared = !docs_config.brand_per_request?
|
|
78
|
+
expires_in LLMS_MAX_AGE, public: shared
|
|
79
|
+
stale?(etag: [DocsKit::VERSION, body], public: shared)
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# A page's Markdown twin, rendered through this controller's view context so
|
|
@@ -78,6 +78,17 @@ module DocsKit
|
|
|
78
78
|
# stylesheets (e.g. a separate rouge theme) lists them here.
|
|
79
79
|
attr_accessor :stylesheets
|
|
80
80
|
|
|
81
|
+
# The import map entry point the Shell loads. Defaults to "application"
|
|
82
|
+
# (javascript_importmap_tags' own default). A host with a dedicated docs
|
|
83
|
+
# import map names its entry point here, e.g. "docs".
|
|
84
|
+
attr_accessor :importmap_entry_point
|
|
85
|
+
|
|
86
|
+
# The import map the Shell renders: nil (the default → the host's
|
|
87
|
+
# Rails.application.importmap), an Importmap::Map, or a zero-arity callable
|
|
88
|
+
# returning one, resolved per render so a map rebuilt on code reload is
|
|
89
|
+
# picked up. Read via #resolved_importmap.
|
|
90
|
+
attr_accessor :importmap
|
|
91
|
+
|
|
81
92
|
# The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
|
|
82
93
|
# This is the BASE (light) theme, emitted un-scoped so it applies to every
|
|
83
94
|
# theme unless a dark override wins (see #code_theme_dark).
|
|
@@ -307,6 +318,8 @@ module DocsKit
|
|
|
307
318
|
@nav_registries = {}
|
|
308
319
|
@version_badge = nil
|
|
309
320
|
@stylesheets = %w[application]
|
|
321
|
+
@importmap_entry_point = "application"
|
|
322
|
+
@importmap = nil
|
|
310
323
|
@code_theme = "Rouge::Themes::Monokai"
|
|
311
324
|
@code_theme_dark = nil
|
|
312
325
|
@dark_themes = DEFAULT_DARK_THEMES
|
|
@@ -544,14 +557,54 @@ module DocsKit
|
|
|
544
557
|
|
|
545
558
|
public
|
|
546
559
|
|
|
560
|
+
# The localStorage namespace. A callable brand can't be slugged: the key must
|
|
561
|
+
# be stable per browser, not per request, so a callable brand needs an
|
|
562
|
+
# explicit c.nav_storage_key (checked again by DocsKit.configure).
|
|
547
563
|
def nav_storage_key
|
|
548
|
-
@nav_storage_key
|
|
564
|
+
return @nav_storage_key if @nav_storage_key
|
|
565
|
+
if @brand.respond_to?(:call)
|
|
566
|
+
raise DocsKit::Error,
|
|
567
|
+
"c.brand is a callable, so set c.nav_storage_key to a stable key " \
|
|
568
|
+
"(the sidebar/theme localStorage namespace must not change per request)"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
@brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
549
572
|
end
|
|
550
573
|
|
|
574
|
+
# The raw configured suffix (or the raw brand when unset). The chrome reads
|
|
575
|
+
# #title_suffix_text, which resolves a callable.
|
|
551
576
|
def title_suffix
|
|
552
577
|
@title_suffix || @brand
|
|
553
578
|
end
|
|
554
579
|
|
|
580
|
+
# The rendered brand. A String is returned as-is; a zero-arity callable is
|
|
581
|
+
# invoked on every read (never memoize it — the point is a per-request
|
|
582
|
+
# value, e.g. -> { Current.tenant_name }).
|
|
583
|
+
def brand_text
|
|
584
|
+
resolve_text(@brand).to_s
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# True when the brand is a callable, i.e. anything that embeds the brand
|
|
588
|
+
# (the chrome, llms.txt) may differ per request and must not be stored in a
|
|
589
|
+
# shared cache.
|
|
590
|
+
def brand_per_request?
|
|
591
|
+
@brand.respond_to?(:call)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# The rendered <title>/og:title suffix: #title_suffix resolved like
|
|
595
|
+
# #brand_text, falling back to the resolved brand. nil only when both are nil,
|
|
596
|
+
# so the title join drops it instead of leaving a dangling separator.
|
|
597
|
+
def title_suffix_text
|
|
598
|
+
resolve_text(@title_suffix.nil? ? @brand : @title_suffix)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Raises at configure time for a config the chrome can't render (today: a
|
|
602
|
+
# callable brand without a stable nav_storage_key). Called by DocsKit.configure.
|
|
603
|
+
def validate!
|
|
604
|
+
nav_storage_key
|
|
605
|
+
self
|
|
606
|
+
end
|
|
607
|
+
|
|
555
608
|
# Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
|
|
556
609
|
# optional `mcp` gem is loadable — the same "toggle AND capability" shape as
|
|
557
610
|
# #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
|
|
@@ -609,6 +662,12 @@ module DocsKit
|
|
|
609
662
|
@version_badge.to_s
|
|
610
663
|
end
|
|
611
664
|
|
|
665
|
+
# The import map the Shell renders, or nil for the host's default map. A
|
|
666
|
+
# callable is invoked on every read (once per render).
|
|
667
|
+
def resolved_importmap
|
|
668
|
+
@importmap.respond_to?(:call) ? @importmap.call : @importmap
|
|
669
|
+
end
|
|
670
|
+
|
|
612
671
|
# The default Rouge theme both #code_theme_class and #code_theme_dark_class
|
|
613
672
|
# fall back to when a configured theme name can't be resolved — so a typo'd
|
|
614
673
|
# theme name degrades gracefully instead of raising on every code block.
|
|
@@ -652,5 +711,12 @@ module DocsKit
|
|
|
652
711
|
rescue NameError
|
|
653
712
|
nil
|
|
654
713
|
end
|
|
714
|
+
|
|
715
|
+
# A String-or-callable knob's value for this render: a callable is invoked
|
|
716
|
+
# and stringified, nil stays nil, anything else is stringified.
|
|
717
|
+
def resolve_text(value)
|
|
718
|
+
value = value.call if value.respond_to?(:call)
|
|
719
|
+
value&.to_s
|
|
720
|
+
end
|
|
655
721
|
end
|
|
656
722
|
end
|
data/lib/docs_kit/engine.rb
CHANGED
|
@@ -36,10 +36,17 @@ module DocsKit
|
|
|
36
36
|
app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Host-facing engine options. `config.docs_kit.auto_pin = false` (in
|
|
40
|
+
# config/application.rb) keeps the gem's pin out of the host's shared import
|
|
41
|
+
# map, for a host that pins `docs_kit/controllers` in a dedicated docs map.
|
|
42
|
+
config.docs_kit = ActiveSupport::OrderedOptions.new
|
|
43
|
+
config.docs_kit.auto_pin = true
|
|
44
|
+
|
|
39
45
|
# Auto-pin the controller for importmap-rails consumers, so a host app gets
|
|
40
46
|
# `docs_kit/controllers/docs_nav_controller` with no manual pin.
|
|
41
47
|
initializer "docs_kit.importmap", before: "importmap" do |app|
|
|
42
48
|
next unless app.config.respond_to?(:importmap)
|
|
49
|
+
next unless app.config.docs_kit.auto_pin
|
|
43
50
|
|
|
44
51
|
importmap = app.config.importmap
|
|
45
52
|
importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
|
data/lib/docs_kit/llms_text.rb
CHANGED
|
@@ -33,7 +33,7 @@ module DocsKit
|
|
|
33
33
|
# single tight list (no blank lines between bullets), per the llmstxt.org
|
|
34
34
|
# convention.
|
|
35
35
|
def index(config, base_url: nil)
|
|
36
|
-
blocks = ["# #{config.
|
|
36
|
+
blocks = ["# #{config.brand_text}"]
|
|
37
37
|
tagline = config.tagline
|
|
38
38
|
blocks << "> #{tagline}" if tagline && !tagline.to_s.empty?
|
|
39
39
|
blocks.concat(section_blocks(config, base_url))
|
data/lib/docs_kit/mcp_server.rb
CHANGED
|
@@ -28,7 +28,7 @@ module DocsKit
|
|
|
28
28
|
return unless mcp_available?
|
|
29
29
|
|
|
30
30
|
server = MCP::Server.new(
|
|
31
|
-
name: config.
|
|
31
|
+
name: config.brand_text,
|
|
32
32
|
version: DocsKit::VERSION,
|
|
33
33
|
instructions: instructions_for(config, base_url),
|
|
34
34
|
server_context: { config:, base_url:, view_context: }
|
|
@@ -54,7 +54,7 @@ module DocsKit
|
|
|
54
54
|
lines = []
|
|
55
55
|
tagline = config.tagline
|
|
56
56
|
lines << tagline.to_s if tagline && !tagline.to_s.empty?
|
|
57
|
-
lines << "Read-only documentation tools for #{config.
|
|
57
|
+
lines << "Read-only documentation tools for #{config.brand_text}. " \
|
|
58
58
|
"Use search_docs to find sections, get_page to read a page's Markdown, " \
|
|
59
59
|
"and list_pages to enumerate the docs. Full index: #{llms}."
|
|
60
60
|
lines.join(" ")
|
|
@@ -64,7 +64,7 @@ after_bundle do
|
|
|
64
64
|
|
|
65
65
|
# dash 4 renamed the on-host proxy (kamal-proxy → dash-proxy) and migrates a
|
|
66
66
|
# host in place; an older CLI must not deploy this config.
|
|
67
|
-
minimum_version: 4.0.
|
|
67
|
+
minimum_version: 4.0.7
|
|
68
68
|
|
|
69
69
|
# A stateless docs site never rolls back far — keep the host tidy.
|
|
70
70
|
retain_containers: 2
|
data/lib/docs_kit/version.rb
CHANGED
data/lib/docs_kit.rb
CHANGED
|
@@ -57,6 +57,12 @@ module DocsKit
|
|
|
57
57
|
|
|
58
58
|
def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}"
|
|
59
59
|
|
|
60
|
+
# The generated Tailwind @source globs file bin/build-css rewrites on
|
|
61
|
+
# every build — gitignored fleet-wide (see ignore_generated_css_sources).
|
|
62
|
+
# The covering/negation line regexes live beside the path in SyncReport,
|
|
63
|
+
# which shares them for the tracked-file drift check.
|
|
64
|
+
TAILWIND_SOURCES = SyncReport::TAILWIND_SOURCES
|
|
65
|
+
|
|
60
66
|
# The RuboCop wiring docs-kit injects. REQUIRE loads the cops;
|
|
61
67
|
# INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml).
|
|
62
68
|
RUBOCOP_REQUIRE = "docs_kit/rubocop"
|
|
@@ -192,6 +198,35 @@ module DocsKit
|
|
|
192
198
|
create_file "app/assets/builds/.keep", ""
|
|
193
199
|
end
|
|
194
200
|
|
|
201
|
+
# Gitignore the bin/build-css-generated @source globs file (#71). It
|
|
202
|
+
# carries machine-specific absolute gem paths, so a committed copy churns
|
|
203
|
+
# on every rebuild by a different machine/Ruby — and no build consumes it
|
|
204
|
+
# (.dockerignore excludes it; every build path runs bin/build-css first).
|
|
205
|
+
# Additive + idempotent (tolerant of a hand-added entry with or without
|
|
206
|
+
# the leading slash), and NOT --sync-guarded: the ignore is the fleet-wide
|
|
207
|
+
# upgrade this step exists to ship. Untracking an already-committed copy
|
|
208
|
+
# is the site's call — SyncReport warns with the exact command instead
|
|
209
|
+
# (the generator never mutates git state).
|
|
210
|
+
def ignore_generated_css_sources
|
|
211
|
+
entry = "# Generated by bin/build-css (resolved gem @source globs).\n/#{TAILWIND_SOURCES}\n"
|
|
212
|
+
path = File.join(destination_root, ".gitignore")
|
|
213
|
+
return create_file(".gitignore", entry) unless File.exist?(path)
|
|
214
|
+
|
|
215
|
+
# Last-match-wins, like git reads the file: an EFFECTIVE `!` unignore is
|
|
216
|
+
# the site's deliberate opt-out — appending our entry after it would
|
|
217
|
+
# become the last matching rule and silently defeat the hand-edit, so
|
|
218
|
+
# back off (SyncReport skips its nag too). An effective ignore is done.
|
|
219
|
+
case SyncReport.tailwind_sources_rule(File.read(path))
|
|
220
|
+
when :negate
|
|
221
|
+
say_status(:skip, ".gitignore negates tailwind.sources.css (!) — respecting the site's opt-out",
|
|
222
|
+
:yellow)
|
|
223
|
+
when :ignore
|
|
224
|
+
say_status(:identical, ".gitignore (tailwind.sources.css)", :blue)
|
|
225
|
+
else
|
|
226
|
+
append_to_file ".gitignore", "\n#{entry}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
195
230
|
# Install the `docs_kit:og` rake task — gem-owned wiring, refreshed on every
|
|
196
231
|
# run so a site picks up task fixes. It does NOT ship an OG image: the
|
|
197
232
|
# social-share image is SITE content, generated into the site's OWN
|
|
@@ -539,9 +574,9 @@ module DocsKit
|
|
|
539
574
|
"watch:css": "bin/build-css --watch"
|
|
540
575
|
},
|
|
541
576
|
"devDependencies": {
|
|
542
|
-
"@tailwindcss/cli": "^4.
|
|
543
|
-
"daisyui": "^5.
|
|
544
|
-
"tailwindcss": "^4.
|
|
577
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
578
|
+
"daisyui": "^5.7.47",
|
|
579
|
+
"tailwindcss": "^4.3.3"
|
|
545
580
|
}
|
|
546
581
|
}
|
|
547
582
|
JSON
|
|
@@ -578,7 +613,7 @@ module DocsKit
|
|
|
578
613
|
# The RUBY_VERSION build ARG default: the host's running Ruby (X.Y.Z), so a
|
|
579
614
|
# site's image matches its dev Ruby. Used in Dockerfile.tt.
|
|
580
615
|
def ruby_version_arg
|
|
581
|
-
RUBY_VERSION[/\d+\.\d+\.\d+/] || "3.4.
|
|
616
|
+
RUBY_VERSION[/\d+\.\d+\.\d+/] || "3.4.11"
|
|
582
617
|
end
|
|
583
618
|
|
|
584
619
|
# True when the site bundles Thruster (HTTP caching + compression +
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "migration"
|
|
4
|
+
require_relative "migrations/v1_2_0"
|
|
4
5
|
require_relative "../../../docs_kit/version"
|
|
5
6
|
|
|
6
7
|
module DocsKit
|
|
@@ -18,24 +19,26 @@ module DocsKit
|
|
|
18
19
|
# re-run on EVERY sync forever. It can't legitimately exist anyway (a site
|
|
19
20
|
# can't have "arrived" at a release it doesn't have), so it's filtered out.
|
|
20
21
|
#
|
|
21
|
-
# `.default` is the registry the generator uses.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# a one-line `Migration.new(...)` addition here once a release needs one.
|
|
22
|
+
# `.default` is the registry the generator uses. Each release that changes
|
|
23
|
+
# what a site must carry adds one `Migration.new(...)` entry to MIGRATIONS,
|
|
24
|
+
# its transform in migrations/vX_Y_Z.rb.
|
|
25
25
|
class MigrationRegistry
|
|
26
26
|
def initialize(migrations = [])
|
|
27
27
|
@migrations = migrations.sort_by(&:to)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# The registry the install generator runs during `--sync`.
|
|
30
|
+
# The registry the install generator runs during `--sync`.
|
|
31
31
|
def self.default
|
|
32
32
|
@default ||= new(MIGRATIONS)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
# Ordered `Migration.new(to: "1.x.0", description: "...") { ... }` entries, one
|
|
36
|
+
# per release that changes config knobs, routes, templates, or toolchain.
|
|
37
|
+
MIGRATIONS = [
|
|
38
|
+
Migration.new(to: "1.2.0", description: Migrations::V1_2_0::DESCRIPTION) do |root, _generator|
|
|
39
|
+
Migrations::V1_2_0.call(root)
|
|
40
|
+
end
|
|
41
|
+
].freeze
|
|
39
42
|
|
|
40
43
|
# The migrations a site last synced at `from_version` still needs, ascending
|
|
41
44
|
# by version — those in `(from_version, upto]`. `upto` defaults to the
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsKit
|
|
4
|
+
module Generators
|
|
5
|
+
module Migrations
|
|
6
|
+
# The 1.1 → 1.2 upgrade. docs-kit 1.2 needs Ruby >= 3.3, scaffolds Bun 1.4.2
|
|
7
|
+
# and newer Tailwind/daisyUI floors, and its reusable deploy workflow moved to
|
|
8
|
+
# Node 24 actions (only callers that track `@main` get that).
|
|
9
|
+
#
|
|
10
|
+
# Warn-only-safe: a value is rewritten only when it is EXACTLY what an older
|
|
11
|
+
# docs-kit generated (so it can't be a hand edit); anything else older is
|
|
12
|
+
# handed back as a warning, and a newer value is left alone.
|
|
13
|
+
module V1_2_0 # rubocop:disable Naming/ClassAndModuleCamelCase -- named after the release
|
|
14
|
+
DESCRIPTION = "docs-kit 1.2: Ruby >= 3.3, Bun 1.4.2, Tailwind 4.3 / daisyUI 5.7 floors, Node 24 deploy"
|
|
15
|
+
MIN_RUBY = Gem::Version.new("3.3")
|
|
16
|
+
BUN = { from: "1.3.2", to: "1.4.2" }.freeze
|
|
17
|
+
PACKAGE_FLOORS = {
|
|
18
|
+
"tailwindcss" => { from: "^4.1.18", to: "^4.3.3" },
|
|
19
|
+
"@tailwindcss/cli" => { from: "^4.1.18", to: "^4.3.3" },
|
|
20
|
+
"daisyui" => { from: "^5.6.0", to: "^5.7.47" }
|
|
21
|
+
}.freeze
|
|
22
|
+
DEPLOY_CALL = %r{zoolutions/docs-kit/\.github/workflows/deploy\.yml@(\S+)}
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Returns the warnings it could not safely automate.
|
|
27
|
+
def call(root)
|
|
28
|
+
bun(root) + ruby(root) + package_floors(root) + deploy_pins(root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bun(root)
|
|
32
|
+
edit(root, "Dockerfile") do |source, warnings|
|
|
33
|
+
current = source[/^ARG BUN_VERSION=(\S+)$/, 1]
|
|
34
|
+
next source unless current && older?(current, BUN[:to])
|
|
35
|
+
next source.sub("ARG BUN_VERSION=#{current}", "ARG BUN_VERSION=#{BUN[:to]}") if current == BUN[:from]
|
|
36
|
+
|
|
37
|
+
warnings << "Dockerfile: ARG BUN_VERSION=#{current} is older than the #{BUN[:to]} docs-kit now " \
|
|
38
|
+
"scaffolds; raise it (a bun.lock written by a newer Bun can fail " \
|
|
39
|
+
"`bun install --frozen-lockfile`)"
|
|
40
|
+
source
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ruby(root)
|
|
45
|
+
{
|
|
46
|
+
"Dockerfile" => read(root, "Dockerfile")&.[](/^ARG RUBY_VERSION=\S+$/),
|
|
47
|
+
".ruby-version" => read(root, ".ruby-version")&.strip
|
|
48
|
+
}.filter_map do |file, value|
|
|
49
|
+
next unless value && below_min_ruby?(value.delete_prefix("ARG RUBY_VERSION="))
|
|
50
|
+
|
|
51
|
+
"#{file}: #{value} — docs-kit 1.2 requires Ruby >= #{MIN_RUBY}; move the site to 3.3 or newer"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def package_floors(root)
|
|
56
|
+
edit(root, "package.json") do |source, warnings|
|
|
57
|
+
PACKAGE_FLOORS.reduce(source) do |json, (name, floor)|
|
|
58
|
+
raise_floor(json, name, floor, warnings)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def raise_floor(json, name, floor, warnings)
|
|
64
|
+
pair = /("#{Regexp.escape(name)}"\s*:\s*")([^"]+)(")/
|
|
65
|
+
current = json[pair, 2]
|
|
66
|
+
return json unless current && older?(current, floor[:to])
|
|
67
|
+
if current == floor[:from]
|
|
68
|
+
return json.sub(pair) { "#{Regexp.last_match(1)}#{floor[:to]}#{Regexp.last_match(3)}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
warnings << "package.json: #{name} #{current} is below the #{floor[:to]} docs-kit now builds against"
|
|
72
|
+
json
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deploy_pins(root)
|
|
76
|
+
Dir[File.join(root, ".github/workflows/*.{yml,yaml}")].filter_map do |path|
|
|
77
|
+
ref = File.read(path)[DEPLOY_CALL, 1]
|
|
78
|
+
next if ref.nil? || ref == "main"
|
|
79
|
+
|
|
80
|
+
"#{File.basename(path)}: calls docs-kit's deploy.yml@#{ref}; the Node 24 actions and " \
|
|
81
|
+
"ubuntu-26.04 runners are on @main — move the pin forward (or track @main)"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Yields [source, warnings] for an existing file; writes back what the block returns.
|
|
86
|
+
def edit(root, file)
|
|
87
|
+
source = read(root, file)
|
|
88
|
+
return [] unless source
|
|
89
|
+
|
|
90
|
+
warnings = []
|
|
91
|
+
updated = yield(source, warnings)
|
|
92
|
+
File.write(File.join(root, file), updated) unless updated == source
|
|
93
|
+
warnings
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read(root, file)
|
|
97
|
+
path = File.join(root, file)
|
|
98
|
+
File.exist?(path) ? File.read(path) : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "^5.6.0" / "~> 4.1" / "3.2.4" / "ruby-3.2.4" → its version, or nil when it
|
|
102
|
+
# isn't a plain version (a tag like "latest", a git URL, a range).
|
|
103
|
+
def version_of(value)
|
|
104
|
+
number = value.to_s[/\A(?:ruby-)?[\^~]?>?\s*(\d+(?:\.\d+)*)\z/, 1]
|
|
105
|
+
number && Gem::Version.new(number)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def older?(current, target)
|
|
109
|
+
current_version = version_of(current)
|
|
110
|
+
current_version ? current_version < version_of(target) : false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def below_min_ruby?(value)
|
|
114
|
+
version = version_of(value)
|
|
115
|
+
version ? version < MIN_RUBY : false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -17,10 +17,54 @@ module DocsKit
|
|
|
17
17
|
# - a dead IconHelper copy — the gem renders icons via rails_icons.
|
|
18
18
|
# - a Dockerfile stamped by an OLDER docs-kit than the gem now ships — the
|
|
19
19
|
# site should diff against the current template and adopt the improvements.
|
|
20
|
+
# - a git-tracked tailwind.sources.css — generated per-build with machine
|
|
21
|
+
# absolute gem paths (#71); gitignored now, but untracking a committed
|
|
22
|
+
# copy stages a deletion, so the site runs `git rm --cached` itself.
|
|
20
23
|
class SyncReport
|
|
21
24
|
APPLICATION_CONTROLLER = "app/controllers/application_controller.rb"
|
|
22
25
|
ICON_HELPER = "app/helpers/icon_helper.rb"
|
|
23
26
|
DOCKERFILE = "Dockerfile"
|
|
27
|
+
TAILWIND_SOURCES = "app/assets/stylesheets/tailwind.sources.css"
|
|
28
|
+
|
|
29
|
+
# A .gitignore line covering the generated file per gitignore semantics:
|
|
30
|
+
# the anchored path (leading slash optional — a slash-containing pattern
|
|
31
|
+
# is root-anchored either way) or the bare filename (no slash → matches
|
|
32
|
+
# at any depth), each optionally **/-prefixed. Deliberately NOT a full
|
|
33
|
+
# gitignore matcher: an exotic broader glob (`app/assets/stylesheets/*`)
|
|
34
|
+
# is missed at the cost of one redundant, harmless line. This regex
|
|
35
|
+
# drives the generator's APPEND decision only; the drift warning asks
|
|
36
|
+
# git itself (see #ignored_by_git?, scoped to the committed .gitignore
|
|
37
|
+
# files so a user's personal excludes never decide repo content).
|
|
38
|
+
# `[ \t\r]*` (not `[ \t]*`): `$` matches before `\n` but never past a
|
|
39
|
+
# `\r`, so the class must consume the CR of a CRLF-checked-out file.
|
|
40
|
+
TAILWIND_SOURCES_COVER =
|
|
41
|
+
"(?:(?:/|\\*\\*/)?#{Regexp.escape(File.dirname(TAILWIND_SOURCES))}/|(?:\\*\\*/)?)" \
|
|
42
|
+
"#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t\r]*".freeze
|
|
43
|
+
TAILWIND_SOURCES_IGNORED_RE = /^#{TAILWIND_SOURCES_COVER}$/
|
|
44
|
+
# An explicit `!` unignore of the file — the site's deliberate opt-out
|
|
45
|
+
# from the fleet convention (it wants the file committed). The
|
|
46
|
+
# generator's append respects it when it's the file's effective rule
|
|
47
|
+
# (see .tailwind_sources_rule); the drift warning asks git instead
|
|
48
|
+
# (#ignored_by_git?), which resolves full pattern semantics.
|
|
49
|
+
TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/
|
|
50
|
+
|
|
51
|
+
# The file's effective disposition among the recognized .gitignore lines,
|
|
52
|
+
# honoring git's last-match-wins: `:negate` (the site's genuine opt-out),
|
|
53
|
+
# `:ignore` (already covered), or nil (no recognized line). A `!` line
|
|
54
|
+
# overridden by a LATER ignore line is dead — git ignores the file, so
|
|
55
|
+
# treating it as an opt-out would misreport the site's intent. Drives the
|
|
56
|
+
# generator's append decision only (the drift check asks git itself).
|
|
57
|
+
def self.tailwind_sources_rule(gitignore_content)
|
|
58
|
+
rule = nil
|
|
59
|
+
gitignore_content.each_line do |line|
|
|
60
|
+
if line.match?(TAILWIND_SOURCES_NEGATED_RE)
|
|
61
|
+
rule = :negate
|
|
62
|
+
elsif line.match?(TAILWIND_SOURCES_IGNORED_RE)
|
|
63
|
+
rule = :ignore
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
rule
|
|
67
|
+
end
|
|
24
68
|
|
|
25
69
|
# Matches the version stamp the Dockerfile template writes, e.g.
|
|
26
70
|
# `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site
|
|
@@ -34,7 +78,7 @@ module DocsKit
|
|
|
34
78
|
# The drift messages, in the order a site should act on them. Empty when
|
|
35
79
|
# the site is clean.
|
|
36
80
|
def items
|
|
37
|
-
[render_page_drift, icon_helper_drift, dockerfile_drift].compact
|
|
81
|
+
[render_page_drift, icon_helper_drift, dockerfile_drift, tailwind_sources_drift].compact
|
|
38
82
|
end
|
|
39
83
|
|
|
40
84
|
def clean?
|
|
@@ -79,6 +123,48 @@ module DocsKit
|
|
|
79
123
|
"diff against the template (bin/rails g docs_kit:install shows the path) and adopt the changes."
|
|
80
124
|
end
|
|
81
125
|
|
|
126
|
+
# tailwind.sources.css is regenerated by bin/build-css with machine-local
|
|
127
|
+
# absolute gem paths — the generator gitignores it, but a copy committed
|
|
128
|
+
# before the ignore stays tracked (gitignore doesn't untrack). Untracking
|
|
129
|
+
# stages a deletion, so we hand the site the exact command instead of
|
|
130
|
+
# touching its index.
|
|
131
|
+
def tailwind_sources_drift
|
|
132
|
+
return unless tracked_by_git?(TAILWIND_SOURCES)
|
|
133
|
+
# git's own verdict, not the recognized-lines regex: git resolves the
|
|
134
|
+
# FULL pattern semantics (broad globs, ordering, nested .gitignores),
|
|
135
|
+
# so a dead `!` line before a broader ignore still warns, while a
|
|
136
|
+
# genuinely effective negation (the site's opt-out) stays quiet.
|
|
137
|
+
return unless ignored_by_git?(TAILWIND_SOURCES)
|
|
138
|
+
|
|
139
|
+
"#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \
|
|
140
|
+
"run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# True when the repo's own .gitignore rules cover `rel` — git's full
|
|
144
|
+
# pattern semantics (broad globs, `!` ordering, nested .gitignores), but
|
|
145
|
+
# SCOPED to the repo's committed convention: `--exclude-per-directory`
|
|
146
|
+
# consults only the .gitignore files, never `.git/info/exclude` or a
|
|
147
|
+
# global core.excludesFile, so the verdict (and the "the ignore entry is
|
|
148
|
+
# in place" guidance it backs) is identical on every machine.
|
|
149
|
+
# `--cached --ignored` reports a TRACKED path matching an exclude — the
|
|
150
|
+
# exact state this drift check exists to catch. Only called after
|
|
151
|
+
# tracked_by_git? proved git + a repo exist.
|
|
152
|
+
def ignored_by_git?(rel)
|
|
153
|
+
out = IO.popen(["git", "-C", @root, "ls-files", "--cached", "--ignored",
|
|
154
|
+
"--exclude-per-directory=.gitignore", "--", rel],
|
|
155
|
+
err: File::NULL, &:read)
|
|
156
|
+
!out.strip.empty?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# True when the site's git index tracks `rel`. Conservative: no git on
|
|
160
|
+
# PATH, not a repo, or an untracked file all read as "no drift". ls-files
|
|
161
|
+
# only consults the index (no commit needed), and `git -C` resolves the
|
|
162
|
+
# repo upward, so a docs site living in a subdir of a larger repo works.
|
|
163
|
+
def tracked_by_git?(rel)
|
|
164
|
+
system("git", "-C", @root, "ls-files", "--error-unmatch", rel,
|
|
165
|
+
out: File::NULL, err: File::NULL) == true
|
|
166
|
+
end
|
|
167
|
+
|
|
82
168
|
def read(rel)
|
|
83
169
|
path = File.join(@root, rel)
|
|
84
170
|
File.exist?(path) ? File.read(path) : nil
|
|
@@ -66,6 +66,9 @@ end
|
|
|
66
66
|
- **The primary argument is positional; modifiers are keywords.**
|
|
67
67
|
`Section("Title", description:)`, `Code(source, filename:)`,
|
|
68
68
|
`Header("Title", eyebrow:)`.
|
|
69
|
+
- **`Code`'s `filename:` selects the language** (`*.yml` → yaml, `Dockerfile`
|
|
70
|
+
→ docker, `*.sh` → shell, …); pass `lexer:` only to override the guess or when
|
|
71
|
+
there is no filename (the default is ruby).
|
|
69
72
|
- **Wrappers that take no positional arg use lowercase page helpers** so a block
|
|
70
73
|
needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`,
|
|
71
74
|
`operation "operationId"`. (A bare `DocsUI::Prose do` is a Ruby SyntaxError; the
|
|
@@ -101,13 +101,19 @@ module DocsKit
|
|
|
101
101
|
source.match?(/^\s*entries\s*\[/) && !source.match?(/^\s*page\s+["']/)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
# Inject after the last existing `page` line so ordering lands
|
|
105
|
-
# of the group; else after view_namespace/path_prefix;
|
|
106
|
-
# `extend DocsKit::Registry` line.
|
|
104
|
+
# Inject after the last existing `page` line of the FILE so ordering lands
|
|
105
|
+
# at the end of the last group; else after view_namespace/path_prefix;
|
|
106
|
+
# else after the `extend DocsKit::Registry` line.
|
|
107
|
+
#
|
|
108
|
+
# The `page` anchor is the last matching line as a String, not a Regexp:
|
|
109
|
+
# Thor's inject_into_file replaces EVERY match of a Regexp `after:`, so a
|
|
110
|
+
# "page line not followed by a page line" pattern fires once per group in
|
|
111
|
+
# a registry laid out with blank-line-separated groups (the layout
|
|
112
|
+
# docs_kit:install produces) and duplicates the entry.
|
|
107
113
|
def registry_anchor(source)
|
|
108
114
|
case source
|
|
109
115
|
when /^\s*page\s+["']/
|
|
110
|
-
/^\s*page
|
|
116
|
+
source.lines.grep(/^\s*page\s+["']/).last
|
|
111
117
|
when /^\s*view_namespace\s/
|
|
112
118
|
/^\s*view_namespace .*\n/
|
|
113
119
|
when /^\s*path_prefix\s/
|