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.
@@ -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 { [@title, config.title_suffix].compact.join(" · ") }
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
- javascript_importmap_tags
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.brand)
200
+ render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand_text)
187
201
  else
188
- plain config.brand
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.brand)
62
+ render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand_text)
63
63
  else
64
- plain config.brand
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
- expires_in LLMS_MAX_AGE, public: true
76
- stale?(etag: [DocsKit::VERSION, body], public: true)
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 || @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
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
@@ -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)
@@ -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.brand}"]
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))
@@ -28,7 +28,7 @@ module DocsKit
28
28
  return unless mcp_available?
29
29
 
30
30
  server = MCP::Server.new(
31
- name: config.brand.to_s,
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.brand}. " \
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.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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.1.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -25,7 +25,7 @@ module DocsKit
25
25
 
26
26
  def configure
27
27
  yield(configuration)
28
- configuration
28
+ configuration.validate!
29
29
  end
30
30
 
31
31
  def reset_configuration!
@@ -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.1.18",
543
- "daisyui": "^5.6.0",
544
- "tailwindcss": "^4.1.18"
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.2"
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. It SHIPS EMPTY at 1.0.x —
22
- # the mechanism (stamp the synced version, detect the gap, run ordered
23
- # transforms) is the deliverable; the first concrete `1.x → 1.y` transform is
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`. Empty today.
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
- # No migrations to register yet — the mechanism is the 1.0 deliverable.
36
- # Add ordered `Migration.new(to: "1.x.0", description: "...") { ... }`
37
- # entries here as future releases change config knobs, routes, or templates.
38
- MIGRATIONS = [].freeze
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
@@ -15,7 +15,7 @@ FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
15
15
  # Rails app lives here.
16
16
  WORKDIR /rails
17
17
 
18
- ARG BUN_VERSION=1.3.2
18
+ ARG BUN_VERSION=1.4.2
19
19
  ENV BUN_INSTALL="/usr/local/bun" \
20
20
  PATH="/usr/local/bun/bin:$PATH" \
21
21
  BUNDLE_DEPLOYMENT="1" \
@@ -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 at the end
105
- # of the group; else after view_namespace/path_prefix; else after the
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 .*\n(?!\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/