docs-kit 1.3.0 → 1.4.1

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: feed239eb09deeafd1a9dc6e92e91d192c14f927fa562105139f4b16473d1a60
4
- data.tar.gz: 6149bf7b9509fb611ce86e9c183a38c027ce77addee69669be41a0ce85fb292d
3
+ metadata.gz: 29ec90ecec4c794dcfd2f0dfa7254a02844ccafa76019303b2b2bdbaf87c62c3
4
+ data.tar.gz: 3b8e8d345e6f795d00f44cea26529c9433eeb9ebb60e36cd07ce07541eec0fd5
5
5
  SHA512:
6
- metadata.gz: 5af123f6815e81ccad67dcb7737836c0a2727fb60e963bfc16d6ff0a6540325ca1a67fa2aaaea2ff824ba3b36f2210d8fb4aaab43f097195ec7f020cbfc8c54e
7
- data.tar.gz: 32e24110ca11cfba1c795d60ea93d6ecb2f1e42af912274ebc816718cd235d93e217bb1ac2a6082b08039eaefe162c6b8287f4ed328c685ab3c669c840e2e359
6
+ metadata.gz: cb1ef7f9d6c3673f48d965b3bdd71c85b38b0f7c053d492078046f8fd37b094a98cb92ace7b6b777e986e00fd1cd2be187f455f591ab23437a9da8dee4ac31a6
7
+ data.tar.gz: 573e0e3db675e201e7457c8adee993d69e239789d08a3019b89c2409dd1cd6ec154e37fc6977c1a40e95369118ea78fa815e0e1da48ba4d551c6375a81692063
data/CHANGELOG.md CHANGED
@@ -2,6 +2,100 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Upgrading from 1.4
6
+
7
+ Nothing to change for a site that registers the controller with
8
+ `eagerLoadControllersFrom("docs_kit/controllers", application)`, as the
9
+ generated `index.js` does: `docs-nav` still registers on every page. It is no
10
+ longer preloaded, so on docs pages the eager loader's `import()` fetches it
11
+ one request later instead of from the preload cache. To keep the preload, set
12
+ `config.docs_kit.preload = true` in `config/application.rb`.
13
+
14
+ ### Added
15
+
16
+ - **`config.docs_kit.controllers_under`** (default `"docs_kit/controllers"`)
17
+ sets the auto-pin's module namespace. `"controllers"` pins the controller as
18
+ `controllers/docs_nav_controller` (same asset), so the host's own loader for
19
+ `controllers` finds it, with no docs-kit line in `index.js`; with
20
+ `lazyLoadControllersFrom("controllers", application)` a page fetches it only
21
+ if it renders `data-controller="docs-nav"`. Set it in
22
+ `config/application.rb`. (#107)
23
+
24
+ ### Changed
25
+
26
+ - **`docs-nav` is no longer preloaded on every page.** The auto-pin took
27
+ importmap-rails' default `preload: true`, so in a host whose docs share the
28
+ application's import map every page, docs or not, carried a
29
+ `<link rel="modulepreload">` for the 24 KB controller, and a host `pin` with
30
+ `preload: false` could not override it (a `pin_all_from` directory expands
31
+ over plain pins). `config.docs_kit.preload` (default `false`) is now the
32
+ pin's `preload:`: `true` restores 1.4's behaviour, an entry-point name
33
+ (importmap-rails 2.x) preloads it only where that entry point is rendered.
34
+ (#107)
35
+
36
+ ### Fixed
37
+
38
+ - **One documented registration.** The README and the pin file's header now
39
+ point at one "Registering the controller" section. The README no longer
40
+ recommends `lazyLoadControllersFrom("docs_kit/controllers", application)`,
41
+ which in a host with its own controllers requests
42
+ `docs_kit/controllers/<identifier>_controller` for every host
43
+ `data-controller` and logs a "Failed to autoload controller" error for each,
44
+ on every page. (#107)
45
+
46
+ ## [1.4.0] — 2026-10-15
47
+
48
+ ### Upgrading from 1.3
49
+
50
+ Nothing to change for a site whose initializer configures docs-kit in a
51
+ `to_prepare` block, as the generated one does. Three things now fail loudly
52
+ instead of later or never: a broken `brand_logo` raises at boot, in
53
+ development too; mutating a registry DSL or `DocsUI::Page` macro value in place
54
+ raises `FrozenError`; so does mutating one of docs-kit's constants.
55
+
56
+ ### Fixed
57
+
58
+ - **A cold archived version is built once, however many requests ask.**
59
+ `DocsKit::Snapshot.for` cached snapshots in an unguarded class ivar, so
60
+ threads that missed a version together each read and parsed its
61
+ `manifest.json` and raced to store it. The cache is now per Ractor and its
62
+ check-then-set runs under a lock; `Configuration#openapi_document`'s
63
+ mtime-checked memo likewise. (#91)
64
+ - **No per-render constant lookups once the app has booted.** The engine now
65
+ calls `DocsKit.finalize!` once the host has configured the kit, with no app
66
+ code: the Rouge code themes and every registered page's view class are
67
+ resolved once into frozen maps instead of `Object.const_get` /
68
+ `safe_constantize` on every render, and the brand logo, SEO, landing and
69
+ optional-mcp memos are filled at boot rather than by whichever request comes
70
+ first. `DocsKit.configure` re-opens it, and a theme assigned directly
71
+ afterwards still wins. Outside Rails, call `DocsKit.finalize!` yourself. (#91)
72
+
73
+ ### Changed
74
+
75
+ - **Finalized after boot in every environment, and after every reload.** The
76
+ engine finalizes in `after_initialize` in development too. With code
77
+ reloading on, it finalizes again after each reload, once the host's
78
+ `to_prepare` configure has re-run and re-opened the configuration, so the
79
+ resolved page views are the reloaded classes. The host's callables (`brand`,
80
+ `nav`, `version_badge`, `importmap`, ...) are kept as given, never frozen:
81
+ they may close over app state, so `DocsKit.configuration` stays
82
+ main-Ractor-only and finalizing never fails on one. (#91)
83
+ - **Boot-time values are frozen.** The registry DSL (`page`, `path_prefix`,
84
+ `view_namespace`, `group_by_attribute`) and `DocsUI::Page`'s `title`,
85
+ `eyebrow`, `description` and `on_page` macros deep-freeze what they are
86
+ given (Ractor-shareable), as do the gem's own constants, and
87
+ `DocsKit.configuration` is built at load rather than on first read. Code
88
+ that mutated one of those values in place after declaring it now raises
89
+ `FrozenError`. (#91)
90
+ - **A broken `brand_logo` fails at boot.** `DocsKit.finalize!` builds the logo,
91
+ so a missing file or non-SVG markup raises when the app boots, in every
92
+ environment, instead of on the first render. (#91)
93
+ - docs-kit is held to [conformance-kit](https://github.com/zoolutions/conformance-kit)
94
+ 0.4 in CI: the rubocop-thread_safety cops, thread and fiber contention specs,
95
+ a Ruby 4.0 Ractor canary (search index, llms.txt, the sidebar tree) and
96
+ `conformance-audit --strict`, whose allowlist names Rails' and Thor's class
97
+ state on the engine, controllers and generators by `ancestor:` rule. (#91)
98
+
5
99
  ## [1.3.0] — 2026-10-13
6
100
 
7
101
  ### Fixed
@@ -42,7 +136,15 @@
42
136
  `on_page` macros inherit from a parent page when unset. Pages using only the
43
137
  class macros render byte-identically. (#95)
44
138
 
45
- ## [1.2.1] — 2026-09-30 (with 1.2.0, same day)
139
+ ## [1.2.1] — 2026-09-30
140
+
141
+ ### Changed
142
+
143
+ - **daisyui 2.x is allowed.** The dependency is now `>= 1.2, < 3`. 1.2.0 was
144
+ tagged but never reached RubyGems (its release run failed a stale spec), so
145
+ 1.2.1 is the first published 1.2 release.
146
+
147
+ ## [1.2.0] — 2026-09-30 (tagged; never published to RubyGems)
46
148
 
47
149
  ### Upgrading from 1.1
48
150
 
@@ -77,9 +179,6 @@ bundle exec rspec
77
179
 
78
180
  ### Changed
79
181
 
80
- - **daisyui 2.x is allowed.** The dependency is now `>= 1.2, < 3`. 1.2.0 was
81
- tagged but never reached RubyGems (its release run failed a stale spec), so
82
- 1.2.1 is the first published 1.2 release.
83
182
  - **The reusable deploy workflow runs on Node 24 actions.** `deploy.yml` now
84
183
  uses `actions/checkout@v7`, `docker/setup-buildx-action@v4`,
85
184
  `docker/login-action@v4`, `docker/build-push-action@v7` and
@@ -116,21 +215,68 @@ bundle exec rspec
116
215
  `lexer:` still wins, and a block with neither (or an unguessable filename)
117
216
  stays Ruby, so existing output is unchanged.
118
217
 
119
- ## [1.1.1] and earlier
218
+ ## [1.1.1] — 2026-08-27
120
219
 
121
220
  ### Fixed
122
221
 
123
- - **SEO `og:image` 404.** The og:image tag pointed at the raw config path
124
- (`https://site/og/og.png`), which isn't a served URL — Propshaft serves the
125
- digested asset under `/assets`. A relative `og_image` is now resolved through
126
- the site's asset pipeline (`image_url`) to the digested `/assets/og/og-<digest>.png`
127
- URL, and the image is treated as **site content**: the gem ships none,
128
- `c.seo.og_image` defaults to **nil** (unset → no og:image tag, never a 404), and
129
- the `docs_kit:og` task writes into the *site's* `app/assets/images/`. Added a
130
- booted-app integration test in the dogfood site
131
- (`docs/test/integration/seo_meta_tags_test.rb`) that asserts og:image resolves
132
- to a `/assets` URL that actually returns 200 — the coverage isolated component
133
- specs can't provide.
222
+ - **`/llms.txt` sends a public max-age** (`expires_in 300, public: true`), so
223
+ shared caches such as dash-proxy can store it; ETag revalidation is
224
+ unchanged. (#75)
225
+
226
+ ## [1.1.0] — 2026-08-27
227
+
228
+ ### Added
229
+
230
+ - **Docs versions foundation.** `c.versions` (`DocsKit::DocVersion`),
231
+ `c.repo_url` and `c.snapshots_path`, a request-scoped version
232
+ (`DocsKit::Scope`) and a snapshot content source. A site that configures no
233
+ versions renders unchanged. (#67)
234
+
235
+ ### Changed
236
+
237
+ - **The reusable deploy workflow uses dash 4.** `deploy.yml` installs
238
+ `dash ~> 4.0` and runs `dash doctor` before deploying; the registry secret is
239
+ now `DASH_REGISTRY_PASSWORD` (`KAMAL_REGISTRY_PASSWORD` is still exported),
240
+ and the `docs-kit new` deploy template turns on dash-proxy compression,
241
+ caching and security headers. (#74)
242
+
243
+ ### Fixed
244
+
245
+ - **Repointed at the `zoolutions` org.** The reusable deploy workflow, the
246
+ `docs-kit new` and install templates, the README and the gemspec metadata
247
+ now reference `zoolutions/docs-kit` (reusable-workflow `uses:` refs don't
248
+ follow the repo move's redirect). (#70)
249
+
250
+ ## [1.0.8] — 2026-08-04
251
+
252
+ ### Added
253
+
254
+ - **`c.brand_logo`.** A config-driven brand mark (`svg:`, `paths:`, `markup:`,
255
+ `file:` or `src:`) rendered by the new `DocsUI::Logo` in the topbar and
256
+ sidebar brand links, falling back to `c.brand`; `c.landing.logo` accepts the
257
+ same forms. `c.topbar_brand = :mobile_only` hides the topbar copy where the
258
+ sidebar brand is already visible. (#68)
259
+
260
+ ### Changed
261
+
262
+ - **The sidebar's top-level heading is flattened.** With one nav heading it
263
+ is no longer rendered; with several, each is a static `menu-title` label and
264
+ only subgroups collapse. The landing doc index follows the same rule. (#66)
265
+
266
+ ## [1.0.7] — 2026-08-03
267
+
268
+ ### Added
269
+
270
+ - **Version-aware `--sync` migrations.** Install and `--sync` stamp the
271
+ initializer with `# docs-kit synced: vX.Y.Z`, and `--sync` runs the ordered
272
+ migrations between that version and the gem's, printing what it can't
273
+ automate. The migration registry ships empty in 1.0.x. (#56)
274
+ - **Two homes: `c.app_link`.** An opt-in `c.app_link = { href:, label: }`
275
+ renders a topbar link back to the hosting app, and the page masthead's link
276
+ now follows `c.brand_href` instead of the host's `root_path` and reads
277
+ "← Docs home". (#63)
278
+
279
+ ## [1.0.6] — 2026-07-04
134
280
 
135
281
  ### Added
136
282
 
@@ -141,6 +287,11 @@ bundle exec rspec
141
287
  site's asset pipeline). nil (the default) omits it, so it's backwards-compatible.
142
288
  This closes the gap a mounted docs app hit — a hero with no way to show the
143
289
  product's own logo — the first consumer to try `DocsUI::Landing` on a real site.
290
+
291
+ ## [1.0.5] — 2026-07-04
292
+
293
+ ### Added
294
+
144
295
  - **`DocsUI::Landing` — a config-driven marketing landing page.** Every consuming
145
296
  site (and this dogfood site) was hand-rolling a home page; now render
146
297
  `DocsUI::Landing` and drive it from a new `c.landing` config block
@@ -154,6 +305,48 @@ bundle exec rspec
154
305
  now renders it and the initializer documents `c.landing`. This is the first
155
306
  landing pattern proven on a **mounted** docs app (a docs section inside a larger
156
307
  Rails app whose `/` is already taken), contributed back from that use case.
308
+
309
+ ## [1.0.4] — 2026-07-04
310
+
311
+ ### Changed
312
+
313
+ - **rouge 5 is allowed.** The dependency is now `>= 4.0, < 6`; the `< 5` cap
314
+ was downgrading sites already on rouge 5. (#54)
315
+
316
+ ### Fixed
317
+
318
+ - **`docs_kit:install --sync` no longer re-injects the commented `/mcp` route
319
+ scaffold** into a site that already draws live MCP routes. (#53)
320
+
321
+ ## [1.0.3] — 2026-07-04
322
+
323
+ ### Added
324
+
325
+ - **`docs_kit:install` templates a lean multi-stage `Dockerfile` and a
326
+ `.dockerignore`.** The `Dockerfile` is site-owned (skipped if present) and
327
+ carries a `# docs-kit Dockerfile vX.Y.Z` marker that `--sync` warns about
328
+ when it is older than the gem; `docs-kit new` uses the same template. (#52)
329
+
330
+ ## [1.0.2] — 2026-07-03
331
+
332
+ ### Fixed
333
+
334
+ - **SEO `og:image` 404.** The og:image tag pointed at the raw config path
335
+ (`https://site/og/og.png`), which isn't a served URL — Propshaft serves the
336
+ digested asset under `/assets`. A relative `og_image` is now resolved through
337
+ the site's asset pipeline (`image_url`) to the digested `/assets/og/og-<digest>.png`
338
+ URL, and the image is treated as **site content**: the gem ships none,
339
+ `c.seo.og_image` defaults to **nil** (unset → no og:image tag, never a 404), and
340
+ the `docs_kit:og` task writes into the *site's* `app/assets/images/`. Added a
341
+ booted-app integration test in the dogfood site
342
+ (`docs/test/integration/seo_meta_tags_test.rb`) that asserts og:image resolves
343
+ to a `/assets` URL that actually returns 200 — the coverage isolated component
344
+ specs can't provide.
345
+
346
+ ## [1.0.1] — 2026-07-03
347
+
348
+ ### Added
349
+
157
350
  - **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
158
351
  meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
159
352
  theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
@@ -165,6 +358,11 @@ bundle exec rspec
165
358
  Backwards-compatible: a site that sets no `c.seo` renders a valid minimal card
166
359
  and its `<head>` is a strict superset of before. The install generator documents
167
360
  `c.seo` and installs the task; `docs-kit new` reminds the owner to run it. (#48)
361
+
362
+ ## [1.0.0] — 2026-07-03
363
+
364
+ ### Added
365
+
168
366
  - Release tooling matching the sibling gems (daisyui/phlex-reactive/pgbus): a
169
367
  `rake release[X.Y.Z]` task (version bump → lockfile update → build-verify →
170
368
  commit → push → GitHub Release; `pre`/`force` supported, `main`-only, clean-tree
data/README.md CHANGED
@@ -124,7 +124,7 @@ generators:
124
124
  |-------|---------------|-----|
125
125
  | `ApplicationController#render_page` defined by hand | `DocsKit::Controller#render_page` is included (the generator injects `include DocsKit::Controller`) | Delete the method — keep the `include`. |
126
126
  | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. |
127
- | 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. |
127
+ | 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. A line kept only to change `preload:` or `under:` becomes `config.docs_kit.preload` or `config.docs_kit.controllers_under` in `config/application.rb` (see [Registering the controller](#registering-the-controller)). |
128
128
  | `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). |
129
129
  | `app/assets/stylesheets/tailwind.sources.css` committed to git | `bin/build-css` regenerates it on every build with machine-specific absolute gem paths — the committed copy churns per machine/Ruby and no build consumes it. The generator adds the `.gitignore` entry, but gitignoring doesn't untrack an already-committed copy. | `git rm --cached app/assets/stylesheets/tailwind.sources.css` and commit. |
130
130
 
@@ -363,6 +363,33 @@ c.nav = lambda do
363
363
  end
364
364
  ```
365
365
 
366
+ ### After boot: finalized, threads and Ractors
367
+
368
+ The engine calls `DocsKit.finalize!` once the app has booted (`after_initialize`,
369
+ after your `to_prepare` configure) in every environment, and in development again
370
+ after every code reload. It resolves the code themes and every registered page's
371
+ view class once, into frozen maps, and builds the brand logo, SEO, landing and
372
+ optional-mcp memos up front, so no request races another to fill a memo, or
373
+ looks up a theme or a resolved page view. A page whose view class didn't exist
374
+ at boot is still looked up when it is read, so it turns up once written. That
375
+ is also why a broken `brand_logo` fails at boot.
376
+
377
+ - `DocsKit.configure` re-opens a finalized configuration, so re-running it (a
378
+ development reload, a spec) takes effect.
379
+ - Outside Rails (a script or Rack app that renders the chrome), call
380
+ `DocsKit.finalize!` yourself once the configuration is complete.
381
+ - Your callables (`brand`, `title_suffix`, `nav`, `version_badge`, `importmap`)
382
+ are kept exactly as given: they may close over app state (`Current`, a
383
+ tenant), so they are never frozen or copied, and finalizing never fails on
384
+ one. That makes `DocsKit.configuration` main-Ractor-only. Code that runs in a
385
+ non-main Ractor builds its own `DocsKit::Configuration` from plain values and
386
+ passes it in (`DocsKit::LlmsText.index(config, ...)`,
387
+ `DocsKit::Snapshot.for(version, config:)`).
388
+ - What the registry DSL (`page`, `path_prefix`, `view_namespace`) and
389
+ `DocsUI::Page`'s macros (`title`, `eyebrow`, `description`, `on_page`) are
390
+ given is deep-frozen when declared, as are docs-kit's own constants: mutating
391
+ one in place raises `FrozenError`.
392
+
366
393
  ## Render
367
394
 
368
395
  ```ruby
@@ -1096,12 +1123,74 @@ server round-trip:
1096
1123
  of three placements, auto-hiding on short pages. No server-side knowledge of
1097
1124
  the headings, no per-page wiring.
1098
1125
 
1126
+ ### Registering the controller
1127
+
1128
+ The engine pins `docs-nav` into the host's import map as
1129
+ `docs_kit/controllers/docs_nav_controller`, with no `<link rel="modulepreload">`
1130
+ on any page. Register it in `app/javascript/controllers/index.js` one of two
1131
+ ways.
1132
+
1133
+ **Eager** — what `docs_kit:install` writes, next to the stock eager loader:
1134
+
1135
+ ```js
1136
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
1137
+ eagerLoadControllersFrom("docs_kit/controllers", application)
1138
+ ```
1139
+
1140
+ It imports the controller on every page that loads the entry point, whether
1141
+ the page renders `docs-nav` or not. Right for a docs-only site, where every page
1142
+ renders the sidebar.
1143
+
1144
+ **Through the host's own loader** — pin `docs-nav` under the host's
1145
+ `controllers` namespace instead, and add no docs-kit line to `index.js`:
1146
+
1147
+ ```ruby
1148
+ # config/application.rb
1149
+ config.docs_kit.controllers_under = "controllers"
1150
+ ```
1151
+
1152
+ The module becomes `controllers/docs_nav_controller` (same asset), so the
1153
+ host's existing `eagerLoadControllersFrom("controllers", application)` or
1154
+ `lazyLoadControllersFrom("controllers", application)` finds it by convention.
1155
+ With the lazy loader, a page fetches `docs-nav` only if it renders
1156
+ `data-controller="docs-nav"`: the setup for a host whose docs share an app with
1157
+ other pages. Drop an old `eagerLoadControllersFrom("docs_kit/controllers", ...)`
1158
+ line, which no longer matches anything. Don't keep a host file named
1159
+ `app/javascript/controllers/docs_nav_controller.js`: it has the same module
1160
+ name, and the host's `config/importmap.rb` is drawn after the engine's, so it
1161
+ replaces docs-kit's controller.
1162
+
1163
+ > **Don't** call `lazyLoadControllersFrom("docs_kit/controllers", application)`
1164
+ > in a host with its own controllers. A lazy loader imports
1165
+ > `<under>/<identifier>_controller` for *every* `data-controller` on the page
1166
+ > that isn't registered yet, so `data-controller="dropdown"` makes it request
1167
+ > `docs_kit/controllers/dropdown_controller`, which isn't in the map: one
1168
+ > "Failed to autoload controller" console error per host identifier, on every
1169
+ > page. A second lazy loader is only safe on a namespace that owns every
1170
+ > identifier on the page.
1171
+
1172
+ **Preloading.** `config.docs_kit.preload` (default `false`) is the pin's
1173
+ `preload:`. `true` puts a modulepreload for `docs-nav` on every page that renders
1174
+ the import map (docs-kit 1.4's behaviour). An entry-point name, such as
1175
+ `"docs"`, preloads it only on pages that render that entry point; it needs
1176
+ importmap-rails 2.x (1.x takes only `true`/`false`, and docs-kit doesn't check).
1177
+
1178
+ ```ruby
1179
+ # config/application.rb — defaults shown
1180
+ config.docs_kit.preload = false # true | "docs" | %w[docs admin]
1181
+ config.docs_kit.controllers_under = "docs_kit/controllers"
1182
+ ```
1183
+
1184
+ importmap-rails draws the pin in its own initializer, before
1185
+ `config/initializers/` run, so set both in `config/application.rb`. With
1186
+ `config.docs_kit.auto_pin = false` neither applies: the host owns the pin.
1187
+
1099
1188
  ### A dedicated docs import map
1100
1189
 
1101
1190
  By default the Shell loads the host's `application` entry point from
1102
1191
  `Rails.application.importmap`, and the engine adds the `docs-nav` pin to that
1103
1192
  map. A host with several import maps can give its docs their own entry point
1104
- and map, and keep the docs controller out of every other page's preloads:
1193
+ and map:
1105
1194
 
1106
1195
  ```ruby
1107
1196
  # config/application.rb — skip the engine's auto-pin into the shared map
@@ -1123,14 +1212,17 @@ pin "docs"
1123
1212
  pin "@hotwired/turbo-rails", to: "turbo.min.js"
1124
1213
  pin "@hotwired/stimulus", to: "stimulus.min.js"
1125
1214
  pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
1215
+ # This map only loads on docs pages, so preloading docs-nav here is the point.
1126
1216
  pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
1127
- under: "docs_kit/controllers", to: "docs_kit/controllers"
1217
+ under: "docs_kit/controllers", to: "docs_kit/controllers", preload: true
1128
1218
  ```
1129
1219
 
1130
1220
  `c.importmap` takes an `Importmap::Map` or a callable returning one; a callable
1131
1221
  is resolved on every render, so a map rebuilt on code reload is picked up. The
1132
- `docs` entry point registers the controllers with
1133
- `eagerLoadControllersFrom("docs_kit/controllers", application)`.
1222
+ `docs` entry point registers the controller eagerly, as in
1223
+ [Registering the controller](#registering-the-controller). With one shared map,
1224
+ a `docs` entry point and `config.docs_kit.preload = "docs"` preload `docs-nav`
1225
+ on docs pages only, without a second map.
1134
1226
 
1135
1227
  ### On this page (auto-TOC)
1136
1228
 
@@ -1156,13 +1248,7 @@ headings, so they need zero per-page data. A page with fewer than 2 sections
1156
1248
  hides the TOC. Scroll-spy highlights the section you're reading.
1157
1249
 
1158
1250
  `DocsUI::Sidebar` already carries `data-controller="docs-nav"`. Register the
1159
- controller in the host app:
1160
-
1161
- ```js
1162
- // app/javascript/controllers/index.js
1163
- import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
1164
- lazyLoadControllersFrom("docs_kit/controllers", application)
1165
- ```
1251
+ controller as in [Registering the controller](#registering-the-controller).
1166
1252
 
1167
1253
  The **active nav item** needs no JS — it's server-rendered from the request path.
1168
1254
 
@@ -7,9 +7,9 @@ module DocsUI
7
7
  # render DocsUI::Callout.new(:warning) { "Restart the server after…" }
8
8
  class Callout < Phlex::HTML
9
9
  LEVELS = {
10
- note: { klass: "alert-info", icon: "info" },
11
- tip: { klass: "alert-success", icon: "lightbulb" },
12
- warning: { klass: "alert-warning", icon: "triangle-alert" }
10
+ note: { klass: "alert-info", icon: "info" }.freeze,
11
+ tip: { klass: "alert-success", icon: "lightbulb" }.freeze,
12
+ warning: { klass: "alert-warning", icon: "triangle-alert" }.freeze
13
13
  }.freeze
14
14
 
15
15
  def initialize(level = :note, title: nil)
@@ -24,7 +24,9 @@ module DocsUI
24
24
  class Code < Phlex::HTML
25
25
  include Phlex::Rails::Helpers::ContentSecurityPolicyNonce
26
26
 
27
- FORMATTER = Rouge::Formatters::HTML.new
27
+ # Ractor-shareable (deep-frozen): Rouge's HTML formatter keeps no per-call
28
+ # state, so one instance serves every render (and every Ractor).
29
+ FORMATTER = DocsKit::Shareable.make(Rouge::Formatters::HTML.new)
28
30
 
29
31
  def initialize(source, lexer: nil, filename: nil)
30
32
  @source = source.to_s.strip
@@ -35,15 +35,19 @@ module DocsUI
35
35
  # friction-free path that never trips the parens-with-blocks gotcha.
36
36
  include DocsUI::PageHelpers
37
37
 
38
+ # The page macros run in a page class's body, at load (boot, on the main
39
+ # Ractor), and make each value shareable on write: request threads only
40
+ # read them afterwards, and a non-main Ractor can too.
41
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written once in the class body at load; values made shareable
38
42
  class << self
39
43
  def title(value = nil)
40
- @title = value if value
44
+ @title = DocsKit::Shareable.make(value) if value
41
45
  @title
42
46
  end
43
47
 
44
48
  # Inherited: a subclass that sets no eyebrow shows its parent page's.
45
49
  def eyebrow(value = nil)
46
- @eyebrow = value if value
50
+ @eyebrow = DocsKit::Shareable.make(value) if value
47
51
  @eyebrow || (superclass.eyebrow if parent_page?)
48
52
  end
49
53
 
@@ -53,7 +57,7 @@ module DocsUI
53
57
  # description, no lead) falls back to config.seo.description in MetaTags.
54
58
  # Inherited from a parent page when unset.
55
59
  def description(value = nil)
56
- @description = value if value
60
+ @description = DocsKit::Shareable.make(value) if value
57
61
  @description || (superclass.description if parent_page?)
58
62
  end
59
63
 
@@ -62,7 +66,7 @@ module DocsUI
62
66
  # or :panel/:toggle/:sidebar to override per page. Inherited from a parent
63
67
  # page when unset.
64
68
  def on_page(value = :__unset__)
65
- @on_page = value unless value == :__unset__
69
+ @on_page = DocsKit::Shareable.make(value) unless value == :__unset__
66
70
  return @on_page if defined?(@on_page)
67
71
 
68
72
  parent_page? ? superclass.on_page : DocsKit.configuration.on_page_default
data/config/importmap.rb CHANGED
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Auto-pins the gem's bundled Stimulus controllers for importmap-rails consumers.
4
- # Exposes `docs_kit/controllers/docs_nav_controller`. Register it in the host app:
3
+ # Auto-pins the gem's bundled Stimulus controller (docs-nav) for importmap-rails
4
+ # consumers. With the defaults it is `docs_kit/controllers/docs_nav_controller`,
5
+ # preloaded nowhere. Two engine options, set in the host's config/application.rb,
6
+ # shape the pin:
5
7
  #
6
- # // app/javascript/controllers/index.js
7
- # import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
8
- # eagerLoadControllersFrom("docs_kit/controllers", application)
8
+ # config.docs_kit.preload = false # true, or an entry point ("docs")
9
+ # config.docs_kit.controllers_under = "docs_kit/controllers" # "controllers" rides the host's lazy loader
9
10
  #
10
- # Use eagerLoadControllersFrom (already imported in the default index.js), NOT
11
- # lazyLoadControllersFrom — the latter isn't imported there, so calling it throws
12
- # a ReferenceError that aborts the module and no controllers register at all.
11
+ # How to register the controller in the host's controllers/index.js, and which
12
+ # loader to avoid: README → "Registering the controller".
13
+ docs_kit = DocsKit::Engine.config.docs_kit
13
14
  pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
14
- under: "docs_kit/controllers",
15
- to: "docs_kit/controllers"
15
+ under: docs_kit.controllers_under,
16
+ to: "docs_kit/controllers",
17
+ preload: docs_kit.preload
@@ -31,22 +31,26 @@ module DocsKit
31
31
 
32
32
  # The four generic-HTTP clients every site starts from. Order is stable
33
33
  # (curl → javascript → ruby → python) and preserved when a site merges its own.
34
- ApiClient::DEFAULTS = {
35
- curl: ApiClient.new(
36
- label: "cURL", lexer: :curl, filename: "request.sh",
37
- template: ApiTemplates.method(:curl)
38
- ),
39
- javascript: ApiClient.new(
40
- label: "JavaScript", lexer: :javascript, filename: "request.js",
41
- template: ApiTemplates.method(:javascript)
42
- ),
43
- ruby: ApiClient.new(
44
- label: "Ruby", lexer: :ruby, filename: "request.rb",
45
- template: ApiTemplates.method(:ruby)
46
- ),
47
- python: ApiClient.new(
48
- label: "Python", lexer: :python, filename: "request.py",
49
- template: ApiTemplates.method(:python)
50
- )
51
- }.freeze
34
+ # Shareable (lambdas, not Method objects, which never are), so a non-main
35
+ # Ractor can read the defaults.
36
+ ApiClient::DEFAULTS = DocsKit::Shareable.make(
37
+ {
38
+ curl: ApiClient.new(
39
+ label: "cURL", lexer: :curl, filename: "request.sh",
40
+ template: ->(request) { ApiTemplates.curl(request) }
41
+ ),
42
+ javascript: ApiClient.new(
43
+ label: "JavaScript", lexer: :javascript, filename: "request.js",
44
+ template: ->(request) { ApiTemplates.javascript(request) }
45
+ ),
46
+ ruby: ApiClient.new(
47
+ label: "Ruby", lexer: :ruby, filename: "request.rb",
48
+ template: ->(request) { ApiTemplates.ruby(request) }
49
+ ),
50
+ python: ApiClient.new(
51
+ label: "Python", lexer: :python, filename: "request.py",
52
+ template: ->(request) { ApiTemplates.python(request) }
53
+ )
54
+ }
55
+ )
52
56
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shareable"
3
4
  require_relative "seo_config"
4
5
  require_relative "landing_config"
5
6
 
@@ -276,7 +277,7 @@ module DocsKit
276
277
 
277
278
  # The sentinel "no explicit nav" lambda. #nav_groups compares against this
278
279
  # identity to decide whether to derive the sidebar from #nav_registries.
279
- DEFAULT_NAV = -> { {} }
280
+ DEFAULT_NAV = DocsKit::Shareable.make(-> { {} })
280
281
 
281
282
  # The search-palette shortcuts before this was configurable — "/" and the
282
283
  # platform command chord — so a site that never sets #search_shortcuts keeps
@@ -347,6 +348,8 @@ module DocsKit
347
348
  @versions = []
348
349
  @repo_url = nil
349
350
  @snapshots_path = nil
351
+ @openapi_memo = nil
352
+ @finalized = nil
350
353
  end
351
354
 
352
355
  # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -453,16 +456,60 @@ module DocsKit
453
456
  # spec in development is picked up without a server restart. Raises a
454
457
  # DocsKit::Error naming the knob when read while #openapi is unset — a missing
455
458
  # spec has nothing useful to degrade to.
459
+ #
460
+ # The memo lives on this configuration, so a replaced one takes its
461
+ # Document with it, and its mtime check-then-set runs under a per-Ractor
462
+ # lock (DocsKit::Store): threads that miss together load the spec once and
463
+ # read the same Document. A configuration is only ever read in the Ractor
464
+ # that built it, so that Ractor's lock is the one that matters.
456
465
  def openapi_document
457
466
  raise DocsKit::Error, "no OpenAPI spec configured — set c.openapi to a path or Hash" if @openapi.nil?
458
467
 
468
+ source = @openapi
459
469
  mtime = openapi_source_mtime
460
- return @openapi_document if defined?(@openapi_document) && @openapi_document_mtime == mtime
470
+ DocsKit::Store.fetch(:docs_kit_openapi_lock) { Mutex.new }.synchronize do
471
+ memo = @openapi_memo
472
+ next memo.fetch(:document) if memo && memo.fetch(:source).equal?(source) && memo.fetch(:mtime) == mtime
473
+
474
+ DocsKit::OpenApi.load(source).tap do |document|
475
+ @openapi_memo = { source: source, mtime: mtime, document: document }
476
+ end
477
+ end
478
+ end
461
479
 
462
- @openapi_document_mtime = mtime
463
- @openapi_document = DocsKit::OpenApi.load(@openapi)
480
+ # Resolves, once at boot, what a render would otherwise look up per call:
481
+ # the Rouge theme classes and every registered page's view class (frozen,
482
+ # so a non-main Ractor can read them), and fills the lazy memos (brand logo,
483
+ # SEO, landing, the optional-mcp probe) so no request races to fill them.
484
+ # Called by DocsKit.finalize! (the engine's after_initialize, and after every
485
+ # code reload); DocsKit.configure re-opens it (#reopen!). The host's
486
+ # callables (brand, nav, version_badge, importmap, ...) are kept as they are:
487
+ # they can close over app state, so the configuration itself stays
488
+ # main-Ractor-only. A theme assigned directly afterwards still wins:
489
+ # the resolved class is only used while its source setting is unchanged.
490
+ def finalize!
491
+ brand_logo
492
+ seo
493
+ landing
494
+ mcp_gem_present?
495
+ each_registry { |registry| registry.finalize! if registry.respond_to?(:finalize!) }
496
+ @finalized = DocsKit::Shareable.make(
497
+ code_theme: @code_theme, code_theme_class: resolve_code_theme_class,
498
+ code_theme_dark: @code_theme_dark, code_theme_dark_class: resolve_code_theme_dark_class
499
+ )
500
+ self
501
+ end
502
+
503
+ # Drops what #finalize! resolved (and the registries' resolved view classes),
504
+ # so the next reads resolve lazily against the current settings.
505
+ def reopen!
506
+ @finalized = nil
507
+ each_registry { |registry| registry.reopen! if registry.respond_to?(:reopen!) }
508
+ self
464
509
  end
465
510
 
511
+ def finalized? = !@finalized.nil?
512
+
466
513
  # The effective client map for DocsUI::RequestExample: the four shipped
467
514
  # defaults with site overrides/extensions merged over them. Hash#merge keeps
468
515
  # a reused token in its original position and appends new tokens in
@@ -677,9 +724,7 @@ module DocsKit
677
724
  # name that doesn't resolve degrades to the default theme rather than raising
678
725
  # NameError on every DocsUI::Code render.
679
726
  def code_theme_class
680
- return @code_theme if @code_theme.is_a?(Class)
681
-
682
- resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
727
+ resolved(:code_theme_class, :code_theme, @code_theme) { resolve_code_theme_class }
683
728
  end
684
729
 
685
730
  # The dark Rouge theme class resolved from #code_theme_dark (String or
@@ -687,10 +732,7 @@ module DocsKit
687
732
  # dark code CSS only when this is non-nil, so an unresolvable name degrades to
688
733
  # nil (no dark restyle) rather than raising.
689
734
  def code_theme_dark_class
690
- return if @code_theme_dark.nil?
691
- return @code_theme_dark if @code_theme_dark.is_a?(Class)
692
-
693
- resolve_theme(@code_theme_dark)
735
+ resolved(:code_theme_dark_class, :code_theme_dark, @code_theme_dark) { resolve_code_theme_dark_class }
694
736
  end
695
737
 
696
738
  # The dark themes the site actually ships: #dark_themes intersected with
@@ -703,6 +745,33 @@ module DocsKit
703
745
 
704
746
  private
705
747
 
748
+ def resolve_code_theme_class
749
+ return @code_theme if @code_theme.is_a?(Class)
750
+
751
+ resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
752
+ end
753
+
754
+ def resolve_code_theme_dark_class
755
+ return if @code_theme_dark.nil?
756
+ return @code_theme_dark if @code_theme_dark.is_a?(Class)
757
+
758
+ resolve_theme(@code_theme_dark)
759
+ end
760
+
761
+ # What #finalize! resolved for key, while the setting it came from is still
762
+ # the same object; otherwise the block resolves it now (never finalized,
763
+ # re-opened, or the setting was reassigned since).
764
+ def resolved(key, setting, current)
765
+ finalized = @finalized
766
+ return yield unless finalized && finalized.fetch(setting).equal?(current)
767
+
768
+ finalized.fetch(key)
769
+ end
770
+
771
+ def each_registry(&)
772
+ (@nav_registries || {}).each_value(&)
773
+ end
774
+
706
775
  # Resolve a Rouge theme constant from its String name, returning nil (not
707
776
  # raising) when the name doesn't resolve — a typo'd or unloaded theme must
708
777
  # not crash every code block on the page.
@@ -15,7 +15,7 @@ module DocsKit
15
15
  config.eager_load_paths = []
16
16
  config.paths["app"].skip_eager_load!
17
17
 
18
- JAVASCRIPT_PATH = root.join("app/javascript")
18
+ JAVASCRIPT_PATH = root.join("app/javascript").freeze
19
19
 
20
20
  initializer "docs_kit.controller_helper" do
21
21
  ActiveSupport.on_load(:action_controller_base) do
@@ -31,16 +31,40 @@ module DocsKit
31
31
  Mime::Type.register("text/markdown", :md) unless Mime::Type.lookup_by_extension(:md)
32
32
  end
33
33
 
34
+ # Resolve the configuration's per-render lookups once (DocsKit.finalize!)
35
+ # after the host has configured the kit: its to_prepare DocsKit.configure
36
+ # runs before after_initialize at boot. With code reloading on, every
37
+ # reload re-runs that configure (which re-opens the configuration) against
38
+ # the reloaded classes, so finalize again after it: a to_prepare added
39
+ # here, after boot, runs after every to_prepare the host registered.
40
+ initializer "docs_kit.finalize" do |app|
41
+ app.config.after_initialize do
42
+ DocsKit.finalize!
43
+ app.reloader.to_prepare { DocsKit.finalize! } if app.config.reloading_enabled?
44
+ end
45
+ end
46
+
34
47
  # Serve the bundled Stimulus controller (docs_nav) as an asset.
35
48
  initializer "docs_kit.assets" do |app|
36
49
  app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
37
50
  end
38
51
 
39
- # Host-facing engine options. `config.docs_kit.auto_pin = false` (in
40
- # config/application.rb) keeps the gem's pin out of the host's shared import
41
- # map, for a host that pins `docs_kit/controllers` in a dedicated docs map.
52
+ # Host-facing engine options, set in config/application.rb: importmap-rails
53
+ # draws the auto-pin in its own initializer, before config/initializers run.
54
+ # `config.docs_kit.auto_pin = false` keeps the gem's pin out of the host's
55
+ # shared import map, for a host that pins `docs_kit/controllers` in a
56
+ # dedicated docs map.
42
57
  config.docs_kit = ActiveSupport::OrderedOptions.new
43
58
  config.docs_kit.auto_pin = true
59
+ # The auto-pin's `preload:`. false (the default) keeps docs-nav out of every
60
+ # page's modulepreloads; true preloads it everywhere; an entry-point name (or
61
+ # an array, importmap-rails 2.x) preloads it only where that entry point loads.
62
+ config.docs_kit.preload = false
63
+ # The auto-pin's module namespace. "controllers" pins docs-nav as
64
+ # `controllers/docs_nav_controller`, so the host's own
65
+ # `lazyLoadControllersFrom("controllers", application)` loads it by convention
66
+ # on the pages that render it. The asset path never changes.
67
+ config.docs_kit.controllers_under = "docs_kit/controllers"
44
68
 
45
69
  # Auto-pin the controller for importmap-rails consumers, so a host app gets
46
70
  # `docs_kit/controllers/docs_nav_controller` with no manual pin.
@@ -106,7 +106,9 @@ module DocsKit
106
106
 
107
107
  port = free_port
108
108
  app = Rails.application
109
- server_thread = Thread.new do
109
+ # The docs_kit:og rake task's one-shot local server (a CLI, never a request
110
+ # path): one thread, killed in the ensure below.
111
+ server_thread = Thread.new do # rubocop:disable ThreadSafety/NewThread
110
112
  Rackup::Handler.get("webrick").run(app, Port: port, Host: "127.0.0.1", Logger: null_logger, AccessLog: [])
111
113
  rescue LoadError
112
114
  Rack::Handler.get("webrick").run(app, Port: port, Host: "127.0.0.1", Logger: null_logger, AccessLog: [])
@@ -62,34 +62,44 @@ module DocsKit
62
62
  # given. Appends to the registry in declaration order (== sidebar order).
63
63
  #
64
64
  # page "Getting started", group: "Guide", icon: "rocket", slug: "start", view: "Start"
65
+ #
66
+ # The DSL runs in the registry's class body, at load. Each value is made
67
+ # Ractor-shareable on write (the page list is replaced, never appended to
68
+ # in place), so a non-main Ractor can read a registry and no thread can
69
+ # mutate it afterwards.
65
70
  def page(title, group:, slug: nil, view: nil, icon: nil)
66
71
  raise Error, "cannot mix `page` and `entries` in one registry" if defined?(@entries) && @entries
67
72
 
68
- (@pages ||= []) << {
73
+ entry = {
69
74
  title: title,
70
75
  group: group,
71
76
  slug: slug || title.parameterize,
72
77
  view: view || title.parameterize(separator: "_").camelize,
73
78
  icon: icon
74
- }.freeze
79
+ }
80
+ @pages = DocsKit::Shareable.make([*(defined?(@pages) && @pages), entry])
75
81
  end
76
82
 
77
83
  # href = "#{path_prefix}/#{slug}". Defaults to "/docs".
78
84
  def path_prefix(value = nil)
79
- @path_prefix = value if value
85
+ @path_prefix = DocsKit::Shareable.make(value) if value
80
86
  @path_prefix || "/docs"
81
87
  end
82
88
 
83
89
  # The namespace a page's view_class resolves under (v2 pages only), e.g.
84
90
  # "Views::Docs::Pages". Required to resolve views via the default Entry.
91
+ # Setting it drops the views #finalize! resolved under the old namespace.
85
92
  def view_namespace(value = nil)
86
- @view_namespace = value if value
93
+ if value
94
+ @view_namespace = DocsKit::Shareable.make(value)
95
+ @view_classes = nil
96
+ end
87
97
  @view_namespace
88
98
  end
89
99
 
90
100
  # The attribute used by #grouped (default :group).
91
101
  def group_by_attribute(attr = nil)
92
- @group_by_attribute = attr if attr
102
+ @group_by_attribute = DocsKit::Shareable.make(attr) if attr
93
103
  @group_by_attribute || :group
94
104
  end
95
105
 
@@ -98,12 +108,32 @@ module DocsKit
98
108
  # v2 pages are wrapped in the default Entry; hash entries in the site's class.
99
109
  def all
100
110
  if defined?(@pages) && @pages
101
- @pages.map { |attrs| Entry.new(attrs, path_prefix, view_namespace) }
111
+ views = @view_classes || Entry::NO_VIEWS
112
+ @pages.map { |attrs| Entry.new(attrs, path_prefix, view_namespace, views) }
102
113
  else
103
114
  (entries || []).map { |entry| new(entry) }
104
115
  end
105
116
  end
106
117
 
118
+ # Resolves every `page`'s view class once (DocsKit.finalize!, at boot) into
119
+ # a frozen { view name => class } map the entries read instead of
120
+ # constantizing on every call. Only views that resolve are kept, so a page
121
+ # written later still turns up. An `entries` registry resolves its own views.
122
+ def finalize!
123
+ resolved = Array(defined?(@pages) && @pages).each_with_object({}) do |attrs, map|
124
+ view_class = Entry.resolve(view_namespace, attrs[:view])
125
+ map[attrs[:view]] = view_class if view_class
126
+ end
127
+ @view_classes = DocsKit::Shareable.make(resolved)
128
+ self
129
+ end
130
+
131
+ # Drops the resolved view classes: entries constantize again (DocsKit.configure).
132
+ def reopen!
133
+ @view_classes = nil
134
+ self
135
+ end
136
+
107
137
  # The instance whose slug matches, or nil.
108
138
  def from_slug(slug)
109
139
  all.find { |item| item.slug.to_s == slug.to_s }
@@ -129,15 +159,26 @@ module DocsKit
129
159
  # resolved under the registry's view_namespace (nil until the class exists,
130
160
  # preserving the no-dead-links behavior).
131
161
  class Entry
162
+ NO_VIEWS = {}.freeze
163
+
164
+ # The page class `view_name` names under `view_namespace`, or nil.
165
+ def self.resolve(view_namespace, view_name)
166
+ return unless view_namespace
167
+
168
+ "#{view_namespace}::#{view_name}".safe_constantize
169
+ end
170
+
132
171
  attr_reader :slug, :title, :group, :icon, :view_name, :href
133
172
 
134
- def initialize(attrs, path_prefix, view_namespace)
173
+ # view_classes: the registry's finalized { view name => class } map.
174
+ def initialize(attrs, path_prefix, view_namespace, view_classes = NO_VIEWS)
135
175
  @slug = attrs[:slug]
136
176
  @title = attrs[:title]
137
177
  @group = attrs[:group]
138
178
  @icon = attrs[:icon]
139
179
  @view_name = attrs[:view]
140
180
  @view_namespace = view_namespace
181
+ @view_classes = view_classes
141
182
  @href = "#{path_prefix}/#{@slug}"
142
183
  end
143
184
 
@@ -145,7 +186,7 @@ module DocsKit
145
186
  def view_class
146
187
  return unless @view_namespace
147
188
 
148
- "#{@view_namespace}::#{@view_name}".safe_constantize
189
+ @view_classes.fetch(@view_name) { self.class.resolve(@view_namespace, @view_name) }
149
190
  end
150
191
 
151
192
  # The renderable instance for this page (nil when unauthored) — the seam
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Makes a boot-time value Ractor-shareable: Ractor.make_shareable (a deep
5
+ # freeze; a capture-free Proc becomes an isolated one) where the engine has
6
+ # Ractors, a plain #freeze where it has none (TruffleRuby, JRuby). For
7
+ # constants and DSL values written once at load, so a non-main Ractor can read
8
+ # them and no thread can mutate them afterwards.
9
+ module Shareable
10
+ def self.make(value)
11
+ defined?(::Ractor) && ::Ractor.respond_to?(:make_shareable) ? ::Ractor.make_shareable(value) : value.freeze
12
+ end
13
+ end
14
+ end
@@ -28,27 +28,41 @@ module DocsKit
28
28
  # invalidated when manifest.json's mtime changes — the same
29
29
  # reload-on-change posture as Configuration#openapi_document, so editing
30
30
  # a snapshot in development is picked up without a server restart.
31
+ #
32
+ # The cache is per Ractor (DocsKit::Store) and its check-then-set runs
33
+ # under that Ractor's lock, so threads that miss a cold key together
34
+ # build it once and all get the same instance.
31
35
  def for(version, config: DocsKit.configuration)
32
36
  version = DocVersion.from(version)
33
37
  root = root_for(version, config)
34
38
  mtime = manifest_mtime(root)
35
- key = [version.id.to_s, root.to_s]
36
-
37
- @cache ||= {}
38
- cached = @cache[key]
39
- return cached.fetch(:snapshot) if cached && cached.fetch(:mtime) == mtime
40
-
41
- new(version: version, root: root).tap do |snapshot|
42
- @cache[key] = { snapshot: snapshot, mtime: mtime }
43
- end
39
+ memoize([version.id.to_s, root.to_s], mtime) { new(version: version, root: root) }
44
40
  end
45
41
 
46
42
  def reset_cache!
47
- @cache = {}
43
+ cache = cache_store
44
+ cache.fetch(:lock).synchronize { cache.fetch(:entries).clear }
48
45
  end
49
46
 
50
47
  private
51
48
 
49
+ def cache_store
50
+ DocsKit::Store.fetch(:docs_kit_snapshots) { { lock: Mutex.new, entries: {} } }
51
+ end
52
+
53
+ # The cached snapshot for key while its manifest mtime is unchanged, else
54
+ # the block's fresh one (stored) — the whole check-then-set under the lock.
55
+ def memoize(key, mtime)
56
+ cache = cache_store
57
+ cache.fetch(:lock).synchronize do
58
+ entries = cache.fetch(:entries)
59
+ cached = entries[key]
60
+ next cached.fetch(:snapshot) if cached && cached.fetch(:mtime) == mtime
61
+
62
+ yield.tap { |snapshot| entries[key] = { snapshot: snapshot, mtime: mtime } }
63
+ end
64
+ end
65
+
52
66
  # <snapshots_path>/<id>, or nil when no snapshots path resolves (no
53
67
  # config, no Rails) — which reads back as an empty snapshot.
54
68
  def root_for(version, config)
@@ -70,10 +84,13 @@ module DocsKit
70
84
 
71
85
  attr_reader :version, :root
72
86
 
87
+ # Built whole here, never lazily: a cached Snapshot is shared by every
88
+ # server thread, so it carries no memo a reader could race to fill.
73
89
  def initialize(version:, root:)
74
90
  @version = version
75
91
  @root = root
76
92
  @manifest = read_manifest
93
+ @registries = build_registries
77
94
  end
78
95
 
79
96
  # Every snapshot page across the manifest's registries, in manifest order —
@@ -116,8 +133,10 @@ module DocsKit
116
133
  private
117
134
 
118
135
  # The manifest's registries as { heading:, entries: [Snapshot::Entry] }.
119
- def registries
120
- @registries ||= Array(@manifest && @manifest["registries"]).map do |registry|
136
+ attr_reader :registries
137
+
138
+ def build_registries
139
+ Array(@manifest && @manifest["registries"]).map do |registry|
121
140
  prefix = registry["path_prefix"] || "/docs"
122
141
  {
123
142
  heading: registry["heading"],
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Per-Ractor lazy storage for docs-kit's caches: `Store.fetch(:docs_kit_x) { {} }`.
5
+ # A copy of conformance-kit's ConformanceKit::Store (MIT, same author): the kit
6
+ # is a development dependency only, so the gem carries its own.
7
+ #
8
+ # On Ruby 3.4+ this is `Ractor.store_if_absent`, atomic and per-Ractor. Below
9
+ # 3.4 (and on engines without Ractor) the main Ractor uses one Hash guarded by
10
+ # a Mutex, created at load (never lazily, so there is no check-then-set race);
11
+ # it is allowlisted main-only in .conformance.yml. A non-main Ractor on 3.3
12
+ # cannot read it, so it keeps its own values in Ractor-local storage instead.
13
+ # That path has no lock (none is shareable): threads of one Ractor that miss
14
+ # together may each build a value, but only the first one stored is kept and
15
+ # returned to all of them.
16
+ module Store
17
+ # rubocop:disable ThreadSafety/MutableClassInstanceVariable -- main-only fallback, guarded by @fallback_lock
18
+ @fallback_lock = Mutex.new
19
+ @fallback_values = {}
20
+ # rubocop:enable ThreadSafety/MutableClassInstanceVariable
21
+
22
+ class << self
23
+ def fetch(key, &init)
24
+ raise ArgumentError, "DocsKit::Store.fetch(#{key.inspect}) needs an initializer block" unless init
25
+ return ::Ractor.store_if_absent(key, &init) if native?
26
+ return ractor_local(key, &init) unless main_ractor?
27
+
28
+ @fallback_lock.synchronize do
29
+ @fallback_values.fetch(key) { @fallback_values[key] = yield }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def main_ractor? = !defined?(::Ractor) || ::Ractor.current == ::Ractor.main
36
+
37
+ # Boxed, so a nil or false value is cached rather than rebuilt on every fetch.
38
+ # The value is built before the check-then-store, which runs no Ruby code
39
+ # between the read and the write, so a thread that built a value after
40
+ # another stored one gets the stored one.
41
+ def ractor_local(key)
42
+ slot = :"docs_kit_store/#{key}"
43
+ stored = ::Ractor.current[slot]
44
+ return stored.first if stored
45
+
46
+ built = [yield]
47
+ (::Ractor.current[slot] ||= built).first
48
+ end
49
+
50
+ public
51
+
52
+ def native?
53
+ defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.1"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -5,6 +5,7 @@ require "rouge"
5
5
  require "zeitwerk"
6
6
 
7
7
  require_relative "docs_kit/version"
8
+ require_relative "docs_kit/shareable"
8
9
  require_relative "docs_kit/configuration"
9
10
 
10
11
  # DocsKit — a reusable docs-site component library: the shared Phlex chrome
@@ -17,19 +18,36 @@ require_relative "docs_kit/configuration"
17
18
  module DocsKit
18
19
  class Error < StandardError; end
19
20
 
21
+ # Built at load, never lazily: no first-request race to build two.
22
+ @configuration = Configuration.new
23
+
20
24
  class << self
21
- # The current configuration, memoized. Reset with DocsKit.reset_configuration!.
22
- def configuration
23
- @configuration ||= Configuration.new
24
- end
25
+ # The current configuration. Reset with DocsKit.reset_configuration!.
26
+ # Assigned at load and by reset_configuration! (boot, specs) on the main
27
+ # Ractor; request threads only read it. Main-only (.conformance.yml): it
28
+ # holds the host's per-request callables (brand, nav, version_badge, ...).
29
+ attr_reader :configuration
25
30
 
31
+ # Re-opens a finalized configuration first (a development reload re-runs
32
+ # the host's to_prepare configure), so every setting takes effect.
26
33
  def configure
34
+ configuration.reopen!
27
35
  yield(configuration)
28
36
  configuration.validate!
29
37
  end
30
38
 
39
+ # Resolves the configuration's per-render lookups once (see
40
+ # Configuration#finalize!). In a Rails app the engine calls it after the
41
+ # host has configured the kit, and again after every code reload; outside
42
+ # Rails, call it once the configuration is complete.
43
+ def finalize!
44
+ configuration.finalize!
45
+ end
46
+
47
+ # A fresh default configuration (specs; a host that re-boots its config).
48
+ # Runs at boot or between examples, never on a request.
31
49
  def reset_configuration!
32
- @configuration = Configuration.new
50
+ @configuration = Configuration.new # rubocop:disable ThreadSafety/ClassInstanceVariable
33
51
  end
34
52
  end
35
53
  end
@@ -54,6 +72,8 @@ loader.push_dir(File.expand_path("docs_kit", __dir__), namespace: DocsKit)
54
72
  loader.push_dir(File.expand_path("../app/components/docs_ui", __dir__), namespace: DocsUI)
55
73
  loader.ignore(File.expand_path("docs_kit/version.rb", __dir__))
56
74
  loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
75
+ # Required eagerly above (configuration.rb's constants use it at load).
76
+ loader.ignore(File.expand_path("docs_kit/shareable.rb", __dir__))
57
77
  # Required eagerly by configuration.rb (a plain-Ruby value object, no Rails), so
58
78
  # ignore it here too or zeitwerk double-manages the constant.
59
79
  loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
@@ -14,14 +14,19 @@ module DocsKit
14
14
  # and hands back strings for whatever needs a human. A `nil` return means
15
15
  # "nothing to warn about".
16
16
  class Migration
17
- attr_reader :to, :description
17
+ attr_reader :description
18
18
 
19
+ # Keeps `to` as a frozen String, so a migration list can be made
20
+ # Ractor-shareable without freezing Gem::Version's process-wide cached
21
+ # instances; #to hands back the Gem::Version.
19
22
  def initialize(to:, description:, &block)
20
- @to = Gem::Version.new(to.to_s)
23
+ @to = -to.to_s
21
24
  @description = description
22
25
  @block = block
23
26
  end
24
27
 
28
+ def to = Gem::Version.new(@to)
29
+
25
30
  # Run the transform against the site. Returns the (possibly empty) list of
26
31
  # manual-cleanup warnings — never nil, so callers can flat-map safely.
27
32
  def call(root, generator)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../../docs_kit/shareable"
3
4
  require_relative "migration"
4
5
  require_relative "migrations/v1_2_0"
5
6
  require_relative "../../../docs_kit/version"
@@ -24,21 +25,25 @@ module DocsKit
24
25
  # its transform in migrations/vX_Y_Z.rb.
25
26
  class MigrationRegistry
26
27
  def initialize(migrations = [])
27
- @migrations = migrations.sort_by(&:to)
28
+ @migrations = migrations.sort_by(&:to).freeze
28
29
  end
29
30
 
30
- # The registry the install generator runs during `--sync`.
31
- def self.default
32
- @default ||= new(MIGRATIONS)
33
- end
31
+ # The registry the install generator runs during `--sync` (DEFAULT, below).
32
+ def self.default = DEFAULT
34
33
 
35
34
  # Ordered `Migration.new(to: "1.x.0", description: "...") { ... }` entries, one
36
35
  # per release that changes config knobs, routes, templates, or toolchain.
37
- MIGRATIONS = [
38
- Migration.new(to: "1.2.0", description: Migrations::V1_2_0::DESCRIPTION) do |root, _generator|
39
- Migrations::V1_2_0.call(root)
40
- end
41
- ].freeze
36
+ # Shareable: each block is capture-free (its self is this class).
37
+ MIGRATIONS = DocsKit::Shareable.make(
38
+ [
39
+ Migration.new(to: "1.2.0", description: Migrations::V1_2_0::DESCRIPTION) do |root, _generator|
40
+ Migrations::V1_2_0.call(root)
41
+ end
42
+ ]
43
+ )
44
+
45
+ # Built at load from MIGRATIONS, never lazily (no first-call race).
46
+ DEFAULT = DocsKit::Shareable.make(new(MIGRATIONS))
42
47
 
43
48
  # The migrations a site last synced at `from_version` still needs, ascending
44
49
  # by version — those in `(from_version, upto]`. `upto` defaults to the
@@ -12,12 +12,14 @@ module DocsKit
12
12
  # handed back as a warning, and a newer value is left alone.
13
13
  module V1_2_0 # rubocop:disable Naming/ClassAndModuleCamelCase -- named after the release
14
14
  DESCRIPTION = "docs-kit 1.2: Ruby >= 3.3, Bun 1.4.2, Tailwind 4.3 / daisyUI 5.7 floors, Node 24 deploy"
15
- MIN_RUBY = Gem::Version.new("3.3")
15
+ # A String: Gem::Version.new hands out process-wide cached instances,
16
+ # which a constant must not freeze. Compared via Gem::Version below.
17
+ MIN_RUBY = "3.3"
16
18
  BUN = { from: "1.3.2", to: "1.4.2" }.freeze
17
19
  PACKAGE_FLOORS = {
18
- "tailwindcss" => { from: "^4.1.18", to: "^4.3.3" },
19
- "@tailwindcss/cli" => { from: "^4.1.18", to: "^4.3.3" },
20
- "daisyui" => { from: "^5.6.0", to: "^5.7.47" }
20
+ "tailwindcss" => { from: "^4.1.18", to: "^4.3.3" }.freeze,
21
+ "@tailwindcss/cli" => { from: "^4.1.18", to: "^4.3.3" }.freeze,
22
+ "daisyui" => { from: "^5.6.0", to: "^5.7.47" }.freeze
21
23
  }.freeze
22
24
  DEPLOY_CALL = %r{zoolutions/docs-kit/\.github/workflows/deploy\.yml@(\S+)}
23
25
 
@@ -112,7 +114,7 @@ module DocsKit
112
114
 
113
115
  def below_min_ruby?(value)
114
116
  version = version_of(value)
115
- version ? version < MIN_RUBY : false
117
+ version ? version < Gem::Version.new(MIN_RUBY) : false
116
118
  end
117
119
  end
118
120
  end
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.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -235,9 +235,11 @@ files:
235
235
  - lib/docs_kit/search_index.rb
236
236
  - lib/docs_kit/search_index/snippet.rb
237
237
  - lib/docs_kit/seo_config.rb
238
+ - lib/docs_kit/shareable.rb
238
239
  - lib/docs_kit/shortcut.rb
239
240
  - lib/docs_kit/snapshot.rb
240
241
  - lib/docs_kit/snapshot/entry.rb
242
+ - lib/docs_kit/store.rb
241
243
  - lib/docs_kit/templates/new_site.rb
242
244
  - lib/docs_kit/topbar_link.rb
243
245
  - lib/docs_kit/version.rb