From 171891556ce8573d536c7fc2e4acb7c2157a0dbf Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 10:09:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?chore(docs):=20upgrade=20to=20docs-kit=201.?= =?UTF-8?q?0.3=20=E2=80=94=20Thruster=20CMD=20+=20Docker=20image=20slimmin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the docs app from the GitHub ref (locked to pre-1.0 SHA 82d76a7, gemspec 0.1.0) to the released gem: gem "docs-kit", "~> 1.0.3". The GitHub source existed only because the sibling repo sat outside the Docker build context — obsolete now the gem is on RubyGems. Side effect: rouge is pinned back from 5.0.0 to 4.7.0 by docs-kit's `rouge (>= 4.0, < 5)` constraint. Thruster now fronts Puma in the container (compression, static-asset caching, X-Sendfile): added `gem "thruster", require: false`, the bin/thrust binstub (scaffolded by `docs_kit:install --sync`), and the port topology — Thruster listens on HTTP_PORT=3000 (Kamal app_port / EXPOSE / healthcheck unchanged) and proxies to Puma on TARGET_PORT=3001 (Thruster sets PORT for the child; config/puma.rb reads it). Thruster's default HTTP_PORT of 80 would neither bind as non-root nor be routed to by kamal-proxy. Build-stage prunes: rm -rf node_modules /usr/local/bun after assets:precompile, so the final stage's COPY of /gem no longer ships the ~103M of JS deps. tmp/cache is deliberately kept — it holds the bootsnap cache precompiled in the build stage. The bundle-install prune (bundler cache + git checkouts) was already present. Dockerignore: repo-root .dockerignore (the active one — build context is the repo root) gains coverage/, pkg/, tmp/, .bundle, .worktrees/, .rspec_status, docs/.rspec, docs/coverage. docs/.dockerignore is now the gem-owned standalone-flavor file force-written by --sync; inert here but kept as gem-owned. --sync also created lib/tasks/docs_kit_og.rake and refreshed the AGENTS.md docs-kit block (operation helper / OpenAPI docs). Verification: image builds from the repo root (554MB, node_modules-free — verified CLEAN in the final image); container boots with Thruster on :3000 and Puma "Listening on http://0.0.0.0:3001"; GET /up -> 200; a digested /assets/*.css served with Content-Encoding: gzip + X-Cache: miss (Puma alone never compresses — proves Thruster serves). Docs suite: 8 examples, 0 failures (bin/rspec; the suite is the request specs — spec/system contains only support files, no system specs exist). Part of the docs-kit 1.0.3 dogfood rollout. Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- .dockerignore | 8 +++++ docs/.dockerignore | 58 ++++++++++++++++++++------------- docs/AGENTS.md | 9 +++-- docs/Dockerfile | 18 ++++++++-- docs/Gemfile | 12 ++++--- docs/Gemfile.lock | 32 +++++++++--------- docs/bin/thrust | 5 +++ docs/lib/tasks/docs_kit_og.rake | 44 +++++++++++++++++++++++++ 8 files changed, 138 insertions(+), 48 deletions(-) create mode 100755 docs/bin/thrust create mode 100644 docs/lib/tasks/docs_kit_og.rake diff --git a/.dockerignore b/.dockerignore index 716c26c0..f007ce3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,12 @@ /.rspec /CLAUDE.md /codedb.snapshot +/coverage/ +/pkg/ +/tmp/ +/.bundle +/.worktrees/ +.rspec_status # Docs development files /docs/.bundle @@ -28,6 +34,8 @@ /docs/tmp/* /docs/storage/* /docs/spec/ +/docs/.rspec +/docs/coverage/ /docs/.dockerignore /docs/.kamal/ /docs/config/master.key diff --git a/docs/.dockerignore b/docs/.dockerignore index 14a15a7a..86cac7a8 100644 --- a/docs/.dockerignore +++ b/docs/.dockerignore @@ -1,43 +1,57 @@ -# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. +# docs-kit .dockerignore — keep the build context (and image layers) small. +# Gem-owned: `bin/rails g docs_kit:install` refreshes this on every run. +# See https://docs.docker.com/build/building/context/#dockerignore-files -# Ignore git directory. +# Git metadata — the app build never needs it (a released gem carries its files). /.git/ +/.gitignore +/.gitattributes -# Ignore bundler config. +# Bundler config (BUNDLE_* env in the Dockerfile drives the install instead). /.bundle -# Ignore all environment files (except templates). +# Environment files and credentials — never bake secrets into an image. /.env* !/.env*.erb - -# Ignore all default key files. /config/master.key /config/credentials/*.key -# Ignore all logfiles and tempfiles. +# Logs and tempfiles (keep the dirs, drop the contents). /log/* -/tmp/* !/log/.keep +/tmp/* !/tmp/.keep - -# Ignore pidfiles, but keep the directory. /tmp/pids/* !/tmp/pids/.keep -# Ignore storage (uploaded files in development and any SQLite databases). -/storage/* -!/storage/.keep -/tmp/storage/* -!/tmp/storage/.keep - -# Ignore assets. +# JS deps + generated assets: `bun install` + `assets:precompile` rebuild them. /node_modules/ /app/assets/builds/* !/app/assets/builds/.keep +# Generated by bin/build-css — Tailwind resolves the gem @source globs at build. +/app/assets/stylesheets/tailwind.sources.css /public/assets -# Vite Ruby -/public/vite* -# Vite uses dotenv and suggests to ignore local-only env files. See -# https://vitejs.dev/guide/env-and-mode.html#env-files -*.local +# Tests + coverage — not needed to run the site in production. +/spec/ +/.rspec +/coverage/ +/spec/examples.txt + +# CI / deploy / Docker meta — not part of the runtime image. +/.github/ +/.kamal/ +/Dockerfile* +/.dockerignore + +# Editor + AI-assistant configs. +/.claude/ +/.cursor/ +/.vscode/ +/.idea/ +/.ruby-lsp/ +/.solargraph.yml + +# Docs-kit's own tooling snapshots (harmless if absent in a consuming site). +/codedb.snapshot +/.worktrees/ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 7fca0eed..25d80a7a 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -67,11 +67,16 @@ end `Section("Title", description:)`, `Code(source, filename:)`, `Header("Title", eyebrow:)`. - **Wrappers that take no positional arg use lowercase page helpers** so a block - needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`. (A bare - `DocsUI::Prose do` is a Ruby SyntaxError; the helpers sidestep it.) + needs no parens: `md <<~'MD' … MD`, `prose { … }`, `example { |ex| … }`, + `operation "operationId"`. (A bare `DocsUI::Prose do` is a Ruby SyntaxError; the + helpers sidestep it.) - **Reference material has dedicated helpers** — reach for these before prose: `DocsUI::PropTable`, `DocsUI::FieldTable`, `DocsUI::RequestExample`, `DocsUI::Callout(:note | :tip | :warning)`. +- **OpenAPI-backed endpoints** (when `c.openapi` is set): `operation "createInvoice"` + renders a whole endpoint from the spec — badge, field/error tables, request tabs, + response — no hand-restatement. Append prose with a block; filter tabs with + `clients:`. ### Invariants — do not break diff --git a/docs/Dockerfile b/docs/Dockerfile index 0be40049..2fb9169e 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -54,8 +54,12 @@ COPY --link docs/ . # Precompile bootsnap code for faster boot times RUN bundle exec bootsnap precompile app/ lib/ -# Precompiling assets for production without requiring secret RAILS_MASTER_KEY -RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY. +# Drop node_modules + the bun toolchain afterwards — only the built CSS is kept, +# so the final stage's COPY of /gem never carries the JS deps. tmp/cache stays: +# it holds the bootsnap cache precompiled above. +RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile && \ + rm -rf node_modules /usr/local/bun # Final stage for app image @@ -89,4 +93,12 @@ ENTRYPOINT ["/gem/docs/bin/docker-entrypoint"] EXPOSE 3000 VOLUME /data -CMD ["./bin/rails", "server"] +# Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on +# HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which +# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's +# `app_port: 3000` in config/deploy.yml, the EXPOSE above) — Thruster's default +# is 80, which the non-root user can't reliably bind AND which kamal-proxy would +# never route to, silently bypassing Thruster straight into Puma. +ENV HTTP_PORT="3000" \ + TARGET_PORT="3001" +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/docs/Gemfile b/docs/Gemfile index e61cdc47..8c34f873 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -21,16 +21,20 @@ gem "turbo-rails" gem "daisyui", path: ".." # Shared docs-site chrome (Shell/Sidebar/Code/Page/...) — the single place the # layout/design lives, shared with the phlex-reactive docs site. Configured -# per-site via DocsKit.configure (config/initializers/docs_kit.rb). Referenced -# from GitHub (not a local path) so the Docker build — whose context is THIS repo -# — can resolve it; the gem lives in a sibling repo, outside the build context. -gem "docs-kit", github: "mhenrixon/docs-kit", require: "docs_kit" +# per-site via DocsKit.configure (config/initializers/docs_kit.rb). Now pulled +# from RubyGems as a released gem — the old GitHub ref existed only because the +# sibling repo sat outside the Docker build context, which no longer applies. +gem "docs-kit", "~> 1.0.3", require: "docs_kit" gem "method_source" gem "phlex-rails" gem "phlex-reactive" gem "rails_icons", "~> 1.1" gem "rouge" +# HTTP/2 proxy in front of Puma in the container (compression, static-asset +# caching, X-Sendfile). Invoked via bin/thrust in the Dockerfile CMD. +gem "thruster", require: false + # Database & Performance gem "friendly_id" gem "litestack", ">= 0.4.5", github: "oldmoe/litestack" diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 46536696..b6771a59 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,16 +1,3 @@ -GIT - remote: https://github.com/mhenrixon/docs-kit.git - revision: 82d76a761cf21f1380f71eccdbbae49fe8c1ad6f - specs: - docs-kit (0.1.0) - commonmarker (~> 2.0) - daisyui (>= 1.2) - nokogiri (>= 1.15) - phlex-rails (>= 2.0, < 3) - rails_icons (~> 1.1) - rouge (>= 4.0) - zeitwerk (~> 2.6) - GIT remote: https://github.com/oldmoe/litestack.git revision: e598e1b1f0d46f45df1e2c6213ff9b136b63d9bf @@ -149,6 +136,14 @@ GEM diff-lcs (1.6.2) dockerfile-rails (1.7.10) rails (>= 3.0.0) + docs-kit (1.0.3) + commonmarker (~> 2.0) + daisyui (>= 1.2, < 2) + nokogiri (>= 1.15, < 2) + phlex-rails (>= 2.0, < 3) + rails_icons (~> 1.1) + rouge (>= 4.0, < 5) + zeitwerk (~> 2.6) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -360,8 +355,7 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) - rouge (5.0.0) - strscan (~> 3.1) + rouge (4.7.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -427,8 +421,11 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - strscan (3.1.8) thor (1.5.0) + thruster (0.1.22-aarch64-linux) + thruster (0.1.22-arm64-darwin) + thruster (0.1.22-x86_64-darwin) + thruster (0.1.22-x86_64-linux) tilt (2.6.1) timeout (0.6.1) tsort (0.2.0) @@ -472,7 +469,7 @@ DEPENDENCIES daisyui! debug dockerfile-rails - docs-kit! + docs-kit (~> 1.0.3) dotenv factory_bot_rails faker @@ -502,6 +499,7 @@ DEPENDENCIES ruby-vips sqlite3 stimulus-rails + thruster turbo-rails tzinfo-data web-console diff --git a/docs/bin/thrust b/docs/bin/thrust new file mode 100755 index 00000000..36bde2d8 --- /dev/null +++ b/docs/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/docs/lib/tasks/docs_kit_og.rake b/docs/lib/tasks/docs_kit_og.rake new file mode 100644 index 00000000..a45530d6 --- /dev/null +++ b/docs/lib/tasks/docs_kit_og.rake @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Generate this site's social-share (Open Graph / Twitter) images by +# screenshotting its OWN landing page, so a shared link renders a real card of +# your docs — not the neutral placeholder docs-kit ships. Run it whenever the +# landing page changes materially: +# +# bin/rails docs_kit:og +# +# It boots the app, serves it locally, screenshots "/" at the standard sizes, and +# writes them into app/assets/images/og/. Point c.seo.og_image at the result +# (the default "og/og.png" already matches). This is a documented, manual routine +# (like phlex-reactive's vendored-client re-sync) — never run automatically, so a +# machine without a headless browser is never blocked at deploy time. +# +# It shells out to a headless-browser CLI you already have; it is NOT a docs-kit +# runtime dependency. Supported (auto-detected, first one found wins), override +# with DOCS_KIT_SHOT: +# * shot-scraper (https://shot-scraper.datasette.io) — `pipx install shot-scraper` +# * chromium/chrome headless --screenshot +# Set DOCS_KIT_OG_URL to shoot a deployed URL instead of booting locally. +namespace :docs_kit do + desc "Screenshot the landing page into app/assets/images/og/{og,twitter,square}.png" + task og: :environment do + require "docs_kit/og_generator" + + sizes = { + "og.png" => [1200, 630], # Open Graph / twitter summary_large_image + "twitter.png" => [1024, 512], # Twitter summary card + "square.png" => [600, 600] # square fallback (some chat clients) + } + out_dir = Rails.root.join("app/assets/images/og") + + DocsKit::OgGenerator.new( + url: ENV.fetch("DOCS_KIT_OG_URL", nil), + out_dir: out_dir, + sizes: sizes, + shooter: ENV.fetch("DOCS_KIT_SHOT", nil) + ).call + + puts "✅ Wrote #{sizes.keys.join(', ')} to #{out_dir.to_s.sub("#{Dir.pwd}/", '')}" + puts " Point c.seo.og_image at one of them (default \"og/og.png\" already does)." + end +end From ceb8ef39f26ea14ab56478ab324685f3546b28cb Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 10:44:18 +0200 Subject: [PATCH 2/2] fix(docs): lint the generated og rake to the site style; drop the dead local cop copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.0.3 sync installed lib/tasks/docs_kit_og.rake, which tripped this site's stricter RuboCop config (trailing comma, Rails/FilePath arguments style) — autocorrected to the site style. Also removed the hand-copied RenderComponentPreferred cop + its require: the docs-kit gem ships the cop (docs_kit/rubocop, already wired), and loading both produced constant-redefinition warnings on every lint run. Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- docs/.rubocop.yml | 3 - .../docs_kit/render_component_preferred.rb | 147 ------------------ docs/lib/tasks/docs_kit_og.rake | 4 +- 3 files changed, 2 insertions(+), 152 deletions(-) delete mode 100644 docs/lib/rubocop/cop/docs_kit/render_component_preferred.rb diff --git a/docs/.rubocop.yml b/docs/.rubocop.yml index 68254c53..61d6a9d5 100644 --- a/docs/.rubocop.yml +++ b/docs/.rubocop.yml @@ -5,14 +5,11 @@ plugins: - rubocop-rake - rubocop-rspec require: -- "./lib/rubocop/cop/docs_kit/render_component_preferred.rb" - docs_kit/rubocop DocsKit/RenderComponentPreferred: Enabled: true Include: - app/**/*.rb - Exclude: - - lib/rubocop/cop/**/*.rb AllCops: TargetRubyVersion: 3.4 NewCops: enable diff --git a/docs/lib/rubocop/cop/docs_kit/render_component_preferred.rb b/docs/lib/rubocop/cop/docs_kit/render_component_preferred.rb deleted file mode 100644 index 68489b35..00000000 --- a/docs/lib/rubocop/cop/docs_kit/render_component_preferred.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -module RuboCop - module Cop - module DocsKit - # Enforces the kit helper form over `render ::.new(...)`. - # - # The docs-kit `DocsUI` module and the `DaisyUI` gem are both extended with - # `Phlex::Kit`, which defines a singleton method per component class. That - # makes `DocsUI::Code(...)` equivalent to `render DocsUI::Code.new(...)` but - # terser and consistent. Adapted from cosmos' Cosmos/RenderComponentPreferred. - # - # @example - # # bad - # render DocsUI::Code.new(source, filename: "a.rb") - # render DocsUI::Section.new("Title") { ... } - # render DaisyUI::Button.new(:primary) { "Save" } - # - # # good - # DocsUI::Code(source, filename: "a.rb") - # DocsUI::Section("Title") { ... } - # DaisyUI::Button(:primary) { "Save" } - # - # The cop keeps the namespace prefix (`DocsUI::Code(...)` rather than - # `Code(...)`) because the unqualified helper may resolve to a different kit - # depending on inclusion order. Keeping the prefix makes the rewrite - # mechanically safe in every rendering context. - # - # Contexts the cop does NOT fire in: - # - `render ` like `render UI::Modal.clear` — not a .new. - # - elements of a `turbo_stream: [...]` array — class-method calls that - # return Turbo Stream payloads, not `.new` component instances. - class RenderComponentPreferred < Base - extend AutoCorrector - - MSG = "Use `%s` instead of `%s`." - - # Kit modules recognised by the cop. - KIT_MODULES = %w[ - DocsUI - DaisyUI - ].to_set.freeze - - # `render Kit::Class.new(args)` — plain send. - def_node_matcher :render_new_send, <<~PATTERN - (send nil? :render $(send $const :new ...)) - PATTERN - - # `render Kit::Class.new(args) { ... }` — brace block glued onto .new. - def_node_matcher :render_new_block, <<~PATTERN - (send nil? :render (block $(send $const :new ...) _ _)) - PATTERN - - def on_send(node) - return if inside_array_literal?(node) - return unless node.arguments.length == 1 - - brace_block_node = nil - match = render_new_send(node) - if match.nil? - block_match = render_new_block(node) - return unless block_match - - new_call_node, const_node = block_match - brace_block_node = node.arguments.first - else - new_call_node, const_node = match - end - - namespace = kit_namespace(const_node) - return unless namespace - - parent = node.parent - outer_node = if parent&.block_type? && parent.send_node == node - parent - else - node - end - - suggestion = build_helper_call(new_call_node, const_node, outer_node, brace_block_node) - helper_headline = helper_headline(new_call_node, const_node) - original_headline = "render #{new_call_node.source}" - - add_offense(node, message: format(MSG, suggestion: helper_headline, original: original_headline)) do |corrector| - corrector.replace(outer_node, suggestion) - end - end - - private - - def inside_array_literal?(node) - node.parent&.array_type? - end - - def kit_namespace(const_node) - segments = const_segments(const_node) - return nil if segments.nil? || segments.length < 2 - - KIT_MODULES.include?(segments.first) ? segments.first : nil - end - - def const_segments(node) - parts = [] - cur = node - while cur&.const_type? - parts.unshift(cur.short_name.to_s) - cur = cur.children.first - end - parts - end - - def helper_headline(new_call_node, const_node) - args_source = call_args_source(new_call_node) - prefix = const_node.source - args_source.empty? ? "#{prefix}()" : "#{prefix}(#{args_source})" - end - - def build_helper_call(new_call_node, const_node, outer_node, brace_block_node = nil) - args_source = call_args_source(new_call_node) - prefix = const_node.source - helper = args_source.empty? ? "#{prefix}()" : "#{prefix}(#{args_source})" - - if outer_node.block_type? - send_node = outer_node.send_node - block_source = outer_node.source[send_node.source.length..] - "#{helper}#{block_source}" - elsif brace_block_node - block_source = brace_block_node.source[new_call_node.source.length..] - "#{helper}#{block_source}" - else - helper - end - end - - def call_args_source(new_call_node) - return "" if new_call_node.arguments.empty? - - first = new_call_node.arguments.first - last = new_call_node.arguments.last - first_pos = first.source_range.begin_pos - last_pos = last.source_range.end_pos - new_call_node.source_range.source_buffer.source[first_pos...last_pos] - end - end - end - end -end diff --git a/docs/lib/tasks/docs_kit_og.rake b/docs/lib/tasks/docs_kit_og.rake index a45530d6..aec01456 100644 --- a/docs/lib/tasks/docs_kit_og.rake +++ b/docs/lib/tasks/docs_kit_og.rake @@ -27,9 +27,9 @@ namespace :docs_kit do sizes = { "og.png" => [1200, 630], # Open Graph / twitter summary_large_image "twitter.png" => [1024, 512], # Twitter summary card - "square.png" => [600, 600] # square fallback (some chat clients) + "square.png" => [600, 600], # square fallback (some chat clients) } - out_dir = Rails.root.join("app/assets/images/og") + out_dir = Rails.root.join("app", "assets", "images", "og") DocsKit::OgGenerator.new( url: ENV.fetch("DOCS_KIT_OG_URL", nil),