docs-kit 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72b8c93e9a6c2896bb31bd9ba8aaaf8cf5692424ab84d92ca8e5817d1b85291c
4
- data.tar.gz: bfc8a48ab90cbb0d62083f864fd8f188a645070b86bf4af1ff63c9a302160e99
3
+ metadata.gz: 4ede4d90b3f6ca240a59c5f13d14967a29cf8a806fa041a3c7edfe65adf1ed2c
4
+ data.tar.gz: 015f6f05b3d3b09581679810ae41b3486c2428dc219d2d47f32775a40b449759
5
5
  SHA512:
6
- metadata.gz: 6d6846fbbe1169e4513a2061b298a15ae65281da4eacaf3486d34392ceb52ecfcff7542df8eb28c2f8d5ff2c3b443c0e75a8371019b524293a51e76fbd964e80
7
- data.tar.gz: ee32039817d7e32af7b6a97e2abb2bed4ff5142974fe8e2c43f40949755343e8d6c96781d82603b61fa4794f92f3c24ef78832d33be0288c32c39308dfea1a64
6
+ metadata.gz: 2b315f74f9de60f0e02354b3e8e093a85d06c99dc6bc8e9d1f96ad9ac0abc0bf7fe0d5db37f716b68e1fac2daabcafae1bd69b5c22cf587d7068cfc3b27c3050
7
+ data.tar.gz: f7868334e630ce1ab3ec99c9fbcfd91fbb6302c0f999c4b87dfc1c04959cbd8aac7cfecb49623ba2e4f2557665dcd844439f1d69211c97d5baa2ba55ad7efd6e
data/CHANGELOG.md CHANGED
@@ -2,20 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - **SEO `og:image` 404.** The og:image tag pointed at the raw config path
8
+ (`https://site/og/og.png`), which isn't a served URL — Propshaft serves the
9
+ digested asset under `/assets`. A relative `og_image` is now resolved through
10
+ the site's asset pipeline (`image_url`) to the digested `/assets/og/og-<digest>.png`
11
+ URL, and the image is treated as **site content**: the gem ships none,
12
+ `c.seo.og_image` defaults to **nil** (unset → no og:image tag, never a 404), and
13
+ the `docs_kit:og` task writes into the *site's* `app/assets/images/`. Added a
14
+ booted-app integration test in the dogfood site
15
+ (`docs/test/integration/seo_meta_tags_test.rb`) that asserts og:image resolves
16
+ to a `/assets` URL that actually returns 200 — the coverage isolated component
17
+ specs can't provide.
18
+
5
19
  ### Added
6
20
 
7
21
  - **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
8
22
  meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
9
23
  theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
10
24
  new `c.seo` config block (`DocsKit::SeoConfig`). Pages carry an authorable
11
- `description "..."` (falling back to the page's `#lead`). docs-kit ships a
12
- neutral 1200×630 default OG image, and installs a `docs_kit:og` rake task that
13
- screenshots a site's OWN landing page into `app/assets/images/og/` (host-side
14
- headless browser; never a gem runtime dependency). A guard spec keeps the
15
- shipped default present. Backwards-compatible: a site that sets no `c.seo`
16
- renders a valid minimal card and its `<head>` is a strict superset of before.
17
- The install generator documents `c.seo` and installs the task + default image;
18
- `docs-kit new` reminds the owner to run it. (#48)
25
+ `description "..."` (falling back to the page's `#lead`). The social-share image
26
+ is site content (not shipped by the gem): the `docs_kit:og` rake task screenshots
27
+ a site's OWN landing page into its `app/assets/images/og/` (host-side headless
28
+ browser; never a gem runtime dependency), and `c.seo.og_image` points at it.
29
+ Backwards-compatible: a site that sets no `c.seo` renders a valid minimal card
30
+ and its `<head>` is a strict superset of before. The install generator documents
31
+ `c.seo` and installs the task; `docs-kit new` reminds the owner to run it. (#48)
19
32
  - Release tooling matching the sibling gems (daisyui/phlex-reactive/pgbus): a
20
33
  `rake release[X.Y.Z]` task (version bump → lockfile update → build-verify →
21
34
  commit → push → GitHub Release; `pre`/`force` supported, `main`-only, clean-tree
data/README.md CHANGED
@@ -92,6 +92,39 @@ generators:
92
92
  | `ApplicationController#render_page` defined by hand | `DocsKit::Controller#render_page` is included (the generator injects `include DocsKit::Controller`) | Delete the method — keep the `include`. |
93
93
  | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. |
94
94
  | Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. |
95
+ | `Dockerfile` stamped by an older docs-kit (`# docs-kit Dockerfile vX.Y.Z`) | docs-kit ships an optimized, multi-stage Dockerfile; a stale copy misses image-size wins | Diff yours against the current template (`lib/generators/docs_kit/install/templates/Dockerfile.tt` in the gem), adopt the changes or replace it. See [Upgrading your Dockerfile](#upgrading-your-dockerfile). |
96
+
97
+ ### Upgrading your Dockerfile
98
+
99
+ The generator ships two Docker files:
100
+
101
+ - **`.dockerignore`** is gem-owned — every `docs_kit:install` (or `--sync`) run
102
+ **refreshes** it, so you always get the current build-context excludes
103
+ (`node_modules`, `.git`, `log`, `tmp`, `spec`, `coverage`, …). It carries no
104
+ site-specific content, so overwriting it is safe.
105
+ - **`Dockerfile`** is site-owned — the generator **never clobbers** it (you tune
106
+ packages, the `CMD`, extra build steps). Instead it stamps a version marker
107
+ (`# docs-kit Dockerfile v<VERSION>`) so `--sync` can tell you when yours is
108
+ stale relative to the gem's current template.
109
+
110
+ When the site bundles `thruster` (a Rails 8 default), the generated Dockerfile
111
+ fronts Puma with Thruster (`CMD ["./bin/thrust", "./bin/rails", "server"]`) for
112
+ HTTP caching, compression, and X-Sendfile — and the generator scaffolds the
113
+ `bin/thrust` binstub if the app lacks one, since the exec-form CMD needs the
114
+ file to exist in the image. Thruster listens on the routed port
115
+ (`HTTP_PORT=3000` — Kamal's `app_port`) and proxies to Puma on `TARGET_PORT=3001`.
116
+ Without thruster in the *production* bundle (absent, or only in a
117
+ development/test group that `BUNDLE_WITHOUT` excludes) the CMD falls back to
118
+ plain `rails server` — never a thrust CMD that would crash at boot.
119
+
120
+ When `--sync` reports your Dockerfile is behind, compare it against the shipped
121
+ template and pull in the improvements (or replace it wholesale if you never
122
+ customized it):
123
+
124
+ ```bash
125
+ # The template path is printed by the generator; it lives in the installed gem:
126
+ diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templates/Dockerfile.tt"
127
+ ```
95
128
 
96
129
  ## Configure (per site)
97
130
 
@@ -180,9 +213,9 @@ nothing still gets a valid minimal card, so this is fully backwards-compatible.
180
213
  ```ruby
181
214
  # config/initializers/docs_kit.rb
182
215
  c.seo.description = "What these docs cover, in one sentence."
183
- c.seo.og_image = "og/og.png" # generated by `bin/rails docs_kit:og`
216
+ c.seo.og_image = "og/og.png" # a path in YOUR app/assets/images/
184
217
  c.seo.twitter_site = "@your_handle"
185
- c.seo.site_url = "https://docs.example.com" # absolutizes og:image + canonical
218
+ c.seo.site_url = "https://docs.example.com" # your canonical base URL
186
219
  # c.seo.robots = "noindex, nofollow" # keep a staging/private site out of search
187
220
  # c.seo.theme_color = "#0f172a" # tints mobile browser chrome
188
221
  ```
@@ -199,19 +232,26 @@ class Views::Docs::Pages::Installation < DocsUI::Page
199
232
  end
200
233
  ```
201
234
 
202
- **The social-share image.** docs-kit ships a neutral 1200×630 default so
203
- `og:image` is never broken. Generate one from your **own** landing page it
204
- screenshots `/` into `app/assets/images/og/{og,twitter,square}.png` whenever the
205
- page changes materially:
235
+ **The social-share image is your content, not the gem's.** docs-kit ships no OG
236
+ image your landing page isn't docs-kit's to render. Until you set `c.seo.og_image`,
237
+ **no `og:image` tag is emitted** (a valid card, never a broken-image 404). Generate
238
+ one from your **own** landing page it screenshots `/` into
239
+ `app/assets/images/og/{og,twitter,square}.png` — then point `og_image` at it:
206
240
 
207
241
  ```bash
208
242
  bin/rails docs_kit:og # needs a headless browser: shot-scraper or chromium/chrome
209
243
  ```
210
244
 
211
- It's a documented, manual routine (never run at deploy time), so a machine without
212
- a browser is never blocked. A guard spec keeps the shipped default present. Set
213
- `DOCS_KIT_OG_URL` to shoot a deployed URL instead of booting locally, or
214
- `DOCS_KIT_SHOT` to force a specific browser CLI.
245
+ `og_image` is a **logical asset path in your pipeline** (`"og/og.png"`), resolved
246
+ through `image_url` to the digested `/assets/og/og-<digest>.png` URL Propshaft
247
+ serves never the raw path, which 404s. So the image must be **precompiled**
248
+ (your Dockerfile's `assets:precompile` handles this at deploy); a configured
249
+ `og_image` that isn't in the pipeline fails loudly at deploy, not silently in
250
+ production. An absolute URL (`"https://cdn.example.com/card.png"`) is used verbatim.
251
+
252
+ `docs_kit:og` is a documented, manual routine (never run at deploy time), so a
253
+ machine without a browser is never blocked. Set `DOCS_KIT_OG_URL` to shoot a
254
+ deployed URL instead of booting locally, or `DOCS_KIT_SHOT` to force a browser CLI.
215
255
 
216
256
  ### Custom nav (advanced)
217
257
 
@@ -744,8 +784,9 @@ and applies docs-kit's application template, which:
744
784
  - runs `rails g docs_kit:install` (initializers, controllers, a Doc registry, a
745
785
  sample guide page, the Bun/Tailwind build, the docs-nav Stimulus wiring),
746
786
  - syncs the lucide icons and builds the CSS,
747
- - scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, `Dockerfile`) and a
748
- thin `.github/workflows/deploy-docs.yml` that calls the reusable workflow.
787
+ - scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, an optimized
788
+ multi-stage `Dockerfile` + a `.dockerignore`) and a thin
789
+ `.github/workflows/deploy-docs.yml` that calls the reusable workflow.
749
790
 
750
791
  Then `cd my-docs && bin/dev`. Already have a Rails app? Run the install generator
751
792
  instead:
@@ -11,12 +11,15 @@ module DocsUI
11
11
  #
12
12
  # All free text (title, description, brand) is emitted as normal Phlex
13
13
  # attribute values, so Phlex escapes it — config free text is never trusted
14
- # markup. og:image/og:url/canonical are absolutized against config.seo.site_url
15
- # when set, else the request base URL; with neither, og:image degrades to its
16
- # raw path and canonical/og:url are omitted (guarded like Shell#csp_nonce, so
17
- # an isolated render or a request-less static build never raises).
14
+ # markup. The og:image is SITE content (nil by default → no og:image tag): a
15
+ # relative og_image is resolved through the SITE'S asset pipeline (image_url) to
16
+ # the DIGESTED /assets URL Propshaft serves NOT the raw config path, which
17
+ # 404s; an absolute URL passes through. canonical/og:url come from
18
+ # config.seo.site_url, else the request URL; both are omitted off a request
19
+ # (guarded like Shell#csp_nonce, so an isolated render never raises).
18
20
  class MetaTags < Phlex::HTML
19
21
  include Phlex::Rails::Helpers::Request
22
+ include Phlex::Rails::Helpers::ImageURL
20
23
 
21
24
  # title: the page title (nil on a page that sets none, e.g. the home
22
25
  # page) — combined with config.title_suffix for og:title.
@@ -108,17 +111,37 @@ module DocsUI
108
111
  meta(name: "theme-color", content: seo.theme_color)
109
112
  end
110
113
 
111
- # The og:image as an absolute-when-possible URL: an already-absolute
112
- # og_image is returned as-is; a relative path is joined onto the resolved
113
- # base (config.seo.site_url or the request base); with no base it degrades to
114
- # the raw path so og:image is never empty.
114
+ # The og:image as an absolute URL a crawler can fetch, or nil to emit NO
115
+ # og:image (a valid card without an image never a 404). nil when og_image is
116
+ # unset (the default). An already-absolute og_image passes through. A relative
117
+ # path is a logical asset in the SITE'S pipeline, resolved through image_url to
118
+ # the DIGESTED, host-qualified /assets URL Propshaft actually serves
119
+ # (https://host/assets/og/og-<digest>.png) — never the raw config path, which
120
+ # 404s. Off a request (an isolated render / static build) there is no asset
121
+ # pipeline to resolve a relative path, so we emit nothing rather than a
122
+ # guessed-and-wrong URL; a real app always renders with a view context.
115
123
  def og_image_url
116
124
  image = seo.og_image
117
125
  return if image.to_s.empty?
118
126
  return image if absolute?(image)
119
127
 
120
- base = base_url
121
- base ? "#{base}/#{image.delete_prefix('/')}" : image
128
+ resolve_asset(image)
129
+ end
130
+
131
+ # Resolve a logical asset path to its served (digested, host-qualified) URL via
132
+ # the Rails asset helper. nil when there's no view context (image_url delegates
133
+ # to view_context, which raises without one — the same seam Shell#csp_nonce
134
+ # guards), so an isolated render emits no og:image rather than raising.
135
+ #
136
+ # A configured-but-unresolvable og_image (asset missing / not precompiled)
137
+ # raises the pipeline's MissingAssetError — intended, NOT rescued: a broken
138
+ # og_image is a real misconfiguration that must surface at deploy time
139
+ # (assets:precompile runs before the app serves), not ship silently-broken
140
+ # social cards. A site with no card image leaves og_image nil (see #og_image_url).
141
+ def resolve_asset(path)
142
+ return unless view_context
143
+
144
+ image_url(path)
122
145
  end
123
146
 
124
147
  # The canonical/og:url for this page. From config.seo.site_url when set (its
@@ -132,28 +155,8 @@ module DocsUI
132
155
  request.original_url
133
156
  end
134
157
 
135
- # The base URL for absolutizing a relative og:image: config.seo.site_url's
136
- # origin, else the request base URL, else nil. site_url may include a path
137
- # (for canonical); strip it to an origin so the image path joins cleanly.
138
- def base_url
139
- return origin_of(seo.site_url) if seo.site_url
140
- return unless request?
141
-
142
- request.base_url
143
- end
144
-
145
158
  def absolute?(url) = url.to_s.match?(%r{\Ahttps?://}i)
146
159
 
147
- # Just the scheme+host(+port) of a URL, dropping any path — so joining an
148
- # image path onto it never doubles a path segment.
149
- def origin_of(url)
150
- uri = URI.parse(url)
151
- port = uri.port && uri.default_port != uri.port ? ":#{uri.port}" : ""
152
- "#{uri.scheme}://#{uri.host}#{port}"
153
- rescue URI::InvalidURIError
154
- url
155
- end
156
-
157
160
  # True only when there's a live Rails view context AND a request on it — the
158
161
  # phlex-rails #request helper delegates to view_context, which raises without
159
162
  # one. Mirrors Shell#csp_nonce's guard so an isolated Phlex render (the specs,
@@ -22,18 +22,20 @@ module DocsKit
22
22
  # (DocsUI::Page derives a per-page value from #lead when a page sets none).
23
23
  attr_accessor :description
24
24
 
25
- # The social-share image, as an asset path (resolved via the Rails asset
26
- # helper) or an absolute URL og:image / twitter:image. Defaults to the
27
- # neutral image docs-kit ships at app/assets/images/og/og.png so og:image is
28
- # never broken; a site regenerates its own with `bin/rails docs_kit:og`.
25
+ # The social-share image og:image / twitter:image. Either a logical asset
26
+ # path in the SITE'S OWN pipeline (e.g. "og/og.png", resolved through
27
+ # image_url to the digested /assets URL Propshaft serves) or an absolute URL.
28
+ # The image is SITE content, not shipped by the gem — nil by default, so a
29
+ # site with no card image emits NO og:image (a valid card, never a 404).
30
+ # Generate one into app/assets/images/ with `bin/rails docs_kit:og`, then set
31
+ # this to its path.
29
32
  attr_accessor :og_image
30
33
 
31
34
  # The og:type. "website" for a docs site; a page could override to "article".
32
35
  attr_accessor :og_type
33
36
 
34
37
  # The twitter:card style. "summary_large_image" renders a full-width banner
35
- # (the shipped og:image is 1200×630, sized for it); "summary" is the small
36
- # square card.
38
+ # (size your og:image 1200×630 for it); "summary" is the small square card.
37
39
  attr_accessor :twitter_card
38
40
 
39
41
  # The site's @handle → twitter:site, and the content author's → twitter:creator.
@@ -64,7 +66,7 @@ module DocsKit
64
66
 
65
67
  def initialize
66
68
  @description = nil
67
- @og_image = "og/og.png"
69
+ @og_image = nil
68
70
  @og_type = "website"
69
71
  @twitter_card = "summary_large_image"
70
72
  @twitter_site = nil
@@ -4,7 +4,10 @@ require "securerandom"
4
4
 
5
5
  # Rails application template for a docs-kit docs site. Run via:
6
6
  #
7
- # rails new my-docs --minimal -a propshaft -j importmap --skip-... -m new_site.rb
7
+ # rails new my-docs -a propshaft -j importmap --skip-... -m new_site.rb
8
+ #
9
+ # (NOT --minimal — that strips JS the shell needs AND the thruster gem the
10
+ # generated Dockerfile fronts Puma with; exe/docs-kit passes the right flags.)
8
11
  #
9
12
  # or, more simply, via the `docs-kit new` CLI (exe/docs-kit) which supplies the
10
13
  # right `rails new` flags. It:
@@ -101,45 +104,13 @@ after_bundle do
101
104
  KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
102
105
  SH
103
106
 
104
- create_file "Dockerfile", <<~DOCKER
105
- # syntax = docker/dockerfile:1
106
- ARG RUBY_VERSION=3.4.2
107
- FROM ruby:$RUBY_VERSION-slim AS base
108
-
109
- ARG BUN_VERSION=1.3.2
110
- ENV BUN_INSTALL="/usr/local/bun"
111
- ENV PATH="/usr/local/bun/bin:$PATH"
112
- WORKDIR /rails
113
- ENV BUNDLE_WITHOUT="development:test" RAILS_ENV="production"
114
-
115
- RUN apt-get update -qq && \\
116
- apt-get install --no-install-recommends -y curl libjemalloc2 && \\
117
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
118
- RUN gem update --system --no-document && gem install -N bundler
119
-
120
- FROM base AS build
121
- RUN apt-get update -qq && \\
122
- apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip
123
- RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
124
- COPY Gemfile Gemfile.lock ./
125
- RUN bundle install && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
126
- COPY . .
127
- RUN bun install --frozen-lockfile
128
- # assets:precompile runs bun run build:css via the css:build rake enhance.
129
- RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
130
-
131
- FROM base
132
- # Kamal verifies this label on --skip-push deploy; must equal `service:`.
133
- LABEL service="#{service}"
134
- COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
135
- COPY --from=build /rails /rails
136
- RUN groupadd --system --gid 1000 rails && \\
137
- useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \\
138
- chown -R 1000:1000 /rails/log /rails/tmp
139
- USER 1000:1000
140
- EXPOSE 3000
141
- CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
142
- DOCKER
107
+ # The Dockerfile + .dockerignore are written by `docs_kit:install` (run above in
108
+ # after_bundle) so a scaffolded site and an upgrading site share ONE optimized,
109
+ # version-stamped Dockerfile — no divergent copy to maintain here. The generator
110
+ # derives the LABEL service from the app dir basename (= app_name); if the site
111
+ # deploys under a DIFFERENT Kamal service (`--service`), correct the label to
112
+ # match config/deploy.yml so Kamal's --skip-push validate_image passes.
113
+ gsub_file "Dockerfile", /LABEL service=".*"/, %(LABEL service="#{service}") if service != app_name
143
114
 
144
115
  create_file ".github/workflows/deploy-docs.yml", <<~YAML
145
116
  name: Deploy docs
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.3"
5
5
  end
@@ -4,6 +4,7 @@ require "erb"
4
4
  require "yaml"
5
5
  require "rails/generators/base"
6
6
  require_relative "sync_report"
7
+ require_relative "../../../docs_kit/version"
7
8
 
8
9
  module DocsKit
9
10
  module Generators
@@ -168,18 +169,57 @@ module DocsKit
168
169
  create_file "app/assets/builds/.keep", ""
169
170
  end
170
171
 
171
- # The SEO social-share image routine: the `docs_kit:og` rake task (gem-owned
172
- # wiring, refreshed on every run so a site gets task fixes) plus the neutral
173
- # default OG image (site content `copy_file` skips it if the site already
174
- # generated/customized its own, so `bin/rails docs_kit:og` output is never
175
- # clobbered). c.seo.og_image defaults to "og/og.png" so a site's og:image is
176
- # valid immediately, before it runs the task.
172
+ # Install the `docs_kit:og` rake task gem-owned wiring, refreshed on every
173
+ # run so a site picks up task fixes. It does NOT ship an OG image: the
174
+ # social-share image is SITE content, generated into the site's OWN
175
+ # app/assets/images/ by `bin/rails docs_kit:og`. Until a site runs it (and
176
+ # sets c.seo.og_image), no og:image tag is emitted — a valid card, never a
177
+ # 404 for an image the gem can't provide.
177
178
  def create_og_task
178
179
  template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake"
179
- copy_file(
180
- File.expand_path("../../../../app/assets/images/og/og.png", __dir__),
181
- "app/assets/images/og/og.png"
182
- )
180
+ end
181
+
182
+ # The production Dockerfile — a lean multi-stage build for a standalone
183
+ # docs site. Site-customizable (a site tunes packages/CMD), so skip when it
184
+ # exists and point an upgrader at the current template for a manual diff.
185
+ # The template stamps a `# docs-kit Dockerfile vX.Y.Z` marker so a `--sync`
186
+ # upgrade (SyncReport) can flag a stale copy. (create_initializer follows
187
+ # this same skip-if-exists + template-hint pattern.)
188
+ def create_dockerfile
189
+ dockerfile = "Dockerfile"
190
+ if File.exist?(File.join(destination_root, dockerfile))
191
+ template_path = File.join(self.class.source_root, "Dockerfile.tt")
192
+ return say_status(:skip, "#{dockerfile} exists — compare with #{template_path} if upgrading", :blue)
193
+ end
194
+
195
+ template "Dockerfile.tt", dockerfile
196
+ end
197
+
198
+ # The .dockerignore — gem-owned build-context trimming (node_modules, .git,
199
+ # logs, specs, coverage). Refreshed on every run (like create_og_task): it
200
+ # carries no site-specific content, so a re-run always ships the current
201
+ # excludes rather than fossilizing an old list.
202
+ def create_dockerignore
203
+ copy_file "dockerignore", ".dockerignore", force: true
204
+ end
205
+
206
+ # When the site bundles thruster but lacks the binstub, create it — the
207
+ # Dockerfile's exec-form `CMD ["./bin/thrust", ...]` needs the file to EXIST
208
+ # in the image (`bundle install` installs the gem, not app binstubs, and
209
+ # `COPY . .` can't ship a file the repo doesn't have). Without this, the
210
+ # image builds green and the container crashes at boot. Skip-if-exists: a
211
+ # hand-tuned binstub is never touched.
212
+ def create_thrust_binstub
213
+ return unless gemfile_bundles_thruster? && !thrust_binstub?
214
+
215
+ create_file "bin/thrust", <<~RUBY
216
+ #!/usr/bin/env ruby
217
+ require "rubygems"
218
+ require "bundler/setup"
219
+
220
+ load Gem.bin_path("thruster", "thrust")
221
+ RUBY
222
+ chmod "bin/thrust", 0o755
183
223
  end
184
224
 
185
225
  def wire_assets_and_package_json
@@ -456,6 +496,56 @@ module DocsKit
456
496
  name = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.class&.module_parent_name
457
497
  (name || File.basename(destination_root)).to_s.underscore.humanize
458
498
  end
499
+
500
+ # The Kamal `service` name stamped as the Dockerfile's LABEL — the app dir
501
+ # basename (a docs site's repo name), matching the `docs-kit new` default.
502
+ # Used in Dockerfile.tt via <%= docker_service %>.
503
+ def docker_service
504
+ File.basename(destination_root)
505
+ end
506
+
507
+ # The RUBY_VERSION build ARG default: the host's running Ruby (X.Y.Z), so a
508
+ # site's image matches its dev Ruby. Used in Dockerfile.tt.
509
+ def ruby_version_arg
510
+ RUBY_VERSION[/\d+\.\d+\.\d+/] || "3.4.2"
511
+ end
512
+
513
+ # True when the site bundles Thruster (HTTP caching + compression +
514
+ # X-Sendfile in front of Puma) — a Rails 8 `rails new` ships bin/thrust +
515
+ # the gem, but docs_kit:install also runs on older apps where a thrust CMD
516
+ # would crash the container at boot (Gem.bin_path raises). Decides which
517
+ # CMD Dockerfile.tt emits. A committed binstub is authoritative; otherwise
518
+ # the Gemfile decides (and create_thrust_binstub scaffolds the binstub).
519
+ def thruster?
520
+ thrust_binstub? || gemfile_bundles_thruster?
521
+ end
522
+
523
+ def thrust_binstub?
524
+ File.exist?(File.join(destination_root, "bin/thrust"))
525
+ end
526
+
527
+ # True when the Gemfile declares thruster where the PRODUCTION bundle sees
528
+ # it. The Dockerfile sets BUNDLE_WITHOUT="development:test", so a
529
+ # `group :development do ... end` entry (or an inline `group:` kwarg) must
530
+ # NOT count — the gem wouldn't be installed and thrust would raise at boot.
531
+ # Line-level block tracking: any `... do` pushes, `end` pops; a gem line
532
+ # counts only outside every group block (nested non-group blocks are fine).
533
+ def gemfile_bundles_thruster?
534
+ gemfile = File.join(destination_root, "Gemfile")
535
+ return false unless File.exist?(gemfile)
536
+
537
+ block_stack = []
538
+ File.foreach(gemfile) do |line|
539
+ if line.match?(/\bdo\s*(\|[^|]*\|)?\s*$/)
540
+ block_stack.push(line.match?(/^\s*group\b/))
541
+ elsif line.match?(/^\s*end\b/)
542
+ block_stack.pop
543
+ elsif block_stack.none? && line.match?(/^\s*gem\s+["']thruster["']/) && !line.match?(/\bgroup:/)
544
+ return true
545
+ end
546
+ end
547
+ false
548
+ end
459
549
  end
460
550
  end
461
551
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../../docs_kit/version"
4
+
3
5
  module DocsKit
4
6
  module Generators
5
7
  # Detects manual drift in an existing docs site that the install generator
@@ -9,13 +11,21 @@ module DocsKit
9
11
  # generator prints these as a checklist during a `--sync` upgrade; the site
10
12
  # owner deletes the flagged code by hand.
11
13
  #
12
- # Two drift items, both from the consumer audits:
14
+ # Drift items, from the consumer audits:
13
15
  # - ApplicationController hand-defines `render_page` — DocsKit::Controller
14
16
  # (included by the generator for months) already provides it.
15
17
  # - a dead IconHelper copy — the gem renders icons via rails_icons.
18
+ # - a Dockerfile stamped by an OLDER docs-kit than the gem now ships — the
19
+ # site should diff against the current template and adopt the improvements.
16
20
  class SyncReport
17
21
  APPLICATION_CONTROLLER = "app/controllers/application_controller.rb"
18
22
  ICON_HELPER = "app/helpers/icon_helper.rb"
23
+ DOCKERFILE = "Dockerfile"
24
+
25
+ # Matches the version stamp the Dockerfile template writes, e.g.
26
+ # `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site
27
+ # brought itself — which we deliberately leave alone (no marker → no warning).
28
+ DOCKERFILE_MARKER = /docs-kit Dockerfile v(\d+\.\d+\.\d+)/
19
29
 
20
30
  def initialize(destination_root)
21
31
  @root = destination_root
@@ -24,7 +34,7 @@ module DocsKit
24
34
  # The drift messages, in the order a site should act on them. Empty when
25
35
  # the site is clean.
26
36
  def items
27
- [render_page_drift, icon_helper_drift].compact
37
+ [render_page_drift, icon_helper_drift, dockerfile_drift].compact
28
38
  end
29
39
 
30
40
  def clean?
@@ -53,6 +63,22 @@ module DocsKit
53
63
  "rails_icons (DocsUI::Icon); delete it."
54
64
  end
55
65
 
66
+ # The site's Dockerfile carries a docs-kit version stamp OLDER than the gem
67
+ # now ships. We never rewrite the site's Dockerfile (it's tuned per site) —
68
+ # we point the owner at the current template to diff or replace. A file with
69
+ # no marker (a hand-written Dockerfile) is left alone: no stamp, no warning.
70
+ def dockerfile_drift
71
+ source = read(DOCKERFILE)
72
+ stamped = source&.match(DOCKERFILE_MARKER)
73
+ return unless stamped
74
+
75
+ site_version = stamped[1]
76
+ return if site_version == DocsKit::VERSION
77
+
78
+ "#{DOCKERFILE} is v#{site_version}, docs-kit now ships v#{DocsKit::VERSION} — " \
79
+ "diff against the template (bin/rails g docs_kit:install shows the path) and adopt the changes."
80
+ end
81
+
56
82
  def read(rel)
57
83
  path = File.join(@root, rel)
58
84
  File.exist?(path) ? File.read(path) : nil
@@ -0,0 +1,96 @@
1
+ # syntax = docker/dockerfile:1
2
+ # docs-kit Dockerfile v<%= DocsKit::VERSION %> — regenerate/diff: bin/rails g docs_kit:install
3
+ #
4
+ # A standalone docs-kit site: a lean, multi-stage production image. The final
5
+ # stage carries only the runtime (Ruby + the installed bundle + the app), never
6
+ # the build toolchain (build-essential, git, bun) — those live in the throwaway
7
+ # `build` stage. Pair it with the shipped .dockerignore so the build context
8
+ # stays small (no node_modules, .git, logs, specs, coverage).
9
+ #
10
+ # Build context is the app root; `docker build .` (Kamal: context: ".").
11
+
12
+ ARG RUBY_VERSION=<%= ruby_version_arg %>
13
+ FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
14
+
15
+ # Rails app lives here.
16
+ WORKDIR /rails
17
+
18
+ ARG BUN_VERSION=1.3.2
19
+ ENV BUN_INSTALL="/usr/local/bun" \
20
+ PATH="/usr/local/bun/bin:$PATH" \
21
+ BUNDLE_DEPLOYMENT="1" \
22
+ BUNDLE_PATH="/usr/local/bundle" \
23
+ BUNDLE_WITHOUT="development:test" \
24
+ RAILS_ENV="production"
25
+
26
+ # Base packages the RUNTIME needs (jemalloc for a smaller/faster heap, curl for
27
+ # the container healthcheck). Anything only the build needs goes in `build`.
28
+ RUN apt-get update -qq && \
29
+ apt-get install --no-install-recommends -y curl libjemalloc2 && \
30
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
31
+
32
+
33
+ # --- Build stage (throwaway — its layers never reach the final image) ---------
34
+ FROM base AS build
35
+
36
+ # Toolchain to compile native gems + fetch bun. Skip the apt cleanup: this whole
37
+ # stage is discarded.
38
+ RUN apt-get update -qq && \
39
+ apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip
40
+ RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"
41
+
42
+ # Install gems first (cached until Gemfile/Gemfile.lock change), then prune the
43
+ # bundler cache + any git-sourced gem checkouts so they don't bloat the layer.
44
+ COPY Gemfile Gemfile.lock ./
45
+ RUN bundle install && \
46
+ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
47
+
48
+ # Install JS deps (the Tailwind/daisyUI CLI), then the app.
49
+ COPY package.json bun.lock* ./
50
+ RUN bun install --frozen-lockfile
51
+ COPY . .
52
+
53
+ # assets:precompile runs `bun run build:css` via the css:build rake enhance.
54
+ # Drop the node_modules + bun cache afterwards — the built CSS is all we keep.
55
+ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \
56
+ rm -rf node_modules .bun tmp/cache
57
+
58
+
59
+ # --- Final stage --------------------------------------------------------------
60
+ FROM base
61
+
62
+ # Kamal's validate_image greps this label on a --skip-push deploy; it must equal
63
+ # `service:` in config/deploy.yml. The reusable deploy workflow also stamps it,
64
+ # but keeping it here means `docker build` alone produces a Kamal-valid image.
65
+ LABEL service="<%= docker_service %>"
66
+
67
+ # Copy ONLY the built artifacts from the build stage: the installed bundle and
68
+ # the app (with precompiled assets). No toolchain, no node_modules.
69
+ COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
70
+ COPY --from=build /rails /rails
71
+
72
+ # Run as a non-root user; own only the runtime dirs it writes.
73
+ RUN groupadd --system --gid 1000 rails && \
74
+ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
75
+ chown -R 1000:1000 /rails/log /rails/tmp
76
+ USER 1000:1000
77
+
78
+ EXPOSE 3000
79
+ <% if thruster? -%>
80
+ # Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on
81
+ # HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which
82
+ # config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's
83
+ # `app_port`, the EXPOSE above) — Thruster's default is 80, which the non-root
84
+ # user can't reliably bind AND which kamal-proxy (app_port: 3000) would never
85
+ # route to, silently bypassing Thruster straight into Puma.
86
+ ENV HTTP_PORT="3000" \
87
+ TARGET_PORT="3001"
88
+ CMD ["./bin/thrust", "./bin/rails", "server"]
89
+ <% else -%>
90
+ # No Thruster in this app's bundle — plain Puma. To front it with Thruster
91
+ # (HTTP caching/compression/X-Sendfile): add `gem "thruster"`, then delete this
92
+ # file and re-run `bin/rails g docs_kit:install` — the generator never
93
+ # overwrites an existing Dockerfile, so a re-run alone won't update it. (It
94
+ # also scaffolds the bin/thrust binstub the thrust CMD needs.)
95
+ CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
96
+ <% end -%>
@@ -0,0 +1,57 @@
1
+ # docs-kit .dockerignore — keep the build context (and image layers) small.
2
+ # Gem-owned: `bin/rails g docs_kit:install` refreshes this on every run.
3
+ # See https://docs.docker.com/build/building/context/#dockerignore-files
4
+
5
+ # Git metadata — the app build never needs it (a released gem carries its files).
6
+ /.git/
7
+ /.gitignore
8
+ /.gitattributes
9
+
10
+ # Bundler config (BUNDLE_* env in the Dockerfile drives the install instead).
11
+ /.bundle
12
+
13
+ # Environment files and credentials — never bake secrets into an image.
14
+ /.env*
15
+ !/.env*.erb
16
+ /config/master.key
17
+ /config/credentials/*.key
18
+
19
+ # Logs and tempfiles (keep the dirs, drop the contents).
20
+ /log/*
21
+ !/log/.keep
22
+ /tmp/*
23
+ !/tmp/.keep
24
+ /tmp/pids/*
25
+ !/tmp/pids/.keep
26
+
27
+ # JS deps + generated assets: `bun install` + `assets:precompile` rebuild them.
28
+ /node_modules/
29
+ /app/assets/builds/*
30
+ !/app/assets/builds/.keep
31
+ # Generated by bin/build-css — Tailwind resolves the gem @source globs at build.
32
+ /app/assets/stylesheets/tailwind.sources.css
33
+ /public/assets
34
+
35
+ # Tests + coverage — not needed to run the site in production.
36
+ /spec/
37
+ /.rspec
38
+ /coverage/
39
+ /spec/examples.txt
40
+
41
+ # CI / deploy / Docker meta — not part of the runtime image.
42
+ /.github/
43
+ /.kamal/
44
+ /Dockerfile*
45
+ /.dockerignore
46
+
47
+ # Editor + AI-assistant configs.
48
+ /.claude/
49
+ /.cursor/
50
+ /.vscode/
51
+ /.idea/
52
+ /.ruby-lsp/
53
+ /.solargraph.yml
54
+
55
+ # Docs-kit's own tooling snapshots (harmless if absent in a consuming site).
56
+ /codedb.snapshot
57
+ /.worktrees/
@@ -36,19 +36,21 @@ Rails.application.config.to_prepare do
36
36
  # that sets none still gets a valid minimal card. Per-page descriptions come
37
37
  # from each page's `description "..."` (falling back to its lead paragraph).
38
38
  # c.seo.description = "What these docs cover, in one sentence."
39
- # c.seo.og_image = "og/og.png" # generated by `bin/rails docs_kit:og`
40
39
  # c.seo.twitter_card = "summary_large_image" # default; "summary" = small card
41
40
  # c.seo.twitter_site = "@your_handle"
42
41
  # c.seo.twitter_creator = "@author_handle"
43
- # c.seo.site_url = "https://docs.example.com" # absolutizes og:image/canonical off a request
42
+ # c.seo.site_url = "https://docs.example.com" # your canonical base URL
44
43
  # c.seo.locale = "en_US"
45
44
  # c.seo.robots = "noindex, nofollow" # keep a staging/private site out of search
46
45
  # c.seo.favicon = "/favicon.ico"
47
46
  # c.seo.theme_color = "#0f172a" # tints mobile browser chrome
48
47
  #
49
- # Regenerate the social-share image from your OWN landing page (screenshots
50
- # "/" into app/assets/images/og/) whenever it changes materially:
48
+ # The social-share image is YOUR content, not shipped by the gem. Generate one
49
+ # from your landing page (screenshots "/" into app/assets/images/og/) and point
50
+ # og_image at it — it's resolved through YOUR asset pipeline to the digested
51
+ # /assets URL. Until you set it, no og:image is emitted (a valid card, no 404).
51
52
  # bin/rails docs_kit:og
53
+ # c.seo.og_image = "og/og.png" # a path in app/assets/images/
52
54
 
53
55
  # Code blocks use one Rouge theme by default. To keep them readable in BOTH
54
56
  # light and dark daisyUI themes, set a light base + a dark override — the dark
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docs-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -164,8 +164,6 @@ files:
164
164
  - CHANGELOG.md
165
165
  - LICENSE.txt
166
166
  - README.md
167
- - app/assets/images/og/og.png
168
- - app/assets/images/og/og.svg
169
167
  - app/components/docs_ui/brand_mark.rb
170
168
  - app/components/docs_ui/callout.rb
171
169
  - app/components/docs_ui/code.rb
@@ -235,11 +233,13 @@ files:
235
233
  - lib/generators/docs_kit/install/USAGE
236
234
  - lib/generators/docs_kit/install/install_generator.rb
237
235
  - lib/generators/docs_kit/install/sync_report.rb
236
+ - lib/generators/docs_kit/install/templates/Dockerfile.tt
238
237
  - lib/generators/docs_kit/install/templates/agents_md.erb
239
238
  - lib/generators/docs_kit/install/templates/application.tailwind.css.erb
240
239
  - lib/generators/docs_kit/install/templates/build-css
241
240
  - lib/generators/docs_kit/install/templates/build_css.rake
242
241
  - lib/generators/docs_kit/install/templates/doc.rb.erb
242
+ - lib/generators/docs_kit/install/templates/dockerignore
243
243
  - lib/generators/docs_kit/install/templates/docs_controller.rb.erb
244
244
  - lib/generators/docs_kit/install/templates/docs_kit.rb.erb
245
245
  - lib/generators/docs_kit/install/templates/docs_kit_og.rake
Binary file
@@ -1 +0,0 @@
1
- <svg height="630" viewBox="0 0 1200 630" width="1200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#0f172a"/><stop offset="1" stop-color="#1e293b"/></linearGradient><linearGradient id="b" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#38bdf8"/><stop offset="1" stop-color="#818cf8"/></linearGradient><path d="m0 0h1200v630h-1200z" fill="url(#a)"/><path d="m0 0h1200v8h-1200z" fill="url(#b)"/><path d="m80 470h240m-240 40h180m-180 40h220" fill="none" opacity=".5" stroke="#334155" stroke-width="2"/><g transform="translate(80 150)"><rect fill="url(#b)" height="96" rx="20" width="96"/><path d="m28 32h40m-40 16h40m-40 16h28" stroke="#0f172a" stroke-linecap="round" stroke-width="7"/></g><g font-family="Helvetica, Arial, sans-serif"><text fill="#f8fafc" font-size="88" font-weight="700" x="80" y="330">Documentation</text><text fill="#94a3b8" font-size="34" font-weight="400" x="82" y="392">Built with docs-kit</text></g></svg>