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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04c64683910c10c37f660008f8891ca9f87296a0aea1ddc67f9d9a199975cccd
4
- data.tar.gz: d357822c1df8899b50008f28366992bde0b9d6f622ec90975b5926f11d2de2d7
3
+ metadata.gz: 6340fb386df08272f63e525ca81d43baaa1f440a82287e4a34e9a392b220a530
4
+ data.tar.gz: 3740655d5194976ea25a3ed3cdd6a633bb593c03c288a1c3a069d16d7c522cfe
5
5
  SHA512:
6
- metadata.gz: a2b905a1b80c5e156ea7e652ba45cb4d2ec54c91ad040754112cdd481644b7698fd5356459faf06f46ac140c0c6562b8989039b69585b3f257a3b5629cfe0d49
7
- data.tar.gz: 9837ae7885fb3c6ad358b585853aa7d0dee63b9807c574f15e8543f70ae5de6ecea4f5a2755391588a111fa5d05fcf2456a76b9040a648b86bcf3cf767700171
6
+ metadata.gz: 01e79925a8f67168a27e898fb13f6a9a5658b331b8a5b1ba854c9481c1afc5256f7ec89fb80a6e762f9ec0fd399425c38be82a1943a8fc9517e5cd0803e32fa4
7
+ data.tar.gz: a9a74e687605433c9a23069d6760a5a14093e898e0f64b133ede84f951f9f4deead0e98ad7735ca5a87e9f79ba86c9a340993004f1ece46731bda6016e6b9bab
data/CHANGELOG.md CHANGED
@@ -2,6 +2,101 @@
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
+
58
+ ## [1.3.0] — 2026-10-13
59
+
60
+ ### Fixed
61
+
62
+ - **Markdown links drop a script-bearing href.** `[text](javascript:…)`,
63
+ `data:`, `vbscript:` and any other scheme outside http(s), `mailto:` and
64
+ `tel:` now render the link text with no `<a>` (Phlex already refused
65
+ `javascript:`; `data:` and the rest slipped through). Scheme-less hrefs are
66
+ unchanged. The same guard `DocsUI::Markdown` images have.
67
+
68
+ ### Added
69
+
70
+ - **A per-request `brand` and `title_suffix`.** Both accept a zero-arity
71
+ callable (`c.brand = -> { Current.tenant_name }`), evaluated on every render:
72
+ the topbar, sidebar header, `<title>`, `og:site_name`, llms.txt and the MCP
73
+ server name show that request's value. A callable brand needs an explicit
74
+ `c.nav_storage_key`; `DocsKit.configure` raises without one. New readers
75
+ `Configuration#brand_text` / `#title_suffix_text`; a String brand renders
76
+ exactly as before. (#92)
77
+ - **Markdown images render.** `![alt](path "title")` in an `md` island now
78
+ renders a lazy-loaded `<img>` (it rendered nothing before). In a Rails view
79
+ a relative path resolves through the site's asset pipeline (`image_path`;
80
+ off a request it is kept as written); an absolute,
81
+ protocol-relative or root-relative URL is kept as written; the alt is the
82
+ description's plain text. A `javascript:`, `data:` or other non-http(s)
83
+ scheme renders no image. The page's `.md` twin exports it back to
84
+ `![alt](src)`. (#94)
85
+ - **A dedicated docs import map.** `c.importmap_entry_point` (default
86
+ `"application"`) and `c.importmap` (an `Importmap::Map` or a callable
87
+ returning one, resolved per render) choose what the Shell's
88
+ `javascript_importmap_tags` loads, and `config.docs_kit.auto_pin = false` (in
89
+ `config/application.rb`) keeps the gem's `docs-nav` pin out of the host's
90
+ shared map. Unset, the head is unchanged. (#93)
91
+ - **`DocsUI::Page` title, description and eyebrow per instance.** The page now
92
+ reads `#title`, `#description` and `#eyebrow`, which default to the class
93
+ macros, so one page class can render many records (`def title = @widget.name`)
94
+ without an anonymous subclass per render. The `eyebrow`, `description` and
95
+ `on_page` macros inherit from a parent page when unset. Pages using only the
96
+ class macros render byte-identically. (#95)
97
+
98
+ ## [1.2.1] — 2026-09-30 (with 1.2.0, same day)
99
+
5
100
  ### Upgrading from 1.1
6
101
 
7
102
  ```bash
data/README.md CHANGED
@@ -193,6 +193,20 @@ DocsKit.configure do |c|
193
193
  end
194
194
  ```
195
195
 
196
+ **One app, several hosts?** `brand` and `title_suffix` also take a zero-arity
197
+ callable, evaluated on every render — the topbar, the sidebar header, `<title>`,
198
+ `og:site_name`, llms.txt and the MCP server name all show that request's value.
199
+ A callable brand can't name the localStorage namespace (it must stay stable per
200
+ browser), so set `nav_storage_key` too; `DocsKit.configure` raises without it.
201
+
202
+ ```ruby
203
+ DocsKit.configure do |c|
204
+ c.brand = -> { Current.tenant_name } # Current < ActiveSupport::CurrentAttributes
205
+ c.title_suffix = -> { "#{Current.tenant_name} docs" } # optional; unset follows the brand
206
+ c.nav_storage_key = "docs"
207
+ end
208
+ ```
209
+
196
210
  The nav is **derived from the registry**, so you never hand-write it. Each
197
211
  registry maps a heading to its authored pages (`Doc.nav_items`); a page that
198
212
  isn't written yet is skipped, so there are no dead links. Register a page with
@@ -349,6 +363,33 @@ c.nav = lambda do
349
363
  end
350
364
  ```
351
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
+
352
393
  ## Render
353
394
 
354
395
  ```ruby
@@ -431,6 +472,37 @@ end
431
472
  `DocsUI::Page` includes the kit, so inside `#content` you call the components
432
473
  directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`.
433
474
 
475
+ ### One page class, many records
476
+
477
+ `title`, `eyebrow` and `description` are class macros by default, but the page
478
+ reads them through **instance** methods — override those when one page class
479
+ renders many records (one reference page per item):
480
+
481
+ ```ruby
482
+ class Views::Docs::Widget < DocsUI::Page
483
+ eyebrow "Widgets"
484
+
485
+ def initialize(widget:) = @widget = widget
486
+ def title = @widget.name # masthead <h1> and <title>
487
+ def description = @widget.summary # meta/OG description (default: class macro, then #lead)
488
+ def content = md(@widget.notes)
489
+ end
490
+
491
+ # controller
492
+ def show = render_page(Views::Docs::Widget.new(widget: Widget.find(params[:id])))
493
+ ```
494
+
495
+ `eyebrow`, `description` and `on_page` inherit: a subclass that leaves one unset
496
+ uses its parent page's. `title` does not — each page names itself.
497
+
498
+ Such a page usually lives on its own route, outside the registry. If that route
499
+ has no `(.:format)` segment, the masthead's **Markdown** action (which links
500
+ `request.path + ".md"`) would 404 — hide it for that page:
501
+
502
+ ```ruby
503
+ def markdown_action? = false
504
+ ```
505
+
434
506
  ### The authoring convention
435
507
 
436
508
  One rule covers the whole kit: **the primary argument is positional; modifiers
@@ -1051,6 +1123,42 @@ server round-trip:
1051
1123
  of three placements, auto-hiding on short pages. No server-side knowledge of
1052
1124
  the headings, no per-page wiring.
1053
1125
 
1126
+ ### A dedicated docs import map
1127
+
1128
+ By default the Shell loads the host's `application` entry point from
1129
+ `Rails.application.importmap`, and the engine adds the `docs-nav` pin to that
1130
+ map. A host with several import maps can give its docs their own entry point
1131
+ and map, and keep the docs controller out of every other page's preloads:
1132
+
1133
+ ```ruby
1134
+ # config/application.rb — skip the engine's auto-pin into the shared map
1135
+ config.docs_kit.auto_pin = false
1136
+
1137
+ # config/initializers/docs_kit.rb — build the second map (importmap-rails has no
1138
+ # registry for extra maps; the host owns it) and point the docs chrome at it
1139
+ DOCS_IMPORTMAP = Importmap::Map.new.draw(Rails.root.join("config/importmaps/docs.rb"))
1140
+
1141
+ DocsKit.configure do |c|
1142
+ c.importmap_entry_point = "docs" # default "application"
1143
+ c.importmap = -> { DOCS_IMPORTMAP } # default nil → Rails.application.importmap
1144
+ end
1145
+ ```
1146
+
1147
+ ```ruby
1148
+ # config/importmaps/docs.rb — drawn into the docs Importmap::Map above
1149
+ pin "docs"
1150
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
1151
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
1152
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
1153
+ pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
1154
+ under: "docs_kit/controllers", to: "docs_kit/controllers"
1155
+ ```
1156
+
1157
+ `c.importmap` takes an `Importmap::Map` or a callable returning one; a callable
1158
+ is resolved on every render, so a map rebuilt on code reload is picked up. The
1159
+ `docs` entry point registers the controllers with
1160
+ `eagerLoadControllersFrom("docs_kit/controllers", application)`.
1161
+
1054
1162
  ### On this page (auto-TOC)
1055
1163
 
1056
1164
  `DocsUI::Page` renders it automatically. The placement is a strategy — set the
@@ -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
@@ -33,7 +33,7 @@ module DocsUI
33
33
  include Phlex::Rails::Helpers::Request
34
34
 
35
35
  def view_template
36
- render DocsUI::Shell.new(title: landing.eyebrow || config.brand) do
36
+ render DocsUI::Shell.new(title: landing.eyebrow || config.brand_text) do
37
37
  div(class: "mx-auto max-w-5xl") do
38
38
  hero
39
39
  feature_grid
@@ -78,7 +78,7 @@ module DocsUI
78
78
  # (the one bit of markdown we honor, so a site can accent a word without HTML).
79
79
  def heading
80
80
  h1(class: "text-4xl font-bold tracking-tight md:text-5xl") do
81
- (landing.title || config.brand).to_s.split(/\*\*(.+?)\*\*/).each_with_index do |part, index|
81
+ (landing.title || config.brand_text).to_s.split(/\*\*(.+?)\*\*/).each_with_index do |part, index|
82
82
  next if part.empty?
83
83
 
84
84
  index.odd? ? span(class: "text-primary") { part } : plain(part)
@@ -34,6 +34,9 @@ module DocsUI
34
34
  TABLE_WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300"
35
35
  TABLE_CLASSES = "table table-sm table-zebra"
36
36
 
37
+ # ![alt](src) → <img>, asset-pipeline resolved (see MarkdownImages).
38
+ include MarkdownImages
39
+
37
40
  # Render source as INLINE markdown: no Prose wrapper div, and a single
38
41
  # top-level paragraph is unwrapped so its inline children (strong/em/code/
39
42
  # link) sit directly in the surrounding element. Used for a [:md, "…"] table
@@ -88,9 +91,7 @@ module DocsUI
88
91
 
89
92
  def node_document(node) = visit_children(node)
90
93
 
91
- def node_paragraph(node)
92
- p { visit_children(node) }
93
- end
94
+ def node_paragraph(node) = p { visit_children(node) }
94
95
 
95
96
  # Demote so Markdown headings never collide with the page masthead/section
96
97
  # headings: h1→h3, h2→h4, anything deeper caps at h4.
@@ -127,8 +128,11 @@ module DocsUI
127
128
  render DocsUI::Code.new(node.string_content, lexer:)
128
129
  end
129
130
 
131
+ # A link keeps its href only for an allowed scheme (MarkdownImages::
132
+ # LINK_SCHEMES) or none at all; otherwise the text renders bare.
130
133
  def node_link(node)
131
- a(href: node.url) { visit_children(node) }
134
+ href = link_href(node.url)
135
+ href ? a(href:) { visit_children(node) } : visit_children(node)
132
136
  end
133
137
 
134
138
  def node_list(node)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # DocsUI::Markdown's image handler: `![alt](src "title")` → a lazy-loaded
5
+ # <img>. A relative src resolves through the site's asset pipeline
6
+ # (image_path → the digested /assets URL, Propshaft or Sprockets); an absolute,
7
+ # protocol-relative or root-relative URL is kept as written. Off a request (an
8
+ # isolated render) the src is used verbatim — the DocsUI::Logo posture.
9
+ module MarkdownImages
10
+ include Phlex::Rails::Helpers::ImagePath
11
+
12
+ # A Markdown image sits in the prose like the kit's other bordered boxes.
13
+ IMAGE_CLASSES = "my-4 rounded-box border border-base-300"
14
+
15
+ # An image src kept verbatim: absolute, protocol-relative or root-relative.
16
+ ABSOLUTE_SRC = %r{\A(?:https?:)?/}i
17
+
18
+ # Any URL scheme. An image src may only carry http(s); a link href one of
19
+ # LINK_SCHEMES. Scheme-less URLs (relative, root-relative, protocol-relative,
20
+ # #anchor) always pass; javascript:, data:, vbscript: and the rest never do.
21
+ URL_SCHEME = /\A[a-z][a-z0-9+.-]*:/i
22
+ LINK_SCHEMES = %w[http https mailto tel].freeze
23
+
24
+ # The descendants whose string_content makes up the alt text; a soft or hard
25
+ # line break between them becomes a space so words don't run together.
26
+ ALT_TEXT_NODES = %i[text code].freeze
27
+ ALT_BREAK_NODES = %i[softbreak linebreak].freeze
28
+
29
+ private
30
+
31
+ # An unsafe scheme renders nothing — the alt text is dropped with it rather
32
+ # than left behind as stray prose.
33
+ def node_image(node)
34
+ src = image_src(node.url)
35
+ return unless src
36
+
37
+ title = node.title.to_s
38
+ img(src:, alt: alt_text(node), title: (title unless title.empty?),
39
+ loading: "lazy", class: IMAGE_CLASSES)
40
+ end
41
+
42
+ # Browsers strip ASCII whitespace/control characters before reading a
43
+ # scheme ("java\tscript:"), so the check does too.
44
+ def image_src(url)
45
+ bare = bare_url(url)
46
+ return url if bare.match?(ABSOLUTE_SRC)
47
+ return if bare.match?(URL_SCHEME)
48
+
49
+ view_context ? image_path(url) : url
50
+ end
51
+
52
+ # A link's href as written, or nil when it carries a scheme outside
53
+ # LINK_SCHEMES (the same whitespace-stripped scheme read as image_src).
54
+ def link_href(url)
55
+ scheme = bare_url(url)[URL_SCHEME]
56
+ return url if scheme.nil? || LINK_SCHEMES.include?(scheme.chomp(":").downcase)
57
+
58
+ nil
59
+ end
60
+
61
+ def bare_url(url) = url.to_s.gsub(/[\x00-\x20]/, "")
62
+
63
+ # Commonmarker 2 has no plain-text export, so the alt is the image's text
64
+ # (and inline code) descendants joined — emphasis markup is dropped.
65
+ def alt_text(node)
66
+ node.walk.filter_map do |n|
67
+ if ALT_TEXT_NODES.include?(n.type) then n.string_content
68
+ elsif ALT_BREAK_NODES.include?(n.type) then " "
69
+ end
70
+ end.join
71
+ end
72
+ end
73
+ end
@@ -52,7 +52,7 @@ module DocsUI
52
52
  # The full title used for og:title, identical to what Shell puts in <title>:
53
53
  # "Page · Suffix", or just the suffix on a title-less page (the home page).
54
54
  def full_title
55
- [@title, config.title_suffix].compact.join(" · ")
55
+ [@title, config.title_suffix_text].compact.join(" · ")
56
56
  end
57
57
 
58
58
  def description_meta
@@ -67,7 +67,7 @@ module DocsUI
67
67
  def open_graph
68
68
  meta(property: "og:title", content: full_title)
69
69
  meta(property: "og:type", content: seo.og_type)
70
- meta(property: "og:site_name", content: config.brand)
70
+ meta(property: "og:site_name", content: config.brand_text)
71
71
  open_graph_optional
72
72
  end
73
73
 
@@ -14,6 +14,16 @@ module DocsUI
14
14
  # render DocsUI::Section.new("Add the gem") { … }
15
15
  # end
16
16
  # end
17
+ #
18
+ # A page class that renders many records overrides the INSTANCE readers
19
+ # (#title, #description, #eyebrow) instead — they default to the class macros:
20
+ #
21
+ # class Views::Docs::Widget < DocsUI::Page
22
+ # eyebrow "Widgets"
23
+ # def initialize(widget:) = @widget = widget
24
+ # def title = @widget.name
25
+ # def content = md(@widget.notes)
26
+ # end
17
27
  class Page < Phlex::HTML
18
28
  include Phlex::Rails::Helpers::Routes
19
29
  # #request drives the "Markdown" masthead action's href (request.path + .md).
@@ -25,41 +35,52 @@ module DocsUI
25
35
  # friction-free path that never trips the parens-with-blocks gotcha.
26
36
  include DocsUI::PageHelpers
27
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
28
42
  class << self
29
43
  def title(value = nil)
30
- @title = value if value
44
+ @title = DocsKit::Shareable.make(value) if value
31
45
  @title
32
46
  end
33
47
 
48
+ # Inherited: a subclass that sets no eyebrow shows its parent page's.
34
49
  def eyebrow(value = nil)
35
- @eyebrow = value if value
36
- @eyebrow
50
+ @eyebrow = DocsKit::Shareable.make(value) if value
51
+ @eyebrow || (superclass.eyebrow if parent_page?)
37
52
  end
38
53
 
39
54
  # The per-page SEO/social description → DocsUI::MetaTags (via Shell). Set it
40
55
  # for a hand-tuned description; when unset, Page derives one from #lead, so
41
56
  # an existing page gets a sensible description for free. nil (no
42
57
  # description, no lead) falls back to config.seo.description in MetaTags.
58
+ # Inherited from a parent page when unset.
43
59
  def description(value = nil)
44
- @description = value if value
45
- @description
60
+ @description = DocsKit::Shareable.make(value) if value
61
+ @description || (superclass.description if parent_page?)
46
62
  end
47
63
 
48
64
  # The "On this page" auto-TOC placement for this page. Defaults to the
49
65
  # configured DocsKit.configuration.on_page_default; set false to opt out,
50
- # or :panel/:toggle/:sidebar to override per page.
66
+ # or :panel/:toggle/:sidebar to override per page. Inherited from a parent
67
+ # page when unset.
51
68
  def on_page(value = :__unset__)
52
- @on_page = value unless value == :__unset__
53
- defined?(@on_page) ? @on_page : DocsKit.configuration.on_page_default
69
+ @on_page = DocsKit::Shareable.make(value) unless value == :__unset__
70
+ return @on_page if defined?(@on_page)
71
+
72
+ parent_page? ? superclass.on_page : DocsKit.configuration.on_page_default
54
73
  end
74
+
75
+ private
76
+
77
+ # Whether the superclass is a page too (true below DocsUI::Page itself), so
78
+ # an unset macro falls back to it.
79
+ def parent_page? = superclass <= DocsUI::Page
55
80
  end
56
81
 
57
82
  def view_template
58
- render DocsUI::Shell.new(
59
- title: self.class.title,
60
- description: self.class.description || lead,
61
- on_page: self.class.on_page
62
- ) do
83
+ render DocsUI::Shell.new(title:, description:, on_page: self.class.on_page) do
63
84
  # data-md-skip drops this nav from the Markdown export — it's chrome, not
64
85
  # page content (DocsKit::MarkdownExport strips [data-md-skip]). The
65
86
  # "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
@@ -69,7 +90,7 @@ module DocsUI
69
90
  render DocsUI::MarkdownAction.new(request.path) if markdown_action?
70
91
  end
71
92
 
72
- render DocsUI::Header.new(self.class.title, eyebrow: self.class.eyebrow) do
93
+ render DocsUI::Header.new(title, eyebrow:) do
73
94
  plain lead if lead
74
95
  end
75
96
 
@@ -78,9 +99,19 @@ module DocsUI
78
99
  end
79
100
 
80
101
  # Whether to show the "Markdown" masthead action — the config knob
81
- # (DocsKit.configuration.page_markdown_action, default true).
102
+ # (DocsKit.configuration.page_markdown_action, default true). The action links
103
+ # request.path + ".md"; override to false on a page whose route has no .md twin.
82
104
  def markdown_action? = DocsKit.configuration.page_markdown_action
83
105
 
106
+ # The page title (masthead <h1> and <title>), eyebrow and SEO description.
107
+ # Each defaults to its class macro; override per instance when one page class
108
+ # renders many records. #title shadows Phlex's <title> element method, on
109
+ # pages only — the document <title> is DocsUI::Shell's, never a page body's.
110
+ def title = self.class.title
111
+ def eyebrow = self.class.eyebrow
112
+ # Falls back to #lead, then (in DocsUI::MetaTags) to config.seo.description.
113
+ def description = self.class.description || lead
114
+
84
115
  # The lowercase authoring helpers md/prose/example come from DocsUI::PageHelpers
85
116
  # (included above) — the parens-free path that never hits the constant-reference
86
117
  # SyntaxError. The kit forms (DocsUI::Prose(), DocsUI::Example()) stay valid too.
@@ -72,9 +72,13 @@ module DocsUI
72
72
  # injected by the controller under the active nav link (no content slot).
73
73
  def content_toc? = %i[panel toggle].include?(@on_page)
74
74
 
75
+ # "Page · Suffix", or just the suffix on a title-less page. The suffix is
76
+ # resolved per render (a callable title_suffix/brand → this request's value).
77
+ def document_title = [@title, config.title_suffix_text].compact.join(" · ")
78
+
75
79
  def render_head
76
80
  head do
77
- title { [@title, config.title_suffix].compact.join(" · ") }
81
+ title { document_title }
78
82
  meta(charset: "utf-8")
79
83
  meta(name: "viewport", content: "width=device-width,initial-scale=1")
80
84
  # SEO / social-share tags (description, Open Graph, Twitter Card,
@@ -90,10 +94,20 @@ module DocsUI
90
94
  meta(name: "turbo-refresh-scroll", content: "preserve")
91
95
  config.stylesheets.each { |sheet| stylesheet_link_tag(sheet, data: { turbo_track: "reload" }) }
92
96
  theme_restore_script
93
- javascript_importmap_tags
97
+ importmap_tags
94
98
  end
95
99
  end
96
100
 
101
+ # The import map tags for config.importmap_entry_point, from config.importmap
102
+ # when one is set. With nothing configured this is the helper's own default
103
+ # call, so the head matches a Shell that predates the knobs.
104
+ def importmap_tags
105
+ map = config.resolved_importmap
106
+ return javascript_importmap_tags(config.importmap_entry_point) if map.nil?
107
+
108
+ javascript_importmap_tags(config.importmap_entry_point, importmap: map)
109
+ end
110
+
97
111
  # Restore the persisted theme BEFORE first paint so there's no flash of the
98
112
  # server default before the docs-nav controller runs. Reads the same
99
113
  # localStorage key the controller writes (docs-kit:<site>:theme). Runs on
@@ -183,9 +197,9 @@ module DocsUI
183
197
  # text brand stays the mark's accessible-name fallback.
184
198
  def brand_mark
185
199
  if (logo = config.brand_logo)
186
- render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand)
200
+ render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand_text)
187
201
  else
188
- plain config.brand
202
+ plain config.brand_text
189
203
  end
190
204
  end
191
205
 
@@ -59,9 +59,9 @@ module DocsUI
59
59
  # Slightly taller than the topbar's h-6: this is the masthead.
60
60
  def brand_mark
61
61
  if (logo = config.brand_logo)
62
- render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand)
62
+ render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand_text)
63
63
  else
64
- plain config.brand
64
+ plain config.brand_text
65
65
  end
66
66
  end
67
67
 
@@ -71,9 +71,12 @@ module DocsKit
71
71
  # Revalidate on the rendered body itself (so any registry/config/page change
72
72
  # busts it) plus the gem version as the etag salt. In development, always
73
73
  # re-render; production sites deploy immutably so the version etag is stable.
74
+ # A callable brand (#92) makes the body per-request, so the response is then
75
+ # `private`: a shared cache must not hand one tenant's llms.txt to another.
74
76
  def stale_llms?(body)
75
- expires_in LLMS_MAX_AGE, public: true
76
- stale?(etag: [DocsKit::VERSION, body], public: true)
77
+ shared = !docs_config.brand_per_request?
78
+ expires_in LLMS_MAX_AGE, public: shared
79
+ stale?(etag: [DocsKit::VERSION, body], public: shared)
77
80
  end
78
81
 
79
82
  # A page's Markdown twin, rendered through this controller's view context so
@@ -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