From e8cb9800ea8605a8d8714e7b9f738fc04612b5f4 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 4 Aug 2026 12:14:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(shell):=20topbar=20version=20switcher?= =?UTF-8?q?=20=E2=80=94=20CSS-only=20dropdown,=20same-slug=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 4 of #61 (step 11): DocsUI::VersionSwitcher in the topbar before the repo/social links. The ThemeSwitcher dropdown pattern (daisyUI :focus-within — works with JS off, no new Stimulus controller); renders NOTHING unless versioning_enabled?, so an unversioned topbar is byte-identical. Each entry links the same slug in the target version, falling back to that version's first page (never a 404); the in-scope version is aria-current. ## Test Coverage - version_switcher_spec: empty render when disabled (the pin), one link per version, same-slug mapping both directions, aria-current marking, missing-slug fallback, no-request render, CSS-only (no data-controller) - shell_spec: switcher present with versions, absent without ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec passes Refs #61 --- app/components/docs_ui/shell.rb | 3 + app/components/docs_ui/version_switcher.rb | 84 ++++++++++++++++ spec/docs_ui/shell_spec.rb | 28 ++++++ spec/docs_ui/version_switcher_spec.rb | 110 +++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 app/components/docs_ui/version_switcher.rb create mode 100644 spec/docs_ui/version_switcher_spec.rb diff --git a/app/components/docs_ui/shell.rb b/app/components/docs_ui/shell.rb index 8214e36..01bbfd3 100644 --- a/app/components/docs_ui/shell.rb +++ b/app/components/docs_ui/shell.rb @@ -161,6 +161,9 @@ def topbar end render DocsUI::SearchBox.new if config.search_enabled? div(class: "flex-none items-center") do + # The docs-version switcher (config.versions) renders first; nothing + # unless versioning is enabled, so an unversioned topbar is unchanged. + render DocsUI::VersionSwitcher.new # Config-driven repo/social links (config.topbar_links) render as # icon-only ghost buttons BEFORE the switcher; nothing when unset. render DocsUI::TopbarLinks.new diff --git a/app/components/docs_ui/version_switcher.rb b/app/components/docs_ui/version_switcher.rb new file mode 100644 index 0000000..015d726 --- /dev/null +++ b/app/components/docs_ui/version_switcher.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module DocsUI + # The topbar documentation-version switcher — the DocsUI::ThemeSwitcher + # dropdown pattern (tabindex/role=button + dropdown-content): daisyUI's + # dropdown opens on CSS :focus-within, so it works with JavaScript off, and + # every entry is a plain — no Stimulus controller (the ONE-controller + # rule). + # + # Renders NOTHING unless config.versioning_enabled? (two or more configured + # versions), so an unversioned site's topbar is byte-identical to before. + # + # Each link targets the SAME slug in the target version when that page exists + # there, falling back to the target version's first page — a slug missing + # from an older snapshot must never link a 404. + class VersionSwitcher < Phlex::HTML + include Phlex::Rails::Helpers::Request + + def view_template + return unless config.versioning_enabled? + + div(class: "dropdown dropdown-end", data: { testid: "version-switcher" }) do + div(tabindex: "0", role: "button", class: "btn btn-sm btn-ghost gap-1") do + render DocsUI::Icon.new("layers", class: "size-4") + plain scope_version.label + end + ul(tabindex: "0", + class: "dropdown-content bg-base-300 rounded-box z-10 w-44 p-2 shadow-2xl") do + config.versions.each { |version| version_option(version) } + end + end + end + + private + + def config = DocsKit.configuration + + # The version this render serves: the request scope, else the current + # version (versioning_enabled? guarantees one exists — with none marked + # current, the first configured entry is it). + def scope_version + DocsKit::Scope.version || config.current_version + end + + def version_option(version) + in_scope = version.id == scope_version&.id + li do + a( + href: target_href(version), + class: "btn btn-sm btn-block btn-ghost justify-start", + aria_current: (in_scope ? "true" : nil) + ) { version.label } + end + end + + # The same slug in the target version when it exists there; else the + # target's first page (guaranteed routable — never a 404); else the + # target-prefixed docs root (an empty snapshot is already a degraded state). + def target_href(version) + pages = DocsKit::LlmsText.pages(config, version: version) + candidate = candidate_href(version) + return candidate if candidate && pages.any? { |page| page.href == candidate } + + pages.first&.href || "#{version.path_prefix}/docs" + end + + # The current request path re-prefixed for the target version: strip the + # in-scope version's prefix, add the target's. nil without a request. + def candidate_href(version) + path = current_path + return unless path + + "#{version.path_prefix}#{path.delete_prefix(DocsKit::Scope.path_prefix)}" + end + + # The request path, nil when rendered without a live request (an isolated + # render, a static build) — the DocsUI::Sidebar#current_path guard. + def current_path + request&.path + rescue StandardError + nil + end + end +end diff --git a/spec/docs_ui/shell_spec.rb b/spec/docs_ui/shell_spec.rb index fd9d2a8..2da67a6 100644 --- a/spec/docs_ui/shell_spec.rb +++ b/spec/docs_ui/shell_spec.rb @@ -81,6 +81,34 @@ def view_template = topbar end end + # The version switcher sits in the topbar right before the repo/social links. + # It renders NOTHING unless versioning is enabled, so an unversioned site's + # topbar stays byte-identical. + describe "the topbar version switcher" do + let(:topbar_only) do + Class.new(described_class) do + def view_template = topbar + end + end + + it "renders no switcher on an unversioned site (the byte-identical pin)" do + html = topbar_only.new.call + + expect(html).not_to include("version-switcher") + end + + it "renders the switcher when two or more versions are configured" do + DocsKit.configure do |c| + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + end + + html = topbar_only.new.call + + expect(html).to include("1.1") + expect(html).to include("1.0") + end + end + # The opt-in brand mark (config.brand_logo) — rendered inside the brand anchor # in place of the text brand. Absent config → the text brand, byte-identical # to before. config.topbar_brand = :mobile_only additionally hides the topbar diff --git a/spec/docs_ui/version_switcher_spec.rb b/spec/docs_ui/version_switcher_spec.rb new file mode 100644 index 0000000..57df6bd --- /dev/null +++ b/spec/docs_ui/version_switcher_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::VersionSwitcher do + let(:fixtures_root) { File.expand_path("../fixtures/snapshots", __dir__) } + + # A live registry with an authored "installation" page (the slug the 1.0 + # fixture snapshot also has) plus a live-only page absent from the snapshot. + let(:live_registry) do + entry = Struct.new(:title, :href, :slug, :group, :icon, :view_class, keyword_init: true) + pages = [ + entry.new(title: "Installation", href: "/docs/installation", slug: "installation", + group: "Guide", icon: nil, view_class: Class.new), + entry.new(title: "Live only", href: "/docs/live-only", slug: "live-only", + group: "Guide", icon: nil, view_class: Class.new) + ] + Class.new do + define_singleton_method(:all) { pages } + define_singleton_method(:nav_items) { {} } + end + end + + def configure_versions + DocsKit.configure do |c| + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + c.snapshots_path = fixtures_root + c.nav_registries = { "Docs" => live_registry } + end + end + + # The switcher reads the request path through the guarded #current_path seam + # (like DocsUI::Sidebar); pin it per example instead of faking a Rails request. + def switcher(path: nil) + Class.new(described_class) do + define_method(:current_path) { path } + end.new + end + + before { DocsKit::Snapshot.reset_cache! } + + it "renders NOTHING when versioning is not enabled (the byte-identical-topbar pin)" do + expect(switcher(path: "/docs/installation").call).to eq("") + end + + it "renders one link per configured version" do + configure_versions + + html = switcher(path: "/docs/installation").call + + expect(html).to include('href="/docs/installation"') + expect(html).to include('href="/1.0/docs/installation"') + end + + it "keeps the same slug when moving from an archived version back to current" do + configure_versions + + html = nil + DocsKit::Scope.with(version: DocsKit.configuration.version("1.0")) do + html = switcher(path: "/1.0/docs/installation").call + end + + expect(html).to include('href="/docs/installation"') + expect(html).to include('href="/1.0/docs/installation"') + end + + it "marks the in-scope version" do + configure_versions + + html = nil + DocsKit::Scope.with(version: DocsKit.configuration.version("1.0")) do + html = switcher(path: "/1.0/docs/installation").call + end + + expect(html).to include('aria-current="true"') + expect(html).to match(/aria-current="true"[^>]*>(\s*)1\.0/m) + end + + it "shows the in-scope version's label on the trigger" do + configure_versions + + DocsKit::Scope.with(version: DocsKit.configuration.version("1.0")) do + expect(switcher(path: "/1.0/docs/installation").call).to include("1.0") + end + end + + it "falls back to the target version's first page when the slug is absent there" do + configure_versions + + # /docs/live-only has no 1.0 twin → the 1.0 link goes to 1.0's first page, + # never a 404. + html = switcher(path: "/docs/live-only").call + + expect(html).to include('href="/1.0/docs/installation"') + expect(html).not_to include('href="/1.0/docs/live-only"') + end + + it "works without a request path (isolated render — links fall back, no raise)" do + configure_versions + + expect { switcher.call }.not_to raise_error + end + + it "is a CSS-only daisyUI dropdown (no Stimulus controller, works with JS off)" do + configure_versions + + html = switcher(path: "/docs/installation").call + + expect(html).to include("dropdown") + expect(html).not_to include("data-controller") + end +end From faf19460bc3ae59bcab32b2192a0892a8f92f318 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 4 Aug 2026 12:14:27 +0200 Subject: [PATCH 2/3] feat(page): archived-version banner on DocsUI::ArchivedPage ## Summary Phase 4 of #61 (step 10): the "You are viewing the 1.0 docs" Callout banner, linking the same slug in the current version and falling back to the docs home when the page no longer exists there. data-md-skip keeps it out of the .md twin; absent for current-version or versionless entries. Snapshot::Entry now exposes #version (it already received it) so the banner can tell archived from current. ## Test Coverage - archived_page_spec: banner text + equivalent link, removed-slug fallback, absent on current/versionless entries, data-md-skip - snapshot_spec: Entry#version exposure ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec passes Refs #61 --- app/components/docs_ui/archived_page.rb | 46 +++++++++++++-- lib/docs_kit/snapshot/entry.rb | 3 +- spec/docs_kit/snapshot_spec.rb | 4 ++ spec/docs_ui/archived_page_spec.rb | 74 ++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/app/components/docs_ui/archived_page.rb b/app/components/docs_ui/archived_page.rb index 58e5f22..985d2b5 100644 --- a/app/components/docs_ui/archived_page.rb +++ b/app/components/docs_ui/archived_page.rb @@ -10,9 +10,6 @@ module DocsUI # Every kwarg defaults, so even a naive `entry.view_class.new` (a custom # registry predating #renderable) renders an empty page rather than raising. # - # NOTE (issue #61 phase 4): the "you are viewing the 1.0 docs" banner with a - # link to the current equivalent lands with the version switcher, not here. - # # Deliberately does NOT include Phlex::Rails::Helpers::Routes/Request — their # bodies run Rails.* at class load, which would make this class (and # everything referencing it, like Snapshot::Entry#view_class) unloadable in a @@ -28,16 +25,53 @@ def view_template render DocsUI::Shell.new(title: @entry&.title) { body } end - # The masthead + Markdown body — separated from the Shell wrapper so it can - # render (and be specced) without a Rails view context, the same seam as - # Shell's own topbar/theme-script specs. + # The banner + masthead + Markdown body — separated from the Shell wrapper + # so it can render (and be specced) without a Rails view context, the same + # seam as Shell's own topbar/theme-script specs. def body + banner render DocsUI::Header.new(@entry.title) if @entry&.title render DocsUI::Markdown.new(markdown_source) unless markdown_source.empty? end private + # The "you are viewing archived docs" banner, linking the same slug in the + # current version (falling back to the docs home when the page no longer + # exists there). data-md-skip drops it from the Markdown twin — it's chrome, + # not page content. Absent for a current-version entry (a snapshot of the + # current release rendered directly) and for entries carrying no version. + def banner + version = entry_version + return unless version&.archived? + + current = DocsKit.configuration.current_version + div(data: { md_skip: true }) do + render DocsUI::Callout.new(:warning) do + plain "You are viewing the #{version.label} docs." + if current + plain " The current version is #{current.label} — " + a(href: current_equivalent_href, class: "link") { "read it there" } + plain "." + end + end + end + end + + def entry_version + @entry.version if @entry.respond_to?(:version) + end + + # The current-version page with this entry's slug, or the docs home when + # the slug has no current equivalent (a page removed since this version). + def current_equivalent_href + config = DocsKit.configuration + slug = @entry.respond_to?(:slug) ? @entry.slug : nil + live = slug && DocsKit::LlmsText.pages(config, version: config.current_version) + .find { |page| page.slug.to_s == slug.to_s } + live&.href || config.brand_href + end + def markdown_source @markdown_source ||= @entry ? @entry.markdown.to_s : "" end diff --git a/lib/docs_kit/snapshot/entry.rb b/lib/docs_kit/snapshot/entry.rb index d119af3..5758e93 100644 --- a/lib/docs_kit/snapshot/entry.rb +++ b/lib/docs_kit/snapshot/entry.rb @@ -9,7 +9,7 @@ class Snapshot # DocsUI::ArchivedPage constant, so the `select(&:view_class)` authored-page # filter passes unchanged. class Entry - attr_reader :slug, :title, :group, :icon, :file, :digest, :href + attr_reader :slug, :title, :group, :icon, :file, :digest, :href, :version def initialize(attrs, version:, root:, registry_prefix:) @slug = attrs["slug"] @@ -19,6 +19,7 @@ def initialize(attrs, version:, root:, registry_prefix:) @file = attrs["file"] @digest = attrs["digest"] @root = root + @version = version @href = "#{version.path_prefix}#{registry_prefix}/#{@slug}" end diff --git a/spec/docs_kit/snapshot_spec.rb b/spec/docs_kit/snapshot_spec.rb index 9005cec..3d7b336 100644 --- a/spec/docs_kit/snapshot_spec.rb +++ b/spec/docs_kit/snapshot_spec.rb @@ -123,6 +123,10 @@ def snapshot it "reads the entry's markdown body from its snapshot file" do expect(snapshot.from_slug("installation").markdown).to include('gem "docs_kit"') end + + it "exposes the entry's version (the ArchivedPage banner reads it)" do + expect(snapshot.from_slug("installation").version).to eq(version) + end end describe "degrading to an empty snapshot" do diff --git a/spec/docs_ui/archived_page_spec.rb b/spec/docs_ui/archived_page_spec.rb index 739d9f6..0bef13d 100644 --- a/spec/docs_ui/archived_page_spec.rb +++ b/spec/docs_ui/archived_page_spec.rb @@ -11,8 +11,35 @@ def view_template = body end end + let(:archived_version) { DocsKit::DocVersion.new(id: "1.0") } + let(:entry) do - Struct.new(:title, :markdown).new("Installation", "Add the **gem** first.") + entry_struct.new(title: "Installation", markdown: "Add the **gem** first.", + slug: "installation", version: archived_version) + end + + def entry_struct + Struct.new(:title, :markdown, :slug, :version, keyword_init: true) + end + + # A live registry authoring the same slug, so the banner can link the + # current-version equivalent. + def live_registry + page_struct = Struct.new(:title, :href, :slug, :group, :icon, :view_class, keyword_init: true) + page = page_struct.new(title: "Installation", href: "/docs/installation", slug: "installation", + group: "Guide", icon: nil, view_class: Class.new) + Class.new do + define_singleton_method(:all) { [page] } + define_singleton_method(:nav_items) { {} } + end + end + + def configure_versions + registry = live_registry + DocsKit.configure do |c| + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + c.nav_registries = { "Docs" => registry } + end end it "renders the entry's Markdown body through the chrome's Markdown island" do @@ -35,4 +62,49 @@ def view_template = body expect(described_class.new).to be_a(described_class) expect(body_only.new.call).to eq("") end + + describe "the archived banner" do + it "names both versions and links the current-version equivalent" do + configure_versions + + html = body_only.new(entry: entry).call + + expect(html).to include("You are viewing the 1.0 docs") + expect(html).to include("The current version is 1.1") + expect(html).to include('href="/docs/installation"') + end + + it "falls back to the docs home when the slug has no current equivalent" do + configure_versions + gone = entry_struct.new(title: "Removed", markdown: "Old.", slug: "removed", + version: archived_version) + + html = body_only.new(entry: gone).call + + expect(html).to include("You are viewing the 1.0 docs") + expect(html).to include(%(href="#{DocsKit.configuration.brand_href}")) + end + + it "is absent for a current-version entry" do + configure_versions + current = entry_struct.new(title: "Installation", markdown: "New.", slug: "installation", + version: DocsKit::DocVersion.new(id: "1.1", current: true)) + + expect(body_only.new(entry: current).call).not_to include("You are viewing") + end + + it "is absent for an entry that carries no version (a bare stub)" do + versionless = Struct.new(:title, :markdown).new("Installation", "Body.") + + expect(body_only.new(entry: versionless).call).not_to include("You are viewing") + end + + it "carries data-md-skip so it never leaks into the .md twin" do + configure_versions + + html = body_only.new(entry: entry).call + + expect(html).to match(/data-md-skip[^>]*>.*You are viewing/m) + end + end end From 281d74cb305f00e30b5d10d0242f0c852fbdc618 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 4 Aug 2026 12:14:27 +0200 Subject: [PATCH 3/3] feat(seo): noindex, follow for archived versions in scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 4 of #61 (step 12): MetaTags#robots_meta emits "noindex, follow" when the in-scope version is noindex'd (the archived default), overridable per version with noindex: false; canonical stays self-referential. Unversioned and current-version renders are unchanged. ## Test Coverage - meta_tags_spec: archived scope → noindex,follow (canonical untouched); current scope unchanged (regression pin); noindex: false restores seo.robots ## Verification - [x] bundle exec rubocop passes - [x] bundle exec rspec passes Refs #61 --- app/components/docs_ui/meta_tags.rb | 10 ++++++++++ spec/docs_ui/meta_tags_spec.rb | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/components/docs_ui/meta_tags.rb b/app/components/docs_ui/meta_tags.rb index 797a14b..d2dfdb5 100644 --- a/app/components/docs_ui/meta_tags.rb +++ b/app/components/docs_ui/meta_tags.rb @@ -99,12 +99,22 @@ def favicon_link link(rel: "icon", href: seo.favicon) end + # An archived version in scope is noindex'd ("noindex, follow" — search + # engines keep pointing at the current docs while still crawling through) + # unless the version opts out with noindex: false; the canonical stays + # self-referential (pointing it at different content would send a second, + # conflicting signal). Otherwise: today's opt-in seo.robots exactly. def robots_meta + return meta(name: "robots", content: "noindex, follow") if scope_noindex? return unless seo.robots meta(name: "robots", content: seo.robots) end + def scope_noindex? + !!DocsKit::Scope.version&.noindex + end + def theme_color_meta return unless seo.theme_color diff --git a/spec/docs_ui/meta_tags_spec.rb b/spec/docs_ui/meta_tags_spec.rb index b84af73..0cbc3ee 100644 --- a/spec/docs_ui/meta_tags_spec.rb +++ b/spec/docs_ui/meta_tags_spec.rb @@ -174,6 +174,34 @@ def image_url(path) = "https://d.example.com/assets/#{path.sub('.png', '-abc123. expect(render_tags).to include('') end + describe "robots under a version scope" do + it "emits noindex, follow for an archived version (canonical untouched)" do + archived = DocsKit::DocVersion.new(id: "1.0") + + html = DocsKit::Scope.with(version: archived) { render_tags } + + expect(html).to include('') + expect(html).not_to include('rel="canonical"') + end + + it "keeps today's behavior for the current version in scope (regression pin)" do + current = DocsKit::DocVersion.new(id: "1.1", current: true) + + html = DocsKit::Scope.with(version: current) { render_tags } + + expect(html).not_to include('name="robots"') + end + + it "restores seo.robots for a version with noindex: false" do + DocsKit.configure { |c| c.seo.robots = "index, follow" } + opted_out = DocsKit::DocVersion.new(id: "1.0", noindex: false) + + html = DocsKit::Scope.with(version: opted_out) { render_tags } + + expect(html).to include('') + end + end + it "emits only when config.seo.theme_color is set" do html = render_tags expect(html).not_to include("theme-color")