Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ DocsKit.configure do |c|
# once in the topbar, right after the brand. Unset (default) renders nothing.
c.app_link = { href: "/", label: "Back to the app" }

# Your own mark in the topbar + sidebar header instead of the text brand.
c.brand_logo = { paths: ["M4 2h9l5 5…Z"], viewbox: "0 0 81 45" }
c.topbar_brand = :mobile_only # drop the desktop duplicate (default :always)

# Repo/social links in the topbar (next to the theme switcher).
c.topbar_links = [
{ href: "https://github.com/you/phlex-reactive", label: "GitHub", icon: :github },
Expand Down Expand Up @@ -200,6 +204,8 @@ overriding route helpers:
|------|---------|--------------|
| `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`. |
| `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`. |
| `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. |
| `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. |
| `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. |
| `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]`). |

Expand All @@ -209,6 +215,33 @@ blocks with no JavaScript and no flash. The Rouge CSS is inlined per block
(not part of the Tailwind build), so the [theme-sync invariant](#css--the-canonical-build)
is unaffected — a `code_theme_dark` doesn't need a CSS rebuild.

### The brand mark

`c.brand_logo` replaces the text brand in the shell chrome (topbar + sidebar
header) with your own mark — no more copying private `Shell`/`Sidebar` methods
that go stale on upgrades. It takes **exactly one** of five forms (mixing forms,
or a malformed value, raises at config time):

```ruby
c.brand_logo = { svg: "M4 2h9l5 5…Z", viewbox: "0 0 22 24", label: "Acme" } # one path-d
c.brand_logo = { paths: ["M4 2…Z", "M9 7…Z"], viewbox: "0 0 81 45" } # multi-path wordmark
c.brand_logo = { markup: File.read("mark.svg") } # raw <svg> markup, embedded verbatim
c.brand_logo = { file: "app/assets/images/mark.svg" } # a .svg file, embedded inline
c.brand_logo = { src: "logo.png", alt: "Acme" } # an <img> from the asset pipeline
```

- The `svg:`/`paths:` forms render with `fill="currentColor"`, so the mark
**recolors with the active daisyUI theme**. `markup:`/`file:` are embedded
as-authored — use `currentColor` inside them to stay theme-adaptive. An
`src:` `<img>` **cannot** inherit `currentColor` and won't adapt.
- `markup:`/`file:` embed your own SVG verbatim (they are your site's content,
same trust domain as your views); both are shape-checked to be an `<svg>`
element at config time, and a `file:` re-reads on change in development.
- `label:`/`alt:` name the mark for assistive tech; unset, the mark falls back
to `c.brand`. The sidebar keeps the `version_badge` next to the mark.
- Sizing is fixed per surface (topbar `h-6`, sidebar `h-7`, landing hero `h-9`).
- `c.landing.logo` (the landing-hero mark) accepts the same five forms.

### Topbar links (repo & social)

Point readers at your source repo, chat, or socials from the topbar (next to the
Expand Down
17 changes: 3 additions & 14 deletions app/components/docs_ui/landing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ module DocsUI
# walks the same #docs-content region Shell stamps.
class Landing < Phlex::HTML
include Phlex::Rails::Helpers::Request
# For the image-form hero logo (c.landing.logo = { src: … }): resolve the asset
# path through the site's pipeline to its digested /assets URL.
include Phlex::Rails::Helpers::ImageURL

def view_template
render DocsUI::Shell.new(title: landing.eyebrow || config.brand) do
Expand Down Expand Up @@ -63,20 +60,12 @@ def hero
end
end

# The brand mark — an inline single-path SVG (currentColor, theme-adaptive) or
# an <img>. Rendered above the eyebrow, like a product wordmark.
# The brand mark — the shared DocsUI::Logo renderer (any DocsKit::BrandLogo
# form) at hero size. Rendered above the eyebrow, like a product wordmark.
def logo
return unless (mark = landing.hero_logo)

if mark.inline?
svg(viewbox: mark.viewbox, class: "h-9 w-auto text-primary", fill: "currentColor",
role: "img", aria_label: mark.label) do |s|
s.title { mark.label } if mark.label
s.path(d: mark.svg)
end
else
img(src: image_url(mark.src), alt: mark.alt.to_s, class: "h-9 w-auto")
end
render DocsUI::Logo.new(mark, class: "h-9 w-auto text-primary")
end

def eyebrow
Expand Down
74 changes: 74 additions & 0 deletions app/components/docs_ui/logo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true

module DocsUI
# Renders a DocsKit::BrandLogo (config.brand_logo / config.landing.logo) as the
# brand mark — an inline currentColor <svg> (theme-adaptive), a verbatim
# site-authored <svg> embed, or an <img>.
#
# render DocsUI::Logo.new(config.brand_logo, class: "h-6 w-auto", label: config.brand)
#
# label: is the accessible-name fallback when the logo itself carries none
# (callers pass config.brand, so the mark always announces the site).
#
# The svg:/paths: forms emit each path-d as an ordinary Phlex-escaped
# attribute — config free text never bypasses the escape. The markup:/file:
# forms DO embed markup verbatim via raw(safe(...)): that content is the
# site's own deliberately-configured SVG (its initializer / its asset file —
# the same trust domain as the site's own views, which can already render
# anything), not third-party free text, and DocsKit::BrandLogo shape-checks it
# to be an <svg> element at config time. That authored-by-the-trusting-site
# rationale is the same carve-out DocsUI::BrandMark uses for its gem-authored
# path constants.
class Logo < Phlex::HTML
# For the src: image form — resolve the asset path through the site's
# pipeline to its digested /assets URL, exactly like DocsUI::Landing's img.
include Phlex::Rails::Helpers::ImageURL

def initialize(logo, label: nil, **attributes)
@logo = DocsKit::BrandLogo.from(logo)
@label = label
@attributes = attributes
end

def view_template
if @logo.image?
img(src: resolved_src, alt: accessible_name.to_s, **@attributes)
elsif @logo.embed?
embedded_svg
else
inline_svg
end
end

private

def accessible_name = @logo.label || @label

# The svg:/paths: forms: a currentColor mark that recolors with the active
# daisyUI theme; every path-d is an escaped attribute value.
def inline_svg
svg(viewBox: @logo.viewbox, fill: "currentColor", role: "img",
aria_label: accessible_name, **@attributes) do |s|
s.title { accessible_name } if accessible_name
@logo.paths.each { |d| s.path(d: d) }
end
end

# The markup:/file: forms: the site's own <svg> embedded verbatim (see the
# class comment for the trust rationale) inside a wrapper that carries the
# caller's sizing — the inner svg fills it. Literal arbitrary variants so
# Tailwind scans them from this file.
def embedded_svg
classes = [@attributes[:class], "inline-flex [&>svg]:h-full [&>svg]:w-auto"].compact.join(" ")
span(role: "img", aria_label: accessible_name, **@attributes, class: classes) do
raw(safe(@logo.svg_markup))
end
end

# The digested asset URL when a view context is present; off a request (an
# isolated render) degrade to the raw src — the DocsUI::MetaTags posture.
def resolved_src
view_context ? image_url(@logo.src) : @logo.src
end
end
end
22 changes: 21 additions & 1 deletion app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def topbar
div(class: "flex-1 items-center gap-2") do
label(for: DRAWER_ID, class: "btn btn-square btn-ghost btn-sm lg:hidden",
aria_label: "Open menu") { render DocsUI::Icon.new("menu", class: "size-5") }
a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
a(href: config.brand_href, class: topbar_brand_classes) { brand_mark }
app_home_link
end
render DocsUI::SearchBox.new if config.search_enabled?
Expand All @@ -169,6 +169,26 @@ def topbar
end
end

# The brand anchor's classes. config.topbar_brand = :mobile_only adds
# lg:hidden — at the drawer-pinned breakpoint the sidebar brand is already
# visible, so a site can drop the duplicate. The default (:always) keeps
# the pre-knob classes verbatim.
def topbar_brand_classes
base = "btn btn-ghost text-lg font-bold"
config.topbar_brand == :mobile_only ? "#{base} lg:hidden" : base
end

# The brand: the configured mark (config.brand_logo) when set, else the
# text brand — byte-identical to before for a site that sets nothing. The
# text brand stays the mark's accessible-name fallback.
def brand_mark
if (logo = config.brand_logo)
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand)
else
plain config.brand
end
end

# The opt-in App Home link (config.app_link) — the way back to the hosting
# app, rendered once, right after the brand. Nothing renders when unset, so
# the topbar stays byte-identical for a site that never configures it.
Expand Down
13 changes: 12 additions & 1 deletion app/components/docs_ui/sidebar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,23 @@ def nav_groups = config.nav_groups

def header_section
div(class: "flex min-h-16 items-center gap-2 px-4") do
a(href: config.brand_href, class: "text-lg font-bold text-base-content") { config.brand }
a(href: config.brand_href, class: "text-lg font-bold text-base-content") { brand_mark }
badge = config.version_badge_text
span(class: "badge badge-sm badge-ghost") { badge } if badge
end
end

# The brand: the configured mark (config.brand_logo) when set, else the
# text brand — byte-identical to before for a site that sets nothing.
# Slightly taller than the topbar's h-6: this is the masthead.
def brand_mark
if (logo = config.brand_logo)
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand)
else
plain config.brand
end
end

# A top-level group (e.g. "Docs") holding collapsible sub-groups (e.g.
# "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash. The
# heading label only renders when the sidebar shows SEVERAL groups
Expand Down
3 changes: 3 additions & 0 deletions lib/docs_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ module DocsUI
# ignore it here too or zeitwerk double-manages the constant.
loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
loader.ignore(File.expand_path("docs_kit/landing_config.rb", __dir__))
# Required eagerly by landing_config.rb (the LandingConfig::Logo alias resolves
# it at require time, before this loader is set up), so ignore it here too.
loader.ignore(File.expand_path("docs_kit/brand_logo.rb", __dir__))
# Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
# gem runtime — so its Rack/browser tooling is never pulled into a host that
# doesn't run the task. Ignore it so eager_load! doesn't require it.
Expand Down
124 changes: 124 additions & 0 deletions lib/docs_kit/brand_logo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# frozen_string_literal: true

module DocsKit
# The normalized brand mark for the shell chrome (config.brand_logo) and the
# landing hero (config.landing.logo). A site configures a Hash in exactly one
# of five forms; DocsUI::Logo renders the result:
#
# { svg: "M0 0Z", viewbox: "0 0 24 24", label: "Acme" } # one path-d (landing-compat)
# { paths: ["M0 0Z", "M4 4Z"], viewbox: "…", label: "…" } # multi-path wordmark
# { markup: "<svg …>…</svg>", label: "Acme" } # raw SVG markup, embedded verbatim
# { file: "app/assets/images/mark.svg", label: "Acme" } # a .svg file, embedded inline
# { src: "logo.png", alt: "Acme" } # an <img> (not theme-adaptive)
#
# The svg:/paths: forms render each `d` as an ordinary Phlex-escaped attribute.
# The markup:/file: forms embed SITE-AUTHORED markup verbatim (see DocsUI::Logo
# for the trust rationale); both are shape-checked here — the content must be an
# <svg> element — so a mis-pasted snippet fails loudly at config time, never as
# a silently broken (or script-bearing) header. Mixing forms, or giving none,
# is ambiguous config and raises. `label`/`alt` fall back to each other so
# either knob names the mark for assistive tech.
#
# A file: mark memoizes its content and re-reads on an mtime change (the
# Configuration#openapi_document posture), so editing the SVG in development
# shows up without a server restart.
class BrandLogo
# The config keys that each select a render form — exactly one must be given.
FORM_KEYS = %i[svg paths markup file src].freeze

# A loose "is this an <svg> element" shape check for the markup:/file: forms.
SVG_SHAPE = /\A\s*<svg[\s>]/i

DEFAULT_VIEWBOX = "0 0 24 24"

attr_reader :paths, :viewbox, :markup, :file, :src

# Coerce a config value (Hash with symbol or string keys, or an
# already-normalized BrandLogo) into a BrandLogo.
def self.from(logo)
return logo if logo.is_a?(self)

new(logo.to_h)
end

def initialize(attrs = {})
attrs = attrs.transform_keys(&:to_sym)
given = attrs.slice(*FORM_KEYS).compact
unless given.size == 1
raise ArgumentError,
"brand_logo takes exactly one of #{FORM_KEYS.inspect} (got #{given.keys.inspect})"
end

@viewbox = attrs[:viewbox] || DEFAULT_VIEWBOX
@label = attrs[:label]
@alt = attrs[:alt]
build_form(given.keys.first, attrs)
end

# The single path-d, for the landing-compat svg: shape (first of #paths).
def svg = paths&.first

def inline? = !paths.nil?
def markup? = !markup.nil?
def file? = !file.nil?
def image? = !src.nil?

# Whether the mark embeds site-authored markup verbatim (markup: or file:).
def embed? = markup? || file?

# The accessible name — label falls back to alt (and vice versa) so a site
# setting either names the mark; nil defers to the render-time brand fallback.
def label = @label || @alt
def alt = @alt || @label

# The markup to embed: the literal markup: string, or the file's content —
# memoized per mtime so a dev edit re-reads without a restart.
def svg_markup
return @markup if markup?

mtime = begin
@file.mtime
rescue StandardError
nil
end
return @file_content if defined?(@file_content) && @file_mtime == mtime

@file_mtime = mtime
@file_content = check_svg_shape!(@file.read, "file #{@file}")
end

private

# Store the one given form. file: primes #svg_markup immediately so a bad
# file fails at config time (boot), not on first render.
def build_form(form, attrs)
case form
when :svg, :paths then @paths = Array(attrs[:paths] || attrs[:svg]).map(&:to_s)
when :markup then @markup = check_svg_shape!(attrs[:markup].to_s, "markup")
when :src then @src = attrs[:src]
when :file
@file = resolve_file!(attrs[:file])
svg_markup
end
end

# Validate + resolve the file: form eagerly, so a bad path fails at config
# time (boot), not on first render. Relative paths resolve against Rails.root
# when Rails is loaded, else the process working directory.
def resolve_file!(file)
path = Pathname.new(file.to_s)
path = Rails.root.join(path) if path.relative? && defined?(Rails) && Rails.respond_to?(:root) && Rails.root
raise ArgumentError, "brand_logo file must be a .svg (got #{path.basename})" unless path.extname.casecmp?(".svg")
raise ArgumentError, "brand_logo file not found: #{path}" unless path.file?

path
end

# The markup:/file: shape guard — the content must BE an <svg> element.
def check_svg_shape!(content, source)
return content if content.match?(SVG_SHAPE)

raise ArgumentError, "brand_logo #{source} must be an <svg> element (got #{content[0, 40].inspect})"
end
end
end
Loading
Loading