Skip to content

Commit db166ca

Browse files
authored
feat(registry): version scope + snapshot content source — the #61 foundation (phases 1-3) (#67)
* feat(config): DocVersion value object + the versions config surface ## 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 * feat(controller): request-scoped DocsKit::Scope + controller wiring ## 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 * feat(registry): snapshot content source + version-aware enumeration ## 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 * ci: break the blocks.rb render chain one call per line 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
1 parent b9f0d04 commit db166ca

30 files changed

Lines changed: 1374 additions & 19 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module DocsUI
4+
# Renders one page of an ARCHIVED documentation version — a frozen Markdown
5+
# body (DocsKit::Snapshot::Entry) through today's live chrome, so archived
6+
# docs get every future Shell/Sidebar/Code fix for free. The live counterpart
7+
# is DocsUI::Page; this mirrors its shape with the content coming from the
8+
# snapshot file instead of an authored #content method.
9+
#
10+
# Every kwarg defaults, so even a naive `entry.view_class.new` (a custom
11+
# registry predating #renderable) renders an empty page rather than raising.
12+
#
13+
# NOTE (issue #61 phase 4): the "you are viewing the 1.0 docs" banner with a
14+
# link to the current equivalent lands with the version switcher, not here.
15+
#
16+
# Deliberately does NOT include Phlex::Rails::Helpers::Routes/Request — their
17+
# bodies run Rails.* at class load, which would make this class (and
18+
# everything referencing it, like Snapshot::Entry#view_class) unloadable in a
19+
# Rails-free render. Nothing here needs a request.
20+
class ArchivedPage < Phlex::HTML
21+
include DocsUI
22+
23+
def initialize(entry: nil)
24+
@entry = entry
25+
end
26+
27+
def view_template
28+
render DocsUI::Shell.new(title: @entry&.title) { body }
29+
end
30+
31+
# The masthead + Markdown body — separated from the Shell wrapper so it can
32+
# render (and be specced) without a Rails view context, the same seam as
33+
# Shell's own topbar/theme-script specs.
34+
def body
35+
render DocsUI::Header.new(@entry.title) if @entry&.title
36+
render DocsUI::Markdown.new(markdown_source) unless markdown_source.empty?
37+
end
38+
39+
private
40+
41+
def markdown_source
42+
@markdown_source ||= @entry ? @entry.markdown.to_s : ""
43+
end
44+
end
45+
end

app/controllers/docs_kit/llms_controller.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class LlmsController < ActionController::Base
2929
# GET-only, sessionless, public text endpoints (no token to verify).
3030
protect_from_forgery with: :null_session
3131

32+
# Every action runs in the request's version scope (params[:version] on the
33+
# version-prefixed routes, else the current version), so the enumeration
34+
# below serves the version the URL asked for.
35+
include DocsKit::Scoping
36+
3237
def index
3338
body = DocsKit::LlmsText.index(docs_config, base_url: request.base_url)
3439
render_text(body) if stale_llms?(body)
@@ -67,9 +72,11 @@ def stale_llms?(body)
6772
# A page's Markdown twin, rendered through this controller's view context so
6873
# url helpers/CSRF resolve and relative links absolutize to portable URLs —
6974
# the same path DocsKit::Controller#render_page takes for a `.md` request.
75+
# renderable_for is the live-or-snapshot shim (a snapshot entry renders an
76+
# ArchivedPage carrying its frozen Markdown).
7077
def render_page_markdown(page)
7178
DocsKit::MarkdownExport.new(
72-
page.view_class.new, view_context:, base_url: request.base_url
79+
DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url
7380
).to_md
7481
end
7582
end

app/controllers/docs_kit/mcp_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class McpController < ActionController::Base
3131
# protection outright.
3232
skip_forgery_protection
3333

34+
# MCP tool calls run in the request's version scope; the version-aware tool
35+
# arguments (issue #61 phase 6) layer per-call resolution on top of this.
36+
include DocsKit::Scoping
37+
3438
def create
3539
return head(:not_found) unless docs_config.mcp_enabled?
3640

app/controllers/docs_kit/search_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ class SearchController < ActionController::Base
2828
# public endpoint.
2929
protect_from_forgery with: :null_session
3030

31+
# Search follows the request's version: /1.0/docs/search searches the 1.0
32+
# snapshot, /docs/search searches current — the scope swaps the enumeration
33+
# source underneath DocsKit::LlmsText.pages.
34+
include DocsKit::Scoping
35+
3136
def index
3237
hits = search_index.search(query)
3338

@@ -53,7 +58,7 @@ def query = params[:q].to_s
5358
def search_index
5459
triples = DocsKit::LlmsText.pages(docs_config).map do |page|
5560
markdown = DocsKit::MarkdownExport.new(
56-
page.view_class.new, view_context:, base_url: request.base_url
61+
DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url
5762
).to_md
5863
[page.title, page.href, markdown]
5964
end

lib/docs_kit/configuration.rb

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,27 @@ def topbar_brand=(value)
242242
# #openapi_document (which memoizes + reloads on file change), never @openapi.
243243
attr_accessor :openapi
244244

245+
# The documentation versions this site serves — a list of Hashes
246+
# ({ id:, label:, ref:, current:, noindex: }) or DocsKit::DocVersion objects;
247+
# #versions normalizes them. Defaults to [] → versioning is off and the site
248+
# is byte-identical to before. The `current` entry keeps serving unprefixed
249+
# at /docs; every other entry serves a committed Markdown snapshot at
250+
# /<id>/docs (see DocsKit::Snapshot). A version id must match v?\d+(\.\d+)*
251+
# so the host's static version route constraint recognizes it. Read via
252+
# #versions, never @versions.
253+
attr_writer :versions
254+
255+
# The site's source repository root (e.g. "https://github.com/me/repo"),
256+
# used for the GitHub compare link between two versions' refs
257+
# (#compare_url). Defaults to nil → no compare link renders.
258+
attr_accessor :repo_url
259+
260+
# Where committed version snapshots live. Defaults to nil, which the reader
261+
# resolves to Rails.root/"docs_snapshots" under Rails (nil outside Rails —
262+
# the standalone suite points at fixtures explicitly). Read via
263+
# #snapshots_path, never @snapshots_path.
264+
attr_writer :snapshots_path
265+
245266
# The sentinel "no explicit nav" lambda. #nav_groups compares against this
246267
# identity to decide whether to derive the sidebar from #nav_registries.
247268
DEFAULT_NAV = -> { {} }
@@ -310,6 +331,9 @@ def initialize
310331
@brand_logo = nil
311332
@brand_logo_raw = nil
312333
@topbar_brand = :always
334+
@versions = []
335+
@repo_url = nil
336+
@snapshots_path = nil
313337
end
314338

315339
# The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -338,6 +362,61 @@ def topbar_links
338362
Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
339363
end
340364

365+
# The normalized version list (DocsKit::DocVersion list), in declaration
366+
# order. Each configured Hash/DocVersion is coerced via DocVersion.from, so
367+
# the switcher and the snapshot reader only ever see value objects.
368+
# Blank/nil config yields [].
369+
def versions
370+
Array(@versions).map { |version| DocsKit::DocVersion.from(version) }
371+
end
372+
373+
# The version serving unprefixed at /docs: the entry marked current: true,
374+
# else the first configured entry, else nil (an unversioned site).
375+
def current_version
376+
versions.find(&:current?) || versions.first
377+
end
378+
379+
# The configured version with this id, or nil when unknown (or nil id).
380+
def version(id)
381+
return if id.nil?
382+
383+
versions.find { |version| version.id.to_s == id.to_s }
384+
end
385+
386+
# The version a request's :version param resolves to: the strict #version
387+
# lookup, falling back to #current_version for an unknown or missing id —
388+
# one rule shared by DocsKit::Controller#render_page and the gem's own
389+
# controllers (DocsKit::Scoping), so a bad param degrades to the current
390+
# docs instead of 500ing.
391+
def resolve_version(id)
392+
version(id) || current_version
393+
end
394+
395+
# Whether the version chrome (switcher, llms.txt Versions block) renders.
396+
# A single configured version is not worth a switcher, so this needs two —
397+
# and an unconfigured site stays byte-identical to before.
398+
def versioning_enabled?
399+
versions.size > 1
400+
end
401+
402+
# The resolved snapshots directory: the configured value verbatim, else
403+
# Rails.root/"docs_snapshots" under Rails, else nil (no Rails, no default —
404+
# the standalone suite passes explicit paths).
405+
def snapshots_path
406+
return @snapshots_path if @snapshots_path
407+
408+
Rails.root.join("docs_snapshots") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
409+
end
410+
411+
# The GitHub compare URL between two versions' refs
412+
# ("{repo_url}/compare/{from.ref}...{to.ref}"), or nil unless #repo_url and
413+
# BOTH refs are present — absent value, absent link, never a broken one.
414+
def compare_url(from, to)
415+
return if repo_url.nil? || from&.ref.nil? || to&.ref.nil?
416+
417+
"#{repo_url.chomp('/')}/compare/#{from.ref}...#{to.ref}"
418+
end
419+
341420
# The SEO / social-share knobs (DocsKit::SeoConfig), read by DocsUI::MetaTags.
342421
# Lazily built and memoized so a `c.seo.description = ...` block mutates the
343422
# one instance the Shell later reads. A site that never touches it gets the
@@ -502,11 +581,17 @@ def default_theme
502581

503582
# The resolved nav Hash for this request. Always returns a Hash.
504583
#
505-
# An explicit #nav lambda wins. Otherwise the sidebar derives from
506-
# #nav_registries: each heading maps to its registry's .nav_items, and a
507-
# heading whose pages are all unauthored (empty nav_items) is dropped so no
508-
# empty group renders.
584+
# An ARCHIVED version in DocsKit::Scope wins outright: the sidebar derives
585+
# from that version's snapshot manifest (hrefs already version-prefixed), so
586+
# an archived page never links into the live docs — even a site's explicit
587+
# #nav lambda describes the live pages, not the frozen ones. With no scope
588+
# (or the current version) nothing changes: an explicit #nav lambda wins,
589+
# else the sidebar derives from #nav_registries — each heading maps to its
590+
# registry's .nav_items, and a heading whose pages are all unauthored
591+
# (empty nav_items) is dropped so no empty group renders.
509592
def nav_groups
593+
scope_version = DocsKit::Scope.version
594+
return DocsKit::Snapshot.for(scope_version, config: self).nav_groups if scope_version&.archived?
510595
return nav_groups_from_registries unless @nav_explicit
511596

512597
result = @nav.respond_to?(:call) ? @nav.call : @nav

lib/docs_kit/controller.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,19 @@ module Controller
1919
# from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So
2020
# `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the
2121
# author writes nothing extra, and the two never drift.
22+
#
23+
# The render runs inside the request's DocsKit::Scope (the version resolved
24+
# from params[:version], falling back to the current version), so the
25+
# sidebar/meta tags/enumeration all see the version the URL asked for.
26+
# `render` renders synchronously inside the action, so this block wrapper is
27+
# sufficient — no around_action, no host code changes. On an unversioned
28+
# site the scope is nil: today's behavior exactly.
2229
def render_page(view)
23-
return render_markdown(view) if markdown_request?
30+
DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version])) do
31+
return render_markdown(view) if markdown_request?
2432

25-
render view, layout: false
33+
render view, layout: false
34+
end
2635
end
2736

2837
private

lib/docs_kit/doc_version.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
module DocsKit
4+
# One documentation version a site serves. Sites declare these in config as
5+
# plain Hashes; #versions normalizes each into a DocVersion so the chrome and
6+
# the AI surfaces stay value-object-driven (like DocsKit::TopbarLink):
7+
#
8+
# c.versions = [
9+
# { id: "1.1", ref: "v1.1.0", current: true },
10+
# { id: "1.0", ref: "v1.0.0" },
11+
# ]
12+
#
13+
# #id is the URL segment (an archived version serves at "/#{id}/docs/...");
14+
# #label is the switcher text (defaults to the id); #ref is the git ref backing
15+
# the GitHub compare link (optional); #current marks the version serving
16+
# unprefixed at /docs (exactly today's URLs); #noindex defaults to the inverse
17+
# of #current — archived copies are noindex'd so search engines keep pointing
18+
# at the current docs, overridable per version with `noindex: false`.
19+
#
20+
# Named DocVersion, not Version — lib/docs_kit/version.rb already owns that
21+
# file slot and defines DocsKit::VERSION.
22+
DocVersion = Data.define(:id, :label, :ref, :current, :noindex) do
23+
def initialize(id:, label: nil, ref: nil, current: false, noindex: nil)
24+
super(
25+
id: id,
26+
label: label || id.to_s,
27+
ref: ref,
28+
current: current,
29+
noindex: noindex.nil? ? !current : noindex
30+
)
31+
end
32+
33+
# Build a DocVersion from a Hash (symbol- OR string-keyed, so a YAML/JSON
34+
# config loads cleanly) or pass an existing DocVersion through unchanged.
35+
def self.from(version)
36+
return version if version.is_a?(self)
37+
38+
attrs = version.to_h.transform_keys(&:to_sym)
39+
new(
40+
id: attrs[:id],
41+
label: attrs[:label],
42+
ref: attrs[:ref],
43+
current: attrs.fetch(:current, false),
44+
noindex: attrs[:noindex]
45+
)
46+
end
47+
48+
def current? = !!current
49+
50+
def archived? = !current?
51+
52+
# The root URL segment this version contributes: "" for the current version
53+
# (existing sites and their SEO untouched), "/#{id}" for an archived one.
54+
# Stacks with the i18n locale prefix later ("/de/1.0/docs/...").
55+
def path_prefix
56+
current? ? "" : "/#{id}"
57+
end
58+
end
59+
end

lib/docs_kit/llms_text.rb

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,40 @@ def section_blocks(config, base_url)
5454
end
5555
end
5656

57-
# The authored pages across every registry, in config/registry order — each
58-
# responds to #title / #href / #view_class. The controller renders these to
59-
# Markdown for .full.
60-
def pages(config)
57+
# The authored pages for one version of the docs, in config/registry order —
58+
# each responds to #title / #href / #view_class (render via .renderable_for).
59+
# This is the ONE enumeration seam every AI surface funnels through, so
60+
# making IT version-aware makes llms-full.txt, search, and MCP follow the
61+
# request's version for free.
62+
#
63+
# version: nil resolves through DocsKit::Scope (set per request by the
64+
# controllers), then config.current_version — so an unversioned site, or the
65+
# current version, enumerates the live registries exactly as before. An
66+
# ARCHIVED version enumerates its Markdown snapshot instead
67+
# (DocsKit::Snapshot — every entry is authored by definition).
68+
def pages(config, version: nil)
69+
version ||= DocsKit::Scope.version || config.current_version
70+
return snapshot_pages(config, version) if version&.archived?
71+
6172
config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) }
6273
end
6374

75+
# An archived version's pages, from its committed snapshot. Every entry has
76+
# a view_class by construction; the select keeps the authored-pages contract
77+
# symmetric with the live branch.
78+
def snapshot_pages(config, version)
79+
DocsKit::Snapshot.for(version, config: config).all.select(&:view_class)
80+
end
81+
82+
# The Phlex renderable for a page returned by .pages: the page's own
83+
# #renderable (Registry v2 Entry, Snapshot::Entry) with a backwards-
84+
# compatible fallback to view_class.new for a site's custom `entries`-style
85+
# registry class that predates #renderable. The ONE shim — the controllers
86+
# and MCP tools all call this rather than repeating the respond_to? check.
87+
def renderable_for(page)
88+
page.respond_to?(:renderable) ? page.renderable : page.view_class.new
89+
end
90+
6491
# The llms-full.txt body: each [title, markdown] pair as `# {title}` + body,
6592
# separated by a `---` rule. Empty pairs → "".
6693
def full(_config, title_markdown_pairs)

lib/docs_kit/markdown_export/blocks.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ def initialize(export)
2020
# yield nothing (whitespace-only text nodes) are dropped so no stray blank
2121
# lines accumulate.
2222
def render(node)
23-
node.children.filter_map { |child| block(child) }
24-
.reject(&:empty?)
23+
node.children
24+
.filter_map { |child| block(child) }
25+
.reject(&:empty?)
2526
.join("\n\n")
2627
end
2728

lib/docs_kit/mcp_tools.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ def not_found(config, slug)
8787

8888
# A page's GFM Markdown twin, rendered through the view context so url helpers
8989
# and relative-link absolutization resolve — the LlmsController#full seam.
90+
# renderable_for is the live-or-snapshot shim (see LlmsText.renderable_for).
9091
def render_markdown(page, base_url:, view_context:)
91-
MarkdownExport.new(page.view_class.new, view_context:, base_url:).to_md
92+
MarkdownExport.new(LlmsText.renderable_for(page), view_context:, base_url:).to_md
9293
end
9394

9495
# A DocsKit::SearchIndex over every authored page's twin — the same triples

0 commit comments

Comments
 (0)