Skip to content

feat(registry): version scope + snapshot content source — the #61 foundation (phases 1-3) - #67

Merged
mhenrixon merged 5 commits into
mainfrom
issue-61-version-scope-foundation
Aug 4, 2026
Merged

feat(registry): version scope + snapshot content source — the #61 foundation (phases 1-3)#67
mhenrixon merged 5 commits into
mainfrom
issue-61-version-scope-foundation

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Phases 1–3 of #61 — the foundation the issue says should land as one PR. No user-visible chrome changes yet: this PR builds the config surface, the request scope, and the snapshot content source that phases 4–7 (switcher, ArchivedPage banner, diff engine, routes/generator/MCP tools) build on.

The headline gate: a site that configures no versions renders byte-identical to today — pinned by explicit specs, not inspection.

What's in

Phase 1 — config + the version value object

  • DocsKit::DocVersionData.define(:id, :label, :ref, :current, :noindex) + .from normalizer (the TopbarLink shape, symbol- or string-keyed). label defaults to id; noindex defaults to !current (archived copies are noindex'd unless overridden); #path_prefix is "" for current, "/1.0" for archived.
  • Configuration: c.versions (normalizing reader, default []), c.repo_url, c.snapshots_path (nil → Rails.root/docs_snapshots under Rails), #current_version (current-flag → first → nil), #version(id), #resolve_version(id) (fallback to current), #versioning_enabled? (needs ≥ 2), #compare_url(from, to) (nil unless repo_url and BOTH refs).

Phase 2 — the shared scope seam

  • DocsKit::Scope — the ONE request-scoped holder (with(version:) block-scoped with ensure-restore, Thread.current-backed so it's fiber-local and Rails-free; :locale slot reserved for i18n M2, nothing reads it).
  • DocsKit::Controller#render_page wraps its render in the scope resolved from params[:version]; an unknown id falls back to the current version (degrade, never 500).
  • DocsKit::Scopingaround_action :docs_scope module included by the gem's llms/search/mcp controllers, so llms-full.txt, search, and MCP all see the request's version.

Phase 3 — the snapshot content source

  • DocsKit::Snapshot reads <snapshots_path>/<id>/manifest.json + .md tree back as the registry duck type (#all / #from_slug / #nav_items / #nav_groups / #markdown_for), memoized per [version, root] and invalidated on manifest mtime (the openapi_document posture). Missing/unreadable snapshot → EMPTY snapshot, never raises.
  • Snapshot::Entry#view_class → the new minimal DocsUI::ArchivedPage (frozen Markdown through today's Shell/Header/Markdown; every kwarg defaulted so even a naive .view_class.new renders an empty page).
  • Registry::Entry#renderable + the one LlmsText.renderable_for shim (falls back to view_class.new for custom registry classes predating #renderable) — swapped in at the three call sites: LlmsController, SearchController, McpTools.
  • LlmsText.pages(config, version: nil) — nil resolves through Scopecurrent_version; an archived version enumerates its snapshot. This is the one seam every AI surface funnels through.
  • Configuration#nav_groups derives an archived sidebar from the snapshot's nav_groups (hrefs already version-prefixed, so the Sidebar's strict request.path == href matching works for free).

Test plan

  • bundle exec rspec — 896 examples, 0 failures (94.3% line / 81.25% branch; new files 95–100%).
  • bundle exec rubocop — 134 files, no offenses.
  • Backwards-compat pins (explicit specs): versions default [] + versioning_enabled? false; LlmsText.pages unchanged on an unversioned site; nav_groups unchanged with no scope; render_page scopes to nil on an unversioned site. No Shell/MetaTags/llms.txt/MCP-tool-list changes exist in this PR to pin — those land (with their own pins) in phases 4–6.

Deviations & judgment calls

  • Scope of this PR: the issue prescribes "Phases 1–3 are the foundation and should land as one PR; phases 4–7 can each be their own." This PR is phases 1–3 only (DocVersion + config, Scope seam, Snapshot content source). No switcher, no ArchivedPage banner, no diff engine, no routes/generator changes yet.
  • Minimal DocsUI::ArchivedPage pulled forward from Phase 4: Snapshot::Entry#view_class must return a truthy renderable constant (issue step 7), so a minimal ArchivedPage (Shell + Header + Markdown body, all-defaulted kwargs) ships now; the version banner and its full spec remain Phase 4.
  • Added Snapshot#nav_groups (heading-keyed { heading => { group => [NavItem] } }) beyond the issue's flat #nav_itemsConfiguration#nav_groups returns the heading-keyed shape, so the snapshot must reproduce it from the manifest's per-registry heading.
  • Added Configuration#resolve_version(id) (strict #version(id) lookup with fallback to #current_version) so render_page and the gem controllers share one resolution rule instead of duplicating it.
  • docs_scope implemented as a plain module (DocsKit::Scoping, included hook → around_action :docs_scope), not an ActiveSupport::Concern — no need for the dependency, and the module stays loadable in the Rails-free suite.
  • Snapshot cache keyed by [version.id, root], not id alone — two configs pointing at different snapshot paths (the suite does this) must not share entries.
  • An archived version in scope overrides even an explicit c.nav lambda in Configuration#nav_groups — the site's nav lambda describes the live pages; linking them from a frozen 1.0 page would cross versions mid-navigation. The issue didn't address the explicit-nav interaction; this felt like the least surprising rule.
  • ArchivedPage deliberately omits Phlex::Rails::Helpers::Routes/Request (their bodies run Rails.* at class load — the documented DocsUI::Page suite constraint). Its spec exercises the #body seam; the full-document render is only possible under Rails, like Page.
  • Tooling discovery, not a code change: RuboCop skips files under hidden directories, and Claude worktrees live under .claude/worktrees/, so bundle exec rubocop inspects 0 files from inside a worktree. Verified with an explicit git ls-files | xargs rubocop run (134 files, 0 offenses). CI and normal checkouts are unaffected.
  • Pre-existing coverage gaps left alone: configuration.rb's mcp_gem_present? LoadError branch and openapi_source_mtime rescue were uncovered before this change; everything added here is covered.

## Summary
Phase 1 of #61: `DocsKit::DocVersion` (Data.define + `.from` normalizer,
mirroring TopbarLink) and the config knobs multi-version docs hang off —
`c.versions`, `c.repo_url`, `c.snapshots_path`, plus `current_version`,
`version(id)`, `resolve_version(id)`, `versioning_enabled?`, and
`compare_url(from, to)`. `noindex` defaults to the inverse of `current`;
`versioning_enabled?` needs two versions; everything defaults so an
unconfigured site is byte-identical to before.

## Test Coverage
- doc_version_spec: normalization (both key styles), label/noindex
  defaulting, path_prefix, current?/archived?
- configuration_spec: default [] + versioning_enabled? false (the
  backwards-compat pin), current_version fallback order, compare_url nil
  on missing repo_url/refs

## Verification
- [x] bundle exec rubocop passes
- [x] bundle exec rspec passes

Refs #61
## Summary
Phase 2 of #61: the ONE request-scoped seam (`DocsKit::Scope.with(version:)`,
Thread.current-backed, Rails-free, with the reserved :locale slot for i18n M2).
`DocsKit::Controller#render_page` wraps its render in the scope resolved from
`params[:version]` (unknown ids fall back to current — degrade, never 500);
the gem's own llms/search/mcp controllers get the same scope via the new
`DocsKit::Scoping` around_action module. Unversioned sites scope to nil:
today's behavior exactly.

## Test Coverage
- scope_spec: empty default, ensure-restore (incl. on raise), nesting,
  no leaks across sequential calls, path_prefix
- scoping_spec: around_action registration + params → scope resolution
- controller_spec: render runs inside the resolved scope; unknown param
  falls back to current; scope restored after; nil on unversioned sites
- the three controller source-wiring specs assert `include DocsKit::Scoping`

## Verification
- [x] bundle exec rubocop passes
- [x] bundle exec rspec passes

Refs #61
## Summary
Phase 3 of #61: `DocsKit::Snapshot` reads a committed Markdown snapshot back
as the registry duck type (#all/#from_slug/#nav_items, memoized per version +
manifest-mtime invalidated; missing/unreadable manifest degrades to an EMPTY
snapshot, never raises). `Snapshot::Entry#view_class` is the new minimal
`DocsUI::ArchivedPage` (frozen Markdown through today's chrome; the banner
lands with phase 4). `Registry::Entry#renderable` + the one
`LlmsText.renderable_for` shim make live and snapshot pages render
identically at the three call sites (llms, search, MCP).
`LlmsText.pages(config, version:)` resolves nil → Scope → current, so every
AI surface follows the request's version; `Configuration#nav_groups` derives
an archived sidebar from the snapshot (hrefs already prefixed — the
Sidebar's strict path match works for free).

## Test Coverage
- snapshot_spec: manifest order/grouping, version-prefixed hrefs, mtime
  re-read, empty-snapshot degradation, markdown_for, the duck type
- registry_spec: Entry#renderable (nil when unauthored)
- llms_text_spec: renderable_for shim (legacy fallback), pages per
  version/scope + the unversioned backwards-compat pin
- configuration_spec: nav_groups under archived/current/no scope
- archived_page_spec: markdown body + masthead via the #body seam

## Verification
- [x] bundle exec rubocop passes
- [x] bundle exec rspec passes (896 examples; 94.3% line coverage)

Refs #61
@mhenrixon mhenrixon self-assigned this Aug 4, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Aug 4, 2026
Union of the initialize ivar defaults (main's brand_logo/topbar_brand +
this branch's versions/repo_url/snapshots_path); also quiets the
RSpec/IdenticalEqualityAssertion offense the merged toolchain flagged in
snapshot_spec's memoization example.
CI's freshly-resolved RuboCop and the locally-pinned 1.88.2 disagree on
which receiver `.reject` must align with in the old two-line form (the
gem root's Gemfile.lock is gitignored, so CI floats to the newest cop).
One call per line, all dots aligned, satisfies both readings of
Layout/MultilineMethodCallIndentation.

Refs #61
@mhenrixon
mhenrixon merged commit db166ca into main Aug 4, 2026
5 checks passed
@mhenrixon
mhenrixon deleted the issue-61-version-scope-foundation branch August 4, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant