diff --git a/README.md b/README.md index c536f95..0f105ed 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 38c308e..8a9b1fa 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -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" @@ -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 diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 2d9f1e5..b125a0c 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -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) + 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 @@ -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? @@ -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) + 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 diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 995f413..2e138a3 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -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