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.