Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions app/components/docs_ui/archived_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The effective current version can show an archived banner and link back to itself when no configured version is marked current: true; compare the entry with DocsKit.configuration.current_version before rendering the banner.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/components/docs_ui/archived_page.rb, line 46:

<comment>The effective current version can show an archived banner and link back to itself when no configured version is marked `current: true`; compare the entry with `DocsKit.configuration.current_version` before rendering the banner.</comment>

<file context>
@@ -28,16 +25,53 @@ def view_template
+    # current release rendered directly) and for entries carrying no version.
+    def banner
+      version = entry_version
+      return unless version&.archived?
+
+      current = DocsKit.configuration.current_version
</file context>
Suggested change
return unless version&.archived?
return unless version&.archived? && version.id != DocsKit.configuration.current_version&.id
Fix with cubic


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 }

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Archived pages 500 when the current registry entry lacks #slug, because the equivalent-page lookup calls it unconditionally; guard the call and use the existing home fallback for entries that cannot be matched.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/components/docs_ui/archived_page.rb, line 71:

<comment>Archived pages 500 when the current registry entry lacks `#slug`, because the equivalent-page lookup calls it unconditionally; guard the call and use the existing home fallback for entries that cannot be matched.</comment>

<file context>
@@ -28,16 +25,53 @@ def view_template
+      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
</file context>
Suggested change
.find { |page| page.slug.to_s == slug.to_s }
.find { |page| page.respond_to?(:slug) && page.slug.to_s == slug.to_s }
Fix with cubic

live&.href || config.brand_href
end

def markdown_source
@markdown_source ||= @entry ? @entry.markdown.to_s : ""
end
Expand Down
10 changes: 10 additions & 0 deletions app/components/docs_ui/meta_tags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions app/components/docs_ui/shell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The added VersionSwitcher render now executes before DocsUI::TopbarLinks, so the sibling comment "render as icon-only ghost buttons BEFORE the switcher" is no longer accurate — the topbar links actually render after the switcher. Update that comment (e.g. to "after the switcher") to avoid misleading future readers about render order.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/components/docs_ui/shell.rb, line 166:

<comment>The added VersionSwitcher render now executes before DocsUI::TopbarLinks, so the sibling comment "render as icon-only ghost buttons BEFORE the switcher" is no longer accurate — the topbar links actually render after the switcher. Update that comment (e.g. to "after the switcher") to avoid misleading future readers about render order.</comment>

<file context>
@@ -161,6 +161,9 @@ def topbar
         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.
</file context>
Fix with cubic

# Config-driven repo/social links (config.topbar_links) render as
# icon-only ghost buttons BEFORE the switcher; nothing when unset.
render DocsUI::TopbarLinks.new
Expand Down
84 changes: 84 additions & 0 deletions app/components/docs_ui/version_switcher.rb
Original file line number Diff line number Diff line change
@@ -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 <a> — 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
Comment on lines +38 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Normalize the fallback current version.

Configuration#current_version selects versions.first when no version has current: true. That selected version still has archived? == true. The switcher then resolves its pages as snapshots, and the archived banner can identify the selected current version as archived.

Either normalize the fallback version to current?, or reject configurations without one explicit current version. Add regression coverage for configured versions with no current: true.

  • app/components/docs_ui/version_switcher.rb#L38-L43: keep the selected current version and its archive state consistent.
  • app/components/docs_ui/version_switcher.rb#L59-L64: resolve the selected current version from live registry pages.
  • app/components/docs_ui/archived_page.rb#L45-L48: suppress the banner for the selected current version.
  • app/components/docs_ui/archived_page.rb#L67-L72: resolve the selected current version from live registry pages.
📍 Affects 2 files
  • app/components/docs_ui/version_switcher.rb#L38-L43 (this comment)
  • app/components/docs_ui/version_switcher.rb#L59-L64
  • app/components/docs_ui/archived_page.rb#L45-L48
  • app/components/docs_ui/archived_page.rb#L67-L72
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/docs_ui/version_switcher.rb` around lines 38 - 43, Normalize
the fallback selected by VersionSwitcher#scope_version so a configuration
without an explicit current version is treated consistently as the current,
non-archived version, and add regression coverage for that configuration. In
app/components/docs_ui/version_switcher.rb lines 38-43, update fallback
selection while preserving the request-scoped version; in lines 59-64, resolve
that selected version from live registry pages. In
app/components/docs_ui/archived_page.rb lines 45-48 and 67-72, use the same
normalized selected-current-version logic so the archived banner is suppressed
and page resolution uses live registry pages.


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)

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Every topbar render scans the full page list for each configured version just to decide whether a candidate slug exists there (then typically keeps only pages.first&.href). On an archived multi-version site this means loading and enumerating each version's entire snapshot on every page request, repeated per version. It would be cheaper and more direct to resolve the target page by slug (e.g. a from_slug-style lookup) or to only enumerate lazily when the candidate isn't already known, rather than materializing every page and scanning it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/components/docs_ui/version_switcher.rb, line 60:

<comment>Every topbar render scans the full page list for each configured version just to decide whether a candidate slug exists there (then typically keeps only `pages.first&.href`). On an archived multi-version site this means loading and enumerating each version's entire snapshot on every page request, repeated per version. It would be cheaper and more direct to resolve the target page by slug (e.g. a `from_slug`-style lookup) or to only enumerate lazily when the candidate isn't already known, rather than materializing every page and scanning it.</comment>

<file context>
@@ -0,0 +1,84 @@
+    # 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 }
</file context>
Fix with cubic

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
3 changes: 2 additions & 1 deletion lib/docs_kit/snapshot/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions spec/docs_kit/snapshot_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion spec/docs_ui/archived_page_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
28 changes: 28 additions & 0 deletions spec/docs_ui/meta_tags_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,34 @@ def image_url(path) = "https://d.example.com/assets/#{path.sub('.png', '-abc123.
expect(render_tags).to include('<meta name="robots" content="noindex, nofollow">')
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('<meta name="robots" content="noindex, follow">')
expect(html).not_to include('rel="canonical"')

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This assertion doesn't actually verify the behavior the test/comment claims ('canonical untouched'). In the isolated render there is no config.seo.site_url and no request, so canonical_url returns nil and no canonical is emitted regardless of the noindex change — not_to include('rel="canonical"') passes trivially even before this feature. To pin the documented intent (that noindexing an archived page leaves the self-referential canonical in place), configure site_url in this example and assert the canonical IS still present alongside the noindex meta; otherwise the 'canonical untouched' property is untested.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spec/docs_ui/meta_tags_spec.rb, line 184:

<comment>This assertion doesn't actually verify the behavior the test/comment claims ('canonical untouched'). In the isolated render there is no `config.seo.site_url` and no request, so `canonical_url` returns nil and no canonical is emitted regardless of the noindex change — `not_to include('rel="canonical"')` passes trivially even before this feature. To pin the documented intent (that noindexing an archived page leaves the self-referential canonical in place), configure `site_url` in this example and assert the canonical IS still present alongside the noindex meta; otherwise the 'canonical untouched' property is untested.</comment>

<file context>
@@ -174,6 +174,34 @@ def image_url(path) = "https://d.example.com/assets/#{path.sub('.png', '-abc123.
+        html = DocsKit::Scope.with(version: archived) { render_tags }
+
+        expect(html).to include('<meta name="robots" content="noindex, follow">')
+        expect(html).not_to include('rel="canonical"')
+      end
+
</file context>
Fix with cubic

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('<meta name="robots" content="index, follow">')
end
end

Comment on lines +177 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'render_tags|site_url|original_url|canonical' spec/docs_ui/meta_tags_spec.rb

Repository: mhenrixon/docs-kit

Length of output: 11750


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
fd -t f -i 'meta_tags|scope|doc_version' .
printf '%s\n' '--- relevant definitions and call sites ---'
rg -n -C 12 'def canonical_url|canonical_url|class MetaTags|module MetaTags|def render_tags|site_url|original_url' .

Repository: mhenrixon/docs-kit

Length of output: 29225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

spec = Path("spec/docs_ui/meta_tags_spec.rb").read_text()
impl = Path("app/components/docs_ui/meta_tags.rb").read_text()

helper = re.search(
    r'def render_tags\(.*?\n  end',
    spec,
    re.S,
)
assert helper, "render_tags helper not found"
helper_text = helper.group(0)

archived = re.search(
    r'it "emits noindex, follow for an archived version.*?\n      end',
    spec,
    re.S,
)
assert archived, "archived example not found"
archived_text = archived.group(0)

canonical = re.search(
    r'def canonical_url\n(.*?)\n    end',
    impl,
    re.S,
)
assert canonical, "canonical_url not found"
canonical_body = canonical.group(1)

print("render_tags helper configures site_url:", "site_url" in helper_text)
print("archived example configures site_url:", "site_url" in archived_text)
print("canonical_url returns configured site_url:", "return seo.site_url if seo.site_url" in canonical_body)
print("canonical_url omits URL when request? is false:", "return unless request?" in canonical_body)

assert "site_url" not in helper_text
assert "site_url" not in archived_text
assert "return seo.site_url if seo.site_url" in canonical_body
assert "return unless request?" in canonical_body
PY

Repository: mhenrixon/docs-kit

Length of output: 347


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

spec = Path("spec/docs_ui/meta_tags_spec.rb").read_text()
impl = Path("app/components/docs_ui/meta_tags.rb").read_text()

helper = re.search(r'def render_tags\(.*?\n  end', spec, re.S)
archived = re.search(
    r'it "emits noindex, follow for an archived version.*?\n      end',
    spec,
    re.S,
)
canonical = re.search(r'def canonical_url\n(.*?)\n    end', impl, re.S)

assert helper and archived and canonical

print("render_tags helper configures site_url:", "site_url" in helper.group(0))
print("archived example configures site_url:", "site_url" in archived.group(0))
print("canonical_url uses site_url:", "return seo.site_url if seo.site_url" in canonical.group(1))
print("canonical_url requires request otherwise:", "return unless request?" in canonical.group(1))
PY

Repository: mhenrixon/docs-kit

Length of output: 327


Configure a canonical source in the archived-version example.

render_tags has no seo.site_url, and the isolated render has no request. canonical_url therefore returns nil, so the assertion only tests omission. Set DocsKit.configure { |c| c.seo.site_url = ... } and assert the expected canonical URL remains unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/docs_ui/meta_tags_spec.rb` around lines 177 - 204, Update the
archived-version example around render_tags to configure a non-empty
seo.site_url before rendering, then assert the expected canonical link URL is
present and unchanged alongside the noindex assertion. Keep the current-version
and noindex:false examples unchanged.

it "emits <meta name=\"theme-color\"> only when config.seo.theme_color is set" do
html = render_tags
expect(html).not_to include("theme-color")
Expand Down
28 changes: 28 additions & 0 deletions spec/docs_ui/shell_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

@cubic-dev-ai cubic-dev-ai Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The version-switcher spec covers only the unconfigured (0 versions) and two-version cases, but the behavior's real boundary is a single configured version: versioning_enabled? is versions.size > 1, and the DocVersion/Configuration comments say "A single configured version is not worth a switcher." A site that lists exactly one version is the case most at risk of regressing the byte-identical topbar guarantee and is untested here. Consider adding an example asserting no version-switcher renders with c.versions = [{ id: "1.0", current: true }].

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spec/docs_ui/shell_spec.rb, line 100:

<comment>The version-switcher spec covers only the unconfigured (0 versions) and two-version cases, but the behavior's real boundary is a single configured version: `versioning_enabled?` is `versions.size > 1`, and the DocVersion/Configuration comments say "A single configured version is not worth a switcher." A site that lists exactly one version is the case most at risk of regressing the byte-identical topbar guarantee and is untested here. Consider adding an example asserting no `version-switcher` renders with `c.versions = [{ id: "1.0", current: true }]`.</comment>

<file context>
@@ -81,6 +81,34 @@ def view_template = topbar
+      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" }]
</file context>
Fix with cubic

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
Expand Down
Loading
Loading