Skip to content

Commit 7eeb15c

Browse files
committed
feat(shell): two homes — masthead follows brand_href, opt-in app_link topbar link
## Summary DocsUI::Page's masthead "← Home" hardcoded the host's `root_path` helper — on an app-embedded docs site that's the application root (in Zazu, the authenticated dashboard), so anonymous readers were bounced off the docs and sites had to patch the route helper (getzazu/app#3193). The masthead now follows `config.brand_href` (the DOCS home, same as the topbar/sidebar brand links), relabeled "← Docs home". A new opt-in `c.app_link = { href:, label: }` (default nil → absent) renders the way BACK to the hosting app once, in the topbar right after the brand; external hrefs open in a new tab with rel=noopener, mirroring TopbarLinks. ## Test Coverage - configuration_spec: app_link defaults nil; Hash (symbol/string keys) normalizes to DocsKit::TopbarLink; TopbarLink passes through - shell_spec: unset → topbar unchanged; set → labeled anchor after the brand; external → target=_blank + noopener; relative → neither - page_spec: #home_href defaults "/" and follows c.brand_href (child-process harness — Page can't load in the Rails-free suite) - install_generator_spec: the generated initializer documents the commented c.app_link example ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec passes (795 examples, 94.87% line coverage) Refs #62
1 parent 2f61298 commit 7eeb15c

10 files changed

Lines changed: 189 additions & 5 deletions

File tree

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ Metrics/ClassLength:
124124
# feature grid + a doc index. Each part is a small focused method; the class
125125
# is long only because it renders several sections, like the OpenAPI ones above.
126126
- "app/components/docs_ui/landing.rb"
127+
# Same shape: the whole document chrome (head, theme-restore script, drawer,
128+
# topbar, App Home link) as small focused methods — length is section count.
129+
- "app/components/docs_ui/shell.rb"
127130

128131
Metrics/MethodLength:
129132
Max: 25

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,15 @@ diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templat
157157
# config/initializers/docs_kit.rb
158158
DocsKit.configure do |c|
159159
c.brand = "phlex-reactive"
160-
c.brand_href = "/docs" # brand link target (default "/")
160+
c.brand_href = "/docs" # the DOCS home: brand, sidebar, and "← Docs home" masthead link (default "/")
161161
c.title_suffix = "phlex-reactive"
162162
c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset]
163163
c.version_badge = -> { "v#{Phlex::Reactive::VERSION}" } # optional
164164

165+
# Docs embedded in a bigger app? The way BACK to it — a labeled link rendered
166+
# once in the topbar, right after the brand. Unset (default) renders nothing.
167+
c.app_link = { href: "/", label: "Back to the app" }
168+
165169
# Repo/social links in the topbar (next to the theme switcher).
166170
c.topbar_links = [
167171
{ href: "https://github.com/you/phlex-reactive", label: "GitHub", icon: :github },
@@ -181,13 +185,15 @@ registry maps a heading to its authored pages (`Doc.nav_items`); a page that
181185
isn't written yet is skipped, so there are no dead links. Register a page with
182186
one line (see [Add a page](#add-a-page)) and it appears in the sidebar.
183187

184-
### Brand link and dark code themes
188+
### The two homes, the brand link, and dark code themes
185189

186-
Three knobs cover what sites used to shim by subclassing `DocsUI::Shell`:
190+
These knobs cover what sites used to shim by subclassing `DocsUI::Shell` or
191+
overriding route helpers:
187192

188193
| Knob | Default | What it does |
189194
|------|---------|--------------|
190-
| `c.brand_href` | `"/"` | The href of the topbar brand link. Set it (e.g. `"/docs"`) instead of subclassing `Shell` to copy-paste `#topbar`. |
195+
| `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`. |
196+
| `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 Zazu" }`). Unset renders nothing, so a standalone docs site is unchanged. External hrefs open in a new tab with `rel=noopener`. |
191197
| `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. |
192198
| `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]`). |
193199

app/components/docs_ui/page.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def view_template
6565
# "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
6666
# inside the skipped nav and never appears in the .md twin.
6767
nav(class: "mb-6 flex items-center justify-between gap-4", data: { md_skip: true }) do
68-
a(href: root_path, class: "link link-hover text-sm opacity-70") { "← Home" }
68+
a(href: home_href, class: "link link-hover text-sm opacity-70") { "← Docs home" }
6969
render DocsUI::MarkdownAction.new(request.path) if markdown_action?
7070
end
7171

@@ -92,5 +92,12 @@ def lead = nil
9292
def content
9393
raise NotImplementedError, "#{self.class} must implement #content"
9494
end
95+
96+
private
97+
98+
# The masthead "← Docs home" target: config.brand_href (the DOCS home, like
99+
# the topbar/sidebar brand links) — never the host's root_path helper, which
100+
# on an app-embedded site is the application root, not the docs landing.
101+
def home_href = DocsKit.configuration.brand_href
95102
end
96103
end

app/components/docs_ui/shell.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def topbar
157157
label(for: DRAWER_ID, class: "btn btn-square btn-ghost btn-sm lg:hidden",
158158
aria_label: "Open menu") { render DocsUI::Icon.new("menu", class: "size-5") }
159159
a(href: config.brand_href, class: "btn btn-ghost text-lg font-bold") { config.brand }
160+
app_home_link
160161
end
161162
render DocsUI::SearchBox.new if config.search_enabled?
162163
div(class: "flex-none items-center") do
@@ -167,5 +168,22 @@ def topbar
167168
end
168169
end
169170
end
171+
172+
# The opt-in App Home link (config.app_link) — the way back to the hosting
173+
# app, rendered once, right after the brand. Nothing renders when unset, so
174+
# the topbar stays byte-identical for a site that never configures it.
175+
# External hrefs open in a new tab with rel=noopener (same posture as
176+
# DocsUI::TopbarLinks); a site-relative href opens in place.
177+
def app_home_link
178+
link = config.app_link
179+
return unless link
180+
181+
a(
182+
href: link.href,
183+
class: "link link-hover text-sm opacity-70",
184+
target: (link.external? ? "_blank" : nil),
185+
rel: (link.external? ? "noopener noreferrer" : nil)
186+
) { link.label }
187+
end
170188
end
171189
end

lib/docs_kit/configuration.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,15 @@ def nav=(value)
184184
# Read the effective map via #api_clients (which merges), never @api_clients.
185185
attr_writer :api_clients
186186

187+
# The opt-in "App Home" link — the way back to the application that hosts
188+
# the docs, rendered ONCE in the topbar right after the brand (e.g.
189+
# "Back to Zazu" on a docs site embedded in a bigger app). A Hash
190+
# ({ href:, label: }) or a DocsKit::TopbarLink; #app_link normalizes it.
191+
# Defaults to nil → no link renders and the topbar is byte-identical to
192+
# before. Distinct from #brand_href, which is the DOCS home (the brand,
193+
# sidebar, and page-masthead links). Read via #app_link, never @app_link.
194+
attr_writer :app_link
195+
187196
# External links rendered in the topbar next to the theme switcher — a repo
188197
# link, a chat invite, a social profile. Each entry is a Hash
189198
# ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links
@@ -262,10 +271,19 @@ def initialize
262271
@api_base_url = "https://api.example.com"
263272
@api_auth_header = nil
264273
@api_clients = {}
274+
@app_link = nil
265275
@topbar_links = []
266276
@openapi = nil
267277
end
268278

279+
# The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
280+
# absent config, absent link, exactly like every other opt-in knob.
281+
def app_link
282+
return if @app_link.nil?
283+
284+
DocsKit::TopbarLink.from(@app_link)
285+
end
286+
269287
# The normalized topbar links (DocsKit::TopbarLink list), in declaration
270288
# order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so
271289
# the Shell only ever sees value objects. Blank/nil config yields [].

lib/generators/docs_kit/install/templates/docs_kit.rb.erb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ Rails.application.config.to_prepare do
2121
# docs live under a subpath:
2222
# c.brand_href = "/docs"
2323

24+
# Docs embedded in a bigger app? Add the way BACK to that app — a labeled
25+
# link rendered once in the topbar, right after the brand. brand_href is the
26+
# DOCS home (brand, sidebar, and the page masthead's "← Docs home" all
27+
# follow it); app_link is the APP home. Unset (the default) renders nothing.
28+
# c.app_link = { href: "/", label: "Back to the app" }
29+
2430
# Repo/social links in the topbar, next to the theme switcher. Each renders
2531
# as an icon-only button; `icon` is a shipped brand mark (:github, :gitlab,
2632
# :discord, :x, :rubygems, :bluesky, :mastodon, :slack, :whatsapp, :telegram,

spec/docs_kit/configuration_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@
1616
end
1717
end
1818

19+
describe "#app_link" do
20+
it "defaults to nil (no App Home link renders)" do
21+
expect(described_class.new.app_link).to be_nil
22+
end
23+
24+
it "normalizes a symbol-keyed Hash into a DocsKit::TopbarLink" do
25+
DocsKit.configure { |c| c.app_link = { href: "/", label: "Back to Zazu" } }
26+
27+
link = DocsKit.configuration.app_link
28+
expect(link).to be_a(DocsKit::TopbarLink)
29+
expect(link.href).to eq("/")
30+
expect(link.label).to eq("Back to Zazu")
31+
end
32+
33+
it "normalizes a string-keyed Hash (a YAML/JSON-loaded config) the same way" do
34+
DocsKit.configure { |c| c.app_link = { "href" => "/app", "label" => "App Home" } }
35+
36+
link = DocsKit.configuration.app_link
37+
expect(link.href).to eq("/app")
38+
expect(link.label).to eq("App Home")
39+
end
40+
41+
it "passes an existing DocsKit::TopbarLink through unchanged" do
42+
value = DocsKit::TopbarLink.new(href: "/", label: "Back to the app")
43+
DocsKit.configure { |c| c.app_link = value }
44+
45+
expect(DocsKit.configuration.app_link).to be(value)
46+
end
47+
end
48+
1949
describe "#tagline" do
2050
it "defaults to nil (the llms.txt blockquote line is omitted)" do
2151
expect(described_class.new.tagline).to be_nil

spec/docs_ui/page_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,46 @@ def content = nil
9797
stdout
9898
end
9999

100+
# The masthead "← Docs home" link follows config.brand_href — the DOCS home —
101+
# never the host app's root_path helper (on an app-embedded site root_path is
102+
# the app dashboard, which bounced anonymous readers off the docs; see
103+
# issue #62 / getzazu/app#3193). Exercised through #home_href in the same
104+
# isolated child process (Page needs Rails to load).
105+
define_method(:resolve_home_href) do |config: ""|
106+
script = <<~RUBY
107+
$LOAD_PATH.unshift "#{gem_root}/lib"
108+
require "active_support/all"
109+
require "action_dispatch"
110+
require "phlex/rails"
111+
require "daisy_ui"
112+
module Rails
113+
def self.application
114+
@app ||= Class.new do
115+
def routes = @routes ||= ActionDispatch::Routing::RouteSet.new
116+
end.new
117+
end
118+
end
119+
require "docs_kit"
120+
DocsKit.configure { |c| #{config} }
121+
klass = Class.new(DocsUI::Page) { def content = nil }
122+
print klass.allocate.send(:home_href).inspect
123+
RUBY
124+
stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-e", script)
125+
raise "child process failed: #{stderr}" unless status.success?
126+
127+
stdout
128+
end
129+
130+
describe "DocsUI::Page#home_href (the masthead \"← Docs home\" target)" do
131+
it "defaults to \"/\" — the same destination root_path resolved to on a standalone site" do
132+
expect(resolve_home_href).to eq('"/"')
133+
end
134+
135+
it "follows config.brand_href so an app-embedded site points it at the docs landing" do
136+
expect(resolve_home_href(config: 'c.brand_href = "/docs"')).to eq('"/docs"')
137+
end
138+
end
139+
100140
describe "DocsUI::Page.description (the per-page SEO description)" do
101141
it "returns an explicitly set description" do
102142
expect(resolve_description(body: %(description "Add the gem and render."))).to eq('"Add the gem and render."')

spec/docs_ui/shell_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,54 @@ def view_template = topbar
8181
end
8282
end
8383

84+
# The opt-in App Home link (config.app_link) — the way back to the hosting
85+
# app, rendered ONCE, right after the brand anchor. Absent config → absent
86+
# link, so a site that sets nothing keeps a byte-identical topbar.
87+
describe "the topbar App Home link" do
88+
let(:topbar_only) do
89+
Class.new(described_class) do
90+
def view_template = topbar
91+
end
92+
end
93+
94+
it "renders no App Home link by default" do
95+
html = topbar_only.new.call
96+
97+
expect(html).not_to include("Back to")
98+
end
99+
100+
it "renders the configured link after the brand anchor" do
101+
DocsKit.configure do |c|
102+
c.brand = "Zazu Docs"
103+
c.brand_href = "/docs"
104+
c.app_link = { href: "/", label: "Back to Zazu" }
105+
end
106+
html = topbar_only.new.call
107+
108+
brand = html.index('href="/docs"')
109+
app = html.index("Back to Zazu")
110+
expect(brand).to be_truthy
111+
expect(app).to be > brand
112+
expect(html).to include('href="/"')
113+
end
114+
115+
it "opens an external App Home href in a new tab with rel=noopener" do
116+
DocsKit.configure { |c| c.app_link = { href: "https://zazu.app", label: "Back to Zazu" } }
117+
html = topbar_only.new.call
118+
119+
expect(html).to include('target="_blank"')
120+
expect(html).to include("noopener")
121+
end
122+
123+
it "opens a site-relative App Home href in place (no target/rel)" do
124+
DocsKit.configure { |c| c.app_link = { href: "/", label: "Back to Zazu" } }
125+
html = topbar_only.new.call
126+
127+
expect(html).not_to include('target="_blank"')
128+
expect(html).not_to include("noopener")
129+
end
130+
end
131+
84132
# The topbar search form is the JS-off search entry point: a plain GET form to
85133
# config.search_path with an input named "q". It renders only when search is
86134
# enabled, so a site can opt out with c.search = false.

spec/generators/install_generator_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,14 @@ def capture_stream
390390
expect(initializer).to match(/openapi\.ya?ml/)
391391
end
392392

393+
it "documents the optional app_link knob (commented, so it's opt-in)" do
394+
initializer = read("config/initializers/docs_kit.rb")
395+
396+
# Commented by default — a standalone docs site has no app to link back to.
397+
expect(initializer).to include("# c.app_link = ")
398+
expect(initializer).to include("Back to the app")
399+
end
400+
393401
it "documents the optional topbar_links knob (commented, so it's opt-in)" do
394402
initializer = read("config/initializers/docs_kit.rb")
395403

0 commit comments

Comments
 (0)