docs-kit 1.3.0 → 1.4.0

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: 6340fb386df08272f63e525ca81d43baaa1f440a82287e4a34e9a392b220a530
4
+ data.tar.gz: 3740655d5194976ea25a3ed3cdd6a633bb593c03c288a1c3a069d16d7c522cfe
5
5
  SHA512:
6
- metadata.gz: 5af123f6815e81ccad67dcb7737836c0a2727fb60e963bfc16d6ff0a6540325ca1a67fa2aaaea2ff824ba3b36f2210d8fb4aaab43f097195ec7f020cbfc8c54e
7
- data.tar.gz: 32e24110ca11cfba1c795d60ea93d6ecb2f1e42af912274ebc816718cd235d93e217bb1ac2a6082b08039eaefe162c6b8287f4ed328c685ab3c669c840e2e359
6
+ metadata.gz: 01e79925a8f67168a27e898fb13f6a9a5658b331b8a5b1ba854c9481c1afc5256f7ec89fb80a6e762f9ec0fd399425c38be82a1943a8fc9517e5cd0803e32fa4
7
+ data.tar.gz: a9a74e687605433c9a23069d6760a5a14093e898e0f64b133ede84f951f9f4deead0e98ad7735ca5a87e9f79ba86c9a340993004f1ece46731bda6016e6b9bab
data/CHANGELOG.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.0]
6
+
7
+ ### Upgrading from 1.3
8
+
9
+ Nothing to change for a site whose initializer configures docs-kit in a
10
+ `to_prepare` block, as the generated one does. Three things now fail loudly
11
+ instead of later or never: a broken `brand_logo` raises at boot, in
12
+ development too; mutating a registry DSL or `DocsUI::Page` macro value in place
13
+ raises `FrozenError`; so does mutating one of docs-kit's constants.
14
+
15
+ ### Fixed
16
+
17
+ - **A cold archived version is built once, however many requests ask.**
18
+ `DocsKit::Snapshot.for` cached snapshots in an unguarded class ivar, so
19
+ threads that missed a version together each read and parsed its
20
+ `manifest.json` and raced to store it. The cache is now per Ractor and its
21
+ check-then-set runs under a lock; `Configuration#openapi_document`'s
22
+ mtime-checked memo likewise. (#91)
23
+ - **No per-render constant lookups once the app has booted.** The engine now
24
+ calls `DocsKit.finalize!` once the host has configured the kit, with no app
25
+ code: the Rouge code themes and every registered page's view class are
26
+ resolved once into frozen maps instead of `Object.const_get` /
27
+ `safe_constantize` on every render, and the brand logo, SEO, landing and
28
+ optional-mcp memos are filled at boot rather than by whichever request comes
29
+ first. `DocsKit.configure` re-opens it, and a theme assigned directly
30
+ afterwards still wins. Outside Rails, call `DocsKit.finalize!` yourself. (#91)
31
+
32
+ ### Changed
33
+
34
+ - **Finalized after boot in every environment, and after every reload.** The
35
+ engine finalizes in `after_initialize` in development too. With code
36
+ reloading on, it finalizes again after each reload, once the host's
37
+ `to_prepare` configure has re-run and re-opened the configuration, so the
38
+ resolved page views are the reloaded classes. The host's callables (`brand`,
39
+ `nav`, `version_badge`, `importmap`, ...) are kept as given, never frozen:
40
+ they may close over app state, so `DocsKit.configuration` stays
41
+ main-Ractor-only and finalizing never fails on one. (#91)
42
+ - **Boot-time values are frozen.** The registry DSL (`page`, `path_prefix`,
43
+ `view_namespace`, `group_by_attribute`) and `DocsUI::Page`'s `title`,
44
+ `eyebrow`, `description` and `on_page` macros deep-freeze what they are
45
+ given (Ractor-shareable), as do the gem's own constants, and
46
+ `DocsKit.configuration` is built at load rather than on first read. Code
47
+ that mutated one of those values in place after declaring it now raises
48
+ `FrozenError`. (#91)
49
+ - **A broken `brand_logo` fails at boot.** `DocsKit.finalize!` builds the logo,
50
+ so a missing file or non-SVG markup raises when the app boots, in every
51
+ environment, instead of on the first render. (#91)
52
+ - docs-kit is held to [conformance-kit](https://github.com/zoolutions/conformance-kit)
53
+ 0.4 in CI: the rubocop-thread_safety cops, thread and fiber contention specs,
54
+ a Ruby 4.0 Ractor canary (search index, llms.txt, the sidebar tree) and
55
+ `conformance-audit --strict`, whose allowlist names Rails' and Thor's class
56
+ state on the engine, controllers and generators by `ancestor:` rule. (#91)
57
+
5
58
  ## [1.3.0] — 2026-10-13
6
59
 
7
60
  ### Fixed
data/README.md CHANGED
@@ -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
@@ -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
@@ -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,6 +31,19 @@ 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)
@@ -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.0"
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.0
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