Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ generators:
| `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. |
| Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. |
| `Dockerfile` stamped by an older docs-kit (`# docs-kit Dockerfile vX.Y.Z`) | docs-kit ships an optimized, multi-stage Dockerfile; a stale copy misses image-size wins | Diff yours against the current template (`lib/generators/docs_kit/install/templates/Dockerfile.tt` in the gem), adopt the changes or replace it. See [Upgrading your Dockerfile](#upgrading-your-dockerfile). |
| `app/assets/stylesheets/tailwind.sources.css` committed to git | `bin/build-css` regenerates it on every build with machine-specific absolute gem paths — the committed copy churns per machine/Ruby and no build consumes it. The generator adds the `.gitignore` entry, but gitignoring doesn't untrack an already-committed copy. | `git rm --cached app/assets/stylesheets/tailwind.sources.css` and commit. |

### Upgrading your Dockerfile

Expand Down
35 changes: 35 additions & 0 deletions lib/generators/docs_kit/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ class InstallGenerator < ::Rails::Generators::Base

def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}"

# The generated Tailwind @source globs file bin/build-css rewrites on
# every build — gitignored fleet-wide (see ignore_generated_css_sources).
# The covering/negation line regexes live beside the path in SyncReport,
# which shares them for the tracked-file drift check.
TAILWIND_SOURCES = SyncReport::TAILWIND_SOURCES

# The RuboCop wiring docs-kit injects. REQUIRE loads the cops;
# INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml).
RUBOCOP_REQUIRE = "docs_kit/rubocop"
Expand Down Expand Up @@ -192,6 +198,35 @@ def create_css_build
create_file "app/assets/builds/.keep", ""
end

# Gitignore the bin/build-css-generated @source globs file (#71). It
# carries machine-specific absolute gem paths, so a committed copy churns
# on every rebuild by a different machine/Ruby — and no build consumes it
# (.dockerignore excludes it; every build path runs bin/build-css first).
# Additive + idempotent (tolerant of a hand-added entry with or without
# the leading slash), and NOT --sync-guarded: the ignore is the fleet-wide
# upgrade this step exists to ship. Untracking an already-committed copy
# is the site's call — SyncReport warns with the exact command instead
# (the generator never mutates git state).
def ignore_generated_css_sources
entry = "# Generated by bin/build-css (resolved gem @source globs).\n/#{TAILWIND_SOURCES}\n"
path = File.join(destination_root, ".gitignore")
return create_file(".gitignore", entry) unless File.exist?(path)

# Last-match-wins, like git reads the file: an EFFECTIVE `!` unignore is
# the site's deliberate opt-out — appending our entry after it would
# become the last matching rule and silently defeat the hand-edit, so
# back off (SyncReport skips its nag too). An effective ignore is done.
case SyncReport.tailwind_sources_rule(File.read(path))
when :negate
say_status(:skip, ".gitignore negates tailwind.sources.css (!) — respecting the site's opt-out",
:yellow)
when :ignore
say_status(:identical, ".gitignore (tailwind.sources.css)", :blue)
else
append_to_file ".gitignore", "\n#{entry}"
end
end

# Install the `docs_kit:og` rake task — gem-owned wiring, refreshed on every
# run so a site picks up task fixes. It does NOT ship an OG image: the
# social-share image is SITE content, generated into the site's OWN
Expand Down
88 changes: 87 additions & 1 deletion lib/generators/docs_kit/install/sync_report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,54 @@ module Generators
# - a dead IconHelper copy — the gem renders icons via rails_icons.
# - a Dockerfile stamped by an OLDER docs-kit than the gem now ships — the
# site should diff against the current template and adopt the improvements.
# - a git-tracked tailwind.sources.css — generated per-build with machine
# absolute gem paths (#71); gitignored now, but untracking a committed
# copy stages a deletion, so the site runs `git rm --cached` itself.
class SyncReport
APPLICATION_CONTROLLER = "app/controllers/application_controller.rb"
ICON_HELPER = "app/helpers/icon_helper.rb"
DOCKERFILE = "Dockerfile"
TAILWIND_SOURCES = "app/assets/stylesheets/tailwind.sources.css"

# A .gitignore line covering the generated file per gitignore semantics:
# the anchored path (leading slash optional — a slash-containing pattern
# is root-anchored either way) or the bare filename (no slash → matches
# at any depth), each optionally **/-prefixed. Deliberately NOT a full
# gitignore matcher: an exotic broader glob (`app/assets/stylesheets/*`)
# is missed at the cost of one redundant, harmless line. This regex
# drives the generator's APPEND decision only; the drift warning asks
# git itself (see #ignored_by_git?, scoped to the committed .gitignore
# files so a user's personal excludes never decide repo content).
# `[ \t\r]*` (not `[ \t]*`): `$` matches before `\n` but never past a
# `\r`, so the class must consume the CR of a CRLF-checked-out file.
TAILWIND_SOURCES_COVER =
"(?:(?:/|\\*\\*/)?#{Regexp.escape(File.dirname(TAILWIND_SOURCES))}/|(?:\\*\\*/)?)" \
"#{Regexp.escape(File.basename(TAILWIND_SOURCES))}[ \t\r]*".freeze
TAILWIND_SOURCES_IGNORED_RE = /^#{TAILWIND_SOURCES_COVER}$/
# An explicit `!` unignore of the file — the site's deliberate opt-out
# from the fleet convention (it wants the file committed). The
# generator's append respects it when it's the file's effective rule
# (see .tailwind_sources_rule); the drift warning asks git instead
# (#ignored_by_git?), which resolves full pattern semantics.
TAILWIND_SOURCES_NEGATED_RE = /^!#{TAILWIND_SOURCES_COVER}$/

# The file's effective disposition among the recognized .gitignore lines,
# honoring git's last-match-wins: `:negate` (the site's genuine opt-out),
# `:ignore` (already covered), or nil (no recognized line). A `!` line
# overridden by a LATER ignore line is dead — git ignores the file, so
# treating it as an opt-out would misreport the site's intent. Drives the
# generator's append decision only (the drift check asks git itself).
def self.tailwind_sources_rule(gitignore_content)
Comment thread
mhenrixon marked this conversation as resolved.
rule = nil
gitignore_content.each_line do |line|
if line.match?(TAILWIND_SOURCES_NEGATED_RE)
rule = :negate
elsif line.match?(TAILWIND_SOURCES_IGNORED_RE)
rule = :ignore
end
end
rule
end

# Matches the version stamp the Dockerfile template writes, e.g.
# `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site
Expand All @@ -34,7 +78,7 @@ def initialize(destination_root)
# The drift messages, in the order a site should act on them. Empty when
# the site is clean.
def items
[render_page_drift, icon_helper_drift, dockerfile_drift].compact
[render_page_drift, icon_helper_drift, dockerfile_drift, tailwind_sources_drift].compact
end

def clean?
Expand Down Expand Up @@ -79,6 +123,48 @@ def dockerfile_drift
"diff against the template (bin/rails g docs_kit:install shows the path) and adopt the changes."
end

# tailwind.sources.css is regenerated by bin/build-css with machine-local
# absolute gem paths — the generator gitignores it, but a copy committed
# before the ignore stays tracked (gitignore doesn't untrack). Untracking
# stages a deletion, so we hand the site the exact command instead of
# touching its index.
def tailwind_sources_drift
return unless tracked_by_git?(TAILWIND_SOURCES)
# git's own verdict, not the recognized-lines regex: git resolves the
# FULL pattern semantics (broad globs, ordering, nested .gitignores),
# so a dead `!` line before a broader ignore still warns, while a
# genuinely effective negation (the site's opt-out) stays quiet.
return unless ignored_by_git?(TAILWIND_SOURCES)

"#{TAILWIND_SOURCES} is generated by bin/build-css but tracked by git — " \
"run `git rm --cached #{TAILWIND_SOURCES}` and commit (the ignore entry is in place)."
end

# True when the repo's own .gitignore rules cover `rel` — git's full
# pattern semantics (broad globs, `!` ordering, nested .gitignores), but
# SCOPED to the repo's committed convention: `--exclude-per-directory`
# consults only the .gitignore files, never `.git/info/exclude` or a
# global core.excludesFile, so the verdict (and the "the ignore entry is
# in place" guidance it backs) is identical on every machine.
# `--cached --ignored` reports a TRACKED path matching an exclude — the
# exact state this drift check exists to catch. Only called after
# tracked_by_git? proved git + a repo exist.
def ignored_by_git?(rel)
Comment thread
mhenrixon marked this conversation as resolved.
out = IO.popen(["git", "-C", @root, "ls-files", "--cached", "--ignored",
"--exclude-per-directory=.gitignore", "--", rel],
err: File::NULL, &:read)
!out.strip.empty?
end

# True when the site's git index tracks `rel`. Conservative: no git on
# PATH, not a repo, or an untracked file all read as "no drift". ls-files
# only consults the index (no commit needed), and `git -C` resolves the
# repo upward, so a docs site living in a subdir of a larger repo works.
def tracked_by_git?(rel)
system("git", "-C", @root, "ls-files", "--error-unmatch", rel,
out: File::NULL, err: File::NULL) == true
end

def read(rel)
path = File.join(@root, rel)
File.exist?(path) ? File.read(path) : nil
Expand Down
170 changes: 170 additions & 0 deletions spec/generators/install_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,176 @@ def render_page(view)
end
end

# Fleet convention (#71): bin/build-css regenerates tailwind.sources.css on
# every build with machine-specific absolute gem paths — committed, it churns
# per machine/Ruby and no build consumes the committed copy. The generator
# gitignores it (additive, idempotent, runs under --sync too); untracking an
# already-committed copy is warned via the drift report, never automated.
describe "gitignoring the generated tailwind.sources.css" do
let(:sources_path) { "app/assets/stylesheets/tailwind.sources.css" }

it "appends the ignore entry to an existing .gitignore" do
build_skeleton
write(".gitignore", "/node_modules\n")

run_generator

gitignore = read(".gitignore")
expect(gitignore).to include("/node_modules")
expect(gitignore).to match(%r{^/#{Regexp.escape(sources_path)}$})
end

it "creates a .gitignore carrying the entry when the site has none" do
build_skeleton

run_generator

expect(read(".gitignore")).to match(%r{^/#{Regexp.escape(sources_path)}$})
end

it "is idempotent — a re-run adds no duplicate entry" do
build_skeleton
run_generator
run_generator

expect(read(".gitignore").scan(sources_path).size).to eq(1)
end

it "tolerates a hand-added entry without a leading slash (no duplicate)" do
build_skeleton
write(".gitignore", "#{sources_path}\n")

run_generator

expect(read(".gitignore").scan(sources_path).size).to eq(1)
end

it "treats a bare-filename ignore line as covering (matches at any depth — no duplicate)" do
build_skeleton
write(".gitignore", "tailwind.sources.css\n")

run_generator

expect(read(".gitignore")).to eq("tailwind.sources.css\n")
end

it "respects a site's explicit negation (!) — never appends an override" do
# A site that deliberately unignores + commits the file has opted out of
# the fleet convention. Appending our entry would become the LAST matching
# rule and silently defeat the hand-edit — so the generator backs off.
build_skeleton
write(".gitignore", "app/assets/stylesheets/*\n!/#{sources_path}\n")

output = capture_generator

expect(read(".gitignore")).to eq("app/assets/stylesheets/*\n!/#{sources_path}\n")
expect(output).to match(/negat|opt-out/i)
end

it "recognizes an existing entry on a CRLF .gitignore (no duplicate per re-run)" do
build_skeleton
write(".gitignore", "/#{sources_path}\r\n")

run_generator

expect(read(".gitignore")).to eq("/#{sources_path}\r\n")
end

it "honors last-match-wins: a dead negation followed by an ignore line is NOT an opt-out" do
# git reads the LAST matching line — a later ignore rule overrides the
# negation, so the file is effectively ignored: no append needed, and the
# tracked-file drift warning must still fire.
build_skeleton
write(".gitignore", "!#{sources_path}\n/#{sources_path}\n")
write(sources_path, "/* tracked while effectively ignored */\n")
system("git", "-C", destination, "init", "-q")
system("git", "-C", destination, "add", "-f", sources_path)

output = capture_generator(sync: true)

expect(read(".gitignore")).to eq("!#{sources_path}\n/#{sources_path}\n")
expect(output).to include("git rm --cached #{sources_path}")
end

it "still warns when a dead negation precedes an UNRECOGNIZED broad ignore (git's verdict wins)" do
# The recognized-lines regex can't see `app/assets/stylesheets/*`, but the
# drift check asks git itself — git says the file is effectively ignored,
# so the negation is dead and the tracked copy still gets the nag.
build_skeleton
write(".gitignore", "!#{sources_path}\napp/assets/stylesheets/*\n")
write(sources_path, "/* tracked while effectively ignored by a broad glob */\n")
system("git", "-C", destination, "init", "-q")
system("git", "-C", destination, "add", "-f", sources_path)

output = capture_generator(sync: true)

expect(output).to include("git rm --cached #{sources_path}")
end

it "an explicit negation also silences the git-tracked drift warning (a deliberate commit)" do
build_skeleton
write(sources_path, "/* deliberately committed */\n")
write(".gitignore", "!#{sources_path}\n")
system("git", "-C", destination, "init", "-q")
system("git", "-C", destination, "add", sources_path)

output = capture_generator(sync: true)

expect(output).not_to include("git rm --cached")
end

it "adds the entry on --sync (the fleet-wide upgrade path)" do
build_skeleton
write(".gitignore", "/node_modules\n")

run_generator(sync: true)

expect(read(".gitignore")).to match(%r{^/#{Regexp.escape(sources_path)}$})
end

it "warns to git rm --cached when the file is tracked by git (warn-only, never mutates git)" do
build_skeleton
write(sources_path, "/* stale committed copy */\n")
system("git", "-C", destination, "init", "-q")
system("git", "-C", destination, "add", sources_path)

output = capture_generator(sync: true)

expect(output).to include("git rm --cached #{sources_path}")
# Warn-only: still tracked, file untouched.
expect(system("git", "-C", destination, "ls-files", "--error-unmatch", sources_path,
out: File::NULL, err: File::NULL)).to be(true)
end

it "ignores machine-local excludes (.git/info/exclude) — the sync report is machine-independent" do
# The drift verdict must come from the repo's COMMITTED .gitignore files
# only: a developer's personal excludes (.git/info/exclude or a global
# core.excludesFile) would otherwise flip the warning per machine — and
# its "the ignore entry is in place" guidance would be a lie (the repo
# has no entry). Here only info/exclude ignores the tracked file: the
# report must stay quiet on the tailwind drift.
build_skeleton
write(sources_path, "/* tracked; ignored only by a personal exclude */\n")
system("git", "-C", destination, "init", "-q")
system("git", "-C", destination, "add", sources_path)
FileUtils.mkdir_p(File.join(destination, ".git/info"))
File.write(File.join(destination, ".git/info/exclude"), "#{sources_path}\n")

report = DocsKit::Generators::SyncReport.new(destination)

expect(report.items.join).not_to include("git rm --cached")
end

it "does NOT warn when the site is not a git repository" do
build_skeleton
write(sources_path, "/* generated locally, no repo */\n")

output = capture_generator(sync: true)

expect(output).not_to include("git rm --cached")
end
end

# Version-aware sync: the generator records which docs-kit version a site was
# last synced at (a `# docs-kit synced: vX.Y.Z` stamp in the initializer) so a
# future `--sync` can run the ORDERED migrations between that version and the
Expand Down
Loading