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/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] 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..60687c4 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 @@ -10,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|