docs-kit 1.2.1 → 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.
@@ -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
 
@@ -78,6 +79,17 @@ module DocsKit
78
79
  # stylesheets (e.g. a separate rouge theme) lists them here.
79
80
  attr_accessor :stylesheets
80
81
 
82
+ # The import map entry point the Shell loads. Defaults to "application"
83
+ # (javascript_importmap_tags' own default). A host with a dedicated docs
84
+ # import map names its entry point here, e.g. "docs".
85
+ attr_accessor :importmap_entry_point
86
+
87
+ # The import map the Shell renders: nil (the default → the host's
88
+ # Rails.application.importmap), an Importmap::Map, or a zero-arity callable
89
+ # returning one, resolved per render so a map rebuilt on code reload is
90
+ # picked up. Read via #resolved_importmap.
91
+ attr_accessor :importmap
92
+
81
93
  # The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
82
94
  # This is the BASE (light) theme, emitted un-scoped so it applies to every
83
95
  # theme unless a dark override wins (see #code_theme_dark).
@@ -265,7 +277,7 @@ module DocsKit
265
277
 
266
278
  # The sentinel "no explicit nav" lambda. #nav_groups compares against this
267
279
  # identity to decide whether to derive the sidebar from #nav_registries.
268
- DEFAULT_NAV = -> { {} }
280
+ DEFAULT_NAV = DocsKit::Shareable.make(-> { {} })
269
281
 
270
282
  # The search-palette shortcuts before this was configurable — "/" and the
271
283
  # platform command chord — so a site that never sets #search_shortcuts keeps
@@ -307,6 +319,8 @@ module DocsKit
307
319
  @nav_registries = {}
308
320
  @version_badge = nil
309
321
  @stylesheets = %w[application]
322
+ @importmap_entry_point = "application"
323
+ @importmap = nil
310
324
  @code_theme = "Rouge::Themes::Monokai"
311
325
  @code_theme_dark = nil
312
326
  @dark_themes = DEFAULT_DARK_THEMES
@@ -334,6 +348,8 @@ module DocsKit
334
348
  @versions = []
335
349
  @repo_url = nil
336
350
  @snapshots_path = nil
351
+ @openapi_memo = nil
352
+ @finalized = nil
337
353
  end
338
354
 
339
355
  # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -440,16 +456,60 @@ module DocsKit
440
456
  # spec in development is picked up without a server restart. Raises a
441
457
  # DocsKit::Error naming the knob when read while #openapi is unset — a missing
442
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.
443
465
  def openapi_document
444
466
  raise DocsKit::Error, "no OpenAPI spec configured — set c.openapi to a path or Hash" if @openapi.nil?
445
467
 
468
+ source = @openapi
446
469
  mtime = openapi_source_mtime
447
- 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
448
473
 
449
- @openapi_document_mtime = mtime
450
- @openapi_document = DocsKit::OpenApi.load(@openapi)
474
+ DocsKit::OpenApi.load(source).tap do |document|
475
+ @openapi_memo = { source: source, mtime: mtime, document: document }
476
+ end
477
+ end
451
478
  end
452
479
 
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
509
+ end
510
+
511
+ def finalized? = !@finalized.nil?
512
+
453
513
  # The effective client map for DocsUI::RequestExample: the four shipped
454
514
  # defaults with site overrides/extensions merged over them. Hash#merge keeps
455
515
  # a reused token in its original position and appends new tokens in
@@ -544,14 +604,54 @@ module DocsKit
544
604
 
545
605
  public
546
606
 
607
+ # The localStorage namespace. A callable brand can't be slugged: the key must
608
+ # be stable per browser, not per request, so a callable brand needs an
609
+ # explicit c.nav_storage_key (checked again by DocsKit.configure).
547
610
  def nav_storage_key
548
- @nav_storage_key || @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
611
+ return @nav_storage_key if @nav_storage_key
612
+ if @brand.respond_to?(:call)
613
+ raise DocsKit::Error,
614
+ "c.brand is a callable, so set c.nav_storage_key to a stable key " \
615
+ "(the sidebar/theme localStorage namespace must not change per request)"
616
+ end
617
+
618
+ @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
549
619
  end
550
620
 
621
+ # The raw configured suffix (or the raw brand when unset). The chrome reads
622
+ # #title_suffix_text, which resolves a callable.
551
623
  def title_suffix
552
624
  @title_suffix || @brand
553
625
  end
554
626
 
627
+ # The rendered brand. A String is returned as-is; a zero-arity callable is
628
+ # invoked on every read (never memoize it — the point is a per-request
629
+ # value, e.g. -> { Current.tenant_name }).
630
+ def brand_text
631
+ resolve_text(@brand).to_s
632
+ end
633
+
634
+ # True when the brand is a callable, i.e. anything that embeds the brand
635
+ # (the chrome, llms.txt) may differ per request and must not be stored in a
636
+ # shared cache.
637
+ def brand_per_request?
638
+ @brand.respond_to?(:call)
639
+ end
640
+
641
+ # The rendered <title>/og:title suffix: #title_suffix resolved like
642
+ # #brand_text, falling back to the resolved brand. nil only when both are nil,
643
+ # so the title join drops it instead of leaving a dangling separator.
644
+ def title_suffix_text
645
+ resolve_text(@title_suffix.nil? ? @brand : @title_suffix)
646
+ end
647
+
648
+ # Raises at configure time for a config the chrome can't render (today: a
649
+ # callable brand without a stable nav_storage_key). Called by DocsKit.configure.
650
+ def validate!
651
+ nav_storage_key
652
+ self
653
+ end
654
+
555
655
  # Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
556
656
  # optional `mcp` gem is loadable — the same "toggle AND capability" shape as
557
657
  # #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
@@ -609,6 +709,12 @@ module DocsKit
609
709
  @version_badge.to_s
610
710
  end
611
711
 
712
+ # The import map the Shell renders, or nil for the host's default map. A
713
+ # callable is invoked on every read (once per render).
714
+ def resolved_importmap
715
+ @importmap.respond_to?(:call) ? @importmap.call : @importmap
716
+ end
717
+
612
718
  # The default Rouge theme both #code_theme_class and #code_theme_dark_class
613
719
  # fall back to when a configured theme name can't be resolved — so a typo'd
614
720
  # theme name degrades gracefully instead of raising on every code block.
@@ -618,9 +724,7 @@ module DocsKit
618
724
  # name that doesn't resolve degrades to the default theme rather than raising
619
725
  # NameError on every DocsUI::Code render.
620
726
  def code_theme_class
621
- return @code_theme if @code_theme.is_a?(Class)
622
-
623
- resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME)
727
+ resolved(:code_theme_class, :code_theme, @code_theme) { resolve_code_theme_class }
624
728
  end
625
729
 
626
730
  # The dark Rouge theme class resolved from #code_theme_dark (String or
@@ -628,10 +732,7 @@ module DocsKit
628
732
  # dark code CSS only when this is non-nil, so an unresolvable name degrades to
629
733
  # nil (no dark restyle) rather than raising.
630
734
  def code_theme_dark_class
631
- return if @code_theme_dark.nil?
632
- return @code_theme_dark if @code_theme_dark.is_a?(Class)
633
-
634
- resolve_theme(@code_theme_dark)
735
+ resolved(:code_theme_dark_class, :code_theme_dark, @code_theme_dark) { resolve_code_theme_dark_class }
635
736
  end
636
737
 
637
738
  # The dark themes the site actually ships: #dark_themes intersected with
@@ -644,6 +745,33 @@ module DocsKit
644
745
 
645
746
  private
646
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
+
647
775
  # Resolve a Rouge theme constant from its String name, returning nil (not
648
776
  # raising) when the name doesn't resolve — a typo'd or unloaded theme must
649
777
  # not crash every code block on the page.
@@ -652,5 +780,12 @@ module DocsKit
652
780
  rescue NameError
653
781
  nil
654
782
  end
783
+
784
+ # A String-or-callable knob's value for this render: a callable is invoked
785
+ # and stringified, nil stays nil, anything else is stringified.
786
+ def resolve_text(value)
787
+ value = value.call if value.respond_to?(:call)
788
+ value&.to_s
789
+ end
655
790
  end
656
791
  end
@@ -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,15 +31,35 @@ 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
 
52
+ # Host-facing engine options. `config.docs_kit.auto_pin = false` (in
53
+ # config/application.rb) keeps the gem's pin out of the host's shared import
54
+ # map, for a host that pins `docs_kit/controllers` in a dedicated docs map.
55
+ config.docs_kit = ActiveSupport::OrderedOptions.new
56
+ config.docs_kit.auto_pin = true
57
+
39
58
  # Auto-pin the controller for importmap-rails consumers, so a host app gets
40
59
  # `docs_kit/controllers/docs_nav_controller` with no manual pin.
41
60
  initializer "docs_kit.importmap", before: "importmap" do |app|
42
61
  next unless app.config.respond_to?(:importmap)
62
+ next unless app.config.docs_kit.auto_pin
43
63
 
44
64
  importmap = app.config.importmap
45
65
  importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
@@ -33,7 +33,7 @@ module DocsKit
33
33
  # single tight list (no blank lines between bullets), per the llmstxt.org
34
34
  # convention.
35
35
  def index(config, base_url: nil)
36
- blocks = ["# #{config.brand}"]
36
+ blocks = ["# #{config.brand_text}"]
37
37
  tagline = config.tagline
38
38
  blocks << "> #{tagline}" if tagline && !tagline.to_s.empty?
39
39
  blocks.concat(section_blocks(config, base_url))
@@ -28,7 +28,7 @@ module DocsKit
28
28
  return unless mcp_available?
29
29
 
30
30
  server = MCP::Server.new(
31
- name: config.brand.to_s,
31
+ name: config.brand_text,
32
32
  version: DocsKit::VERSION,
33
33
  instructions: instructions_for(config, base_url),
34
34
  server_context: { config:, base_url:, view_context: }
@@ -54,7 +54,7 @@ module DocsKit
54
54
  lines = []
55
55
  tagline = config.tagline
56
56
  lines << tagline.to_s if tagline && !tagline.to_s.empty?
57
- lines << "Read-only documentation tools for #{config.brand}. " \
57
+ lines << "Read-only documentation tools for #{config.brand_text}. " \
58
58
  "Use search_docs to find sections, get_page to read a page's Markdown, " \
59
59
  "and list_pages to enumerate the docs. Full index: #{llms}."
60
60
  lines.join(" ")
@@ -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.2.1"
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
- configuration
36
+ configuration.validate!
37
+ end
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!
29
45
  end
30
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__))