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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 22 additions & 1 deletion app/components/docs_ui/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/config/initializers/docs_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title> (e.g. "Installation · phlex-reactive").
# Defaults to #brand when unset.
attr_writer :title_suffix
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -128,6 +159,7 @@ class Configuration

def initialize
@brand = "Docs"
@brand_href = "/"
@title_suffix = nil
@themes = %w[dark light]
@default_theme = nil
Expand All @@ -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
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions lib/generators/docs_kit/install/templates/docs_kit.rb.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
83 changes: 83 additions & 0 deletions spec/docs_kit/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
55 changes: 55 additions & 0 deletions spec/docs_ui/code_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions spec/docs_ui/shell_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading