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")