From 7173abecf3265301e62d48ff8ecf2ff3a65f8ecc Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sun, 2 Aug 2026 20:25:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(shell):=20two=20homes=20=E2=80=94=20masthe?= =?UTF-8?q?ad=20follows=20brand=5Fhref,=20opt-in=20app=5Flink=20topbar=20l?= =?UTF-8?q?ink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary DocsUI::Page's masthead "← Home" hardcoded the host's `root_path` helper — on a docs site embedded in a bigger app that's the application root (often an authenticated dashboard), so readers were bounced off the docs, and sites had to override the route helper to cope. 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 --- .rubocop.yml | 3 ++ README.md | 14 ++++-- app/components/docs_ui/page.rb | 9 +++- app/components/docs_ui/shell.rb | 18 +++++++ lib/docs_kit/configuration.rb | 18 +++++++ .../install/templates/docs_kit.rb.erb | 6 +++ spec/docs_kit/configuration_spec.rb | 30 ++++++++++++ spec/docs_ui/page_spec.rb | 40 ++++++++++++++++ spec/docs_ui/shell_spec.rb | 48 +++++++++++++++++++ spec/generators/install_generator_spec.rb | 8 ++++ 10 files changed, 189 insertions(+), 5 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 3f546d1..790efe2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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 diff --git a/README.md b/README.md index 07b21d8..2793fdc 100644 --- a/README.md +++ b/README.md @@ -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 }, @@ -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]`). | diff --git a/app/components/docs_ui/page.rb b/app/components/docs_ui/page.rb index 9d651f2..f151032 100644 --- a/app/components/docs_ui/page.rb +++ b/app/components/docs_ui/page.rb @@ -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 @@ -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 diff --git a/app/components/docs_ui/shell.rb b/app/components/docs_ui/shell.rb index 2130bed..b4d85b4 100644 --- a/app/components/docs_ui/shell.rb +++ b/app/components/docs_ui/shell.rb @@ -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 @@ -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 diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index 56e0e3a..18a646f 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -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 @@ -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 []. 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 9ee6028..e10c750 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -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, diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index b3e2d04..82224f2 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -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 diff --git a/spec/docs_ui/page_spec.rb b/spec/docs_ui/page_spec.rb index 9b0863a..bd99d6a 100644 --- a/spec/docs_ui/page_spec.rb +++ b/spec/docs_ui/page_spec.rb @@ -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."') diff --git a/spec/docs_ui/shell_spec.rb b/spec/docs_ui/shell_spec.rb index 14d0740..60c2881 100644 --- a/spec/docs_ui/shell_spec.rb +++ b/spec/docs_ui/shell_spec.rb @@ -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. diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 1d87c47..fef8ea8 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -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")