docs-kit 1.1.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: 4fee711dcf42e9b92cf749684ea9b94c07ef9d614cf21746881f2c7402f744ea
4
- data.tar.gz: 76e246db5be838d3b31b8f08a98d033c57e3765d82900245723afc32fd4cad5f
3
+ metadata.gz: feed239eb09deeafd1a9dc6e92e91d192c14f927fa562105139f4b16473d1a60
4
+ data.tar.gz: 6149bf7b9509fb611ce86e9c183a38c027ce77addee69669be41a0ce85fb292d
5
5
  SHA512:
6
- metadata.gz: 1a9cd702122f1e7af103b4279d7e3f4d45e8e662d5838931ac622383ce23609befd8557daddd2b3ac4afda772fc148cf0f5458546a8776b6bae3168408f59065
7
- data.tar.gz: 05ce8bb90585331cdaefa92973c36b67d55b2b6164aae03f55d4679a35d8ce7c4128295e4919a3775123cecaa923355baf1622481741ca3a4312394d5b0dd0a2
6
+ metadata.gz: 5af123f6815e81ccad67dcb7737836c0a2727fb60e963bfc16d6ff0a6540325ca1a67fa2aaaea2ff824ba3b36f2210d8fb4aaab43f097195ec7f020cbfc8c54e
7
+ data.tar.gz: 32e24110ca11cfba1c795d60ea93d6ecb2f1e42af912274ebc816718cd235d93e217bb1ac2a6082b08039eaefe162c6b8287f4ed328c685ab3c669c840e2e359
data/CHANGELOG.md CHANGED
@@ -2,6 +2,122 @@
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
+
47
+ ### Upgrading from 1.1
48
+
49
+ ```bash
50
+ bundle update docs-kit
51
+ bin/rails g docs_kit:install --sync # applies the 1.2 migration, prints what it can't
52
+ bun install && bun run build:css
53
+ bundle exec rspec
54
+ ```
55
+
56
+ - **Ruby 3.3 or newer is required.** docs-kit 1.2 won't install on 3.2. Move
57
+ `.ruby-version` and the Dockerfile's `ARG RUBY_VERSION` first (`--sync` warns
58
+ about either one if it's still below 3.3).
59
+ - **`--sync` raises the toolchain it scaffolded.** In a Dockerfile that still
60
+ carries docs-kit's old `ARG BUN_VERSION=1.3.2`, it bumps that to 1.4.2. In
61
+ `package.json`, it raises docs-kit's old `tailwindcss` / `@tailwindcss/cli
62
+ ^4.1.18` and `daisyui ^5.6.0` floors to `^4.3.3` / `^5.7.47`. Values you
63
+ picked yourself are never rewritten: an older one is printed as a warning,
64
+ and a newer one is left alone. Review the result with `git diff`.
65
+ - **Deploys pick up Node 24 actions and `ubuntu-26.04` automatically** when your
66
+ `deploy-docs.yml` calls `zoolutions/docs-kit/.github/workflows/deploy.yml@main`
67
+ (what `docs-kit new` scaffolds). `--sync` warns about a caller pinned to a tag
68
+ or SHA; move the pin forward.
69
+ - **Untrack `app/assets/stylesheets/tailwind.sources.css`** if it's committed:
70
+ `git rm --cached app/assets/stylesheets/tailwind.sources.css`. The generator
71
+ now gitignores it; `--sync` reminds you when it's still tracked.
72
+
73
+ ### Removed
74
+
75
+ - **Ruby 3.2 support.** `required_ruby_version` is now `>= 3.3` (3.2 reached
76
+ end of life in March 2026), and CI no longer tests it.
77
+
78
+ ### Changed
79
+
80
+ - **daisyui 2.x is allowed.** The dependency is now `>= 1.2, < 3`. 1.2.0 was
81
+ tagged but never reached RubyGems (its release run failed a stale spec), so
82
+ 1.2.1 is the first published 1.2 release.
83
+ - **The reusable deploy workflow runs on Node 24 actions.** `deploy.yml` now
84
+ uses `actions/checkout@v7`, `docker/setup-buildx-action@v4`,
85
+ `docker/login-action@v4`, `docker/build-push-action@v7` and
86
+ `webfactory/ssh-agent@v0.10.0`, clearing the Node 20 deprecation warning for
87
+ every site that calls it. Its jobs run on `ubuntu-26.04` (ahead of the
88
+ `ubuntu-latest` migration).
89
+ - **`docs_kit:install` scaffolds current toolchain floors.** The `package.json`
90
+ stub requires `tailwindcss`/`@tailwindcss/cli` `^4.3.3` and `daisyui`
91
+ `^5.7.47` (a spec keeps them in lockstep with the gem's own docs site), and
92
+ the generated `Dockerfile` installs Bun 1.4.2 (and falls back to Ruby 3.4.11
93
+ when the host Ruby version can't be read).
94
+ - **`docs_kit:install` gitignores the generated `tailwind.sources.css`.**
95
+ `bin/build-css` rewrites it on every build with machine-specific gem paths, so
96
+ a committed copy only churns. `--sync` adds the `.gitignore` entry and warns
97
+ when the file is still tracked. (#72)
98
+ - **`--sync` runs its first release migration** (1.1 → 1.2, above). The
99
+ migration registry no longer ships empty.
100
+
101
+ ### Fixed
102
+
103
+ - **`docs_kit:page` injected the registry line once per group.** In a registry
104
+ laid out as blank-line-separated groups (the layout `docs_kit:install`
105
+ produces), the "page line not followed by a page line" Regexp anchor matched
106
+ the end of *every* group and Thor's `inject_into_file` replaced every match.
107
+ The anchor is now the file's last `page` line as a String, so one run adds
108
+ exactly one line, at the end of the last group.
109
+
110
+ ### Added
111
+
112
+ - **`DocsUI::Code` infers the language from `filename:`.**
113
+ `DocsUI::Code(<<~YAML, filename: "config/deploy.yml")` now highlights as YAML
114
+ instead of Ruby: with no `lexer:`, the lexer is guessed from Rouge's filename
115
+ globs (`*.yml` → yaml, `Dockerfile` → docker, `*.sh` → shell, …). An explicit
116
+ `lexer:` still wins, and a block with neither (or an unguessable filename)
117
+ stays Ruby, so existing output is unchanged.
118
+
119
+ ## [1.1.1] and earlier
120
+
5
121
  ### Fixed
6
122
 
7
123
  - **SEO `og:image` 404.** The og:image tag pointed at the raw config path
data/README.md CHANGED
@@ -102,9 +102,17 @@ A site created before the stamp existed (no comment) is treated as the earliest
102
102
  version, so it gets every migration. Migrations are **warn-only-safe** exactly
103
103
  like the drift report: what a step can't safely automate it prints as a manual
104
104
  checklist (`migration steps to apply by hand:`), never a destructive rewrite of
105
- a line you've edited. There are no migrations to apply yet — the mechanism ships
106
- ahead of the first release that needs one, so the upgrade path is already in
107
- place.
105
+ a line you've edited. A value is only rewritten when it is exactly what an older
106
+ docs-kit generated; anything you picked yourself is reported, never changed.
107
+
108
+ Every release that needs site changes lists them under **"Upgrading from X.Y"**
109
+ at the top of its [CHANGELOG](CHANGELOG.md) entry. That's what `--sync` automates
110
+ and what it leaves to you. Read it before a `bundle update docs-kit` that crosses
111
+ a minor version.
112
+
113
+ | Release | `--sync` does | You do |
114
+ |---------|---------------|--------|
115
+ | 1.2 | Raises the Bun and Tailwind/daisyUI floors that docs-kit generated. Warns about Ruby below 3.3, hand-picked older floors, and a deploy workflow pinned off `@main` | Move to Ruby >= 3.3 before updating. `git rm --cached` a tracked `tailwind.sources.css` |
108
116
 
109
117
  ### One-time cleanup for sites created before these landed
110
118
 
@@ -118,6 +126,7 @@ generators:
118
126
  | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. |
119
127
  | Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. |
120
128
  | `Dockerfile` stamped by an older docs-kit (`# docs-kit Dockerfile vX.Y.Z`) | docs-kit ships an optimized, multi-stage Dockerfile; a stale copy misses image-size wins | Diff yours against the current template (`lib/generators/docs_kit/install/templates/Dockerfile.tt` in the gem), adopt the changes or replace it. See [Upgrading your Dockerfile](#upgrading-your-dockerfile). |
129
+ | `app/assets/stylesheets/tailwind.sources.css` committed to git | `bin/build-css` regenerates it on every build with machine-specific absolute gem paths — the committed copy churns per machine/Ruby and no build consumes it. The generator adds the `.gitignore` entry, but gitignoring doesn't untrack an already-committed copy. | `git rm --cached app/assets/stylesheets/tailwind.sources.css` and commit. |
121
130
 
122
131
  ### Upgrading your Dockerfile
123
132
 
@@ -184,6 +193,20 @@ DocsKit.configure do |c|
184
193
  end
185
194
  ```
186
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
+
187
210
  The nav is **derived from the registry**, so you never hand-write it. Each
188
211
  registry maps a heading to its authored pages (`Doc.nav_items`); a page that
189
212
  isn't written yet is skipped, so there are no dead links. Register a page with
@@ -422,6 +445,37 @@ end
422
445
  `DocsUI::Page` includes the kit, so inside `#content` you call the components
423
446
  directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`.
424
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
+
425
479
  ### The authoring convention
426
480
 
427
481
  One rule covers the whole kit: **the primary argument is positional; modifiers
@@ -433,6 +487,10 @@ DocsUI::Section("Add the gem", id: "add", description: …) # title positional
433
487
  DocsUI::Code(source, lexer: :ruby, filename: "Gemfile") # source positional
434
488
  ```
435
489
 
490
+ `DocsUI::Code` infers the language from `filename:` (`config/deploy.yml` →
491
+ YAML, `Dockerfile` → docker, `bin/setup.sh` → shell); `lexer:` overrides the
492
+ guess, and with neither the block is highlighted as Ruby.
493
+
436
494
  For the two wrappers that take **no** positional argument — prose and a
437
495
  multi-language example — `DocsUI::Page` gives you lowercase helpers so a block
438
496
  needs no parens:
@@ -930,7 +988,7 @@ jobs:
930
988
  ```yaml
931
989
  service: <repo>
932
990
  image: zoolutions/<repo>
933
- minimum_version: 4.0.0 # dash 4: dash-proxy identity + in-place host migration
991
+ minimum_version: 4.0.7 # dash 4.0.7: doctor stale-proxy recovery + registry credential fail-fast
934
992
  retain_containers: 2
935
993
  error_pages_path: public # 502/503/504.html served during a deploy gap
936
994
  registry: { server: ghcr.io, username: mhenrixon, password: [DASH_REGISTRY_PASSWORD] }
@@ -1038,6 +1096,42 @@ server round-trip:
1038
1096
  of three placements, auto-hiding on short pages. No server-side knowledge of
1039
1097
  the headings, no per-page wiring.
1040
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
+
1041
1135
  ### On this page (auto-TOC)
1042
1136
 
1043
1137
  `DocsUI::Page` renders it automatically. The placement is a strategy — set the
@@ -1082,17 +1176,21 @@ application.register("reactive", ReactiveController)
1082
1176
 
1083
1177
  ## Releasing (maintainers)
1084
1178
 
1085
- Cut a release with the version-bumping Rake task — never `gem push` by hand:
1179
+ Cut a release with `bin/release`. Never `gem push` by hand:
1086
1180
 
1087
1181
  ```bash
1088
- rake release[1.0.0] # bump → build-verify → commit → push → GitHub Release
1089
- rake release[1.1.0.rc1] # a pre-release (auto-flagged --prerelease)
1090
- rake release[1.0.0,force] # delete + re-create an existing tag/release
1182
+ bin/release list # last releases + what each bump would give
1183
+ bin/release --dry-run # the version it would cut + the changes since the last tag
1184
+ bin/release # patch bump (minor / major / 1.3.0.rc1 for others)
1185
+ bin/release 1.2.0 --force # delete + re-create an existing tag/release
1091
1186
  ```
1092
1187
 
1093
- The task (on `main`, clean tree only) bumps `lib/docs_kit/version.rb`, updates the
1094
- lockfiles (incl. `docs/Gemfile.lock`), verifies `gem build --strict`, commits,
1095
- pushes, and creates the GitHub Release. Publishing the tag fires
1188
+ `bin/release` checks you're on a clean, up-to-date `main`, confirms, and hands off
1189
+ to `rake release[X.Y.Z]` (`rakelib/release.rake`). That task bumps
1190
+ `lib/docs_kit/version.rb` and the `docs-kit` pin in `docs/Gemfile.lock` (in place,
1191
+ no re-resolve), verifies `gem build --strict`, commits, pushes, and creates the
1192
+ GitHub Release. Both files belong to the zoolutions release kit that every gem
1193
+ shares; see [RELEASE_KIT.md](RELEASE_KIT.md) to adopt or upgrade it elsewhere. Publishing the tag fires
1096
1194
  `.github/workflows/release.yml`, which runs the suite, rebuilds + content-checks
1097
1195
  the gem, signs it with Sigstore, and pushes to RubyGems over **OIDC trusted
1098
1196
  publishing** (no API token stored anywhere).
@@ -9,12 +9,16 @@ module DocsUI
9
9
  # stylesheet asset is required.
10
10
  #
11
11
  # render DocsUI::Code.new(ruby_source) # ruby, no title
12
- # render DocsUI::Code.new(py, lexer: :python, filename: "a.py") # any language
12
+ # render DocsUI::Code.new(yaml, filename: "config/deploy.yml") # yaml, inferred
13
+ # render DocsUI::Code.new(py, lexer: :python, filename: "a.py") # explicit wins
13
14
  #
14
- # Any language Rouge knows (~200 lexers) works by its name or alias — python,
15
- # go, rust, elixir, kotlin, swift, json, dockerfile, ... — no allowlist. Add
16
- # friendly lexer aliases via DocsKit.configure (code_lexer_aliases). An unknown
17
- # language falls back to plaintext (never raises). (Tab labels are a
15
+ # The language resolves in order: an explicit `lexer:`; a guess from the
16
+ # `filename:` (Rouge's own filename globs — *.yml → yaml, Dockerfile → docker,
17
+ # *.sh → shell, …); else ruby. Any language Rouge knows (~200 lexers) works by
18
+ # its name or alias — python, go, rust, elixir, kotlin, swift, json,
19
+ # dockerfile, ... — no allowlist. Add friendly lexer aliases via
20
+ # DocsKit.configure (code_lexer_aliases). An unknown language falls back to
21
+ # plaintext (never raises). (Tab labels are a
18
22
  # DocsUI::Example concern — set via code_language_labels, not here; Code has no
19
23
  # label, only a filename.)
20
24
  class Code < Phlex::HTML
@@ -22,7 +26,7 @@ module DocsUI
22
26
 
23
27
  FORMATTER = Rouge::Formatters::HTML.new
24
28
 
25
- def initialize(source, lexer: :ruby, filename: nil)
29
+ def initialize(source, lexer: nil, filename: nil)
26
30
  @source = source.to_s.strip
27
31
  @lexer = lexer
28
32
  @filename = filename
@@ -66,11 +70,26 @@ module DocsUI
66
70
  end
67
71
  end
68
72
 
69
- # Resolve @lexer to a Rouge lexer instance. Order: an explicit Rouge::Lexer
70
- # class/instance passed through; a configured friendly alias; Rouge's own
71
- # registry (name/alias); then the configured fallback (plaintext).
73
+ # Resolve the lexer to a Rouge lexer instance. Order: an explicit Rouge::Lexer
74
+ # class/instance passed through; an explicit name via a configured friendly
75
+ # alias → Rouge's own registry → the configured fallback (plaintext); with no
76
+ # `lexer:` given, a guess from the filename; else the ruby default.
72
77
  def lexer
73
- explicit_lexer || (find_lexer(@lexer.to_s) || Rouge::Lexers::PlainText).new
78
+ explicit_lexer ||
79
+ (@lexer && (find_lexer(@lexer.to_s) || Rouge::Lexers::PlainText).new) ||
80
+ guessed_lexer ||
81
+ Rouge::Lexers::Ruby.new
82
+ end
83
+
84
+ # A lexer inferred from @filename via Rouge's declared filename globs, or nil
85
+ # when there is no filename, nothing matches, or the match is ambiguous.
86
+ # (Lexer.guesses, not Lexer.guess: the latter answers PlainText for a
87
+ # no-match, which is indistinguishable from a real guess.)
88
+ def guessed_lexer
89
+ return nil if @filename.nil?
90
+
91
+ guesses = Rouge::Lexer.guesses(filename: @filename.to_s)
92
+ guesses.size == 1 ? guesses.first.new : nil
74
93
  end
75
94
 
76
95
  # A Rouge::Lexer instance passed directly (class or instance), else nil.
@@ -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.