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
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ Metrics/ClassLength:
# feature grid + a doc index. Each part is a small focused method; the class
# is long only because it renders several sections, like the OpenAPI ones above.
- "app/components/docs_ui/landing.rb"
# Same shape: the whole document chrome (head, theme-restore script, drawer,
# topbar, App Home link) as small focused methods — length is section count.
- "app/components/docs_ui/shell.rb"

Metrics/MethodLength:
Max: 25
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,15 @@ diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templat
# config/initializers/docs_kit.rb
DocsKit.configure do |c|
c.brand = "phlex-reactive"
c.brand_href = "/docs" # brand link target (default "/")
c.brand_href = "/docs" # the DOCS home: brand, sidebar, and "← Docs home" masthead link (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

# Docs embedded in a bigger app? The way BACK to it — a labeled link rendered
# once in the topbar, right after the brand. Unset (default) renders nothing.
c.app_link = { href: "/", label: "Back to the app" }

# 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 All @@ -181,13 +185,15 @@ 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
### The two homes, the brand link, and dark code themes

Three knobs cover what sites used to shim by subclassing `DocsUI::Shell`:
These knobs cover what sites used to shim by subclassing `DocsUI::Shell` or
overriding route helpers:

| 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.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.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 Down
9 changes: 8 additions & 1 deletion app/components/docs_ui/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def view_template
# "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
# inside the skipped nav and never appears in the .md twin.
nav(class: "mb-6 flex items-center justify-between gap-4", data: { md_skip: true }) do
a(href: root_path, class: "link link-hover text-sm opacity-70") { "← Home" }
a(href: home_href, class: "link link-hover text-sm opacity-70") { "← Docs home" }
render DocsUI::MarkdownAction.new(request.path) if markdown_action?
end

Expand All @@ -92,5 +92,12 @@ def lead = nil
def content
raise NotImplementedError, "#{self.class} must implement #content"
end

private

# The masthead "← Docs home" target: config.brand_href (the DOCS home, like
# the topbar/sidebar brand links) — never the host's root_path helper, which
# on an app-embedded site is the application root, not the docs landing.
def home_href = DocsKit.configuration.brand_href
end
end
18 changes: 18 additions & 0 deletions app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def topbar
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 }
app_home_link
end
render DocsUI::SearchBox.new if config.search_enabled?
div(class: "flex-none items-center") do
Expand All @@ -167,5 +168,22 @@ def topbar
end
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.
# External hrefs open in a new tab with rel=noopener (same posture as
# DocsUI::TopbarLinks); a site-relative href opens in place.
def app_home_link
link = config.app_link
return unless link

a(
href: link.href,
class: "link link-hover text-sm opacity-70",
target: (link.external? ? "_blank" : nil),
rel: (link.external? ? "noopener noreferrer" : nil)
) { link.label }
end
end
end
18 changes: 18 additions & 0 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ def nav=(value)
# Read the effective map via #api_clients (which merges), never @api_clients.
attr_writer :api_clients

# The opt-in "App Home" link — the way back to the application that hosts
# the docs, rendered ONCE in the topbar right after the brand (e.g.
# "Back to the app" on a docs site embedded in a bigger app). A Hash
# ({ href:, label: }) or a DocsKit::TopbarLink; #app_link normalizes it.
# Defaults to nil → no link renders and the topbar is byte-identical to
# before. Distinct from #brand_href, which is the DOCS home (the brand,
# sidebar, and page-masthead links). Read via #app_link, never @app_link.
attr_writer :app_link

# 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
Expand Down Expand Up @@ -262,10 +271,19 @@ def initialize
@api_base_url = "https://api.example.com"
@api_auth_header = nil
@api_clients = {}
@app_link = nil
@topbar_links = []
@openapi = nil
end

# The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
# absent config, absent link, exactly like every other opt-in knob.
def app_link
return if @app_link.nil?

DocsKit::TopbarLink.from(@app_link)
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 [].
Expand Down
6 changes: 6 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 @@ -21,6 +21,12 @@ Rails.application.config.to_prepare do
# docs live under a subpath:
# c.brand_href = "/docs"

# 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
# follow it); app_link is the APP home. Unset (the default) renders nothing.
# c.app_link = { href: "/", label: "Back to the app" }

# Repo/social links in the topbar, next to the theme switcher. Each renders
# as an icon-only button; `icon` is a shipped brand mark (:github, :gitlab,
# :discord, :x, :rubygems, :bluesky, :mastodon, :slack, :whatsapp, :telegram,
Expand Down
30 changes: 30 additions & 0 deletions spec/docs_kit/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@
end
end

describe "#app_link" do
it "defaults to nil (no App Home link renders)" do
expect(described_class.new.app_link).to be_nil
end

it "normalizes a symbol-keyed Hash into a DocsKit::TopbarLink" do
DocsKit.configure { |c| c.app_link = { href: "/", label: "Back to the app" } }

link = DocsKit.configuration.app_link
expect(link).to be_a(DocsKit::TopbarLink)
expect(link.href).to eq("/")
expect(link.label).to eq("Back to the app")
end

it "normalizes a string-keyed Hash (a YAML/JSON-loaded config) the same way" do
DocsKit.configure { |c| c.app_link = { "href" => "/app", "label" => "App Home" } }

link = DocsKit.configuration.app_link
expect(link.href).to eq("/app")
expect(link.label).to eq("App Home")
end

it "passes an existing DocsKit::TopbarLink through unchanged" do
value = DocsKit::TopbarLink.new(href: "/", label: "Back to the app")
DocsKit.configure { |c| c.app_link = value }

expect(DocsKit.configuration.app_link).to be(value)
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
Expand Down
40 changes: 40 additions & 0 deletions spec/docs_ui/page_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,46 @@ def content = nil
stdout
end

# The masthead "← Docs home" link follows config.brand_href — the DOCS home —
# never the host app's root_path helper (on an app-embedded site root_path is
# the app dashboard, which bounced anonymous readers off the docs; see
# issue #62). Exercised through #home_href in the same
# isolated child process (Page needs Rails to load).
define_method(:resolve_home_href) do |config: ""|
script = <<~RUBY
$LOAD_PATH.unshift "#{gem_root}/lib"
require "active_support/all"
require "action_dispatch"
require "phlex/rails"
require "daisy_ui"
module Rails
def self.application
@app ||= Class.new do
def routes = @routes ||= ActionDispatch::Routing::RouteSet.new
end.new
end
end
require "docs_kit"
DocsKit.configure { |c| #{config} }
klass = Class.new(DocsUI::Page) { def content = nil }
print klass.allocate.send(:home_href).inspect
RUBY
stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-e", script)
raise "child process failed: #{stderr}" unless status.success?

stdout
end

describe "DocsUI::Page#home_href (the masthead \"← Docs home\" target)" do
it "defaults to \"/\" — the same destination root_path resolved to on a standalone site" do
expect(resolve_home_href).to eq('"/"')
end

it "follows config.brand_href so an app-embedded site points it at the docs landing" do
expect(resolve_home_href(config: 'c.brand_href = "/docs"')).to eq('"/docs"')
end
end

describe "DocsUI::Page.description (the per-page SEO description)" do
it "returns an explicitly set description" do
expect(resolve_description(body: %(description "Add the gem and render."))).to eq('"Add the gem and render."')
Expand Down
48 changes: 48 additions & 0 deletions spec/docs_ui/shell_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,54 @@ def view_template = topbar
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.
describe "the topbar App Home link" do
let(:topbar_only) do
Class.new(described_class) do
def view_template = topbar
end
end

it "renders no App Home link by default" do
html = topbar_only.new.call

expect(html).not_to include("Back to")
end

it "renders the configured link after the brand anchor" do
DocsKit.configure do |c|
c.brand = "Docs"
c.brand_href = "/docs"
c.app_link = { href: "/", label: "Back to the app" }
end
html = topbar_only.new.call

brand = html.index('href="/docs"')
app = html.index("Back to the app")
expect(brand).to be_truthy
expect(app).to be > brand
expect(html).to include('href="/"')
end

it "opens an external App Home href in a new tab with rel=noopener" do
DocsKit.configure { |c| c.app_link = { href: "https://app.example.com", label: "Back to the app" } }
html = topbar_only.new.call

expect(html).to include('target="_blank"')
expect(html).to include("noopener")
end

it "opens a site-relative App Home href in place (no target/rel)" do
DocsKit.configure { |c| c.app_link = { href: "/", label: "Back to the app" } }
html = topbar_only.new.call

expect(html).not_to include('target="_blank"')
expect(html).not_to include("noopener")
end
end

# The topbar search form is the JS-off search entry point: a plain GET form to
# config.search_path with an input named "q". It renders only when search is
# enabled, so a site can opt out with c.search = false.
Expand Down
8 changes: 8 additions & 0 deletions spec/generators/install_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ def capture_stream
expect(initializer).to match(/openapi\.ya?ml/)
end

it "documents the optional app_link knob (commented, so it's opt-in)" do
initializer = read("config/initializers/docs_kit.rb")

# Commented by default — a standalone docs site has no app to link back to.
expect(initializer).to include("# c.app_link = ")
expect(initializer).to include("Back to the app")
end

it "documents the optional topbar_links knob (commented, so it's opt-in)" do
initializer = read("config/initializers/docs_kit.rb")

Expand Down
Loading