Skip to content

Multi-version docs: snapshot-backed version archive, version switcher, and a computed version diff (MCP-readable) #61

Description

@mhenrixon

Multi-version docs: snapshot-backed version archive, version switcher, and a computed version diff

Problem / Goal

A docs site built on docs-kit can only ever serve ONE version of its documentation — the one whose Phlex page classes are currently in the repo. When a library ships 2.0, the 1.x docs are gone. Readers on an old release have nowhere to go, and an AI agent asked "what changed in the docs between 1.0 and 1.1?" has nothing to read.

Two things are missing:

  1. Serving several versions from one app. Not several deployments — the same Rails app, same image, same chrome, with a topbar switcher.
  2. A machine-readable "what's new". Especially over MCP: an agent should be able to ask which pages were added/removed/changed between two versions and read the actual diff, not be handed a source-level GitHub compare link full of code and test churn.

Done looks like: a site lists its versions in config; the current version keeps serving at /docs/... exactly as today; each archived version serves at /<id>/docs/... from a committed Markdown snapshot rendered through TODAY's chrome; a topbar switcher moves between them with JavaScript off; /docs/changes?from=1.0&to=1.1 shows the computed content diff; MCP exposes list_versions and diff_versions plus an optional version: argument on the existing tools; and llms.txt advertises all of it. A site that configures no versions renders byte-identical to today.

This is deliberately built on the same request-scoped seam the i18n milestone (#59) will need for multi-locale content, so serving /de/docs/... later is config plus a second axis, not a re-architecture. See "Relationship to #59" below.

Context (read these first)

Config and values:

  • lib/docs_kit/configuration.rb — the config surface. #topbar_links (attr_writer + a reader normalizing Hashes into value objects) is the exact shape #versions must follow; #openapi_document (memoize + invalidate on file mtime) is the shape DocsKit::Snapshot caching must follow; #search_enabled? / #mcp_enabled? are the shape of #versioning_enabled?.
  • lib/docs_kit/topbar_link.rb — a Data.define + .from(hash) normalizer. DocsKit::DocVersion copies this exactly.
  • lib/docs_kit/seo_config.rbseo.robots, seo.site_url.

Registry and the one page-enumeration seam:

  • lib/docs_kit/registry.rb.page DSL, .path_prefix (default /docs), .view_namespace, .all, .from_slug, .grouped, .nav_items (line 120, { group => [NavItem] }, authored pages only), and Registry::Entry (line 131) whose #view_class (line 145) is "#{view_namespace}::#{view_name}".safe_constantize.
  • lib/docs_kit/llms_text.rb:61the single seam every AI surface funnels through:
    def pages(config)
      config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) }
    end
  • lib/docs_kit/nav_item.rb — the sidebar link value object.

The .view_class.new call sites that must become version-aware (all three do the same thing):

  • app/controllers/docs_kit/llms_controller.rb:72
  • app/controllers/docs_kit/search_controller.rb:56
  • lib/docs_kit/mcp_tools.rb:91

Rendering:

  • app/components/docs_ui/shell.rb:41 the <html> tag; :154-169 #topbar (where the switcher goes, next to render DocsUI::TopbarLinks.new); :142 div(id: "docs-content"), the anchor MarkdownExport extracts.
  • app/components/docs_ui/theme_switcher.rb — the daisyUI dropdown pattern to copy (tabindex/role=button + dropdown-content; CSS :focus-within, so it works with JS off).
  • app/components/docs_ui/sidebar.rb:38-41 — reads ONLY DocsKit.configuration.nav_groups; :95-104 active-link matching is strict request.path == href equality, so a prefixed href matches for free.
  • app/components/docs_ui/page.rb — the authored-page base class (title/eyebrow/description/on_page DSL, masthead, ← Home + Markdown action nav). DocsUI::ArchivedPage mirrors its shape.
  • app/components/docs_ui/markdown.rb — GFM → Phlex nodes, fenced code through DocsUI::Code, raw HTML dropped. This renders archived page bodies.
  • app/components/docs_ui/meta_tags.rb:102-106 (#robots_meta), :151-157 (#canonical_url).

Markdown production (this is what makes snapshots nearly free):

  • lib/docs_kit/markdown_export.rbMarkdownExport.new(view, view_context:, base_url:).to_md renders a page and converts #docs-content to GFM, stripping [data-md-skip]. llms-full.txt already runs this over every page.
  • lib/docs_kit/controller.rb#render_page (line 22) and #render_markdown (line 40); .md/.text requests get the twin.

Install path and precedent for a host-run task:

  • lib/generators/docs_kit/install/install_generator.rbadd_routes (line 126) and route_once (line 427, idempotent); create_og_task (line 188) installs lib/tasks/docs_kit_og.rake and refreshes it on every run — the exact precedent for the snapshot task.
  • lib/generators/docs_kit/install/templates/docs_kit_og.rake — a host-run rake task that requires a zeitwerk-ignored class. Copy this shape.
  • lib/generators/docs_kit/install/templates/docs_controller.rb.erb — the scaffolded controller (doc&.view_class then render_page view.new).
  • lib/docs_kit.rb:55-77 — the zeitwerk loader.ignore list; og_generator.rb is ignored because only the rake task loads it.
  • docs/config/routes.rb — the dogfood routes, including the ordering comment (search must precede the docs/:doc catch-all).
  • docs/app/models/doc.rb, docs/config/initializers/docs_kit.rb — the dogfood registry and config.

Rules:

  • CLAUDE.md, .claude/rules/coding-style.md, .claude/rules/testing.md, .claude/rules/seo.md.

Decision

Archived versions are committed Markdown snapshots, produced by a host-run rake task, served through today's live chrome, behind a root-level URL prefix, with a computed content diff.

The four load-bearing choices:

1. Archive form: Markdown snapshots, not duplicated Ruby

A bin/rails docs_kit:snapshot[1.0] task renders every authored page through the existing MarkdownExport into <snapshots_path>/1.0/<slug>.md plus a manifest.json describing nav structure. DocsKit::Snapshot reads that back and quacks like a registry class; DocsUI::ArchivedPage renders a body through DocsUI::Markdown.

Why:

  • Archived docs get every future chrome fix for free — they render through the current Shell/Sidebar/Code/search, because only the content is frozen.
  • No old Ruby has to keep compiling. Duplicated page classes would have to keep working against an evolving page DSL forever — the exact churn this repo has been going through.
  • Diffing is nearly free, because both sides are already Markdown. That is what makes the MCP diff_versions tool worth building.
  • It is the abstraction i18n M2 needs anyway. An archived version and a translated locale are both "a content tree that isn't Ruby page classes". One reader serves both.

Rejected: duplicated page classes per version namespace (works today with almost no gem change — path_prefix is already per-class — but every archived page must keep compiling forever, the repo grows a Ruby copy per version, and "what's new" can only ever be a source diff). Rejected: build-time git checkout of old tags (keeps the repo lean but moves the compile-forever problem into CI and needs full history in the Docker build).

2. URL shape: root prefix, current version unprefixed

/docs/installation          current  (unchanged — existing sites and their SEO are untouched)
/1.0/docs/installation      archived
/de/docs/installation       i18n M2
/de/1.0/docs/installation   both axes

One prefix rule stacks for both axes. Rejected: /docs/1.0/installation — needs a constraint to stop the existing docs/:doc catch-all swallowing the version segment, and composes awkwardly with a locale prefix.

3. Diff depth: computed content diff + MCP tool + GitHub compare link

DocsKit::VersionDiff compares two Markdown trees into added / removed / changed page lists (from manifest digests — no file reads) plus an on-demand per-page unified diff. Surfaced three ways: a /docs/changes page, an MCP diff_versions tool, and a config-driven GitHub compare URL. The GitHub link alone would answer the letter of the request but not its intent — a source compare between two releases is mostly code and test churn, which is precisely the noise an agent asking "what's new in the docs" needs filtered out.

4. i18n coupling: build the shared seam, wire only the version axis

Introduce ONE request-scoped DocsKit::Scope (holds :version now, gains :locale in i18n M2), one path-prefix resolver, and the content-source abstraction. Do NOT wire the locale axis here — #59 milestone 1 (c.locale, c.ui_text) isn't implemented yet.

Settled in interview:

  • Archived content is committed Markdown snapshots, produced by a host-run rake task. Not duplicated page classes, not a build-time git checkout.
  • URL shape is root prefix (/1.0/docs/...); the current version stays unprefixed at /docs/....
  • The diff is a computed content diff exposed as a page, an MCP tool, AND a GitHub compare link — all three.
  • This issue builds the shared scope/content-source seam but wires the version axis only. i18n M2 is a separate issue.

Two further decisions taken during design (not re-litigable without a reason):

  • Search scope follows the request. /1.0/docs/search searches 1.0; /docs/search searches current. No new config knob, no cross-version result aggregation — N versions of the same page in one result list is noise, not a feature.
  • Archived pages get noindex, follow; canonical stays self-referential. Pointing canonical at the current version while also serving different content sends Google two conflicting signals. noindex is the unambiguous one. Overridable per version via noindex: false.

Implementation steps

Seven phases, each independently shippable as its own PR. TDD throughout: every step names its spec first (RED), then the change (GREEN).

Phase 1 — Config + the version value object

  1. spec/docs_kit/doc_version_spec.rb, then lib/docs_kit/doc_version.rb (new).
    DocsKit::DocVersion = Data.define(:id, :label, :ref, :current, :noindex) with a .from(hash_or_version) normalizer copied from DocsKit::TopbarLink.from (symbol- OR string-keyed). Defaults: label falls back to id; ref nil; current false; noindex defaults to !current. Add #current?, #archived?, and #path_prefix ("" when current, "/#{id}" otherwise).
    Named DocVersion, NOT Versionlib/docs_kit/version.rb already owns that file slot and defines DocsKit::VERSION.
    Specs: normalization from both key styles; label defaulting to id; noindex defaulting to the inverse of current and being explicitly overridable to false; path_prefix.

  2. spec/docs_kit/configuration_spec.rb, then lib/docs_kit/configuration.rb.

    • attr_writer :versions + a #versions reader mapping through DocVersion.from (mirror #topbar_links, configuration.rb:272). Default [].
    • attr_accessor :repo_url (default nil) — the GitHub repo root, for compare links.
    • attr_accessor :snapshots_path (default nil → resolved by the reader to Rails.root.join("docs_snapshots") when Rails is defined, else nil).
    • #current_version — the entry marked current: true, else the first, else nil.
    • #version(id) — lookup by id, nil when unknown.
    • #versioning_enabled?versions.size > 1. A single configured version is not worth a switcher.
    • #compare_url(from, to)"#{repo_url.chomp('/')}/compare/#{from.ref}...#{to.ref}", nil unless repo_url and BOTH refs are present.
      Specs: default [] and versioning_enabled? false (the backwards-compat pin); normalization; current_version fallback order; compare_url nil on a missing ref or missing repo_url.

Phase 2 — The shared scope seam

  1. spec/docs_kit/scope_spec.rb, then lib/docs_kit/scope.rb (new).

    DocsKit::Scope.with(version: v) { ... }   # block-scoped, restores in an ensure
    DocsKit::Scope.version                    # the DocVersion in scope, or nil
    DocsKit::Scope.locale                     # reserved for i18n M2 — always nil today
    DocsKit::Scope.path_prefix                # "" or "/1.0"

    Backed by Thread.current[:docs_kit_scope] (fiber-local in Ruby, which is what a fibered server wants). Rails-free — it must work in bare Phlex component specs. NOT ActiveSupport::CurrentAttributes, which would force a Rails boot into the component spec layer.
    Specs: default is empty (nil version); with nests and restores; an exception inside the block still restores; nothing leaks across sequential with calls.

  2. spec/docs_kit/controller_spec.rb (extend), then lib/docs_kit/controller.rb.
    #render_page wraps its render in DocsKit::Scope.with(version: resolved_version), where the version comes from params[:version] (looked up via config.version(...)) and falls back to config.current_version. render renders synchronously inside the action, so a block wrapper is sufficient — no around_action needed and no host code changes.
    Also add a docs_scope around_action concern used by the gem's own three controllers (llms, search, mcp) so they see the same scope.
    Spec: an unknown params[:version] falls back to current rather than 404-ing at this layer (the controller's own from_slug lookup already handles a bad slug).

Phase 3 — The snapshot content source

  1. spec/docs_kit/snapshot_spec.rb + a fixture tree at spec/fixtures/snapshots/1.0/ (manifest.json + two .md files), then lib/docs_kit/snapshot.rb and lib/docs_kit/snapshot/entry.rb.

    Manifest schema (JSON, schema: 1 so a future format change is detectable):

    {
      "schema": 1,
      "version": "1.0",
      "docs_kit_version": "1.0.6",
      "registries": [
        { "heading": "Docs", "path_prefix": "/docs",
          "pages": [ { "slug": "installation", "title": "Installation",
                       "group": "Getting started", "icon": null,
                       "file": "installation.md", "digest": "<sha256 of the md>" } ] }
      ]
    }

    The digest is what makes changed-page detection O(pages) with zero file reads.

    DocsKit::Snapshot API — the registry duck type the rest of the kit already speaks:

    • .for(version, config:) — memoized per version id, invalidated on manifest.json mtime (copy Configuration#openapi_document, configuration.rb:299).
    • #all[Snapshot::Entry], #from_slug(slug), #nav_items{ group => [NavItem] }, #path_prefix"/#{version.id}/docs".
    • #markdown_for(slug) — the raw file body.
    • A missing directory or unreadable manifest degrades to an EMPTY snapshot (no pages), never raises. A version configured before its snapshot is written must not take the site down.
      Specs: nav_items grouping and order match the manifest; hrefs carry the version prefix; missing manifest → empty; mtime change re-reads.
  2. spec/docs_kit/registry_spec.rb (extend), then lib/docs_kit/registry.rb.
    Add Registry::Entry#renderableview_class&.new. Purely additive.

  3. Then update the three .view_class.new call sites to page.renderable, with a backwards-compatible shim for sites whose custom entries-style registry classes predate #renderable:

    page.respond_to?(:renderable) ? page.renderable : page.view_class.new

    Sites: app/controllers/docs_kit/llms_controller.rb:72, app/controllers/docs_kit/search_controller.rb:56, lib/docs_kit/mcp_tools.rb:91. Extract the shim to one place (e.g. LlmsText.renderable_for(page)) rather than repeating it three times.
    Snapshot::Entry#view_class returns DocsUI::ArchivedPage (truthy, so the select(&:view_class) filter at llms_text.rb:61 passes unchanged) and #renderable returns DocsUI::ArchivedPage.new(entry: self). Give ArchivedPage.new all-defaulted kwargs so even a naive .view_class.new renders an empty page rather than raising.

  4. lib/docs_kit/llms_text.rb.pages(config, version: nil). When the version is archived, enumerate Snapshot.for(version).all instead of config.nav_registries. Default nilDocsKit::Scope.versionconfig.current_version → today's behavior exactly.

  5. lib/docs_kit/configuration.rb#nav_groups consults DocsKit::Scope.version: an archived version derives the sidebar from that version's snapshot nav_items (hrefs already prefixed), the current version behaves exactly as today. This is the ONLY change needed to make the sidebar version-aware — DocsUI::Sidebar reads nothing else (sidebar.rb:38-41), and its strict request.path == href active-matching (sidebar.rb:95-104) works on prefixed hrefs for free.

Phase 4 — Components

  1. spec/docs_ui/archived_page_spec.rb, then app/components/docs_ui/archived_page.rb.
    Mirrors DocsUI::Page's shape: renders inside DocsUI::Shell, a DocsUI::Header masthead, then the body through DocsUI::Markdown. Adds a DocsUI::Callout banner (data-md-skip, so it never leaks into a .md twin) reading "You are viewing the 1.0 docs. The current version is 1.1." with a link to the same slug in the current version — falling back to the current version's index when that slug does not exist there.
    Specs: markdown body renders; the banner links to the current equivalent; the banner is absent on the current version; the banner carries data-md-skip.

  2. spec/docs_ui/version_switcher_spec.rb, then app/components/docs_ui/version_switcher.rb, rendered in Shell#topbar (shell.rb:162-167) immediately before DocsUI::TopbarLinks.
    Copy the DocsUI::ThemeSwitcher dropdown markup exactly (tabindex/role=button + dropdown-content) — daisyUI's dropdown is CSS :focus-within, so it works with JavaScript off. Every entry is a plain <a>; no new Stimulus controller (the ONE-controller rule).
    Each link points at the same slug in the target version, falling back to that version's index when the slug is absent there.
    Specs: renders NOTHING when versioning_enabled? is false (the byte-identical-topbar pin); one link per version; the in-scope version is marked; a slug missing from a target version links to that version's index, not a 404.

  3. spec/docs_ui/meta_tags_spec.rb (extend), then app/components/docs_ui/meta_tags.rb.
    #robots_meta emits "noindex, follow" when DocsKit::Scope.version&.noindex, else today's seo.robots. Canonical is untouched.
    Spec: unconfigured render is unchanged (the regression pin); an archived version emits noindex; noindex: false on a version restores seo.robots.

  4. spec/docs_ui/version_changes_spec.rb, then app/components/docs_ui/version_changes.rb — the /docs/changes body: a from/to summary, counts, added/removed/changed lists linking to both sides, the per-page unified diff in a DocsUI::Code block (lexer: "diff"), and the GitHub compare button when config.compare_url resolves.
    CSS contract: any status color (text-success, text-error, text-warning, badge-*) must be a STATIC literal in the Ruby, never interpolated — Tailwind scans Ruby source, and an interpolated class never gets generated. If any new class is introduced, bun run build:css must be re-run in the dogfood site and @source inline(...) extended.

Phase 5 — The diff engine

  1. spec/docs_kit/version_diff_spec.rb, then lib/docs_kit/version_diff.rb + lib/docs_kit/version_diff/unified.rb.

    • VersionDiff.new(from:, to:, config:, view_context: nil); each side resolves to { slug => { title:, href:, digest:, markdown: (lazy) } }. A snapshot side reads its manifest; the live (current) side renders through MarkdownExport — pass view_context exactly as McpTools.render_markdown does (mcp_tools.rb:90).
    • #added, #removed, #changed (digest mismatch), #unchanged — all from manifest digests, no file reads.
    • #diff_for(slug) — a unified diff of the two bodies. Implement a small line-LCS in version_diff/unified.rb. Do not add a runtime gem dependencydiff-lcs is currently a dev/RSpec dependency only, and dragging it into the runtime for one feature is not worth it. Keep the file well under the 800-line ceiling.
    • #compare_urlconfig.compare_url(from, to).
    • #as_json → the payload the MCP tool and the JSON route return.
    • Known limitation to document, not to fix: snapshots written by different docs-kit versions may differ in Markdown formatting, so a snapshot-vs-live diff can report cosmetic changes. Mitigation is procedural: snapshot every version at release time (including the one being released), so real diffs are snapshot-vs-snapshot. State this in the README.
      Specs: added/removed/changed classification; unified diff hunks; identical trees → all-unchanged; compare_url nil without repo_url.
  2. spec/docs_kit/changes_controller_spec.rb (or a request-ish spec matching the existing controller spec style), then app/controllers/docs_kit/changes_controller.rbGET /docs/changes?from=&to=, HTML via DocsUI::VersionChanges inside Shell, plus format.json returning VersionDiff#as_json. stale?-cached on [DocsKit::VERSION, from, to], mirroring LlmsController (llms_controller.rb:32). Defaults: to = current version, from = the next-newest configured version.

Phase 6 — AI surfaces

  1. spec/docs_kit/llms_text_spec.rb (extend), then lib/docs_kit/llms_text.rb.
    .index gains a ## Versions block, emitted ONLY when config.versioning_enabled? (so an unversioned site's llms.txt is byte-identical). One line per version — label, docs URL, that version's llms.txt URL — plus the changes URL and the compare URL. This is the discovery hook that lets an agent find the diff without being told it exists.

  2. spec/docs_kit/mcp_tools_spec.rb and spec/docs_kit/mcp_server_spec.rb (extend), then lib/docs_kit/mcp_tools.rb and lib/docs_kit/mcp_server.rb.

    • New tool list_versions[{ id, label, current, url, ref, compare_url }].
    • New tool diff_versions(from:, to:, slug: nil){ from, to, added: [], removed: [], changed: [], compare_url: }; with slug: also returns that page's unified diff.
    • Optional version: argument added to list_pages, get_page, and search_docs, defaulting to the current version. An unknown version id returns a not-found payload naming the valid ids (copy McpTools.not_found, mcp_tools.rb:79).
    • McpServer.instructions_for (mcp_server.rb:52) mentions that several versions are available and names the tools — otherwise an agent never thinks to ask.
      Backwards-compat pin: on an unversioned site the tool LIST and every existing tool's response shape are unchanged. Add an explicit spec for this.

Phase 7 — Install path, snapshot task, docs

  1. spec/generators/install_generator_spec.rb (extend), then lib/generators/docs_kit/install/install_generator.rb.
    Draw via route_once (idempotent, so --sync is safe):

    get "/:version/docs/search" => "docs_kit/search#index", constraints: { version: %r{v?\d+(?:\.\d+)*} }
    get "/:version/docs/:doc(.:format)" => "docs#show", as: :versioned_doc, constraints: { version: %r{v?\d+(?:\.\d+)*} }
    get "/docs/changes(.:format)" => "docs_kit/changes#index", as: :docs_changes

    Route order matters and Thor route PREPENDS — mirror the existing ordering comment at install_generator.rb:133-135. /docs/changes must precede the docs/:doc catch-all or it is swallowed as a slug; the version-prefixed search must precede the version-prefixed :doc.
    The version constraint is a static regex, NOT built from c.versions — routes load once at boot while config runs in to_prepare. Document in the config comments that a version id must match v?\d+(\.\d+)*.

  2. lib/generators/docs_kit/install/templates/docs_kit_snapshot.rake (new template) + create_snapshot_task in the generator, refreshed on every run exactly like create_og_task (install_generator.rb:188).

    bin/rails docs_kit:snapshot[1.0]
    

    Renders every page from LlmsText.pages(config) to Markdown and writes the tree + manifest. It needs a real view context (DocsUI::Page calls root_path and request.path): build one with

    ApplicationController.new.tap { |c|
      c.request  = ActionDispatch::TestRequest.create
      c.response = ActionDispatch::Response.new
    }.view_context

    Verify this in the spec rather than trusting it. The writer class is lib/docs_kit/snapshot/writer.rb, loader.ignored in lib/docs_kit.rb and required only by the rake task — the same posture as og_generator.rb (docs_kit.rb:61-64). Manual and documented, never automatic, never a CI job.

  3. lib/generators/docs_kit/install/templates/docs_kit.rb.erb — commented c.versions, c.repo_url, and c.snapshots_path examples near the seo block, each with a one-line comment. docs-kit new runs this same generator; confirm lib/docs_kit/templates/new_site.rb needs nothing.

  4. lib/generators/docs_kit/install/templates/docs_controller.rb.erb — scaffold the versioned form (params[:version] → snapshot lookup, else the live registry; doc.renderable instead of view.new). Note in the issue's PR description that --sync deliberately does NOT rewrite an existing site's controller (it is site content, install_generator.rb:117), so the README upgrade section must show the diff by hand.

  5. lib/generators/docs_kit/install/sync_report.rb — add a drift item warning when c.versions is configured but the snapshots directory is missing or has no manifest for a configured id. Warn-only, never fails.

  6. .dockerignore template — verify the snapshots directory ships in the image.

  7. Docs: a README "Serve multiple versions" section (the config, the release-time snapshot workflow, the route wiring, the URL shape, the noindex behavior, the compare-link config, and the formatting-drift caveat from step 14); docs/ dogfood — add a page documenting it, and dogfood the feature itself by snapshotting the current release.

Verification gates

  • bundle exec rspec — all green; SimpleCov ≥ 80; spec/docs_kit/configuration_spec.rb keeps Configuration at 100%.
  • bundle exec rubocop -A — no offenses.
  • Backwards compat (the headline gate). With c.versions unset:
    • DocsUI::Shell renders byte-identical markup to main — no switcher, no extra head tags.
    • llms.txt has no ## Versions block.
    • The MCP tool list and every existing tool's response shape are unchanged.
    • LlmsText.pages(config) returns exactly what it returns today.
      Pin each of these with an explicit spec, not by inspection.
  • bun run build:css in the dogfood site — required only if phase 4 introduces a class Tailwind has not already generated. Confirm every status/badge class is a static literal, then verify the switcher and the changes page render correctly.
  • Dogfood end-to-end: cd docs && bin/rails docs_kit:snapshot[1.0], add it to c.versions, bin/dev, then check /1.0/docs/installation renders with the archived banner, the switcher moves between versions with JavaScript disabled, and /docs/changes?from=1.0&to=1.1 shows a real diff.
  • MCP smoke test: POST /mcp with list_versions, then diff_versions with from/to, and confirm the payloads are usable without follow-up questions.

Out of scope

  • The locale axis. DocsKit::Scope gets a :locale slot and nothing more. Do not implement c.locale, c.ui_text (that is i18n milestone 1: config-driven locale + localizable chrome strings (c.locale, c.ui_text) #59 milestone 1), locale routes, hreflang, or a locale switcher.
  • Rendering archived versions from Ruby page classes. Snapshots are the archive form; do not add a second one.
  • Automatic snapshot generation. No CI job, no cron, no release-workflow hook. It is a manual documented command, exactly like docs_kit:og.
  • Cross-version search aggregation or a "search all versions" UI. Search follows the request's version.
  • Redirects for pages removed between versions, sitemap.xml, and version-aware OG images.
  • A new runtime gem dependency for diffing. Write the LCS.
  • A second Stimulus controller. The switcher and the changes page are server-rendered and work with JavaScript off.
  • Do not touch DocsUI::ThemeSwitcher, DocsUI::SearchBox, or the docs-nav controller beyond what the topbar insertion requires.

Relationship to #59 (i18n)

#59 milestone 1 (c.locale, c.ui_text) is independent and can land before or after this — they touch different files. What this issue deliberately builds FOR #59 milestone 2:

  • DocsKit::Scope — one request-scoped holder; M2 adds locale: alongside version: and reuses with/path_prefix verbatim.
  • The prefix rule — /de/1.0/docs/... composes because both axes contribute a root segment and the current/default value is unprefixed.
  • Configuration#nav_groups scope-consultation (step 9) — M2 adds a locale branch at the same seam.
  • DocsKit::Snapshot + DocsUI::ArchivedPage — a translated locale is also "a Markdown tree that isn't Ruby page classes"; M2 reuses the reader and the renderer.
  • DocsUI::VersionSwitcher — M2's locale switcher is the same markup with a different list; extract the shared dropdown if the second one confirms the shape.
  • LlmsText.pages(config, version:) — M2 adds a locale: parameter to the same signature.

Execution

Execute with /lfg <this issue number>. Phases 1–3 are the foundation and should land as one PR; phases 4–7 can each be their own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions