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