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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +108 -0
- data/app/components/docs_ui/callout.rb +3 -3
- data/app/components/docs_ui/code.rb +3 -1
- data/app/components/docs_ui/landing.rb +2 -2
- data/app/components/docs_ui/markdown.rb +8 -4
- data/app/components/docs_ui/markdown_images.rb +73 -0
- data/app/components/docs_ui/meta_tags.rb +2 -2
- data/app/components/docs_ui/page.rb +46 -15
- data/app/components/docs_ui/shell.rb +18 -4
- data/app/components/docs_ui/sidebar.rb +2 -2
- data/app/controllers/docs_kit/llms_controller.rb +5 -2
- data/lib/docs_kit/api_client.rb +22 -18
- data/lib/docs_kit/configuration.rb +147 -12
- data/lib/docs_kit/engine.rb +21 -1
- data/lib/docs_kit/llms_text.rb +1 -1
- data/lib/docs_kit/mcp_server.rb +2 -2
- data/lib/docs_kit/og_generator.rb +3 -1
- data/lib/docs_kit/registry.rb +49 -8
- data/lib/docs_kit/shareable.rb +14 -0
- data/lib/docs_kit/snapshot.rb +31 -12
- data/lib/docs_kit/store.rb +57 -0
- data/lib/docs_kit/version.rb +1 -1
- data/lib/docs_kit.rb +26 -6
- data/lib/generators/docs_kit/install/migration.rb +7 -2
- data/lib/generators/docs_kit/install/migration_registry.rb +15 -10
- data/lib/generators/docs_kit/install/migrations/v1_2_0.rb +7 -5
- metadata +4 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/docs_kit/engine.rb
CHANGED
|
@@ -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)
|
data/lib/docs_kit/llms_text.rb
CHANGED
|
@@ -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.
|
|
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))
|
data/lib/docs_kit/mcp_server.rb
CHANGED
|
@@ -28,7 +28,7 @@ module DocsKit
|
|
|
28
28
|
return unless mcp_available?
|
|
29
29
|
|
|
30
30
|
server = MCP::Server.new(
|
|
31
|
-
name: config.
|
|
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.
|
|
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
|
-
|
|
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: [])
|
data/lib/docs_kit/registry.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/docs_kit/snapshot.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
data/lib/docs_kit/version.rb
CHANGED
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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__))
|