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
31 changes: 31 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ AllCops:
Style/Documentation:
Enabled: false

# The custom RuboCop cops ship rubocop as a gemspec DEVELOPMENT dependency on
# purpose: `docs_kit/rubocop` requires it lazily (never a runtime dep), and
# pinning it here lets the gem's own cop specs run. This is a deliberate design
# choice (see issue #22), not a stray dependency to move to the Gemfile.
Gemspec/DevelopmentDependencies:
Exclude:
- "docs-kit.gemspec"

# lib/rubocop/cop/** holds RuboCop cops. RenderComponentPreferred is upstreamed
# VERBATIM from the proven consumer copy (issue #22 says: don't rewrite it), so
# its on_send node-matching dispatch trips the size/complexity metrics by design.
# Cops are inherently branchy AST walkers — exempt the tree from the two metrics
# it doesn't already cover elsewhere (AbcSize/MethodLength are extended below).
Metrics/CyclomaticComplexity:
Exclude:
- "lib/rubocop/cop/**/*"
Metrics/PerceivedComplexity:
Exclude:
- "lib/rubocop/cop/**/*"

# lib/docs-kit.rb must match the hyphenated gem name for Bundler's auto-require;
# it just requires the underscored real entrypoint.
Naming/FileName:
Expand Down Expand Up @@ -49,6 +69,8 @@ Metrics/AbcSize:
- "app/components/docs_ui/shell.rb"
- "app/components/docs_ui/page.rb"
- "lib/docs_kit/configuration.rb"
# Verbatim-upstreamed cop (issue #22) — its AST dispatch is branchy by design.
- "lib/rubocop/cop/**/*"

# ApiRequest/ApiClient are Data value objects and RequestExample is a public
# component; their keyword-arg constructors mirror the documented API (method:,
Expand All @@ -70,6 +92,9 @@ Style/StringLiterals:

Layout/LineLength:
Max: 120
Exclude:
# Verbatim-upstreamed cop (issue #22) — one add_offense line runs to 122.
- "lib/rubocop/cop/**/*"

Metrics/BlockLength:
Exclude:
Expand All @@ -93,9 +118,15 @@ Metrics/MethodLength:
# length is the knob count, not logic (AbcSize is excluded for the same reason).
Exclude:
- "lib/docs_kit/configuration.rb"
# Verbatim-upstreamed cop (issue #22) — on_send is one long dispatch method.
- "lib/rubocop/cop/**/*"

RSpec/ExampleLength:
Max: 12
# Cop specs pair an expect_offense fixture with an expect_correction fixture,
# both multi-line heredocs — the example length is the fixture size, not logic.
Exclude:
- "spec/rubocop/**/*"

RSpec/MultipleExpectations:
Max: 6
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@
`all`/`from_slug`/`grouped` + the "authored" filter).
- `DocsKit::NavItem` value object consumed by the sidebar.
- `DocsKit::Controller#render_page` and a Rails engine that wires it.
- Custom RuboCop cops shipped from the gem (`require: docs_kit/rubocop` +
`inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }`, wired automatically by
the install generator): `DocsKit/RenderComponentPreferred` (prefer the Phlex-kit
helper form `DocsUI::Code(...)` over `render DocsUI::Code.new(...)`) and
`DocsKit/EscapedInterpolationInHeredoc` (steer `\#{...}` escapes in a
double-quoted heredoc to a single-quoted delimiter). RuboCop stays a
development-time dependency of the host — never a runtime dependency.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,36 @@ bun install && bun run build:css
Then add pages one command at a time — `rails g docs_kit:page "Title"
--group=Guide` (see [Add a page](#add-a-page)).

## Lint — the docs-kit RuboCop cops

docs-kit ships two custom cops so every site enforces the same authoring idioms
instead of hand-copying a cop file that drifts:

- **`DocsKit/RenderComponentPreferred`** — prefers the Phlex-kit helper form
`DocsUI::Code(...)` over `render DocsUI::Code.new(...)` (autocorrectable).
- **`DocsKit/EscapedInterpolationInHeredoc`** — flags the `\#{...}` "escape tax"
inside a double-quoted heredoc and steers you to a single-quoted delimiter
(`<<~'RUBY'`), where `#{...}` is literal. Autocorrects when the heredoc has no
live interpolation; otherwise it reports and leaves the fix to you.

Both are scoped to `app/views/docs/**/*` by default. The install generator wires
them into your `.rubocop.yml` automatically — two lines, merged idempotently
(your existing `inherit_gem` / `require` entries are preserved):

```yaml
# .rubocop.yml
require:
- docs_kit/rubocop
inherit_gem:
docs-kit: config/rubocop/docs_kit.yml
```

RuboCop is a **development-time** dependency of your app, never a runtime
dependency of docs-kit — `docs_kit/rubocop` requires `rubocop` lazily. Every
generated site already has `rubocop` in its Gemfile (via `rubocop-rails-omakase`
from `rails new`); if yours doesn't, add `gem "rubocop"` to the `:development`
group. Then `bundle exec rubocop` runs the docs-kit cops.

## Deploy a new docs site

The build + deploy is defined **once** in this gem's reusable workflow
Expand Down
24 changes: 24 additions & 0 deletions config/rubocop/docs_kit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# docs-kit's custom RuboCop cops, shipped from the gem so consuming sites stop
# hand-copying them. Wire this into a site's `.rubocop.yml` with two lines
# (the install generator does it automatically):
#
# require:
# - docs_kit/rubocop
# inherit_gem:
# docs-kit: config/rubocop/docs_kit.yml
#
# Both cops are scoped to the docs page tree (app/views/docs/**/*) by default —
# that is where the kit-helper form and heredoc examples live. A site can widen
# or narrow the `Include` in its own `.rubocop.yml`.

DocsKit/RenderComponentPreferred:
Description: "Prefer the Phlex-kit helper form (DocsUI::Code(...)) over `render DocsUI::Code.new(...)`."
Enabled: true
Include:
- "app/views/docs/**/*"

DocsKit/EscapedInterpolationInHeredoc:
Description: "Use a single-quoted heredoc delimiter instead of escaping `\\#{...}` in a double-quoted one."
Enabled: true
Include:
- "app/views/docs/**/*"
7 changes: 7 additions & 0 deletions docs-kit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,11 @@ Gem::Specification.new do |s|
# phlex-reactive (reactive demos) and pgbus (Postgres-SSE transport) are
# intentionally NOT dependencies — they are optional, runtime-detected. A site
# that wants reactive examples adds phlex-reactive itself.

# RuboCop is a DEVELOPMENT-time dependency: docs-kit ships custom cops under
# lib/rubocop/cop/docs_kit/, but `require "docs_kit/rubocop"` loads rubocop
# lazily, so it is never pulled into a host app's runtime. A consuming site
# already has `rubocop` in its Gemfile (every generated site does) — that is
# what runs the shipped cops. Pinned here so the gem's own cop specs can run.
s.add_development_dependency "rubocop", ">= 1.75"
end
5 changes: 5 additions & 0 deletions lib/docs_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ module DocsUI
loader.push_dir(File.expand_path("../app/components/docs_ui", __dir__), namespace: DocsUI)
loader.ignore(File.expand_path("docs_kit/version.rb", __dir__))
loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
# docs_kit/rubocop.rb is the RuboCop-cop entry point: it defines cops under
# RuboCop::Cop::DocsKit::*, not a DocsKit::Rubocop constant, so zeitwerk must not
# manage it. It (and the cops under lib/rubocop/, which are outside the loader's
# push_dirs entirely) load only when a `.rubocop.yml` requires "docs_kit/rubocop".
loader.ignore(File.expand_path("docs_kit/rubocop.rb", __dir__))
# engine.rb is required explicitly below only under Rails, so zeitwerk never
# manages it (it would otherwise expect a DocsKit::Engine constant outside Rails).
loader.ignore(File.expand_path("docs_kit/engine.rb", __dir__))
Expand Down
19 changes: 19 additions & 0 deletions lib/docs_kit/rubocop.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

# Entry point for docs-kit's custom RuboCop cops. A consuming site loads them
# with a single line in its `.rubocop.yml`:
#
# require:
# - docs_kit/rubocop
# inherit_gem:
# docs-kit: config/rubocop/docs_kit.yml
#
# RuboCop is required LAZILY here — it is a development-time dependency of the
# HOST app (every generated docs site has `rubocop` in its Gemfile), never a
# runtime dependency of docs-kit itself. Requiring this file outside a RuboCop
# run (e.g. if a stray `require` reaches it) still works: it pulls in rubocop on
# demand rather than assuming it is already loaded.
require "rubocop"

require_relative "../rubocop/cop/docs_kit/render_component_preferred"
require_relative "../rubocop/cop/docs_kit/escaped_interpolation_in_heredoc"
69 changes: 69 additions & 0 deletions lib/generators/docs_kit/install/install_generator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "erb"
require "yaml"
require "rails/generators/base"

module DocsKit
Expand Down Expand Up @@ -29,6 +30,27 @@ class InstallGenerator < ::Rails::Generators::Base
AGENTS_END = "<!-- END docs-kit -->"
AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m

# 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"
RUBOCOP_INHERIT_GEM = "docs-kit"
RUBOCOP_INHERIT_PATH = "config/rubocop/docs_kit.yml"

# The .rubocop.yml written when a site has none yet.
RUBOCOP_STARTER = <<~YAML.freeze
# docs-kit ships its custom cops from the gem — load + enable them here.
# (RuboCop is a development-time dependency; add `gem "rubocop"` to your
# Gemfile if it isn't there.)
require:
- #{RUBOCOP_REQUIRE}

inherit_gem:
#{RUBOCOP_INHERIT_GEM}: #{RUBOCOP_INHERIT_PATH}

AllCops:
NewCops: enable
YAML

def create_phlex_initializer
# Phlex autoload namespaces (Views:: / Components::). Skip if the app
# already configures phlex-rails so we don't clobber a bespoke setup.
Expand Down Expand Up @@ -134,6 +156,25 @@ def create_agent_docs
write_write_docs_page_skill
end

# Wire docs-kit's shipped RuboCop cops into the site's .rubocop.yml: a
# `require: docs_kit/rubocop` entry (loads the cops) plus an
# `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }` entry (enables
# + scopes them). RuboCop is a dev-time dependency the host already has —
# docs-kit never requires it at runtime. Created minimal when absent,
# MERGED into an existing config (a `rails new` app ships an omakase
# inherit_gem we must not drop), and idempotent on re-run.
def wire_rubocop_cops
path = File.join(destination_root, ".rubocop.yml")
return create_file(".rubocop.yml", RUBOCOP_STARTER) unless File.exist?(path)

existing = File.read(path)
merged = merge_rubocop_config(existing)
return say_status(:identical, ".rubocop.yml", :blue) if merged == existing

File.write(path, merged)
say_status(:update, ".rubocop.yml (docs-kit cops)", :green)
end

def register_stimulus_controller
index = stimulus_index_path
return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index
Expand Down Expand Up @@ -206,6 +247,34 @@ def write_write_docs_page_skill
create_file skill, render_template("skill.md.erb")
end

# Merge docs-kit's require + inherit_gem entries into an existing
# .rubocop.yml, preserving everything else. Idempotent: entries already
# present are left untouched, so re-running yields byte-identical output.
# Returns the (possibly unchanged) YAML string.
def merge_rubocop_config(existing)
config = YAML.safe_load(existing) || {}
config = {} unless config.is_a?(Hash)

config["require"] = ensure_in_list(config["require"], RUBOCOP_REQUIRE)

inherit_gem = config["inherit_gem"].is_a?(Hash) ? config["inherit_gem"] : {}
inherit_gem[RUBOCOP_INHERIT_GEM] = ensure_in_list(inherit_gem[RUBOCOP_INHERIT_GEM], RUBOCOP_INHERIT_PATH)
config["inherit_gem"] = inherit_gem

# Round-trip through the same load the merge started from: if nothing
# changed, return the original text verbatim (so :identical is reported
# and re-runs don't churn formatting).
YAML.safe_load(existing) == config ? existing : YAML.dump(config)
end

# Normalise a RuboCop scalar-or-list field to an array and append `value`
# unless already present. `nil` (absent key) becomes `[value]`; a bare
# string is promoted to a list so we never drop the site's own entry.
def ensure_in_list(current, value)
list = Array(current)
list.include?(value) ? list : list + [value]
end

# Render an ERB template from source_root against the generator binding, so
# helpers like app_brand resolve — used where we need the rendered string in
# memory (block extraction/merge) rather than Thor's file-to-file `template`.
Expand Down
119 changes: 119 additions & 0 deletions lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# frozen_string_literal: true

module RuboCop
module Cop
module DocsKit
# Flags an ESCAPED interpolation (`\#{...}`) inside a double-quoted heredoc
# and steers to the single-quoted delimiter (`<<~'RUBY'`), where `#{...}` is
# already literal so no backslash is needed.
#
# Docs pages constantly embed Ruby examples that themselves contain
# `#{...}`. In a double-quoted heredoc every one of those has to be escaped
# as `\#{...}` or Ruby interpolates it — the recurring "escape tax" every
# audited docs site paid. A single-quoted heredoc delimiter turns the whole
# body literal, so the examples read exactly as they will render.
#
# Ruby interpolates three sigils in a double-quoted string — `#{expr}`,
# `#@ivar` (also `#@@cvar`), and `#$global` — so the cop treats all three
# escape forms (`\#{`, `\#@`, `\#$`) as the escape tax, and a LIVE (unescaped)
# occurrence of any of them blocks autocorrection.
#
# @example
# # bad — escape tax
# source = <<~RUBY
# puts "hello \#{name}"
# RUBY
#
# # good — single-quoted delimiter, `#{...}` is literal
# source = <<~'RUBY'
# puts "hello #{name}"
# RUBY
#
# Autocorrection is UNSAFE and only offered when the heredoc has no LIVE
# (unescaped) interpolation: switching the delimiter to single-quoted would
# freeze a live interpolation into literal text, changing behaviour. When a
# live interpolation is present the cop reports but leaves the fix to a human.
class EscapedInterpolationInHeredoc < Base
extend AutoCorrector

MSG = "Use a single-quoted heredoc delimiter (`%<delimiter>s`) so " \
"`\#{...}` is literal without escaping."
MSG_LIVE = "#{MSG} This heredoc also has a live interpolation — fix by hand.".freeze

# A backslash directly in front of an interpolation opener. Ruby opens an
# interpolation with `#{`, `#@` (ivar/cvar), or `#$` (global), so an escape
# is a backslash + `#` + one of those sigil characters. The lookahead keeps
# the sigil out of the match, so de-escaping only strips the backslash.
ESCAPED_INTERPOLATION = /\\#(?=[{@$])/

def on_str(node)
check_heredoc(node)
end

# A heredoc with a live `#{...}` parses as a dstr; the escaped ones inside
# it still show up in the body source. Same check.
def on_dstr(node)
check_heredoc(node)
end

private

def check_heredoc(node)
return unless node.heredoc?

opening = node.loc.expression
return if single_quoted?(opening.source)
return unless escaped_interpolation?(node)

live = live_interpolation?(node)
add_offense(opening, message: message(opening.source, live: live)) do |corrector|
next if live # unsafe to autocorrect — a live #{...} would freeze

autocorrect(corrector, node, opening)
end
end

def message(delimiter, live:)
format(live ? MSG_LIVE : MSG, delimiter: single_quote(delimiter))
end

# `<<~RUBY` → `<<~'RUBY'`. The prefix (`<<`, `<<~`, or `<<-`) is kept; only
# the identifier gets wrapped in single quotes.
def single_quote(delimiter)
delimiter.sub(/(<<[~-]?)(\w+)\z/, "\\1'\\2'")
end

def single_quoted?(delimiter)
delimiter.include?("'")
end

def escaped_interpolation?(node)
node.loc.heredoc_body.source.match?(ESCAPED_INTERPOLATION)
end

# A live interpolation makes the heredoc a dstr whose children include a
# non-`str` node: `#{expr}` → a `begin` child, `#@ivar` → an `ivar` child,
# `#$global` → a `gvar` child. Escaped forms stay inside plain `str`
# children, so a str heredoc — or a dstr of only `str` children — is safe
# to convert. (Checking "not a str" rather than enumerating begin/ivar/gvar
# covers every interpolation node the parser can emit.)
def live_interpolation?(node)
node.dstr_type? && node.children.any? { |child| !child.str_type? }
end

# Swap the opening delimiter for its single-quoted form and drop the
# backslash from every escaped interpolation (`\#{`, `\#@`, `\#$`) in the
# body — the single-quoted delimiter already makes each one literal, and
# leaving a backslash behind would turn an escape-consumed byte into a
# literal one, changing the string.
def autocorrect(corrector, node, opening)
corrector.replace(opening, single_quote(opening.source))

body = node.loc.heredoc_body
unescaped = body.source.gsub(ESCAPED_INTERPOLATION, "#")
corrector.replace(body, unescaped)
end
end
end
end
end
Loading
Loading