docs-kit 1.2.1 → 1.3.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: feed239eb09deeafd1a9dc6e92e91d192c14f927fa562105139f4b16473d1a60
4
+ data.tar.gz: 6149bf7b9509fb611ce86e9c183a38c027ce77addee69669be41a0ce85fb292d
5
5
  SHA512:
6
- metadata.gz: a2b905a1b80c5e156ea7e652ba45cb4d2ec54c91ad040754112cdd481644b7698fd5356459faf06f46ac140c0c6562b8989039b69585b3f257a3b5629cfe0d49
7
- data.tar.gz: 9837ae7885fb3c6ad358b585853aa7d0dee63b9807c574f15e8543f70ae5de6ecea4f5a2755391588a111fa5d05fcf2456a76b9040a648b86bcf3cf767700171
6
+ metadata.gz: 5af123f6815e81ccad67dcb7737836c0a2727fb60e963bfc16d6ff0a6540325ca1a67fa2aaaea2ff824ba3b36f2210d8fb4aaab43f097195ec7f020cbfc8c54e
7
+ data.tar.gz: 32e24110ca11cfba1c795d60ea93d6ecb2f1e42af912274ebc816718cd235d93e217bb1ac2a6082b08039eaefe162c6b8287f4ed328c685ab3c669c840e2e359
data/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.3.0] — 2026-10-13
6
+
7
+ ### Fixed
8
+
9
+ - **Markdown links drop a script-bearing href.** `[text](javascript:…)`,
10
+ `data:`, `vbscript:` and any other scheme outside http(s), `mailto:` and
11
+ `tel:` now render the link text with no `<a>` (Phlex already refused
12
+ `javascript:`; `data:` and the rest slipped through). Scheme-less hrefs are
13
+ unchanged. The same guard `DocsUI::Markdown` images have.
14
+
15
+ ### Added
16
+
17
+ - **A per-request `brand` and `title_suffix`.** Both accept a zero-arity
18
+ callable (`c.brand = -> { Current.tenant_name }`), evaluated on every render:
19
+ the topbar, sidebar header, `<title>`, `og:site_name`, llms.txt and the MCP
20
+ server name show that request's value. A callable brand needs an explicit
21
+ `c.nav_storage_key`; `DocsKit.configure` raises without one. New readers
22
+ `Configuration#brand_text` / `#title_suffix_text`; a String brand renders
23
+ exactly as before. (#92)
24
+ - **Markdown images render.** `![alt](path "title")` in an `md` island now
25
+ renders a lazy-loaded `<img>` (it rendered nothing before). In a Rails view
26
+ a relative path resolves through the site's asset pipeline (`image_path`;
27
+ off a request it is kept as written); an absolute,
28
+ protocol-relative or root-relative URL is kept as written; the alt is the
29
+ description's plain text. A `javascript:`, `data:` or other non-http(s)
30
+ scheme renders no image. The page's `.md` twin exports it back to
31
+ `![alt](src)`. (#94)
32
+ - **A dedicated docs import map.** `c.importmap_entry_point` (default
33
+ `"application"`) and `c.importmap` (an `Importmap::Map` or a callable
34
+ returning one, resolved per render) choose what the Shell's
35
+ `javascript_importmap_tags` loads, and `config.docs_kit.auto_pin = false` (in
36
+ `config/application.rb`) keeps the gem's `docs-nav` pin out of the host's
37
+ shared map. Unset, the head is unchanged. (#93)
38
+ - **`DocsUI::Page` title, description and eyebrow per instance.** The page now
39
+ reads `#title`, `#description` and `#eyebrow`, which default to the class
40
+ macros, so one page class can render many records (`def title = @widget.name`)
41
+ without an anonymous subclass per render. The `eyebrow`, `description` and
42
+ `on_page` macros inherit from a parent page when unset. Pages using only the
43
+ class macros render byte-identically. (#95)
44
+
45
+ ## [1.2.1] — 2026-09-30 (with 1.2.0, same day)
46
+
5
47
  ### Upgrading from 1.1
6
48
 
7
49
  ```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
@@ -431,6 +445,37 @@ end
431
445
  `DocsUI::Page` includes the kit, so inside `#content` you call the components
432
446
  directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`.
433
447
 
448
+ ### One page class, many records
449
+
450
+ `title`, `eyebrow` and `description` are class macros by default, but the page
451
+ reads them through **instance** methods — override those when one page class
452
+ renders many records (one reference page per item):
453
+
454
+ ```ruby
455
+ class Views::Docs::Widget < DocsUI::Page
456
+ eyebrow "Widgets"
457
+
458
+ def initialize(widget:) = @widget = widget
459
+ def title = @widget.name # masthead <h1> and <title>
460
+ def description = @widget.summary # meta/OG description (default: class macro, then #lead)
461
+ def content = md(@widget.notes)
462
+ end
463
+
464
+ # controller
465
+ def show = render_page(Views::Docs::Widget.new(widget: Widget.find(params[:id])))
466
+ ```
467
+
468
+ `eyebrow`, `description` and `on_page` inherit: a subclass that leaves one unset
469
+ uses its parent page's. `title` does not — each page names itself.
470
+
471
+ Such a page usually lives on its own route, outside the registry. If that route
472
+ has no `(.:format)` segment, the masthead's **Markdown** action (which links
473
+ `request.path + ".md"`) would 404 — hide it for that page:
474
+
475
+ ```ruby
476
+ def markdown_action? = false
477
+ ```
478
+
434
479
  ### The authoring convention
435
480
 
436
481
  One rule covers the whole kit: **the primary argument is positional; modifiers
@@ -1051,6 +1096,42 @@ server round-trip:
1051
1096
  of three placements, auto-hiding on short pages. No server-side knowledge of
1052
1097
  the headings, no per-page wiring.
1053
1098
 
1099
+ ### A dedicated docs import map
1100
+
1101
+ By default the Shell loads the host's `application` entry point from
1102
+ `Rails.application.importmap`, and the engine adds the `docs-nav` pin to that
1103
+ map. A host with several import maps can give its docs their own entry point
1104
+ and map, and keep the docs controller out of every other page's preloads:
1105
+
1106
+ ```ruby
1107
+ # config/application.rb — skip the engine's auto-pin into the shared map
1108
+ config.docs_kit.auto_pin = false
1109
+
1110
+ # config/initializers/docs_kit.rb — build the second map (importmap-rails has no
1111
+ # registry for extra maps; the host owns it) and point the docs chrome at it
1112
+ DOCS_IMPORTMAP = Importmap::Map.new.draw(Rails.root.join("config/importmaps/docs.rb"))
1113
+
1114
+ DocsKit.configure do |c|
1115
+ c.importmap_entry_point = "docs" # default "application"
1116
+ c.importmap = -> { DOCS_IMPORTMAP } # default nil → Rails.application.importmap
1117
+ end
1118
+ ```
1119
+
1120
+ ```ruby
1121
+ # config/importmaps/docs.rb — drawn into the docs Importmap::Map above
1122
+ pin "docs"
1123
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
1124
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
1125
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
1126
+ pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
1127
+ under: "docs_kit/controllers", to: "docs_kit/controllers"
1128
+ ```
1129
+
1130
+ `c.importmap` takes an `Importmap::Map` or a callable returning one; a callable
1131
+ is resolved on every render, so a map rebuilt on code reload is picked up. The
1132
+ `docs` entry point registers the controllers with
1133
+ `eagerLoadControllersFrom("docs_kit/controllers", application)`.
1134
+
1054
1135
  ### On this page (auto-TOC)
1055
1136
 
1056
1137
  `DocsUI::Page` renders it automatically. The placement is a strategy — set the
@@ -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).
@@ -31,35 +41,42 @@ module DocsUI
31
41
  @title
32
42
  end
33
43
 
44
+ # Inherited: a subclass that sets no eyebrow shows its parent page's.
34
45
  def eyebrow(value = nil)
35
46
  @eyebrow = value if value
36
- @eyebrow
47
+ @eyebrow || (superclass.eyebrow if parent_page?)
37
48
  end
38
49
 
39
50
  # The per-page SEO/social description → DocsUI::MetaTags (via Shell). Set it
40
51
  # for a hand-tuned description; when unset, Page derives one from #lead, so
41
52
  # an existing page gets a sensible description for free. nil (no
42
53
  # description, no lead) falls back to config.seo.description in MetaTags.
54
+ # Inherited from a parent page when unset.
43
55
  def description(value = nil)
44
56
  @description = value if value
45
- @description
57
+ @description || (superclass.description if parent_page?)
46
58
  end
47
59
 
48
60
  # The "On this page" auto-TOC placement for this page. Defaults to the
49
61
  # configured DocsKit.configuration.on_page_default; set false to opt out,
50
- # or :panel/:toggle/:sidebar to override per page.
62
+ # or :panel/:toggle/:sidebar to override per page. Inherited from a parent
63
+ # page when unset.
51
64
  def on_page(value = :__unset__)
52
65
  @on_page = value unless value == :__unset__
53
- defined?(@on_page) ? @on_page : DocsKit.configuration.on_page_default
66
+ return @on_page if defined?(@on_page)
67
+
68
+ parent_page? ? superclass.on_page : DocsKit.configuration.on_page_default
54
69
  end
70
+
71
+ private
72
+
73
+ # Whether the superclass is a page too (true below DocsUI::Page itself), so
74
+ # an unset macro falls back to it.
75
+ def parent_page? = superclass <= DocsUI::Page
55
76
  end
56
77
 
57
78
  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
79
+ render DocsUI::Shell.new(title:, description:, on_page: self.class.on_page) do
63
80
  # data-md-skip drops this nav from the Markdown export — it's chrome, not
64
81
  # page content (DocsKit::MarkdownExport strips [data-md-skip]). The
65
82
  # "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
@@ -69,7 +86,7 @@ module DocsUI
69
86
  render DocsUI::MarkdownAction.new(request.path) if markdown_action?
70
87
  end
71
88
 
72
- render DocsUI::Header.new(self.class.title, eyebrow: self.class.eyebrow) do
89
+ render DocsUI::Header.new(title, eyebrow:) do
73
90
  plain lead if lead
74
91
  end
75
92
 
@@ -78,9 +95,19 @@ module DocsUI
78
95
  end
79
96
 
80
97
  # Whether to show the "Markdown" masthead action — the config knob
81
- # (DocsKit.configuration.page_markdown_action, default true).
98
+ # (DocsKit.configuration.page_markdown_action, default true). The action links
99
+ # request.path + ".md"; override to false on a page whose route has no .md twin.
82
100
  def markdown_action? = DocsKit.configuration.page_markdown_action
83
101
 
102
+ # The page title (masthead <h1> and <title>), eyebrow and SEO description.
103
+ # Each defaults to its class macro; override per instance when one page class
104
+ # renders many records. #title shadows Phlex's <title> element method, on
105
+ # pages only — the document <title> is DocsUI::Shell's, never a page body's.
106
+ def title = self.class.title
107
+ def eyebrow = self.class.eyebrow
108
+ # Falls back to #lead, then (in DocsUI::MetaTags) to config.seo.description.
109
+ def description = self.class.description || lead
110
+
84
111
  # The lowercase authoring helpers md/prose/example come from DocsUI::PageHelpers
85
112
  # (included above) — the parens-free path that never hits the constant-reference
86
113
  # 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
@@ -78,6 +78,17 @@ module DocsKit
78
78
  # stylesheets (e.g. a separate rouge theme) lists them here.
79
79
  attr_accessor :stylesheets
80
80
 
81
+ # The import map entry point the Shell loads. Defaults to "application"
82
+ # (javascript_importmap_tags' own default). A host with a dedicated docs
83
+ # import map names its entry point here, e.g. "docs".
84
+ attr_accessor :importmap_entry_point
85
+
86
+ # The import map the Shell renders: nil (the default → the host's
87
+ # Rails.application.importmap), an Importmap::Map, or a zero-arity callable
88
+ # returning one, resolved per render so a map rebuilt on code reload is
89
+ # picked up. Read via #resolved_importmap.
90
+ attr_accessor :importmap
91
+
81
92
  # The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
82
93
  # This is the BASE (light) theme, emitted un-scoped so it applies to every
83
94
  # theme unless a dark override wins (see #code_theme_dark).
@@ -307,6 +318,8 @@ module DocsKit
307
318
  @nav_registries = {}
308
319
  @version_badge = nil
309
320
  @stylesheets = %w[application]
321
+ @importmap_entry_point = "application"
322
+ @importmap = nil
310
323
  @code_theme = "Rouge::Themes::Monokai"
311
324
  @code_theme_dark = nil
312
325
  @dark_themes = DEFAULT_DARK_THEMES
@@ -544,14 +557,54 @@ module DocsKit
544
557
 
545
558
  public
546
559
 
560
+ # The localStorage namespace. A callable brand can't be slugged: the key must
561
+ # be stable per browser, not per request, so a callable brand needs an
562
+ # explicit c.nav_storage_key (checked again by DocsKit.configure).
547
563
  def nav_storage_key
548
- @nav_storage_key || @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
564
+ return @nav_storage_key if @nav_storage_key
565
+ if @brand.respond_to?(:call)
566
+ raise DocsKit::Error,
567
+ "c.brand is a callable, so set c.nav_storage_key to a stable key " \
568
+ "(the sidebar/theme localStorage namespace must not change per request)"
569
+ end
570
+
571
+ @brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
549
572
  end
550
573
 
574
+ # The raw configured suffix (or the raw brand when unset). The chrome reads
575
+ # #title_suffix_text, which resolves a callable.
551
576
  def title_suffix
552
577
  @title_suffix || @brand
553
578
  end
554
579
 
580
+ # The rendered brand. A String is returned as-is; a zero-arity callable is
581
+ # invoked on every read (never memoize it — the point is a per-request
582
+ # value, e.g. -> { Current.tenant_name }).
583
+ def brand_text
584
+ resolve_text(@brand).to_s
585
+ end
586
+
587
+ # True when the brand is a callable, i.e. anything that embeds the brand
588
+ # (the chrome, llms.txt) may differ per request and must not be stored in a
589
+ # shared cache.
590
+ def brand_per_request?
591
+ @brand.respond_to?(:call)
592
+ end
593
+
594
+ # The rendered <title>/og:title suffix: #title_suffix resolved like
595
+ # #brand_text, falling back to the resolved brand. nil only when both are nil,
596
+ # so the title join drops it instead of leaving a dangling separator.
597
+ def title_suffix_text
598
+ resolve_text(@title_suffix.nil? ? @brand : @title_suffix)
599
+ end
600
+
601
+ # Raises at configure time for a config the chrome can't render (today: a
602
+ # callable brand without a stable nav_storage_key). Called by DocsKit.configure.
603
+ def validate!
604
+ nav_storage_key
605
+ self
606
+ end
607
+
555
608
  # Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
556
609
  # optional `mcp` gem is loadable — the same "toggle AND capability" shape as
557
610
  # #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
@@ -609,6 +662,12 @@ module DocsKit
609
662
  @version_badge.to_s
610
663
  end
611
664
 
665
+ # The import map the Shell renders, or nil for the host's default map. A
666
+ # callable is invoked on every read (once per render).
667
+ def resolved_importmap
668
+ @importmap.respond_to?(:call) ? @importmap.call : @importmap
669
+ end
670
+
612
671
  # The default Rouge theme both #code_theme_class and #code_theme_dark_class
613
672
  # fall back to when a configured theme name can't be resolved — so a typo'd
614
673
  # theme name degrades gracefully instead of raising on every code block.
@@ -652,5 +711,12 @@ module DocsKit
652
711
  rescue NameError
653
712
  nil
654
713
  end
714
+
715
+ # A String-or-callable knob's value for this render: a callable is invoked
716
+ # and stringified, nil stays nil, anything else is stringified.
717
+ def resolve_text(value)
718
+ value = value.call if value.respond_to?(:call)
719
+ value&.to_s
720
+ end
655
721
  end
656
722
  end
@@ -36,10 +36,17 @@ module DocsKit
36
36
  app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
37
37
  end
38
38
 
39
+ # Host-facing engine options. `config.docs_kit.auto_pin = false` (in
40
+ # config/application.rb) keeps the gem's pin out of the host's shared import
41
+ # map, for a host that pins `docs_kit/controllers` in a dedicated docs map.
42
+ config.docs_kit = ActiveSupport::OrderedOptions.new
43
+ config.docs_kit.auto_pin = true
44
+
39
45
  # Auto-pin the controller for importmap-rails consumers, so a host app gets
40
46
  # `docs_kit/controllers/docs_nav_controller` with no manual pin.
41
47
  initializer "docs_kit.importmap", before: "importmap" do |app|
42
48
  next unless app.config.respond_to?(:importmap)
49
+ next unless app.config.docs_kit.auto_pin
43
50
 
44
51
  importmap = app.config.importmap
45
52
  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(" ")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -25,7 +25,7 @@ module DocsKit
25
25
 
26
26
  def configure
27
27
  yield(configuration)
28
- configuration
28
+ configuration.validate!
29
29
  end
30
30
 
31
31
  def reset_configuration!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docs-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -179,6 +179,7 @@ files:
179
179
  - app/components/docs_ui/logo.rb
180
180
  - app/components/docs_ui/markdown.rb
181
181
  - app/components/docs_ui/markdown_action.rb
182
+ - app/components/docs_ui/markdown_images.rb
182
183
  - app/components/docs_ui/meta_tags.rb
183
184
  - app/components/docs_ui/on_this_page.rb
184
185
  - app/components/docs_ui/open_api_operation.rb