diff --git a/README.md b/README.md index 528bf0c..31f7775 100644 --- a/README.md +++ b/README.md @@ -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 }, @@ -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]`). | @@ -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 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 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:` `` **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 `` + 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 diff --git a/app/components/docs_ui/landing.rb b/app/components/docs_ui/landing.rb index 9ce5e1e..c1cb8fa 100644 --- a/app/components/docs_ui/landing.rb +++ b/app/components/docs_ui/landing.rb @@ -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 @@ -63,20 +60,12 @@ def hero end end - # The brand mark — an inline single-path SVG (currentColor, theme-adaptive) or - # an . 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 diff --git a/app/components/docs_ui/logo.rb b/app/components/docs_ui/logo.rb new file mode 100644 index 0000000..90eeace --- /dev/null +++ b/app/components/docs_ui/logo.rb @@ -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 (theme-adaptive), a verbatim + # site-authored embed, or an . + # + # 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 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 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 diff --git a/app/components/docs_ui/shell.rb b/app/components/docs_ui/shell.rb index b4d85b4..8214e36 100644 --- a/app/components/docs_ui/shell.rb +++ b/app/components/docs_ui/shell.rb @@ -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? @@ -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. diff --git a/app/components/docs_ui/sidebar.rb b/app/components/docs_ui/sidebar.rb index e900251..93a16d1 100644 --- a/app/components/docs_ui/sidebar.rb +++ b/app/components/docs_ui/sidebar.rb @@ -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 diff --git a/lib/docs_kit.rb b/lib/docs_kit.rb index fce9d56..c7ed834 100644 --- a/lib/docs_kit.rb +++ b/lib/docs_kit.rb @@ -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. diff --git a/lib/docs_kit/brand_logo.rb b/lib/docs_kit/brand_logo.rb new file mode 100644 index 0000000..dee48de --- /dev/null +++ b/lib/docs_kit/brand_logo.rb @@ -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: "", 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 (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 + # 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 element" shape check for the markup:/file: forms. + SVG_SHAPE = /\A\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 element. + def check_svg_shape!(content, source) + return content if content.match?(SVG_SHAPE) + + raise ArgumentError, "brand_logo #{source} must be an element (got #{content[0, 40].inspect})" + end + end +end diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index 18a646f..5d5693d 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -193,6 +193,39 @@ def nav=(value) # sidebar, and page-masthead links). Read via #app_link, never @app_link. attr_writer :app_link + # The opt-in shell brand mark, rendered by BOTH the topbar and the sidebar + # header in place of the text #brand (which stays the accessible-name + # fallback). A Hash in exactly one of the DocsKit::BrandLogo forms — + # svg:/paths: (inline path-d, theme-adaptive via currentColor), markup:/file: + # (site-authored embedded verbatim), or src: (an , NOT + # theme-adaptive) — or an already-built BrandLogo. Defaults to nil → the + # text brand renders and the chrome is byte-identical to before. Sibling of + # c.landing.logo, which is the landing-hero mark. Read via #brand_logo, + # never @brand_logo. + def brand_logo=(value) + @brand_logo = nil + @brand_logo_raw = value + end + + # Where the topbar renders the brand: :always (the default — byte-compat), + # or :mobile_only, which hides it at the drawer-pinned breakpoint (lg:) + # where the sidebar brand is already visible, deduplicating the mark. + attr_reader :topbar_brand + + # The topbar-brand placements. At lg: the sidebar (with its own brand) is + # pinned open, so :mobile_only drops the duplicate; :always keeps it. + TOPBAR_BRAND_MODES = %i[always mobile_only].freeze + + def topbar_brand=(value) + mode = value.respond_to?(:to_sym) ? value.to_sym : value + unless TOPBAR_BRAND_MODES.include?(mode) + raise ArgumentError, + "topbar_brand must be one of #{TOPBAR_BRAND_MODES.inspect} (got #{value.inspect})" + end + + @topbar_brand = mode + end + # External links rendered in the topbar next to the theme switcher — a repo # link, a chat invite, a social profile. Each entry is a Hash # ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links @@ -274,6 +307,9 @@ def initialize @app_link = nil @topbar_links = [] @openapi = nil + @brand_logo = nil + @brand_logo_raw = nil + @topbar_brand = :always end # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset — @@ -284,6 +320,17 @@ def app_link DocsKit::TopbarLink.from(@app_link) end + # The normalized shell brand mark (a DocsKit::BrandLogo), or nil when unset. + # Memoized (and invalidated on reassignment) — unlike #app_link's rebuild- + # per-read, because a file: mark shape-checks and reads its SVG on build; + # per-render re-normalization would repeat that IO. A malformed value raises + # here, on first read — loud, never a silently broken header. + def brand_logo + return if @brand_logo_raw.nil? + + @brand_logo ||= DocsKit::BrandLogo.from(@brand_logo_raw) + end + # The normalized topbar links (DocsKit::TopbarLink list), in declaration # order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so # the Shell only ever sees value objects. Blank/nil config yields []. diff --git a/lib/docs_kit/landing_config.rb b/lib/docs_kit/landing_config.rb index fa20af0..cc44ac6 100644 --- a/lib/docs_kit/landing_config.rb +++ b/lib/docs_kit/landing_config.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "brand_logo" + module DocsKit # The per-site landing-page knobs, read by DocsUI::Landing to render a marketing # home page (hero + feature grid + doc index) without a site hand-rolling one. @@ -90,11 +92,11 @@ def install_snippet { code: attrs[:code].to_s, filename: attrs[:filename], lexer: (attrs[:lexer] || :shell).to_sym } end - # The hero logo as a normalized Logo value object, or nil when unset. + # The hero logo as a normalized DocsKit::BrandLogo, or nil when unset. def hero_logo return if @logo.nil? - Logo.from(@logo) + DocsKit::BrandLogo.from(@logo) end # One hero call-to-action button. `style` maps to a daisyUI btn variant @@ -134,27 +136,9 @@ def self.from(feature) end end - # The hero brand logo — either an inline single-path SVG mark (`svg` = the - # `` data, `viewbox` = its viewBox) rendered with fill: currentColor so - # it adapts to the theme, OR an image (`src` = an asset path/URL, `alt` = its - # accessible name). `label` is the accessible name for the inline mark. - Logo = Data.define(:svg, :viewbox, :src, :alt, :label) do - def initialize(svg: nil, viewbox: "0 0 24 24", src: nil, alt: nil, label: nil) - super - end - - def self.from(logo) - return logo if logo.is_a?(self) - - attrs = logo.to_h.transform_keys(&:to_sym) - new( - svg: attrs[:svg], viewbox: attrs[:viewbox] || "0 0 24 24", - src: attrs[:src], alt: attrs[:alt], label: attrs[:label] - ) - end - - # An inline SVG mark (vs. an ). True when `svg` path data is present. - def inline? = !svg.to_s.empty? - end + # The hero brand logo shape now lives in DocsKit::BrandLogo (shared with the + # shell's config.brand_logo); the old nested name stays as an alias so any + # site referencing LandingConfig::Logo keeps working. + Logo = DocsKit::BrandLogo end end diff --git a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb index e10c750..2cf450c 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -21,6 +21,23 @@ Rails.application.config.to_prepare do # docs live under a subpath: # c.brand_href = "/docs" + # Your own mark in the topbar + sidebar header instead of the text brand + # (which stays the accessible name). Exactly ONE of these forms: + # paths: — inline path-d list; fills with currentColor, so the mark + # recolors with the active theme (also `svg:` for one path) + # markup: — a full string, embedded verbatim (use + # fill="currentColor" in it to stay theme-adaptive) + # file: — a .svg under this app (e.g. "app/assets/images/mark.svg"), + # embedded inline at render — same currentColor advice + # src: — an image asset path/URL rendered as an ; note an + # canNOT inherit currentColor, so it won't adapt to the theme + # c.brand_logo = { paths: ["M4 2h9l5 5…Z"], viewbox: "0 0 81 45", label: "<%= app_brand %>" } + + # On desktop (lg:) the pinned sidebar already shows the brand, so the topbar + # copy is a duplicate. :mobile_only hides the topbar brand at lg:; the + # default (:always) keeps it everywhere. + # c.topbar_brand = :mobile_only + # Docs embedded in a bigger app? Add the way BACK to that app — a labeled # link rendered once in the topbar, right after the brand. brand_href is the # DOCS home (brand, sidebar, and the page masthead's "← Docs home" all diff --git a/spec/docs_kit/brand_logo_spec.rb b/spec/docs_kit/brand_logo_spec.rb new file mode 100644 index 0000000..b4aa584 --- /dev/null +++ b/spec/docs_kit/brand_logo_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "tmpdir" +require "fileutils" + +RSpec.describe DocsKit::BrandLogo do + describe ".from" do + it "returns an already-built instance unchanged" do + logo = described_class.from(paths: ["M0 0Z"]) + + expect(described_class.from(logo)).to be(logo) + end + + it "normalizes the single-path svg: form (the landing.logo shape)" do + logo = described_class.from(svg: "M1 1H2Z", viewbox: "0 0 10 10", label: "Acme") + + expect(logo).to be_inline + expect(logo.paths).to eq(["M1 1H2Z"]) + expect(logo.svg).to eq("M1 1H2Z") + expect(logo.viewbox).to eq("0 0 10 10") + expect(logo.label).to eq("Acme") + end + + it "normalizes the multi-path paths: form" do + logo = described_class.from(paths: ["M0 0Z", "M1 1Z"], label: "Acme") + + expect(logo).to be_inline + expect(logo.paths).to eq(["M0 0Z", "M1 1Z"]) + end + + it "defaults the viewbox to a 24×24 box" do + expect(described_class.from(svg: "M0 0Z").viewbox).to eq("0 0 24 24") + end + + it "accepts string keys" do + expect(described_class.from("svg" => "M0 0Z").paths).to eq(["M0 0Z"]) + end + + it "normalizes the raw markup: form" do + logo = described_class.from(markup: %()) + + expect(logo).to be_markup + expect(logo.svg_markup).to include('') + end + + it "rejects markup that is not an element" do + expect { described_class.from(markup: "") } + .to raise_error(ArgumentError, /brand_logo/) + end + + it "normalizes the image src: form" do + logo = described_class.from(src: "logo.png", alt: "Acme") + + expect(logo).to be_image + expect(logo.src).to eq("logo.png") + expect(logo.alt).to eq("Acme") + end + + it "raises when no logo form is given" do + expect { described_class.from(label: "Acme") }.to raise_error(ArgumentError, /brand_logo/) + end + + it "raises when several forms are mixed (ambiguous config)" do + expect { described_class.from(svg: "M0 0Z", src: "logo.png") } + .to raise_error(ArgumentError, /exactly one/) + end + end + + describe "the accessible name" do + it "falls back label → alt and alt → label, so either knob names the mark" do + expect(described_class.from(src: "logo.png", alt: "Acme").label).to eq("Acme") + expect(described_class.from(markup: "", label: "Acme").alt).to eq("Acme") + end + + it "is nil when neither is set (the render-time config.brand fallback applies)" do + expect(described_class.from(svg: "M0 0Z").label).to be_nil + end + end + + describe "the file: form" do + let(:dir) { Dir.mktmpdir } + + after { FileUtils.remove_entry(dir) } + + def write_mark(content, name: "mark.svg") + File.join(dir, name).tap { |path| File.write(path, content) } + end + + it "embeds the file's markup" do + path = write_mark(%()) + logo = described_class.from(file: path, label: "Acme") + + expect(logo).to be_file + expect(logo.svg_markup).to include('') + end + + it "raises on a missing file" do + expect { described_class.from(file: File.join(dir, "nope.svg")) } + .to raise_error(ArgumentError, /brand_logo/) + end + + it "raises on a non-.svg extension (only inline-embeddable SVG is supported)" do + path = write_mark("", name: "mark.png") + + expect { described_class.from(file: path) }.to raise_error(ArgumentError, /\.svg/) + end + + it "raises when the file content is not an element" do + path = write_mark("") + + expect { described_class.from(file: path) }.to raise_error(ArgumentError, /brand_logo/) + end + + it "memoizes the content while the file's mtime is unchanged" do + path = write_mark("") + logo = described_class.from(file: path) + logo.svg_markup + + mtime = File.mtime(path) + File.write(path, "") + File.utime(File.atime(path), mtime, path) + + expect(logo.svg_markup).to include("old") + end + + it "re-reads the content when the file's mtime changes (dev edit, no restart)" do + path = write_mark("") + logo = described_class.from(file: path) + logo.svg_markup + + File.write(path, "") + File.utime(Time.now + 5, Time.now + 5, path) + + expect(logo.svg_markup).to include("new") + end + end +end diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index 82224f2..d7874f2 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -46,6 +46,69 @@ end end + describe "#brand_logo" do + it "defaults to nil (the chrome renders the text brand, byte-identical to before)" do + expect(described_class.new.brand_logo).to be_nil + end + + it "normalizes a Hash into a DocsKit::BrandLogo" do + DocsKit.configure { |c| c.brand_logo = { paths: ["M0 0Z"], label: "Acme" } } + + logo = DocsKit.configuration.brand_logo + expect(logo).to be_a(DocsKit::BrandLogo) + expect(logo.paths).to eq(["M0 0Z"]) + end + + it "memoizes the normalized value (a file: mark must not re-validate per render)" do + DocsKit.configure { |c| c.brand_logo = { svg: "M0 0Z" } } + + config = DocsKit.configuration + first = config.brand_logo + + expect(config.brand_logo).to be(first) + end + + it "re-normalizes after reassignment" do + config = described_class.new + config.brand_logo = { svg: "M0 0Z" } + first = config.brand_logo + config.brand_logo = { svg: "M1 1Z" } + + expect(config.brand_logo).not_to be(first) + expect(config.brand_logo.svg).to eq("M1 1Z") + end + + it "raises on first read of a malformed value (loud, never a silently broken header)" do + config = described_class.new + config.brand_logo = {} + + expect { config.brand_logo }.to raise_error(ArgumentError, /brand_logo/) + end + end + + describe "#topbar_brand" do + it "defaults to :always (strict byte-compat — the topbar brand renders everywhere)" do + expect(described_class.new.topbar_brand).to eq(:always) + end + + it "accepts :mobile_only (the desktop dedup — the sidebar brand already shows at lg:)" do + DocsKit.configure { |c| c.topbar_brand = :mobile_only } + + expect(DocsKit.configuration.topbar_brand).to eq(:mobile_only) + end + + it "accepts the string form (a YAML/ENV-loaded config)" do + DocsKit.configure { |c| c.topbar_brand = "mobile_only" } + + expect(DocsKit.configuration.topbar_brand).to eq(:mobile_only) + end + + it "raises on an unknown mode, naming the knob" do + expect { described_class.new.topbar_brand = :desktop_only } + .to raise_error(ArgumentError, /topbar_brand/) + end + end + describe "#tagline" do it "defaults to nil (the llms.txt blockquote line is omitted)" do expect(described_class.new.tagline).to be_nil diff --git a/spec/docs_ui/landing_spec.rb b/spec/docs_ui/landing_spec.rb index 5649f2a..0ce47b0 100644 --- a/spec/docs_ui/landing_spec.rb +++ b/spec/docs_ui/landing_spec.rb @@ -14,6 +14,41 @@ def nav_item(href, label) DocsKit::NavItem.new(href: href, label: label) end + # The hero logo (config.landing.logo) renders through the shared DocsUI::Logo — + # these pin the pre-unification semantics so the refactor can't drift them. + describe "the hero logo" do + let(:logo_only) do + Class.new(described_class) do + def view_template = logo + end + end + + it "renders nothing when unset" do + expect(logo_only.new.call).to eq("") + end + + it "renders an inline currentColor mark at hero size (theme-adaptive)" do + DocsKit.configure { |c| c.landing.logo = { svg: "M1 1H2Z", viewbox: "0 0 10 10", label: "Acme" } } + html = logo_only.new.call + + expect(html).to include('d="M1 1H2Z"') + expect(html).to include('fill="currentColor"') + expect(html).to match(/viewBox="0 0 10 10"/i) + expect(html).to include("h-9 w-auto text-primary") + expect(html).to include('aria-label="Acme"') + expect(html).to include("Acme") + end + + it "renders the image form as an at hero size" do + DocsKit.configure { |c| c.landing.logo = { src: "logo.svg", alt: "Acme" } } + html = logo_only.new.call + + expect(html).to include(" per entry inside a currentColor svg" do + html = described_class.new(logo(paths: ["M0 0Z", "M4 4Z"], label: "Acme")).call + + expect(html.scan(")" do + html = described_class.new(logo(svg: "M0 0Z", label: "Acme")).call + + expect(html).to include('role="img"') + expect(html).to include('aria-label="Acme"') + expect(html).to include("Acme") + end + + it "falls back to the label: param (the caller passes config.brand) when unlabeled" do + html = described_class.new(logo(svg: "M0 0Z"), label: "Docs").call + + expect(html).to include('aria-label="Docs"') + end + + it "threads class: through to the svg (per-surface sizing)" do + html = described_class.new(logo(svg: "M0 0Z"), class: "h-6 w-auto").call + + expect(html).to include('class="h-6 w-auto"') + end + end + + describe "the markup: form" do + it "embeds the site-authored verbatim inside a sized wrapper" do + html = described_class.new( + logo(markup: %(), label: "Acme"), + class: "h-6 w-auto" + ).call + + expect(html).to include('') + expect(html).to include('role="img"') + expect(html).to include('aria-label="Acme"') + # The wrapper carries the surface sizing; the inner svg fills it. + expect(html).to include("h-6 w-auto") + expect(html).to include("[&>svg]:h-full") + end + end + + describe "the file: form" do + it "embeds the file's markup" do + Dir.mktmpdir do |dir| + path = File.join(dir, "mark.svg") + File.write(path, %()) + + html = described_class.new(logo(file: path, label: "Acme")).call + + expect(html).to include('') + end + end + end + + describe "the image src: form" do + it "renders an with the alt naming the mark" do + html = described_class.new(logo(src: "logo.png", alt: "Acme"), class: "h-6 w-auto").call + + expect(html).to include("]*btn-ghost[^>]*>.*?}m] + expect(anchor).to include("]*class="[^"]*btn-ghost[^"]*lg:hidden/) + end + end + # The opt-in App Home link (config.app_link) — the way back to the hosting # app, rendered ONCE, right after the brand anchor. Absent config → absent # link, so a site that sets nothing keeps a byte-identical topbar. diff --git a/spec/docs_ui/sidebar_spec.rb b/spec/docs_ui/sidebar_spec.rb index 722e7c7..2d3c6ac 100644 --- a/spec/docs_ui/sidebar_spec.rb +++ b/spec/docs_ui/sidebar_spec.rb @@ -25,6 +25,42 @@ def view_template = header_section end end + # The opt-in brand mark (config.brand_logo) — rendered in the header in place + # of the text brand; the version badge survives either way. Absent config → + # the text brand, byte-identical to before. + describe "the brand mark" do + it "renders the text brand with no svg by default (byte-compat)" do + DocsKit.configure { |c| c.brand = "Docs" } + html = header_only.new.call + + expect(html).to include("Docs") + expect(html).not_to include(" { subgroup => [NavItem] } }). # Icon-less items keep the render free of rails_icons; #current_path degrades to # nil outside a Rails request, so the full component renders standalone. diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index fef8ea8..995f413 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -398,6 +398,15 @@ def capture_stream expect(initializer).to include("Back to the app") end + it "documents the optional brand_logo + topbar_brand knobs (commented, so they're opt-in)" do + initializer = read("config/initializers/docs_kit.rb") + + # Commented by default — the text brand renders until a site opts in. + expect(initializer).to include("# c.brand_logo = ") + expect(initializer).to include("# c.topbar_brand = :mobile_only") + expect(initializer).to include("currentColor") + end + it "documents the optional topbar_links knob (commented, so it's opt-in)" do initializer = read("config/initializers/docs_kit.rb")