docs-kit 1.0.7 → 1.0.8

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: fa41c41824768d1db2a5ad5a7ba9b983b0f81032c8b5a3f80277e2611e203449
4
- data.tar.gz: 60cd31b54d83c9c6920ff48d27ec177f81b7a240fdf513e65f9617837cc6c738
3
+ metadata.gz: 78cf5f2d5a4636b8b0c603f3d3b238aeb68c100fd0133edb16d52ce56ade4c39
4
+ data.tar.gz: 50acc90365594b546ba40887c976eb6f7c6e1eca7389b834c6793cd0490bbfb9
5
5
  SHA512:
6
- metadata.gz: e23f649705ac6216e4961c389eab64965961023123937364e4915b608264bf994ec0885c1958929884449d4eb4f6d9202ed6a5fa7fb9dd373c6a2202e8850ea1
7
- data.tar.gz: 0a047f0b8b45fdce21b9121b13461a5c003ec307586ef4b49bbb3ead0c4d79f44db00a243eda7792fc5be173f3b135195efad4212360527cb2ef5e143eafd5e5
6
+ metadata.gz: b650c98a593eda398bafde41a07ed5ad01325d5392b113a8cfebedc06bb71519e139f846b8e6e259c106c8b23a64a2af4c9a0ab5ab1efd1b2b524764be72fa07
7
+ data.tar.gz: 29c352545d1ffcd4ba93461b2df5582663b79d86db835866fc6b2d543018d5d2e0034e43f24f2f67d0ebd953b89e65788a276484901c71b693fe95040668b8ad
data/README.md CHANGED
@@ -166,6 +166,10 @@ DocsKit.configure do |c|
166
166
  # once in the topbar, right after the brand. Unset (default) renders nothing.
167
167
  c.app_link = { href: "/", label: "Back to the app" }
168
168
 
169
+ # Your own mark in the topbar + sidebar header instead of the text brand.
170
+ c.brand_logo = { paths: ["M4 2h9l5 5…Z"], viewbox: "0 0 81 45" }
171
+ c.topbar_brand = :mobile_only # drop the desktop duplicate (default :always)
172
+
169
173
  # Repo/social links in the topbar (next to the theme switcher).
170
174
  c.topbar_links = [
171
175
  { href: "https://github.com/you/phlex-reactive", label: "GitHub", icon: :github },
@@ -185,6 +189,12 @@ registry maps a heading to its authored pages (`Doc.nav_items`); a page that
185
189
  isn't written yet is skipped, so there are no dead links. Register a page with
186
190
  one line (see [Add a page](#add-a-page)) and it appears in the sidebar.
187
191
 
192
+ The registry's page groups ("Getting started", "REST API", …) are the top level
193
+ of the rendered menu — each an open, collapsible section. The heading above them
194
+ only appears when you register **several** headings, and then as a static label
195
+ (no fold): a site with one registry gets no redundant "Documentation" level, and
196
+ nothing in the sidebar is indented deeper than group → page.
197
+
188
198
  ### The two homes, the brand link, and dark code themes
189
199
 
190
200
  These knobs cover what sites used to shim by subclassing `DocsUI::Shell` or
@@ -194,6 +204,8 @@ overriding route helpers:
194
204
  |------|---------|--------------|
195
205
  | `c.brand_href` | `"/"` | The **docs home** — the href of the topbar brand, the sidebar brand, and each page's "← Docs home" masthead link. Set it (e.g. `"/docs"`) when the docs live under a subpath, instead of subclassing `Shell` or overriding `root_path`. |
196
206
  | `c.app_link` | `nil` | The **app home** — an opt-in `{ href:, label: }` link back to the application hosting the docs, rendered once in the topbar right after the brand (e.g. `{ href: "/", label: "Back to the app" }`). Unset renders nothing, so a standalone docs site is unchanged. External hrefs open in a new tab with `rel=noopener`. |
207
+ | `c.brand_logo` | `nil` | Your **brand mark**, rendered inside the brand anchor of BOTH the topbar and the sidebar header in place of the text `c.brand` (which stays the accessible name / `aria-label` fallback). Unset renders the text brand, byte-identical to before. Takes exactly one of five forms — see [The brand mark](#the-brand-mark) below. |
208
+ | `c.topbar_brand` | `:always` | Where the topbar renders the brand. At the drawer-pinned breakpoint (`lg:`) the sidebar brand is always visible, so the topbar copy is a duplicate — `:mobile_only` hides it there (`lg:hidden`). The default keeps today's markup verbatim. |
197
209
  | `c.code_theme_dark` | `nil` | A second Rouge theme for **dark** daisyUI themes. `nil` keeps the single-theme behavior (fully backwards compatible). When set, `DocsUI::Code` also emits this theme's CSS scoped under `[data-theme=X] .code-highlight` for each shipped dark theme, so code blocks stay readable when the switcher flips to a dark theme. |
198
210
  | `c.dark_themes` | daisyUI's built-in dark theme names | Which theme names count as dark for `code_theme_dark`. Intersected with `c.themes` at render time, so only shipped themes emit CSS. Override to name custom dark themes (e.g. `%w[zazu-dark]`). |
199
211
 
@@ -203,6 +215,33 @@ blocks with no JavaScript and no flash. The Rouge CSS is inlined per block
203
215
  (not part of the Tailwind build), so the [theme-sync invariant](#css--the-canonical-build)
204
216
  is unaffected — a `code_theme_dark` doesn't need a CSS rebuild.
205
217
 
218
+ ### The brand mark
219
+
220
+ `c.brand_logo` replaces the text brand in the shell chrome (topbar + sidebar
221
+ header) with your own mark — no more copying private `Shell`/`Sidebar` methods
222
+ that go stale on upgrades. It takes **exactly one** of five forms (mixing forms,
223
+ or a malformed value, raises at config time):
224
+
225
+ ```ruby
226
+ c.brand_logo = { svg: "M4 2h9l5 5…Z", viewbox: "0 0 22 24", label: "Acme" } # one path-d
227
+ c.brand_logo = { paths: ["M4 2…Z", "M9 7…Z"], viewbox: "0 0 81 45" } # multi-path wordmark
228
+ c.brand_logo = { markup: File.read("mark.svg") } # raw <svg> markup, embedded verbatim
229
+ c.brand_logo = { file: "app/assets/images/mark.svg" } # a .svg file, embedded inline
230
+ c.brand_logo = { src: "logo.png", alt: "Acme" } # an <img> from the asset pipeline
231
+ ```
232
+
233
+ - The `svg:`/`paths:` forms render with `fill="currentColor"`, so the mark
234
+ **recolors with the active daisyUI theme**. `markup:`/`file:` are embedded
235
+ as-authored — use `currentColor` inside them to stay theme-adaptive. An
236
+ `src:` `<img>` **cannot** inherit `currentColor` and won't adapt.
237
+ - `markup:`/`file:` embed your own SVG verbatim (they are your site's content,
238
+ same trust domain as your views); both are shape-checked to be an `<svg>`
239
+ element at config time, and a `file:` re-reads on change in development.
240
+ - `label:`/`alt:` name the mark for assistive tech; unset, the mark falls back
241
+ to `c.brand`. The sidebar keeps the `version_badge` next to the mark.
242
+ - Sizing is fixed per surface (topbar `h-6`, sidebar `h-7`, landing hero `h-9`).
243
+ - `c.landing.logo` (the landing-hero mark) accepts the same five forms.
244
+
206
245
  ### Topbar links (repo & social)
207
246
 
208
247
  Point readers at your source repo, chat, or socials from the topbar (next to the
@@ -31,9 +31,6 @@ module DocsUI
31
31
  # walks the same #docs-content region Shell stamps.
32
32
  class Landing < Phlex::HTML
33
33
  include Phlex::Rails::Helpers::Request
34
- # For the image-form hero logo (c.landing.logo = { src: … }): resolve the asset
35
- # path through the site's pipeline to its digested /assets URL.
36
- include Phlex::Rails::Helpers::ImageURL
37
34
 
38
35
  def view_template
39
36
  render DocsUI::Shell.new(title: landing.eyebrow || config.brand) do
@@ -63,20 +60,12 @@ module DocsUI
63
60
  end
64
61
  end
65
62
 
66
- # The brand mark — an inline single-path SVG (currentColor, theme-adaptive) or
67
- # an <img>. Rendered above the eyebrow, like a product wordmark.
63
+ # The brand mark — the shared DocsUI::Logo renderer (any DocsKit::BrandLogo
64
+ # form) at hero size. Rendered above the eyebrow, like a product wordmark.
68
65
  def logo
69
66
  return unless (mark = landing.hero_logo)
70
67
 
71
- if mark.inline?
72
- svg(viewbox: mark.viewbox, class: "h-9 w-auto text-primary", fill: "currentColor",
73
- role: "img", aria_label: mark.label) do |s|
74
- s.title { mark.label } if mark.label
75
- s.path(d: mark.svg)
76
- end
77
- else
78
- img(src: image_url(mark.src), alt: mark.alt.to_s, class: "h-9 w-auto")
79
- end
68
+ render DocsUI::Logo.new(mark, class: "h-9 w-auto text-primary")
80
69
  end
81
70
 
82
71
  def eyebrow
@@ -162,15 +151,17 @@ module DocsUI
162
151
  div(class: "mt-16") do
163
152
  h2(class: "text-sm font-semibold uppercase tracking-wide text-base-content/50") { "Documentation" }
164
153
  div(class: "mt-6 grid gap-8 sm:grid-cols-2") do
165
- groups.each { |heading, items| doc_index_group(heading, items) }
154
+ # A lone heading would just repeat the h2 above (often literally
155
+ # "Documentation"), so only label the columns when there are several.
156
+ groups.each { |heading, items| doc_index_group(heading, items, labeled: groups.size > 1) }
166
157
  end
167
158
  end
168
159
  end
169
160
 
170
- def doc_index_group(heading, items)
161
+ def doc_index_group(heading, items, labeled:)
171
162
  div do
172
- h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading }
173
- ul(class: "mt-3 flex flex-col gap-2") do
163
+ h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading } if labeled
164
+ ul(class: labeled ? "mt-3 flex flex-col gap-2" : "flex flex-col gap-2") do
174
165
  items.each { |item| li { a(href: item.href, class: "link link-hover text-sm") { item.label } } }
175
166
  end
176
167
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Renders a DocsKit::BrandLogo (config.brand_logo / config.landing.logo) as the
5
+ # brand mark — an inline currentColor <svg> (theme-adaptive), a verbatim
6
+ # site-authored <svg> embed, or an <img>.
7
+ #
8
+ # render DocsUI::Logo.new(config.brand_logo, class: "h-6 w-auto", label: config.brand)
9
+ #
10
+ # label: is the accessible-name fallback when the logo itself carries none
11
+ # (callers pass config.brand, so the mark always announces the site).
12
+ #
13
+ # The svg:/paths: forms emit each path-d as an ordinary Phlex-escaped
14
+ # attribute — config free text never bypasses the escape. The markup:/file:
15
+ # forms DO embed markup verbatim via raw(safe(...)): that content is the
16
+ # site's own deliberately-configured SVG (its initializer / its asset file —
17
+ # the same trust domain as the site's own views, which can already render
18
+ # anything), not third-party free text, and DocsKit::BrandLogo shape-checks it
19
+ # to be an <svg> element at config time. That authored-by-the-trusting-site
20
+ # rationale is the same carve-out DocsUI::BrandMark uses for its gem-authored
21
+ # path constants.
22
+ class Logo < Phlex::HTML
23
+ # For the src: image form — resolve the asset path through the site's
24
+ # pipeline to its digested /assets URL, exactly like DocsUI::Landing's img.
25
+ include Phlex::Rails::Helpers::ImageURL
26
+
27
+ def initialize(logo, label: nil, **attributes)
28
+ @logo = DocsKit::BrandLogo.from(logo)
29
+ @label = label
30
+ @attributes = attributes
31
+ end
32
+
33
+ def view_template
34
+ if @logo.image?
35
+ img(src: resolved_src, alt: accessible_name.to_s, **@attributes)
36
+ elsif @logo.embed?
37
+ embedded_svg
38
+ else
39
+ inline_svg
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def accessible_name = @logo.label || @label
46
+
47
+ # The svg:/paths: forms: a currentColor mark that recolors with the active
48
+ # daisyUI theme; every path-d is an escaped attribute value.
49
+ def inline_svg
50
+ svg(viewBox: @logo.viewbox, fill: "currentColor", role: "img",
51
+ aria_label: accessible_name, **@attributes) do |s|
52
+ s.title { accessible_name } if accessible_name
53
+ @logo.paths.each { |d| s.path(d: d) }
54
+ end
55
+ end
56
+
57
+ # The markup:/file: forms: the site's own <svg> embedded verbatim (see the
58
+ # class comment for the trust rationale) inside a wrapper that carries the
59
+ # caller's sizing — the inner svg fills it. Literal arbitrary variants so
60
+ # Tailwind scans them from this file.
61
+ def embedded_svg
62
+ classes = [@attributes[:class], "inline-flex [&>svg]:h-full [&>svg]:w-auto"].compact.join(" ")
63
+ span(role: "img", aria_label: accessible_name, **@attributes, class: classes) do
64
+ raw(safe(@logo.svg_markup))
65
+ end
66
+ end
67
+
68
+ # The digested asset URL when a view context is present; off a request (an
69
+ # isolated render) degrade to the raw src — the DocsUI::MetaTags posture.
70
+ def resolved_src
71
+ view_context ? image_url(@logo.src) : @logo.src
72
+ end
73
+ end
74
+ end
@@ -156,7 +156,7 @@ module DocsUI
156
156
  div(class: "flex-1 items-center gap-2") do
157
157
  label(for: DRAWER_ID, class: "btn btn-square btn-ghost btn-sm lg:hidden",
158
158
  aria_label: "Open menu") { render DocsUI::Icon.new("menu", class: "size-5") }
159
- a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
159
+ a(href: config.brand_href, class: topbar_brand_classes) { brand_mark }
160
160
  app_home_link
161
161
  end
162
162
  render DocsUI::SearchBox.new if config.search_enabled?
@@ -169,6 +169,26 @@ module DocsUI
169
169
  end
170
170
  end
171
171
 
172
+ # The brand anchor's classes. config.topbar_brand = :mobile_only adds
173
+ # lg:hidden — at the drawer-pinned breakpoint the sidebar brand is already
174
+ # visible, so a site can drop the duplicate. The default (:always) keeps
175
+ # the pre-knob classes verbatim.
176
+ def topbar_brand_classes
177
+ base = "btn btn-ghost text-lg font-bold"
178
+ config.topbar_brand == :mobile_only ? "#{base} lg:hidden" : base
179
+ end
180
+
181
+ # The brand: the configured mark (config.brand_logo) when set, else the
182
+ # text brand — byte-identical to before for a site that sets nothing. The
183
+ # text brand stays the mark's accessible-name fallback.
184
+ def brand_mark
185
+ if (logo = config.brand_logo)
186
+ render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand)
187
+ else
188
+ plain config.brand
189
+ end
190
+ end
191
+
172
192
  # The opt-in App Home link (config.app_link) — the way back to the hosting
173
193
  # app, rendered once, right after the brand. Nothing renders when unset, so
174
194
  # the topbar stays byte-identical for a site that never configures it.
@@ -8,6 +8,11 @@ module DocsUI
8
8
  #
9
9
  # nav_groups is an ordered Hash:
10
10
  # { "Heading" => { "Subgroup" => [DocsKit::NavItem, ...] } }
11
+ #
12
+ # Subgroups are the top level of the rendered menu. A lone heading (the common
13
+ # single-registry site) is not rendered at all — the brand masthead already
14
+ # labels the sidebar; with several headings, each renders as a STATIC
15
+ # `.menu-title` label (no fold) and only subgroups collapse.
11
16
  class Sidebar < Phlex::HTML
12
17
  include Phlex::Rails::Helpers::Request
13
18
  include DaisyUI
@@ -29,7 +34,8 @@ module DocsUI
29
34
  header_section
30
35
  div(class: "flex-1 overflow-y-auto px-2 pb-6") do
31
36
  Menu(class: "w-full gap-1") do
32
- nav_groups.each { |heading, grouped| nav_group(heading, grouped) }
37
+ groups = nav_groups.reject { |_, grouped| grouped.nil? || grouped.empty? }
38
+ groups.each { |heading, grouped| nav_group(heading, grouped, labeled: groups.size > 1) }
33
39
  end
34
40
  end
35
41
  end
@@ -42,27 +48,37 @@ module DocsUI
42
48
 
43
49
  def header_section
44
50
  div(class: "flex min-h-16 items-center gap-2 px-4") do
45
- a(href: config.brand_href, class: "text-lg font-bold text-base-content") { config.brand }
51
+ a(href: config.brand_href, class: "text-lg font-bold text-base-content") { brand_mark }
46
52
  badge = config.version_badge_text
47
53
  span(class: "badge badge-sm badge-ghost") { badge } if badge
48
54
  end
49
55
  end
50
56
 
51
- # A top-level collapsible group (e.g. "Docs") holding collapsible sub-groups
52
- # (e.g. "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash.
53
- def nav_group(heading, grouped)
54
- return if grouped.nil? || grouped.empty?
57
+ # The brand: the configured mark (config.brand_logo) when set, else the
58
+ # text brand byte-identical to before for a site that sets nothing.
59
+ # Slightly taller than the topbar's h-6: this is the masthead.
60
+ def brand_mark
61
+ if (logo = config.brand_logo)
62
+ render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand)
63
+ else
64
+ plain config.brand
65
+ end
66
+ end
55
67
 
56
- li do
57
- details(open: true) do
58
- summary(class: "text-xs font-semibold uppercase tracking-wider text-base-content/50 #{MARKER_RESET}") do
59
- heading
60
- end
61
- ul do
62
- grouped.each { |subgroup, items| nav_subgroup(subgroup, items) }
63
- end
68
+ # A top-level group (e.g. "Docs") holding collapsible sub-groups (e.g.
69
+ # "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash. The
70
+ # heading label only renders when the sidebar shows SEVERAL groups
71
+ # (labeled:) — as a static `.menu-title`, never a <details>, so subgroups
72
+ # stay at the menu's top level instead of gaining a nesting indent. The
73
+ # mt-4/first:mt-0 pair is the breathing room between one group's links and
74
+ # the next group's label.
75
+ def nav_group(heading, grouped, labeled:)
76
+ if labeled
77
+ li(class: "menu-title mt-4 text-xs font-semibold uppercase tracking-wider text-base-content/50 first:mt-0") do
78
+ heading
64
79
  end
65
80
  end
81
+ grouped.each { |subgroup, items| nav_subgroup(subgroup, items) }
66
82
  end
67
83
 
68
84
  # A collapsible sub-group: its title is a <summary> so the whole section folds
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The normalized brand mark for the shell chrome (config.brand_logo) and the
5
+ # landing hero (config.landing.logo). A site configures a Hash in exactly one
6
+ # of five forms; DocsUI::Logo renders the result:
7
+ #
8
+ # { svg: "M0 0Z", viewbox: "0 0 24 24", label: "Acme" } # one path-d (landing-compat)
9
+ # { paths: ["M0 0Z", "M4 4Z"], viewbox: "…", label: "…" } # multi-path wordmark
10
+ # { markup: "<svg …>…</svg>", label: "Acme" } # raw SVG markup, embedded verbatim
11
+ # { file: "app/assets/images/mark.svg", label: "Acme" } # a .svg file, embedded inline
12
+ # { src: "logo.png", alt: "Acme" } # an <img> (not theme-adaptive)
13
+ #
14
+ # The svg:/paths: forms render each `d` as an ordinary Phlex-escaped attribute.
15
+ # The markup:/file: forms embed SITE-AUTHORED markup verbatim (see DocsUI::Logo
16
+ # for the trust rationale); both are shape-checked here — the content must be an
17
+ # <svg> element — so a mis-pasted snippet fails loudly at config time, never as
18
+ # a silently broken (or script-bearing) header. Mixing forms, or giving none,
19
+ # is ambiguous config and raises. `label`/`alt` fall back to each other so
20
+ # either knob names the mark for assistive tech.
21
+ #
22
+ # A file: mark memoizes its content and re-reads on an mtime change (the
23
+ # Configuration#openapi_document posture), so editing the SVG in development
24
+ # shows up without a server restart.
25
+ class BrandLogo
26
+ # The config keys that each select a render form — exactly one must be given.
27
+ FORM_KEYS = %i[svg paths markup file src].freeze
28
+
29
+ # A loose "is this an <svg> element" shape check for the markup:/file: forms.
30
+ SVG_SHAPE = /\A\s*<svg[\s>]/i
31
+
32
+ DEFAULT_VIEWBOX = "0 0 24 24"
33
+
34
+ attr_reader :paths, :viewbox, :markup, :file, :src
35
+
36
+ # Coerce a config value (Hash with symbol or string keys, or an
37
+ # already-normalized BrandLogo) into a BrandLogo.
38
+ def self.from(logo)
39
+ return logo if logo.is_a?(self)
40
+
41
+ new(logo.to_h)
42
+ end
43
+
44
+ def initialize(attrs = {})
45
+ attrs = attrs.transform_keys(&:to_sym)
46
+ given = attrs.slice(*FORM_KEYS).compact
47
+ unless given.size == 1
48
+ raise ArgumentError,
49
+ "brand_logo takes exactly one of #{FORM_KEYS.inspect} (got #{given.keys.inspect})"
50
+ end
51
+
52
+ @viewbox = attrs[:viewbox] || DEFAULT_VIEWBOX
53
+ @label = attrs[:label]
54
+ @alt = attrs[:alt]
55
+ build_form(given.keys.first, attrs)
56
+ end
57
+
58
+ # The single path-d, for the landing-compat svg: shape (first of #paths).
59
+ def svg = paths&.first
60
+
61
+ def inline? = !paths.nil?
62
+ def markup? = !markup.nil?
63
+ def file? = !file.nil?
64
+ def image? = !src.nil?
65
+
66
+ # Whether the mark embeds site-authored markup verbatim (markup: or file:).
67
+ def embed? = markup? || file?
68
+
69
+ # The accessible name — label falls back to alt (and vice versa) so a site
70
+ # setting either names the mark; nil defers to the render-time brand fallback.
71
+ def label = @label || @alt
72
+ def alt = @alt || @label
73
+
74
+ # The markup to embed: the literal markup: string, or the file's content —
75
+ # memoized per mtime so a dev edit re-reads without a restart.
76
+ def svg_markup
77
+ return @markup if markup?
78
+
79
+ mtime = begin
80
+ @file.mtime
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ return @file_content if defined?(@file_content) && @file_mtime == mtime
85
+
86
+ @file_mtime = mtime
87
+ @file_content = check_svg_shape!(@file.read, "file #{@file}")
88
+ end
89
+
90
+ private
91
+
92
+ # Store the one given form. file: primes #svg_markup immediately so a bad
93
+ # file fails at config time (boot), not on first render.
94
+ def build_form(form, attrs)
95
+ case form
96
+ when :svg, :paths then @paths = Array(attrs[:paths] || attrs[:svg]).map(&:to_s)
97
+ when :markup then @markup = check_svg_shape!(attrs[:markup].to_s, "markup")
98
+ when :src then @src = attrs[:src]
99
+ when :file
100
+ @file = resolve_file!(attrs[:file])
101
+ svg_markup
102
+ end
103
+ end
104
+
105
+ # Validate + resolve the file: form eagerly, so a bad path fails at config
106
+ # time (boot), not on first render. Relative paths resolve against Rails.root
107
+ # when Rails is loaded, else the process working directory.
108
+ def resolve_file!(file)
109
+ path = Pathname.new(file.to_s)
110
+ path = Rails.root.join(path) if path.relative? && defined?(Rails) && Rails.respond_to?(:root) && Rails.root
111
+ raise ArgumentError, "brand_logo file must be a .svg (got #{path.basename})" unless path.extname.casecmp?(".svg")
112
+ raise ArgumentError, "brand_logo file not found: #{path}" unless path.file?
113
+
114
+ path
115
+ end
116
+
117
+ # The markup:/file: shape guard — the content must BE an <svg> element.
118
+ def check_svg_shape!(content, source)
119
+ return content if content.match?(SVG_SHAPE)
120
+
121
+ raise ArgumentError, "brand_logo #{source} must be an <svg> element (got #{content[0, 40].inspect})"
122
+ end
123
+ end
124
+ end
@@ -193,6 +193,39 @@ module DocsKit
193
193
  # sidebar, and page-masthead links). Read via #app_link, never @app_link.
194
194
  attr_writer :app_link
195
195
 
196
+ # The opt-in shell brand mark, rendered by BOTH the topbar and the sidebar
197
+ # header in place of the text #brand (which stays the accessible-name
198
+ # fallback). A Hash in exactly one of the DocsKit::BrandLogo forms —
199
+ # svg:/paths: (inline path-d, theme-adaptive via currentColor), markup:/file:
200
+ # (site-authored <svg> embedded verbatim), or src: (an <img>, NOT
201
+ # theme-adaptive) — or an already-built BrandLogo. Defaults to nil → the
202
+ # text brand renders and the chrome is byte-identical to before. Sibling of
203
+ # c.landing.logo, which is the landing-hero mark. Read via #brand_logo,
204
+ # never @brand_logo.
205
+ def brand_logo=(value)
206
+ @brand_logo = nil
207
+ @brand_logo_raw = value
208
+ end
209
+
210
+ # Where the topbar renders the brand: :always (the default — byte-compat),
211
+ # or :mobile_only, which hides it at the drawer-pinned breakpoint (lg:)
212
+ # where the sidebar brand is already visible, deduplicating the mark.
213
+ attr_reader :topbar_brand
214
+
215
+ # The topbar-brand placements. At lg: the sidebar (with its own brand) is
216
+ # pinned open, so :mobile_only drops the duplicate; :always keeps it.
217
+ TOPBAR_BRAND_MODES = %i[always mobile_only].freeze
218
+
219
+ def topbar_brand=(value)
220
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
221
+ unless TOPBAR_BRAND_MODES.include?(mode)
222
+ raise ArgumentError,
223
+ "topbar_brand must be one of #{TOPBAR_BRAND_MODES.inspect} (got #{value.inspect})"
224
+ end
225
+
226
+ @topbar_brand = mode
227
+ end
228
+
196
229
  # External links rendered in the topbar next to the theme switcher — a repo
197
230
  # link, a chat invite, a social profile. Each entry is a Hash
198
231
  # ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links
@@ -274,6 +307,9 @@ module DocsKit
274
307
  @app_link = nil
275
308
  @topbar_links = []
276
309
  @openapi = nil
310
+ @brand_logo = nil
311
+ @brand_logo_raw = nil
312
+ @topbar_brand = :always
277
313
  end
278
314
 
279
315
  # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -284,6 +320,17 @@ module DocsKit
284
320
  DocsKit::TopbarLink.from(@app_link)
285
321
  end
286
322
 
323
+ # The normalized shell brand mark (a DocsKit::BrandLogo), or nil when unset.
324
+ # Memoized (and invalidated on reassignment) — unlike #app_link's rebuild-
325
+ # per-read, because a file: mark shape-checks and reads its SVG on build;
326
+ # per-render re-normalization would repeat that IO. A malformed value raises
327
+ # here, on first read — loud, never a silently broken header.
328
+ def brand_logo
329
+ return if @brand_logo_raw.nil?
330
+
331
+ @brand_logo ||= DocsKit::BrandLogo.from(@brand_logo_raw)
332
+ end
333
+
287
334
  # The normalized topbar links (DocsKit::TopbarLink list), in declaration
288
335
  # order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so
289
336
  # the Shell only ever sees value objects. Blank/nil config yields [].
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "brand_logo"
4
+
3
5
  module DocsKit
4
6
  # The per-site landing-page knobs, read by DocsUI::Landing to render a marketing
5
7
  # home page (hero + feature grid + doc index) without a site hand-rolling one.
@@ -90,11 +92,11 @@ module DocsKit
90
92
  { code: attrs[:code].to_s, filename: attrs[:filename], lexer: (attrs[:lexer] || :shell).to_sym }
91
93
  end
92
94
 
93
- # The hero logo as a normalized Logo value object, or nil when unset.
95
+ # The hero logo as a normalized DocsKit::BrandLogo, or nil when unset.
94
96
  def hero_logo
95
97
  return if @logo.nil?
96
98
 
97
- Logo.from(@logo)
99
+ DocsKit::BrandLogo.from(@logo)
98
100
  end
99
101
 
100
102
  # One hero call-to-action button. `style` maps to a daisyUI btn variant
@@ -134,27 +136,9 @@ module DocsKit
134
136
  end
135
137
  end
136
138
 
137
- # The hero brand logo either an inline single-path SVG mark (`svg` = the
138
- # `<path d>` data, `viewbox` = its viewBox) rendered with fill: currentColor so
139
- # it adapts to the theme, OR an image (`src` = an asset path/URL, `alt` = its
140
- # accessible name). `label` is the accessible name for the inline mark.
141
- Logo = Data.define(:svg, :viewbox, :src, :alt, :label) do
142
- def initialize(svg: nil, viewbox: "0 0 24 24", src: nil, alt: nil, label: nil)
143
- super
144
- end
145
-
146
- def self.from(logo)
147
- return logo if logo.is_a?(self)
148
-
149
- attrs = logo.to_h.transform_keys(&:to_sym)
150
- new(
151
- svg: attrs[:svg], viewbox: attrs[:viewbox] || "0 0 24 24",
152
- src: attrs[:src], alt: attrs[:alt], label: attrs[:label]
153
- )
154
- end
155
-
156
- # An inline SVG mark (vs. an <img>). True when `svg` path data is present.
157
- def inline? = !svg.to_s.empty?
158
- end
139
+ # The hero brand logo shape now lives in DocsKit::BrandLogo (shared with the
140
+ # shell's config.brand_logo); the old nested name stays as an alias so any
141
+ # site referencing LandingConfig::Logo keeps working.
142
+ Logo = DocsKit::BrandLogo
159
143
  end
160
144
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.0.7"
4
+ VERSION = "1.0.8"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -58,6 +58,9 @@ loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
58
58
  # ignore it here too or zeitwerk double-manages the constant.
59
59
  loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
60
60
  loader.ignore(File.expand_path("docs_kit/landing_config.rb", __dir__))
61
+ # Required eagerly by landing_config.rb (the LandingConfig::Logo alias resolves
62
+ # it at require time, before this loader is set up), so ignore it here too.
63
+ loader.ignore(File.expand_path("docs_kit/brand_logo.rb", __dir__))
61
64
  # Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
62
65
  # gem runtime — so its Rack/browser tooling is never pulled into a host that
63
66
  # doesn't run the task. Ignore it so eager_load! doesn't require it.
@@ -21,6 +21,23 @@ Rails.application.config.to_prepare do
21
21
  # docs live under a subpath:
22
22
  # c.brand_href = "/docs"
23
23
 
24
+ # Your own mark in the topbar + sidebar header instead of the text brand
25
+ # (which stays the accessible name). Exactly ONE of these forms:
26
+ # paths: — inline path-d list; fills with currentColor, so the mark
27
+ # recolors with the active theme (also `svg:` for one path)
28
+ # markup: — a full <svg>…</svg> string, embedded verbatim (use
29
+ # fill="currentColor" in it to stay theme-adaptive)
30
+ # file: — a .svg under this app (e.g. "app/assets/images/mark.svg"),
31
+ # embedded inline at render — same currentColor advice
32
+ # src: — an image asset path/URL rendered as an <img>; note an <img>
33
+ # canNOT inherit currentColor, so it won't adapt to the theme
34
+ # c.brand_logo = { paths: ["M4 2h9l5 5…Z"], viewbox: "0 0 81 45", label: "<%= app_brand %>" }
35
+
36
+ # On desktop (lg:) the pinned sidebar already shows the brand, so the topbar
37
+ # copy is a duplicate. :mobile_only hides the topbar brand at lg:; the
38
+ # default (:always) keeps it everywhere.
39
+ # c.topbar_brand = :mobile_only
40
+
24
41
  # Docs embedded in a bigger app? Add the way BACK to that app — a labeled
25
42
  # link rendered once in the topbar, right after the brand. brand_href is the
26
43
  # DOCS home (brand, sidebar, and the page masthead's "← Docs home" all
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.0.7
4
+ version: 1.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -175,6 +175,7 @@ files:
175
175
  - app/components/docs_ui/icon.rb
176
176
  - app/components/docs_ui/json_response.rb
177
177
  - app/components/docs_ui/landing.rb
178
+ - app/components/docs_ui/logo.rb
178
179
  - app/components/docs_ui/markdown.rb
179
180
  - app/components/docs_ui/markdown_action.rb
180
181
  - app/components/docs_ui/meta_tags.rb
@@ -205,6 +206,7 @@ files:
205
206
  - lib/docs_kit/api_client.rb
206
207
  - lib/docs_kit/api_request.rb
207
208
  - lib/docs_kit/api_templates.rb
209
+ - lib/docs_kit/brand_logo.rb
208
210
  - lib/docs_kit/configuration.rb
209
211
  - lib/docs_kit/controller.rb
210
212
  - lib/docs_kit/engine.rb