Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
Gemfile.lock
*.gem
.DS_Store
# Claude Code agent scratch worktrees.
/.claude/worktrees/
16 changes: 16 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/**/*"
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 9 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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]
216 changes: 216 additions & 0 deletions spec/generators/install_generator_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading