From e88b70ae79de56d7879a801c1cc2b1602da107ee Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 08:00:17 +0200 Subject: [PATCH 1/3] ci: add the gem's CI workflow + the missing generator specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The gem repo had no CI: `bundle exec rake` (rspec + rubocop) was trust-based on every PR, and CLAUDE.md's claim that "Generator specs cover docs_kit:install output" was false — spec/generators/ did not exist. This wires real CI + real generator specs and makes the docs true. - `.github/workflows/ci.yml`: push to main + all PRs; matrix Ruby 3.2/3.3/3.4; ruby/setup-ruby with bundler-cache; `bundle exec rake`; concurrency-cancel on the same ref. One job — the generator specs run plain Thor against a tmp app skeleton (no Rails boot), so no heavier harness is needed. - `spec/generators/install_generator_spec.rb`: runs the install generator against a throwaway destination root (a minimal fake app skeleton) and asserts the file manifest + key contents (brand substitution, route injection, the `include DocsKit::Controller` inject, package.json scripts, the docs_kit Stimulus registration, chmod 0755 on bin/build-css, theme list ↔ CSS sync). - SimpleCov in spec_helper with `minimum_coverage 80` — the 80% floor from CLAUDE.md is now enforced (suite fails below it, locally and in CI). Line coverage: 86.76%. ## Incidental fixes (CI-blocking, pre-existing) - `bundle exec rubocop` crashed on clean main: it descended into the tracked `docs/` dogfood site (inherits rubocop-rails-omakase, not in the gem's bundle) and into agent scratch worktrees. Excluded `docs/`, `.claude/`, `coverage/`, `tmp/` in .rubocop.yml so `rake` (and CI) runs. - Guarded the `daisyui` path override in the Gemfile (`if File.directory?`) so a fresh clone / CI without the sibling checkout resolves daisyui from rubygems. - Ignored `.claude/worktrees/`. ## Test Coverage - 14 generator examples asserting the install manifest + key file contents against current behavior (largely idempotent; create_initializer re-renders). - SimpleCov gate proven: flipping an assertion goes RED; coverage below 80 fails. ## Verification - [x] bundle exec rake passes (75 examples, 0 failures; 86.76% coverage; rubocop clean — 39 files, 0 offenses) Refs #23 --- .claude/rules/testing.md | 6 +- .github/workflows/ci.yml | 38 ++++ .gitignore | 2 + .rubocop.yml | 16 ++ CLAUDE.md | 5 +- Gemfile | 9 +- README.md | 2 + spec/generators/install_generator_spec.rb | 216 ++++++++++++++++++++++ spec/spec_helper.rb | 15 ++ 9 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 spec/generators/install_generator_spec.rb diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index f5d2897..734eaba 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -17,7 +17,7 @@ in-memory registry, and the generators — cheapest first: |-------|------|-------|---------| | Unit | `spec/docs_kit/**` | nothing | `DocsKit::Registry`, `DocsKit::NavItem`, `Configuration` | | Component | `spec/docs_ui/**` | Phlex render (no Rails request) | that a component renders the expected HTML/daisyUI classes given props | -| Generator | `spec/generators/**` (if present) | generator harness | `docs_kit:install` writes the right files | +| Generator | `spec/generators/install_generator_spec.rb` | plain Thor against a tmp app skeleton (no Rails boot) | `docs_kit:install` writes the right files/manifest + key contents | A component spec renders the Phlex class and asserts on the produced markup — the daisyUI classes emitted, the config-driven values, the active-link state. @@ -26,7 +26,9 @@ present) over brittle full-HTML snapshots. ## Coverage Expectations -- **80% minimum** for all code +- **80% minimum** for all code — enforced by SimpleCov (`minimum_coverage 80` in + `spec/spec_helper.rb`); `bundle exec rspec` / `rake` fails below it, locally and + in CI. - **100%** for the config surface (`DocsKit::Configuration`) and the registry mixin (`DocsKit::Registry`) — the public API sites depend on. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f06fec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +# CI for the docs-kit gem: run the suite + lint on every supported Ruby. +# +# `bundle exec rake` is `spec + rubocop` (see Rakefile) — the same command run +# locally. SimpleCov enforces the 80% coverage floor from within the suite, so a +# coverage regression fails this job too. The generator specs run in the same job: +# they invoke the install generator against a tmp skeleton via plain Thor (no Rails +# boot), so they need no heavier harness than the rest of the suite. +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref (a force-push / new commit to a PR). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rake: + name: rake (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.3", "3.4"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run the suite + lint + run: bundle exec rake diff --git a/.gitignore b/.gitignore index 1729609..d121cea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ Gemfile.lock *.gem .DS_Store +# Claude Code agent scratch worktrees. +/.claude/worktrees/ diff --git a/.rubocop.yml b/.rubocop.yml index 230fc21..f5efca2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,16 @@ AllCops: TargetRubyVersion: 3.2 NewCops: enable SuggestExtensions: false + Exclude: + # SimpleCov's HTML report — generated, not source. + - "coverage/**/*" + - "tmp/**/*" + # The dogfood docs site is a separate consuming app with its own bundle and + # .rubocop.yml (it inherits rubocop-rails-omakase, not installed in the gem's + # bundle). Linting it from here crashes config loading; it lints itself. + - "docs/**/*" + # Agent scratch worktrees — never source. + - ".claude/**/*" Style/Documentation: Enabled: false @@ -67,3 +77,9 @@ RSpec/MultipleExpectations: RSpec/NestedGroups: Max: 4 + +# Generator specs live under spec/generators/ (the conventional Rails location), +# not a path mirroring the DocsKit::Generators:: namespace. +RSpec/SpecFilePathFormat: + Exclude: + - "spec/generators/**/*" diff --git a/CLAUDE.md b/CLAUDE.md index ffdbb31..666a5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,8 +101,9 @@ session model. See `.claude/rules/agents.md`. - Unit specs (`spec/docs_kit/`) cover the config surface and the registry — no Rails boot. - Component specs (`spec/docs_ui/`) render a `DocsUI::` component and assert on the produced markup's semantics (an active link, a present theme option, a config-driven value). -- Generator specs cover `docs_kit:install` output. -- Coverage: 80% minimum; 100% for `DocsKit::Configuration` and `DocsKit::Registry` (the public API sites depend on). +- Generator specs (`spec/generators/install_generator_spec.rb`) run `docs_kit:install` against a throwaway destination root (a tmp app skeleton, plain Thor — no Rails boot) and assert the file manifest + key contents. +- Coverage: SimpleCov enforces `minimum_coverage 80` from within the suite (`bundle exec rspec` / `rake` fails below it); 100% aspired for `DocsKit::Configuration` and `DocsKit::Registry` (the public API sites depend on). +- CI: `.github/workflows/ci.yml` runs `bundle exec rake` on Ruby 3.2/3.3/3.4 for every push to `main` and every PR. - See `.claude/rules/testing.md`. ## Deploy diff --git a/Gemfile b/Gemfile index 51a55ed..15041d4 100644 --- a/Gemfile +++ b/Gemfile @@ -4,9 +4,11 @@ source "https://rubygems.org" gemspec -# The daisyui gem is published on rubygems, but track the local checkout so the -# kit develops against the same source the docs sites use. -gem "daisyui", path: "../daisyui" +# The daisyui gem is published on rubygems. When the sibling checkout exists, +# track it so the kit develops against the same source the docs sites use; +# otherwise (CI, a fresh clone) fall back to the published gem via the gemspec's +# `daisyui >= 1.2` dependency. +gem "daisyui", path: "../daisyui" if File.directory?(File.expand_path("../daisyui", __dir__)) group :development, :test do gem "rake" @@ -15,4 +17,5 @@ group :development, :test do gem "rubocop-performance", require: false gem "rubocop-rake", require: false gem "rubocop-rspec", require: false + gem "simplecov", require: false end diff --git a/README.md b/README.md index 5506dcc..0f2b96e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # docs-kit +[![CI](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml) + Shared [Phlex](https://www.phlex.fun) chrome for documentation sites built on [daisyUI](https://daisyui.com). Extract the shell, sidebar, code blocks, theme switcher, and page kit into one gem so multiple docs sites look identical and are diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb new file mode 100644 index 0000000..e7d1fda --- /dev/null +++ b/spec/generators/install_generator_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "fileutils" +require "tmpdir" +require "rails/generators" +require "generators/docs_kit/install/install_generator" + +# The install generator never touches a booted Rails app — it only reads/writes +# files under destination_root via Thor. So we exercise it against a throwaway +# destination root: a minimal fake app skeleton (routes.rb, application_controller, +# controllers/index.js, assets.rb) built per-example, run the generator, and assert +# the produced file manifest + key contents. +# +# These specs assert CURRENT behavior. Most steps guard against re-runs (Thor's +# `route`/`inject_into_file` skip a line that's already present; several methods +# `say_status(:skip)` when a target exists), so the generator is largely +# idempotent; `create_initializer` is the exception (it re-renders the template +# on every run). If a later change tightens idempotency, update these assertions. +RSpec.describe DocsKit::Generators::InstallGenerator do + # A named destination dir so app_brand humanizes deterministically + # ("my_app_docs" → "My app docs"). Rails isn't booted, so app_brand falls back + # to the basename of destination_root. + let(:app_name) { "my_app_docs" } + let(:destination) { File.join(Dir.tmpdir, "docs-kit-gen-spec", app_name) } + + # A stock Stimulus controllers/index.js: only the eager-load line the generator + # injects the docs_kit path after. + def stimulus_index_source + <<~JS + import { application } from "controllers/application" + eagerLoadControllersFrom("controllers", application) + JS + end + + # Build a minimal Rails-ish skeleton the generator's injections expect to find. + def build_skeleton(routes: true, app_controller: true, stimulus_index: true, package_json: nil) + FileUtils.mkdir_p(File.join(destination, "config/initializers")) + FileUtils.mkdir_p(File.join(destination, "app/controllers")) + FileUtils.mkdir_p(File.join(destination, "app/javascript/controllers")) + + write("config/initializers/assets.rb", %(Rails.application.config.assets.version = "1.0"\n)) + write("config/routes.rb", "Rails.application.routes.draw do\nend\n") if routes + if app_controller + write("app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n") + end + write("app/javascript/controllers/index.js", stimulus_index_source) if stimulus_index + write("package.json", package_json) if package_json + end + + def write(rel, content) + path = File.join(destination, rel) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def read(rel) = File.read(File.join(destination, rel)) + def exist?(rel) = File.exist?(File.join(destination, rel)) + + # Run the generator quietly against the skeleton. + def run_generator + generator = described_class.new([], {}, destination_root: destination) + silence_stream { generator.invoke_all } + end + + # Thor writes progress to $stdout; keep the suite output clean. + def silence_stream + original = $stdout + $stdout = File.open(File::NULL, "w") + yield + ensure + $stdout.close + $stdout = original + end + + before { FileUtils.rm_rf(destination) } + after { FileUtils.rm_rf(destination) } + + describe "the file manifest" do + before do + build_skeleton + run_generator + end + + it "creates the config initializers" do + expect(exist?("config/initializers/docs_kit.rb")).to be(true) + expect(exist?("config/initializers/phlex.rb")).to be(true) + expect(exist?("config/initializers/rails_icons.rb")).to be(true) + end + + it "creates the registry model + controllers + pages" do + expect(exist?("app/models/doc.rb")).to be(true) + expect(exist?("app/controllers/docs_controller.rb")).to be(true) + expect(exist?("app/controllers/landings_controller.rb")).to be(true) + expect(exist?("app/views/docs/pages/installation.rb")).to be(true) + expect(exist?("app/views/landings/show.rb")).to be(true) + end + + it "creates the CSS build files + keeps" do + expect(exist?("app/assets/stylesheets/application.tailwind.css")).to be(true) + expect(exist?("bin/build-css")).to be(true) + expect(exist?("lib/tasks/build_css.rake")).to be(true) + expect(exist?("app/assets/builds/.keep")).to be(true) + expect(exist?("app/components/.keep")).to be(true) + end + end + + describe "config/initializers/docs_kit.rb" do + before do + build_skeleton + run_generator + end + + it "substitutes the humanized app name as the brand" do + initializer = read("config/initializers/docs_kit.rb") + + expect(initializer).to include(%(c.brand = "My app docs")) + expect(initializer).to include(%(c.title_suffix = "My app docs")) + end + + it "ships the theme list that matches the Tailwind @plugin block" do + css = read("app/assets/stylesheets/application.tailwind.css") + initializer = read("config/initializers/docs_kit.rb") + + %w[dark light synthwave retro cyberpunk dracula night nord sunset].each do |theme| + expect(initializer).to include(theme) + expect(css).to include(theme) + end + end + end + + describe "route injection (add_routes)" do + before do + build_skeleton + run_generator + end + + it "adds the docs and root routes" do + routes = read("config/routes.rb") + + expect(routes).to include(%(get "docs/:doc" => "docs#show", as: :doc)) + expect(routes).to include(%(root "landings#show")) + end + end + + describe "controller injection (include_controller_helper)" do + it "injects include DocsKit::Controller into ApplicationController" do + build_skeleton + run_generator + + expect(read("app/controllers/application_controller.rb")) + .to include("include DocsKit::Controller") + end + + it "skips injection when ApplicationController is absent" do + build_skeleton(app_controller: false) + run_generator + + expect(exist?("app/controllers/application_controller.rb")).to be(false) + end + end + + describe "asset paths + package.json (wire_assets_and_package_json)" do + it "appends the builds path to config/initializers/assets.rb" do + build_skeleton + run_generator + + expect(read("config/initializers/assets.rb")) + .to include(%(Rails.application.config.assets.paths << Rails.root.join("app", "assets", "builds"))) + end + + it "creates a package.json stub with the build:css scripts when none exists" do + build_skeleton + run_generator + + package = read("package.json") + expect(package).to include(%("build:css": "bin/build-css --minify")) + expect(package).to include(%("watch:css": "bin/build-css --watch")) + end + + it "does not overwrite an existing package.json that already has build:css" do + existing = %({\n "scripts": { "build:css": "custom" }\n}\n) + build_skeleton(package_json: existing) + run_generator + + expect(read("package.json")).to eq(existing) + end + end + + describe "Stimulus registration (register_stimulus_controller)" do + it "registers the docs_kit controllers path in controllers/index.js" do + build_skeleton + run_generator + + expect(read("app/javascript/controllers/index.js")) + .to include(%(eagerLoadControllersFrom("docs_kit/controllers", application))) + end + + it "skips registration when there is no controllers/index.js" do + build_skeleton(stimulus_index: false) + run_generator + + expect(exist?("app/javascript/controllers/index.js")).to be(false) + end + end + + describe "bin/build-css" do + it "is created executable (chmod 0755)" do + build_skeleton + run_generator + + mode = File.stat(File.join(destination, "bin/build-css")).mode & 0o777 + expect(mode).to eq(0o755) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 41075db..b499e72 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,20 @@ # frozen_string_literal: true +# Coverage. Start SimpleCov BEFORE requiring docs_kit so every lib/ file loaded +# below is tracked. CLAUDE.md sets the 80% floor; enforce it here so `rake` fails +# locally and in CI when coverage regresses. The generators/ tree is the install +# generator, exercised by spec/generators/**. +require "simplecov" +SimpleCov.start do + enable_coverage :branch + add_filter "/spec/" + add_group "Config", "lib/docs_kit/configuration.rb" + add_group "Registry", "lib/docs_kit/registry.rb" + add_group "Components", "app/components" + add_group "Generators", "lib/generators" + minimum_coverage 80 +end + # Component specs render DocsUI:: Phlex components in isolation (no Rails # request). Shell and Code compose phlex-rails value helpers (e.g. # content_security_policy_nonce), so load phlex-rails here — plus the two From e385c7c6b1825eb37e720342ecaf05a46f68a4b2 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 08:03:24 +0200 Subject: [PATCH 2/3] fix(spec): require daisy_ui so the suite loads DaisyUI on CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocsUI::Shell and Sidebar `include DaisyUI` (the daisyUI Phlex kit). The gem name is `daisyui`; the require path is `daisy_ui` — a consuming site loads it via `gem "daisyui", require: "daisy_ui"`. docs_kit itself never requires it (the host app does). The suite requires only `docs_kit`, so nothing loaded the daisyUI kit. Locally it passed by accident — the sibling `../daisyui` checkout's loader happened to be active. On CI (published daisyui 1.2.0, no sibling) `include DaisyUI` raised `NameError: uninitialized constant DocsUI::Shell::DaisyUI` and every component spec errored on load. Fix at the earliest point: `spec_helper.rb` requires `daisy_ui` before `docs_kit`, exactly as a real consuming site does. Verified by hiding the sibling checkout so Bundler resolves daisyui 1.2.0 from rubygems (CI's exact state): 75 examples, 0 failures, 86.76% coverage, rubocop clean. Refs #23 --- spec/spec_helper.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b499e72..60687c4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,6 +25,13 @@ require "active_support/json" require "phlex/rails" +# DocsUI::Shell / Sidebar `include DaisyUI` (the daisyUI Phlex kit). The gem name +# is `daisyui`; the require path is `daisy_ui` — a consuming site loads it via +# `gem "daisyui", require: "daisy_ui"`. docs_kit itself never requires it (the +# host app does), so the standalone suite must load it here or the constant is +# undefined when a component is rendered. +require "daisy_ui" + require "docs_kit" RSpec.configure do |config| From 1df1f66729b1ecad70c83f076cf86b6d83f3832b Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 08:10:47 +0200 Subject: [PATCH 3/3] ci: scope the rubocop rake task to the gem's own source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuboCop discovers and loads .rubocop.yml files across the whole tree, including docs/.rubocop.yml — the dogfood site's config, which `inherit_gem`s rubocop-rails-omakase (and, transitively, panolint). Those gems live in the docs/ bundle, not the gem's, so on CI the inheritance can't resolve and RuboCop crashes during config loading — before linting a single file. An AllCops Exclude of docs/ stops linting those files but NOT loading their config as directory inheritance. The fix is to pass RuboCop explicit paths so it never discovers docs/.rubocop.yml: scope the rake task to app/ lib/ spec/ + the root Ruby files. Verified with --debug that only the root .rubocop.yml is loaded, and that a full `rake` under CI conditions (published daisyui, no sibling checkout) is green: 75 examples 0 failures, 86.76% coverage, 37 files rubocop-clean. docs/ lints itself with its own bundle. The AllCops Exclude stays as defense for anyone running `rubocop` bare. Refs #23 --- Rakefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index d9b6e5e..24cfc5f 100644 --- a/Rakefile +++ b/Rakefile @@ -5,6 +5,14 @@ require "rspec/core/rake_task" require "rubocop/rake_task" RSpec::Core::RakeTask.new(:spec) -RuboCop::RakeTask.new + +# Lint only the gem's own source — NOT the dogfood docs/ site. docs/ is a separate +# consuming app with its own bundle and .rubocop.yml (it `inherit_gem`s +# rubocop-rails-omakase, absent from the gem's bundle). Passing explicit paths +# stops RuboCop from discovering and loading docs/.rubocop.yml, whose gem +# inheritance can't resolve here (and crashes CI). docs/ lints itself. +RuboCop::RakeTask.new do |task| + task.patterns = %w[app lib spec Rakefile Gemfile docs-kit.gemspec] +end task default: %i[spec rubocop]