feat(registry): version scope + snapshot content source — the #61 foundation (phases 1-3) - #67
Merged
Merged
Conversation
## 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::DocVersion—Data.define(:id, :label, :ref, :current, :noindex)+.fromnormalizer (theTopbarLinkshape, symbol- or string-keyed).labeldefaults toid;noindexdefaults to!current(archived copies are noindex'd unless overridden);#path_prefixis""for current,"/1.0"for archived.Configuration:c.versions(normalizing reader, default[]),c.repo_url,c.snapshots_path(nil →Rails.root/docs_snapshotsunder 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;:localeslot reserved for i18n M2, nothing reads it).DocsKit::Controller#render_pagewraps its render in the scope resolved fromparams[:version]; an unknown id falls back to the current version (degrade, never 500).DocsKit::Scoping—around_action :docs_scopemodule 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::Snapshotreads<snapshots_path>/<id>/manifest.json+.mdtree back as the registry duck type (#all/#from_slug/#nav_items/#nav_groups/#markdown_for), memoized per[version, root]and invalidated on manifest mtime (theopenapi_documentposture). Missing/unreadable snapshot → EMPTY snapshot, never raises.Snapshot::Entry#view_class→ the new minimalDocsUI::ArchivedPage(frozen Markdown through today's Shell/Header/Markdown; every kwarg defaulted so even a naive.view_class.newrenders an empty page).Registry::Entry#renderable+ the oneLlmsText.renderable_forshim (falls back toview_class.newfor custom registry classes predating#renderable) — swapped in at the three call sites:LlmsController,SearchController,McpTools.LlmsText.pages(config, version: nil)— nil resolves throughScope→current_version; an archived version enumerates its snapshot. This is the one seam every AI surface funnels through.Configuration#nav_groupsderives an archived sidebar from the snapshot'snav_groups(hrefs already version-prefixed, so the Sidebar's strictrequest.path == hrefmatching 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.versionsdefault[]+versioning_enabled?false;LlmsText.pagesunchanged on an unversioned site;nav_groupsunchanged with no scope;render_pagescopes 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
DocsUI::ArchivedPagepulled forward from Phase 4:Snapshot::Entry#view_classmust 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.Snapshot#nav_groups(heading-keyed{ heading => { group => [NavItem] } }) beyond the issue's flat#nav_items—Configuration#nav_groupsreturns the heading-keyed shape, so the snapshot must reproduce it from the manifest's per-registryheading.Configuration#resolve_version(id)(strict#version(id)lookup with fallback to#current_version) sorender_pageand the gem controllers share one resolution rule instead of duplicating it.docs_scopeimplemented as a plain module (DocsKit::Scoping,includedhook →around_action :docs_scope), not anActiveSupport::Concern— no need for the dependency, and the module stays loadable in the Rails-free suite.[version.id, root], not id alone — two configs pointing at different snapshot paths (the suite does this) must not share entries.c.navlambda inConfiguration#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.ArchivedPagedeliberately omitsPhlex::Rails::Helpers::Routes/Request(their bodies runRails.*at class load — the documentedDocsUI::Pagesuite constraint). Its spec exercises the#bodyseam; the full-document render is only possible under Rails, likePage..claude/worktrees/, sobundle exec rubocopinspects 0 files from inside a worktree. Verified with an explicitgit ls-files | xargs rubocoprun (134 files, 0 offenses). CI and normal checkouts are unaffected.configuration.rb'smcp_gem_present?LoadError branch andopenapi_source_mtimerescue were uncovered before this change; everything added here is covered.