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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6340fb386df08272f63e525ca81d43baaa1f440a82287e4a34e9a392b220a530
|
|
4
|
+
data.tar.gz: 3740655d5194976ea25a3ed3cdd6a633bb593c03c288a1c3a069d16d7c522cfe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.** `` 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
|
+
``. (#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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
#  → <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
|
-
|
|
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: `` → 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.
|
|
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.
|
|
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)
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
200
|
+
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand_text)
|
|
187
201
|
else
|
|
188
|
-
plain config.
|
|
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.
|
|
62
|
+
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand_text)
|
|
63
63
|
else
|
|
64
|
-
plain config.
|
|
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
|
-
|
|
76
|
-
|
|
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
|
data/lib/docs_kit/api_client.rb
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|