diff --git a/README.md b/README.md
index eb326ba..7bbf351 100644
--- a/README.md
+++ b/README.md
@@ -54,10 +54,15 @@ gem "rouge"
# config/initializers/docs_kit.rb
DocsKit.configure do |c|
c.brand = "phlex-reactive"
+ c.brand_href = "/docs" # brand link target (default "/")
c.title_suffix = "phlex-reactive"
c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
c.version_badge = -> { "v#{Phlex::Reactive::VERSION}" } # optional
+ # Code blocks: a light theme by default, a dark theme on dark daisyUI themes.
+ c.code_theme = "Rouge::Themes::Github" # base (light) theme
+ c.code_theme_dark = "Rouge::Themes::Monokai" # optional dark override
+
# The sidebar derives from your registries — one heading → one registry.
c.nav_registries = { "Docs" => Doc }
end
@@ -68,6 +73,22 @@ registry maps a heading to its authored pages (`Doc.nav_items`); a page that
isn't written yet is skipped, so there are no dead links. Register a page with
one line (see [Add a page](#add-a-page)) and it appears in the sidebar.
+### Brand link and dark code themes
+
+Three knobs cover what sites used to shim by subclassing `DocsUI::Shell`:
+
+| Knob | Default | What it does |
+|------|---------|--------------|
+| `c.brand_href` | `"/"` | The href of the topbar brand link. Set it (e.g. `"/docs"`) instead of subclassing `Shell` to copy-paste `#topbar`. |
+| `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]`). |
+
+The dark restyle is **CSS-only** — daisyUI's `[data-theme]` selector is more
+specific than the un-scoped base rule, so the theme switcher restyles code
+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.
+
### Custom nav (advanced)
Sites that interleave several registries under a heading, or need custom
diff --git a/app/components/docs_ui/code.rb b/app/components/docs_ui/code.rb
index d68e0ba..73aef6b 100644
--- a/app/components/docs_ui/code.rb
+++ b/app/components/docs_ui/code.rb
@@ -80,12 +80,33 @@ def find_lexer(name)
end
# Static Rouge theme CSS — no user input. Phlex safe(), not html_safe.
+ #
+ # The base (light) theme is emitted un-scoped so it applies to every theme.
+ # When a dark theme is configured (config.code_theme_dark), its CSS is
+ # additionally emitted scoped under [data-theme=X] .code-highlight for each
+ # shipped dark theme, so daisyUI's more-specific [data-theme] selector wins
+ # and code blocks restyle with the switcher — CSS-only, no JS, no flash.
+ # With no dark theme configured this reduces to the original single-theme
+ # output byte-for-byte (backwards compatible).
def highlight_css
theme = DocsKit.configuration.code_theme_class
raw(safe(<<~CSS))
- #{theme.render(scope: '.code-highlight')}
+ #{theme.render(scope: '.code-highlight')}#{dark_highlight_css}
.code-highlight pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
CSS
end
+
+ # The dark theme's CSS, one block per shipped dark theme, each scoped under
+ # [data-theme=X] .code-highlight. Empty string when no dark theme is
+ # configured (or no shipped theme is dark) so #highlight_css is unchanged.
+ def dark_highlight_css
+ config = DocsKit.configuration
+ dark = config.code_theme_dark_class
+ return "" if dark.nil?
+
+ config.dark_themes_shipped.map do |name|
+ "\n#{dark.render(scope: "[data-theme=#{name}] .code-highlight")}"
+ end.join
+ end
end
end
diff --git a/app/components/docs_ui/shell.rb b/app/components/docs_ui/shell.rb
index f21a572..45d1714 100644
--- a/app/components/docs_ui/shell.rb
+++ b/app/components/docs_ui/shell.rb
@@ -143,7 +143,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: "/", class: "btn btn-ghost text-lg font-bold") { config.brand }
+ a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
end
div(class: "flex-none") do
render DocsUI::ThemeSwitcher.new
diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb
index 689afbd..82afc55 100644
--- a/docs/config/initializers/docs_kit.rb
+++ b/docs/config/initializers/docs_kit.rb
@@ -10,7 +10,13 @@
c.brand = "docs-kit"
c.title_suffix = "docs-kit"
c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
- c.code_theme = "Rouge::Themes::Monokai"
+
+ # Code blocks carry a light theme by default and swap to a dark theme when the
+ # switcher lands on a dark daisyUI theme (dark/synthwave/dracula/night/sunset
+ # here). Dogfooded so the light↔dark restyle is visible across all 9 themes;
+ # it's CSS-only ([data-theme=X] scoping), so there's no JS and no flash.
+ c.code_theme = "Rouge::Themes::Github" # light themes
+ c.code_theme_dark = "Rouge::Themes::Monokai" # dark themes (see c.dark_themes)
# Any language Rouge knows works in code blocks out of the box. Add friendly
# aliases/labels here if you use custom names:
diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb
index c83ddfc..fa7409c 100644
--- a/lib/docs_kit/configuration.rb
+++ b/lib/docs_kit/configuration.rb
@@ -16,6 +16,11 @@ class Configuration
# The brand text shown in the topbar and sidebar header.
attr_accessor :brand
+ # The href the topbar brand link points at. Defaults to "/" (site root). A
+ # site whose docs live under a subpath sets its own (e.g. "/docs") so the
+ # brand link is a one-line config change, not a Shell subclass.
+ attr_accessor :brand_href
+
# Appended to a page's
(e.g. "Installation · phlex-reactive").
# Defaults to #brand when unset.
attr_writer :title_suffix
@@ -57,8 +62,24 @@ class Configuration
attr_accessor :stylesheets
# The Rouge theme class used by Docs::Code for inline syntax-highlight CSS.
+ # This is the BASE (light) theme, emitted un-scoped so it applies to every
+ # theme unless a dark override wins (see #code_theme_dark).
attr_accessor :code_theme
+ # An optional second Rouge theme (String name or Class) used for the site's
+ # DARK daisyUI themes. Default nil → single-theme behavior, fully backwards
+ # compatible. When set, Docs::Code additionally emits this theme's CSS scoped
+ # under [data-theme=X] .code-highlight for each shipped dark theme (see
+ # #dark_themes), so code blocks stay readable when the switcher flips to a
+ # dark theme — CSS-only, no JS, no flash.
+ attr_accessor :code_theme_dark
+
+ # The theme names treated as DARK for #code_theme_dark scoping. Defaults to
+ # the built-in daisyUI dark themes (DEFAULT_DARK_THEMES). Intersected with
+ # #themes at render time (see #dark_themes_shipped) so only shipped themes
+ # generate CSS. Override to name custom dark themes (e.g. %w[zazu-dark]).
+ attr_accessor :dark_themes
+
# The lucide icon name used for a nav group with no explicit icon.
attr_accessor :default_group_icon
@@ -116,6 +137,16 @@ class Configuration
# identity to decide whether to derive the sidebar from #nav_registries.
DEFAULT_NAV = -> { {} }
+ # The built-in daisyUI theme names that are dark. #dark_themes defaults to
+ # this; #dark_themes_shipped intersects it with the site's #themes so only
+ # shipped themes ever generate dark code CSS. A site with custom dark themes
+ # overrides #dark_themes (docs-kit can't see the compiled daisyUI CSS to
+ # detect darkness at render time, so an honest static list + override wins).
+ DEFAULT_DARK_THEMES = %w[
+ dark synthwave halloween forest black luxury dracula
+ business night coffee dim sunset abyss
+ ].freeze
+
# Built-in friendly aliases (kept small — Rouge resolves most names itself).
DEFAULT_LEXER_ALIASES = { curl: "console", console: "console" }.freeze
@@ -128,6 +159,7 @@ class Configuration
def initialize
@brand = "Docs"
+ @brand_href = "/"
@title_suffix = nil
@themes = %w[dark light]
@default_theme = nil
@@ -139,6 +171,8 @@ def initialize
@version_badge = nil
@stylesheets = %w[application]
@code_theme = "Rouge::Themes::Monokai"
+ @code_theme_dark = nil
+ @dark_themes = DEFAULT_DARK_THEMES
@default_group_icon = "file-text"
@icon_library = "lucide"
@nav_storage_key = nil
@@ -253,5 +287,23 @@ def code_theme_class
Object.const_get(@code_theme.to_s)
end
+
+ # The dark Rouge theme class resolved from #code_theme_dark (String or
+ # class), or nil when unset — mirrors #code_theme_class. Docs::Code emits
+ # dark code CSS only when this is non-nil.
+ def code_theme_dark_class
+ return if @code_theme_dark.nil?
+ return @code_theme_dark if @code_theme_dark.is_a?(Class)
+
+ Object.const_get(@code_theme_dark.to_s)
+ end
+
+ # The dark themes the site actually ships: #dark_themes intersected with
+ # #themes, in #themes declaration order. Docs::Code scopes the dark theme's
+ # CSS under [data-theme=X] for each of these, so a dark theme that isn't in
+ # the Tailwind build never emits dead CSS.
+ def dark_themes_shipped
+ Array(@themes) & Array(@dark_themes)
+ end
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 6679b9f..cbd6a99 100644
--- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb
+++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb
@@ -12,6 +12,18 @@ Rails.application.config.to_prepare do
c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
c.code_theme = "Rouge::Themes::Monokai"
+ # The topbar brand link points at "/" by default. Point it elsewhere if your
+ # docs live under a subpath:
+ # c.brand_href = "/docs"
+
+ # Code blocks use one Rouge theme by default. To keep them readable in BOTH
+ # light and dark daisyUI themes, set a light base + a dark override — the dark
+ # theme's CSS is scoped under [data-theme=X] for each shipped dark theme, so
+ # the switcher restyles code blocks with no JS and no flash:
+ # c.code_theme = "Rouge::Themes::Github" # light themes
+ # c.code_theme_dark = "Rouge::Themes::Monokai" # dark themes
+ # c.dark_themes = %w[my-dark] # only if you ship custom dark themes
+
# Any language Rouge knows works in code blocks out of the box. Add friendly
# aliases/labels here if you use custom names:
# c.code_lexer_aliases = { curl: "console" }
diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb
index cb7eabb..526c7f2 100644
--- a/spec/docs_kit/configuration_spec.rb
+++ b/spec/docs_kit/configuration_spec.rb
@@ -1,6 +1,89 @@
# frozen_string_literal: true
RSpec.describe DocsKit::Configuration do
+ describe "#brand_href" do
+ it "defaults to \"/\" (the topbar brand link's current target)" do
+ expect(described_class.new.brand_href).to eq("/")
+ end
+
+ it "is overridable so a site can point the brand link elsewhere (e.g. /docs)" do
+ DocsKit.configure { |c| c.brand_href = "/docs" }
+
+ expect(DocsKit.configuration.brand_href).to eq("/docs")
+ end
+ end
+
+ describe "#code_theme_dark" do
+ it "defaults to nil (single-theme behavior, fully backwards compatible)" do
+ expect(described_class.new.code_theme_dark).to be_nil
+ end
+
+ it "is overridable with a Rouge theme for dark daisyUI themes" do
+ DocsKit.configure { |c| c.code_theme_dark = "Rouge::Themes::Monokai" }
+
+ expect(DocsKit.configuration.code_theme_dark).to eq("Rouge::Themes::Monokai")
+ end
+ end
+
+ describe "#code_theme_dark_class" do
+ it "returns nil when code_theme_dark is unset" do
+ expect(described_class.new.code_theme_dark_class).to be_nil
+ end
+
+ it "resolves a String theme name to the Rouge theme class" do
+ DocsKit.configure { |c| c.code_theme_dark = "Rouge::Themes::Monokai" }
+
+ expect(DocsKit.configuration.code_theme_dark_class).to eq(Rouge::Themes::Monokai)
+ end
+
+ it "passes a Rouge theme Class through unchanged" do
+ DocsKit.configure { |c| c.code_theme_dark = Rouge::Themes::Monokai }
+
+ expect(DocsKit.configuration.code_theme_dark_class).to eq(Rouge::Themes::Monokai)
+ end
+ end
+
+ describe "#dark_themes" do
+ it "defaults to the built-in daisyUI dark theme names" do
+ expect(described_class.new.dark_themes).to eq(DocsKit::Configuration::DEFAULT_DARK_THEMES)
+ end
+
+ it "ships a frozen default constant so it can't be mutated in place" do
+ expect(DocsKit::Configuration::DEFAULT_DARK_THEMES).to be_frozen
+ end
+
+ it "is overridable so a site can name its custom dark themes (e.g. zazu-dark)" do
+ DocsKit.configure { |c| c.dark_themes = %w[zazu-dark] }
+
+ expect(DocsKit.configuration.dark_themes).to eq(%w[zazu-dark])
+ end
+ end
+
+ describe "#dark_themes_shipped" do
+ it "intersects dark_themes with themes so only shipped themes generate CSS" do
+ DocsKit.configure do |c|
+ c.themes = %w[light dark synthwave]
+ end
+
+ # dark + synthwave are dark daisyUI themes AND shipped; light is not dark.
+ expect(DocsKit.configuration.dark_themes_shipped).to eq(%w[dark synthwave])
+ end
+
+ it "preserves theme declaration order (not the dark-list order)" do
+ DocsKit.configure do |c|
+ c.themes = %w[synthwave light dark]
+ end
+
+ expect(DocsKit.configuration.dark_themes_shipped).to eq(%w[synthwave dark])
+ end
+
+ it "is empty when no shipped theme is a dark theme" do
+ DocsKit.configure { |c| c.themes = %w[light retro] }
+
+ expect(DocsKit.configuration.dark_themes_shipped).to eq([])
+ end
+ end
+
describe "#icon_library" do
it "defaults to lucide (matching the lucide icon names docs-kit ships)" do
expect(described_class.new.icon_library).to eq("lucide")
diff --git a/spec/docs_ui/code_spec.rb b/spec/docs_ui/code_spec.rb
index 2afaf60..a1747b6 100644
--- a/spec/docs_ui/code_spec.rb
+++ b/spec/docs_ui/code_spec.rb
@@ -36,6 +36,61 @@ def csp_nonce = "testnonce"
end
end
+ describe "dark-theme code CSS (code_theme_dark)" do
+ it "is byte-identical to the single-theme output when code_theme_dark is nil" do
+ # Regression guard: the default (nil) path must not change existing output.
+ before = described_class.new("puts 'hi'").call
+
+ DocsKit.configure { |c| c.code_theme_dark = nil }
+ after = described_class.new("puts 'hi'").call
+
+ expect(after).to eq(before)
+ end
+
+ it "emits dark-theme CSS scoped under [data-theme=X] for each shipped dark theme" do
+ DocsKit.configure do |c|
+ c.themes = %w[light dark]
+ c.code_theme_dark = "Rouge::Themes::Github"
+ end
+ html = described_class.new("puts 'hi'").call
+
+ expect(html).to include("[data-theme=dark] .code-highlight")
+ end
+
+ it "does NOT emit CSS for a dark theme the site doesn't ship" do
+ DocsKit.configure do |c|
+ c.themes = %w[light dark] # synthwave is dark but NOT shipped
+ c.code_theme_dark = "Rouge::Themes::Github"
+ end
+ html = described_class.new("puts 'hi'").call
+
+ expect(html).not_to include("[data-theme=synthwave]")
+ end
+
+ it "still emits the base (light) theme CSS alongside the dark rules" do
+ DocsKit.configure do |c|
+ c.themes = %w[light dark]
+ c.code_theme_dark = "Rouge::Themes::Github"
+ end
+ html = described_class.new("puts 'hi'").call
+
+ # The un-scoped base rule (light) and the data-theme-scoped dark rule
+ # coexist, so the switcher restyles code blocks per theme with no JS.
+ expect(html).to include(".code-highlight")
+ expect(html).to include("[data-theme=dark] .code-highlight")
+ end
+
+ it "emits no dark rules when no shipped theme is a dark theme" do
+ DocsKit.configure do |c|
+ c.themes = %w[light retro]
+ c.code_theme_dark = "Rouge::Themes::Github"
+ end
+ html = described_class.new("puts 'hi'").call
+
+ expect(html).not_to include("[data-theme=")
+ end
+ end
+
it "renders a title bar with the filename when given" do
html = described_class.new("x = 1", filename: "app/models/x.rb").call
diff --git a/spec/docs_ui/shell_spec.rb b/spec/docs_ui/shell_spec.rb
index 0038e9d..a668c3e 100644
--- a/spec/docs_ui/shell_spec.rb
+++ b/spec/docs_ui/shell_spec.rb
@@ -56,6 +56,31 @@ def csp_nonce = "testnonce"
end
end
+ # The topbar brand link is a config knob so a site can point it at /docs
+ # instead of / without subclassing Shell to copy-paste #topbar.
+ describe "the topbar brand link" do
+ # Renders ONLY the topbar so we don't need a live Rails view context for the
+ # rest of the document (importmap/csrf tags require a real request).
+ let(:topbar_only) do
+ Class.new(described_class) do
+ def view_template = topbar
+ end
+ end
+
+ it "defaults the brand href to \"/\"" do
+ html = topbar_only.new.call
+
+ expect(html).to include('href="/"')
+ end
+
+ it "follows config.brand_href when a site overrides it" do
+ DocsKit.configure { |c| c.brand_href = "/docs" }
+ html = topbar_only.new.call
+
+ expect(html).to include('href="/docs"')
+ end
+ end
+
# A focused proof of the primitive the whole fix relies on: Phlex omits an
# attribute whose value is nil (it does NOT render nonce=""), so the
# no-nonce path degrades cleanly to the pre-fix, un-nonced markup.