diff --git a/app/components/docs_ui/archived_page.rb b/app/components/docs_ui/archived_page.rb new file mode 100644 index 0000000..58e5f22 --- /dev/null +++ b/app/components/docs_ui/archived_page.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module DocsUI + # Renders one page of an ARCHIVED documentation version — a frozen Markdown + # body (DocsKit::Snapshot::Entry) through today's live chrome, so archived + # docs get every future Shell/Sidebar/Code fix for free. The live counterpart + # is DocsUI::Page; this mirrors its shape with the content coming from the + # snapshot file instead of an authored #content method. + # + # 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 + # Rails-free render. Nothing here needs a request. + class ArchivedPage < Phlex::HTML + include DocsUI + + def initialize(entry: nil) + @entry = entry + end + + 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. + def body + render DocsUI::Header.new(@entry.title) if @entry&.title + render DocsUI::Markdown.new(markdown_source) unless markdown_source.empty? + end + + private + + def markdown_source + @markdown_source ||= @entry ? @entry.markdown.to_s : "" + end + end +end diff --git a/app/controllers/docs_kit/llms_controller.rb b/app/controllers/docs_kit/llms_controller.rb index 1c9cb7e..7269812 100644 --- a/app/controllers/docs_kit/llms_controller.rb +++ b/app/controllers/docs_kit/llms_controller.rb @@ -29,6 +29,11 @@ class LlmsController < ActionController::Base # GET-only, sessionless, public text endpoints (no token to verify). protect_from_forgery with: :null_session + # Every action runs in the request's version scope (params[:version] on the + # version-prefixed routes, else the current version), so the enumeration + # below serves the version the URL asked for. + include DocsKit::Scoping + def index body = DocsKit::LlmsText.index(docs_config, base_url: request.base_url) render_text(body) if stale_llms?(body) @@ -67,9 +72,11 @@ def stale_llms?(body) # A page's Markdown twin, rendered through this controller's view context so # url helpers/CSRF resolve and relative links absolutize to portable URLs — # the same path DocsKit::Controller#render_page takes for a `.md` request. + # renderable_for is the live-or-snapshot shim (a snapshot entry renders an + # ArchivedPage carrying its frozen Markdown). def render_page_markdown(page) DocsKit::MarkdownExport.new( - page.view_class.new, view_context:, base_url: request.base_url + DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url ).to_md end end diff --git a/app/controllers/docs_kit/mcp_controller.rb b/app/controllers/docs_kit/mcp_controller.rb index 920ed5e..e899878 100644 --- a/app/controllers/docs_kit/mcp_controller.rb +++ b/app/controllers/docs_kit/mcp_controller.rb @@ -31,6 +31,10 @@ class McpController < ActionController::Base # protection outright. skip_forgery_protection + # MCP tool calls run in the request's version scope; the version-aware tool + # arguments (issue #61 phase 6) layer per-call resolution on top of this. + include DocsKit::Scoping + def create return head(:not_found) unless docs_config.mcp_enabled? diff --git a/app/controllers/docs_kit/search_controller.rb b/app/controllers/docs_kit/search_controller.rb index af9262d..9d79521 100644 --- a/app/controllers/docs_kit/search_controller.rb +++ b/app/controllers/docs_kit/search_controller.rb @@ -28,6 +28,11 @@ class SearchController < ActionController::Base # public endpoint. protect_from_forgery with: :null_session + # Search follows the request's version: /1.0/docs/search searches the 1.0 + # snapshot, /docs/search searches current — the scope swaps the enumeration + # source underneath DocsKit::LlmsText.pages. + include DocsKit::Scoping + def index hits = search_index.search(query) @@ -53,7 +58,7 @@ def query = params[:q].to_s def search_index triples = DocsKit::LlmsText.pages(docs_config).map do |page| markdown = DocsKit::MarkdownExport.new( - page.view_class.new, view_context:, base_url: request.base_url + DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url ).to_md [page.title, page.href, markdown] end diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index 5d5693d..c046913 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -242,6 +242,27 @@ def topbar_brand=(value) # #openapi_document (which memoizes + reloads on file change), never @openapi. attr_accessor :openapi + # The documentation versions this site serves — a list of Hashes + # ({ id:, label:, ref:, current:, noindex: }) or DocsKit::DocVersion objects; + # #versions normalizes them. Defaults to [] → versioning is off and the site + # is byte-identical to before. The `current` entry keeps serving unprefixed + # at /docs; every other entry serves a committed Markdown snapshot at + # //docs (see DocsKit::Snapshot). A version id must match v?\d+(\.\d+)* + # so the host's static version route constraint recognizes it. Read via + # #versions, never @versions. + attr_writer :versions + + # The site's source repository root (e.g. "https://github.com/me/repo"), + # used for the GitHub compare link between two versions' refs + # (#compare_url). Defaults to nil → no compare link renders. + attr_accessor :repo_url + + # Where committed version snapshots live. Defaults to nil, which the reader + # resolves to Rails.root/"docs_snapshots" under Rails (nil outside Rails — + # the standalone suite points at fixtures explicitly). Read via + # #snapshots_path, never @snapshots_path. + attr_writer :snapshots_path + # The sentinel "no explicit nav" lambda. #nav_groups compares against this # identity to decide whether to derive the sidebar from #nav_registries. DEFAULT_NAV = -> { {} } @@ -310,6 +331,9 @@ def initialize @brand_logo = nil @brand_logo_raw = nil @topbar_brand = :always + @versions = [] + @repo_url = nil + @snapshots_path = nil end # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset — @@ -338,6 +362,61 @@ def topbar_links Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) } end + # The normalized version list (DocsKit::DocVersion list), in declaration + # order. Each configured Hash/DocVersion is coerced via DocVersion.from, so + # the switcher and the snapshot reader only ever see value objects. + # Blank/nil config yields []. + def versions + Array(@versions).map { |version| DocsKit::DocVersion.from(version) } + end + + # The version serving unprefixed at /docs: the entry marked current: true, + # else the first configured entry, else nil (an unversioned site). + def current_version + versions.find(&:current?) || versions.first + end + + # The configured version with this id, or nil when unknown (or nil id). + def version(id) + return if id.nil? + + versions.find { |version| version.id.to_s == id.to_s } + end + + # The version a request's :version param resolves to: the strict #version + # lookup, falling back to #current_version for an unknown or missing id — + # one rule shared by DocsKit::Controller#render_page and the gem's own + # controllers (DocsKit::Scoping), so a bad param degrades to the current + # docs instead of 500ing. + def resolve_version(id) + version(id) || current_version + end + + # Whether the version chrome (switcher, llms.txt Versions block) renders. + # A single configured version is not worth a switcher, so this needs two — + # and an unconfigured site stays byte-identical to before. + def versioning_enabled? + versions.size > 1 + end + + # The resolved snapshots directory: the configured value verbatim, else + # Rails.root/"docs_snapshots" under Rails, else nil (no Rails, no default — + # the standalone suite passes explicit paths). + def snapshots_path + return @snapshots_path if @snapshots_path + + Rails.root.join("docs_snapshots") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + end + + # The GitHub compare URL between two versions' refs + # ("{repo_url}/compare/{from.ref}...{to.ref}"), or nil unless #repo_url and + # BOTH refs are present — absent value, absent link, never a broken one. + def compare_url(from, to) + return if repo_url.nil? || from&.ref.nil? || to&.ref.nil? + + "#{repo_url.chomp('/')}/compare/#{from.ref}...#{to.ref}" + end + # The SEO / social-share knobs (DocsKit::SeoConfig), read by DocsUI::MetaTags. # Lazily built and memoized so a `c.seo.description = ...` block mutates the # one instance the Shell later reads. A site that never touches it gets the @@ -502,11 +581,17 @@ def default_theme # The resolved nav Hash for this request. Always returns a Hash. # - # An explicit #nav lambda wins. Otherwise the sidebar derives from - # #nav_registries: each heading maps to its registry's .nav_items, and a - # heading whose pages are all unauthored (empty nav_items) is dropped so no - # empty group renders. + # An ARCHIVED version in DocsKit::Scope wins outright: the sidebar derives + # from that version's snapshot manifest (hrefs already version-prefixed), so + # an archived page never links into the live docs — even a site's explicit + # #nav lambda describes the live pages, not the frozen ones. With no scope + # (or the current version) nothing changes: an explicit #nav lambda wins, + # else the sidebar derives from #nav_registries — each heading maps to its + # registry's .nav_items, and a heading whose pages are all unauthored + # (empty nav_items) is dropped so no empty group renders. def nav_groups + scope_version = DocsKit::Scope.version + return DocsKit::Snapshot.for(scope_version, config: self).nav_groups if scope_version&.archived? return nav_groups_from_registries unless @nav_explicit result = @nav.respond_to?(:call) ? @nav.call : @nav diff --git a/lib/docs_kit/controller.rb b/lib/docs_kit/controller.rb index 5793edf..c341610 100644 --- a/lib/docs_kit/controller.rb +++ b/lib/docs_kit/controller.rb @@ -19,10 +19,19 @@ module Controller # from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So # `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the # author writes nothing extra, and the two never drift. + # + # The render runs inside the request's DocsKit::Scope (the version resolved + # from params[:version], falling back to the current version), so the + # sidebar/meta tags/enumeration all see the version the URL asked for. + # `render` renders synchronously inside the action, so this block wrapper is + # sufficient — no around_action, no host code changes. On an unversioned + # site the scope is nil: today's behavior exactly. def render_page(view) - return render_markdown(view) if markdown_request? + DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version])) do + return render_markdown(view) if markdown_request? - render view, layout: false + render view, layout: false + end end private diff --git a/lib/docs_kit/doc_version.rb b/lib/docs_kit/doc_version.rb new file mode 100644 index 0000000..5019faa --- /dev/null +++ b/lib/docs_kit/doc_version.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module DocsKit + # One documentation version a site serves. Sites declare these in config as + # plain Hashes; #versions normalizes each into a DocVersion so the chrome and + # the AI surfaces stay value-object-driven (like DocsKit::TopbarLink): + # + # c.versions = [ + # { id: "1.1", ref: "v1.1.0", current: true }, + # { id: "1.0", ref: "v1.0.0" }, + # ] + # + # #id is the URL segment (an archived version serves at "/#{id}/docs/..."); + # #label is the switcher text (defaults to the id); #ref is the git ref backing + # the GitHub compare link (optional); #current marks the version serving + # unprefixed at /docs (exactly today's URLs); #noindex defaults to the inverse + # of #current — archived copies are noindex'd so search engines keep pointing + # at the current docs, overridable per version with `noindex: false`. + # + # Named DocVersion, not Version — lib/docs_kit/version.rb already owns that + # file slot and defines DocsKit::VERSION. + DocVersion = Data.define(:id, :label, :ref, :current, :noindex) do + def initialize(id:, label: nil, ref: nil, current: false, noindex: nil) + super( + id: id, + label: label || id.to_s, + ref: ref, + current: current, + noindex: noindex.nil? ? !current : noindex + ) + end + + # Build a DocVersion from a Hash (symbol- OR string-keyed, so a YAML/JSON + # config loads cleanly) or pass an existing DocVersion through unchanged. + def self.from(version) + return version if version.is_a?(self) + + attrs = version.to_h.transform_keys(&:to_sym) + new( + id: attrs[:id], + label: attrs[:label], + ref: attrs[:ref], + current: attrs.fetch(:current, false), + noindex: attrs[:noindex] + ) + end + + def current? = !!current + + def archived? = !current? + + # The root URL segment this version contributes: "" for the current version + # (existing sites and their SEO untouched), "/#{id}" for an archived one. + # Stacks with the i18n locale prefix later ("/de/1.0/docs/..."). + def path_prefix + current? ? "" : "/#{id}" + end + end +end diff --git a/lib/docs_kit/llms_text.rb b/lib/docs_kit/llms_text.rb index 5fb4602..4f6236b 100644 --- a/lib/docs_kit/llms_text.rb +++ b/lib/docs_kit/llms_text.rb @@ -54,13 +54,40 @@ def section_blocks(config, base_url) end end - # The authored pages across every registry, in config/registry order — each - # responds to #title / #href / #view_class. The controller renders these to - # Markdown for .full. - def pages(config) + # The authored pages for one version of the docs, in config/registry order — + # each responds to #title / #href / #view_class (render via .renderable_for). + # This is the ONE enumeration seam every AI surface funnels through, so + # making IT version-aware makes llms-full.txt, search, and MCP follow the + # request's version for free. + # + # version: nil resolves through DocsKit::Scope (set per request by the + # controllers), then config.current_version — so an unversioned site, or the + # current version, enumerates the live registries exactly as before. An + # ARCHIVED version enumerates its Markdown snapshot instead + # (DocsKit::Snapshot — every entry is authored by definition). + def pages(config, version: nil) + version ||= DocsKit::Scope.version || config.current_version + return snapshot_pages(config, version) if version&.archived? + config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) } end + # An archived version's pages, from its committed snapshot. Every entry has + # a view_class by construction; the select keeps the authored-pages contract + # symmetric with the live branch. + def snapshot_pages(config, version) + DocsKit::Snapshot.for(version, config: config).all.select(&:view_class) + end + + # The Phlex renderable for a page returned by .pages: the page's own + # #renderable (Registry v2 Entry, Snapshot::Entry) with a backwards- + # compatible fallback to view_class.new for a site's custom `entries`-style + # registry class that predates #renderable. The ONE shim — the controllers + # and MCP tools all call this rather than repeating the respond_to? check. + def renderable_for(page) + page.respond_to?(:renderable) ? page.renderable : page.view_class.new + end + # The llms-full.txt body: each [title, markdown] pair as `# {title}` + body, # separated by a `---` rule. Empty pairs → "". def full(_config, title_markdown_pairs) diff --git a/lib/docs_kit/markdown_export/blocks.rb b/lib/docs_kit/markdown_export/blocks.rb index 5db1bb2..91003ef 100644 --- a/lib/docs_kit/markdown_export/blocks.rb +++ b/lib/docs_kit/markdown_export/blocks.rb @@ -20,8 +20,9 @@ def initialize(export) # yield nothing (whitespace-only text nodes) are dropped so no stray blank # lines accumulate. def render(node) - node.children.filter_map { |child| block(child) } - .reject(&:empty?) + node.children + .filter_map { |child| block(child) } + .reject(&:empty?) .join("\n\n") end diff --git a/lib/docs_kit/mcp_tools.rb b/lib/docs_kit/mcp_tools.rb index 44f281b..dfcafdd 100644 --- a/lib/docs_kit/mcp_tools.rb +++ b/lib/docs_kit/mcp_tools.rb @@ -87,8 +87,9 @@ def not_found(config, slug) # A page's GFM Markdown twin, rendered through the view context so url helpers # and relative-link absolutization resolve — the LlmsController#full seam. + # renderable_for is the live-or-snapshot shim (see LlmsText.renderable_for). def render_markdown(page, base_url:, view_context:) - MarkdownExport.new(page.view_class.new, view_context:, base_url:).to_md + MarkdownExport.new(LlmsText.renderable_for(page), view_context:, base_url:).to_md end # A DocsKit::SearchIndex over every authored page's twin — the same triples diff --git a/lib/docs_kit/registry.rb b/lib/docs_kit/registry.rb index 6914c0e..be25c25 100644 --- a/lib/docs_kit/registry.rb +++ b/lib/docs_kit/registry.rb @@ -147,6 +147,13 @@ def view_class "#{@view_namespace}::#{@view_name}".safe_constantize end + + # The renderable instance for this page (nil when unauthored) — the seam + # DocsKit::Snapshot::Entry shares, so consumers render live pages and + # snapshot pages identically (see LlmsText.renderable_for). + def renderable + view_class&.new + end end end end diff --git a/lib/docs_kit/scope.rb b/lib/docs_kit/scope.rb new file mode 100644 index 0000000..e8df9b2 --- /dev/null +++ b/lib/docs_kit/scope.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module DocsKit + # The ONE request-scoped content scope: which documentation version (and, + # come i18n M2, which locale) the current render serves. Controllers set it + # around an action (DocsKit::Controller#render_page, DocsKit::Scoping); the + # config and the components consult it (Configuration#nav_groups, + # LlmsText.pages) — so "which content tree?" is asked once per request, not + # threaded through every component. + # + # 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 — nil today + # DocsKit::Scope.path_prefix # "" or "/1.0" + # + # Backed by Thread.current[] — fiber-local in Ruby, which is what a fibered + # server wants — and deliberately Rails-free (NOT CurrentAttributes), so bare + # Phlex component specs can set a scope without booting Rails. An empty scope + # (no `with` in flight) reads as nil version / nil locale, which every + # consumer treats as "the current version" — today's behavior exactly. + module Scope + KEY = :docs_kit_scope + + EMPTY = { version: nil, locale: nil }.freeze + private_constant :EMPTY + + module_function + + # Run the block with this version/locale in scope, restoring the previous + # scope on the way out — even when the block raises — so nothing leaks + # across requests sharing a thread. + def with(version: nil, locale: nil) + previous = Thread.current[KEY] + Thread.current[KEY] = { version: version, locale: locale } + yield + ensure + Thread.current[KEY] = previous + end + + # The DocsKit::DocVersion in scope, or nil (treated as the current version). + def version + current[:version] + end + + # Reserved for i18n M2 — always nil until the locale axis is wired. + def locale + current[:locale] + end + + # The root URL prefix the in-scope version contributes ("" when none/current). + def path_prefix + version&.path_prefix || "" + end + + def current + Thread.current[KEY] || EMPTY + end + end +end diff --git a/lib/docs_kit/scoping.rb b/lib/docs_kit/scoping.rb new file mode 100644 index 0000000..1a69685 --- /dev/null +++ b/lib/docs_kit/scoping.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DocsKit + # Wraps a controller's actions in the request's DocsKit::Scope, so everything + # rendered or enumerated during the action (Configuration#nav_groups, + # LlmsText.pages, the search index) sees the same version the URL asked for. + # Included by the gem's own controllers (Llms, Search, Mcp); a host's docs + # controller gets the same behavior from DocsKit::Controller#render_page's own + # wrapper instead — including this module there would around_action every host + # action, which is not docs-kit's call to make. + # + # A plain module with an included hook, not an ActiveSupport::Concern — it has + # no dependency chain and stays loadable in the Rails-free suite. + module Scoping + def self.included(base) + base.around_action :docs_scope + end + + private + + # The requested version (params[:version], falling back to the current + # version — an unknown id degrades, never 500s) held in scope for the whole + # action. nil on an unversioned site: today's behavior exactly. + def docs_scope(&) + DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version]), &) + end + end +end diff --git a/lib/docs_kit/snapshot.rb b/lib/docs_kit/snapshot.rb new file mode 100644 index 0000000..a3bdc3c --- /dev/null +++ b/lib/docs_kit/snapshot.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "json" +require "pathname" + +module DocsKit + # Reads a committed Markdown snapshot of one documentation version back as the + # registry duck type the rest of the kit already speaks (#all / #from_slug / + # #nav_items), so an archived version renders through TODAY's chrome — only + # the content is frozen. + # + # A snapshot lives at //: a manifest.json + # describing the nav structure (see the schema in the snapshot task) plus one + # .md file per page, written by the host-run `bin/rails docs_kit:snapshot[id]` + # task at release time. + # + # A missing directory or unreadable manifest degrades to an EMPTY snapshot + # (no pages) — a version configured before its snapshot is written must never + # take the site down. The install generator's --sync report warns about the + # drift instead. + class Snapshot + # The manifest format this reader understands; the writer stamps it so a + # future format change is detectable rather than silently misread. + SCHEMA = 1 + + class << self + # The snapshot for this version, memoized per [version id, directory] and + # invalidated when manifest.json's mtime changes — the same + # reload-on-change posture as Configuration#openapi_document, so editing + # a snapshot in development is picked up without a server restart. + def for(version, config: DocsKit.configuration) + version = DocVersion.from(version) + root = root_for(version, config) + mtime = manifest_mtime(root) + key = [version.id.to_s, root.to_s] + + @cache ||= {} + cached = @cache[key] + return cached.fetch(:snapshot) if cached && cached.fetch(:mtime) == mtime + + new(version: version, root: root).tap do |snapshot| + @cache[key] = { snapshot: snapshot, mtime: mtime } + end + end + + def reset_cache! + @cache = {} + end + + private + + # /, or nil when no snapshots path resolves (no + # config, no Rails) — which reads back as an empty snapshot. + def root_for(version, config) + base = config.snapshots_path + return if base.nil? + + Pathname.new(base).join(version.id.to_s) + end + + def manifest_mtime(root) + return if root.nil? + + path = root.join("manifest.json") + path.file? ? path.mtime : nil + rescue StandardError + nil + end + end + + attr_reader :version, :root + + def initialize(version:, root:) + @version = version + @root = root + @manifest = read_manifest + end + + # Every snapshot page across the manifest's registries, in manifest order — + # each a Snapshot::Entry quacking like a Registry::Entry. + def all + registries.flat_map { |registry| registry.fetch(:entries) } + end + + def from_slug(slug) + all.find { |entry| entry.slug.to_s == slug.to_s } + end + + # { group => [NavItem] }, the Registry.nav_items shape — hrefs already carry + # the version prefix, so the Sidebar's strict path == href active-matching + # works unchanged. + def nav_items + nav_items_for(all) + end + + # { heading => { group => [NavItem] } }, the Configuration#nav_groups shape, + # from the manifest's per-registry headings — a heading with no pages is + # dropped so the sidebar never shows an empty group. + def nav_groups + registries.each_with_object({}) do |registry, acc| + items = nav_items_for(registry.fetch(:entries)) + acc[registry.fetch(:heading)] = items unless items.empty? + end + end + + # The version-prefixed docs prefix (e.g. "/1.0/docs"). + def path_prefix + "#{version.path_prefix}/docs" + end + + # The raw Markdown body of the page with this slug, or nil when unknown. + def markdown_for(slug) + from_slug(slug)&.markdown + end + + private + + # The manifest's registries as { heading:, entries: [Snapshot::Entry] }. + def registries + @registries ||= Array(@manifest && @manifest["registries"]).map do |registry| + prefix = registry["path_prefix"] || "/docs" + { + heading: registry["heading"], + entries: Array(registry["pages"]).map do |attrs| + Entry.new(attrs, version: version, root: root, registry_prefix: prefix) + end + } + end + end + + def nav_items_for(entries) + entries.group_by(&:group).transform_values do |grouped| + grouped.map { |entry| NavItem.new(href: entry.href, label: entry.title, icon: entry.icon) } + end + end + + # The parsed manifest Hash, or nil (→ empty snapshot) when the directory or + # manifest is missing/unreadable — degrade, never raise (the site must stay + # up with a version configured before its snapshot exists). + def read_manifest + return if root.nil? + + path = root.join("manifest.json") + return unless path.file? + + JSON.parse(path.read) + rescue JSON::ParserError, SystemCallError + nil + end + end +end diff --git a/lib/docs_kit/snapshot/entry.rb b/lib/docs_kit/snapshot/entry.rb new file mode 100644 index 0000000..d119af3 --- /dev/null +++ b/lib/docs_kit/snapshot/entry.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module DocsKit + class Snapshot + # One snapshot page — the duck type of DocsKit::Registry::Entry (#slug / + # #title / #group / #icon / #href / #view_class / #renderable), so the + # enumeration seam (LlmsText.pages) and its consumers treat a frozen + # Markdown page exactly like a live Ruby one. #view_class is the truthy + # DocsUI::ArchivedPage constant, so the `select(&:view_class)` authored-page + # filter passes unchanged. + class Entry + attr_reader :slug, :title, :group, :icon, :file, :digest, :href + + def initialize(attrs, version:, root:, registry_prefix:) + @slug = attrs["slug"] + @title = attrs["title"] + @group = attrs["group"] + @icon = attrs["icon"] + @file = attrs["file"] + @digest = attrs["digest"] + @root = root + @href = "#{version.path_prefix}#{registry_prefix}/#{@slug}" + end + + # The renderer for every archived page. Truthy (never nil): a snapshot + # page is by definition authored — its content is the committed .md file. + def view_class + DocsUI::ArchivedPage + end + + # The renderable the controllers hand to Phlex — an ArchivedPage carrying + # this entry, where a live Registry::Entry builds `view_class.new`. + def renderable + DocsUI::ArchivedPage.new(entry: self) + end + + # The raw Markdown body from the snapshot file. A missing/unreadable file + # degrades to "" — the page renders empty rather than 500ing. + def markdown + return "" if @root.nil? || @file.nil? + + @root.join(@file).read + rescue SystemCallError + "" + end + end + end +end diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index d7874f2..efdb450 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -649,4 +649,194 @@ def self.nav_items .to raise_error(DocsKit::Error, /c\.openapi/) end end + + describe "#versions" do + it "defaults to [] (an unversioned site is byte-identical to before)" do + expect(described_class.new.versions).to eq([]) + end + + it "normalizes configured Hashes into DocVersion value objects" do + DocsKit.configure do |c| + c.versions = [ + { id: "1.1", current: true }, + { "id" => "1.0", "ref" => "v1.0.0" } + ] + end + + versions = DocsKit.configuration.versions + expect(versions).to all(be_a(DocsKit::DocVersion)) + expect(versions.map(&:id)).to eq(%w[1.1 1.0]) + expect(versions.last.ref).to eq("v1.0.0") + end + + it "passes DocVersion instances through unchanged" do + version = DocsKit::DocVersion.new(id: "1.0") + DocsKit.configure { |c| c.versions = [version] } + + expect(DocsKit.configuration.versions).to eq([version]) + end + + it "coerces a nil assignment back to an empty array" do + DocsKit.configure { |c| c.versions = nil } + + expect(DocsKit.configuration.versions).to eq([]) + end + end + + describe "#versioning_enabled?" do + it "is false by default (the backwards-compat pin)" do + expect(described_class.new.versioning_enabled?).to be(false) + end + + it "is false with a single configured version (no switcher for one entry)" do + DocsKit.configure { |c| c.versions = [{ id: "1.0", current: true }] } + + expect(DocsKit.configuration.versioning_enabled?).to be(false) + end + + it "is true with two or more versions" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] } + + expect(DocsKit.configuration.versioning_enabled?).to be(true) + end + end + + describe "#current_version" do + it "is nil when no versions are configured" do + expect(described_class.new.current_version).to be_nil + end + + it "is the entry marked current: true" do + DocsKit.configure { |c| c.versions = [{ id: "1.0" }, { id: "1.1", current: true }] } + + expect(DocsKit.configuration.current_version.id).to eq("1.1") + end + + it "falls back to the first entry when none is marked current" do + DocsKit.configure { |c| c.versions = [{ id: "1.1" }, { id: "1.0" }] } + + expect(DocsKit.configuration.current_version.id).to eq("1.1") + end + end + + describe "#version" do + it "looks an entry up by id" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] } + + expect(DocsKit.configuration.version("1.0").id).to eq("1.0") + end + + it "is nil for an unknown id" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }] } + + expect(DocsKit.configuration.version("9.9")).to be_nil + end + + it "is nil for nil (no version param on the request)" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }] } + + expect(DocsKit.configuration.version(nil)).to be_nil + end + end + + describe "#resolve_version" do + it "resolves a known id" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] } + + expect(DocsKit.configuration.resolve_version("1.0").id).to eq("1.0") + end + + it "falls back to the current version for an unknown or missing id" do + DocsKit.configure { |c| c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] } + + expect(DocsKit.configuration.resolve_version("9.9").id).to eq("1.1") + expect(DocsKit.configuration.resolve_version(nil).id).to eq("1.1") + end + + it "is nil on an unversioned site" do + expect(described_class.new.resolve_version(nil)).to be_nil + end + end + + describe "#nav_groups under a version scope" do + let(:fixtures_root) { File.expand_path("../fixtures/snapshots", __dir__) } + let(:live_registry) do + Class.new do + def self.nav_items + { "Guide" => [DocsKit::NavItem.new(href: "/docs/live", label: "Live")] } + end + end + end + + before do + DocsKit::Snapshot.reset_cache! + DocsKit.configure do |c| + c.snapshots_path = fixtures_root + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + c.nav_registries = { "Live docs" => live_registry } + end + end + + it "derives the sidebar from the snapshot for an archived version in scope" do + DocsKit::Scope.with(version: DocsKit.configuration.version("1.0")) do + groups = DocsKit.configuration.nav_groups + + expect(groups.keys).to eq(["Docs"]) + expect(groups["Docs"]["Getting started"].map(&:href)) + .to all(start_with("/1.0/docs/")) + end + end + + it "behaves exactly as today for the current version in scope" do + DocsKit::Scope.with(version: DocsKit.configuration.version("1.1")) do + expect(DocsKit.configuration.nav_groups.keys).to eq(["Live docs"]) + end + end + + it "behaves exactly as today with no scope (the backwards-compat pin)" do + expect(DocsKit.configuration.nav_groups.keys).to eq(["Live docs"]) + end + end + + describe "#repo_url" do + it "defaults to nil" do + expect(described_class.new.repo_url).to be_nil + end + end + + describe "#snapshots_path" do + it "defaults to nil outside Rails (no Rails.root to resolve against)" do + expect(described_class.new.snapshots_path).to be_nil + end + + it "returns the configured path verbatim" do + DocsKit.configure { |c| c.snapshots_path = "/srv/app/docs_snapshots" } + + expect(DocsKit.configuration.snapshots_path).to eq("/srv/app/docs_snapshots") + end + end + + describe "#compare_url" do + let(:from) { DocsKit::DocVersion.new(id: "1.0", ref: "v1.0.0") } + let(:to) { DocsKit::DocVersion.new(id: "1.1", ref: "v1.1.0", current: true) } + + it "builds a GitHub compare URL from repo_url and the two refs" do + DocsKit.configure { |c| c.repo_url = "https://github.com/me/repo/" } + + expect(DocsKit.configuration.compare_url(from, to)) + .to eq("https://github.com/me/repo/compare/v1.0.0...v1.1.0") + end + + it "is nil without repo_url" do + expect(described_class.new.compare_url(from, to)).to be_nil + end + + it "is nil when either side has no ref" do + DocsKit.configure { |c| c.repo_url = "https://github.com/me/repo" } + refless = DocsKit::DocVersion.new(id: "0.9") + + expect(DocsKit.configuration.compare_url(refless, to)).to be_nil + expect(DocsKit.configuration.compare_url(from, refless)).to be_nil + end + end end diff --git a/spec/docs_kit/controller_spec.rb b/spec/docs_kit/controller_spec.rb index 290a17f..5edd3a0 100644 --- a/spec/docs_kit/controller_spec.rb +++ b/spec/docs_kit/controller_spec.rb @@ -7,14 +7,15 @@ # the module and stubs the Rails seams (request/render/view_context). RSpec.describe DocsKit::Controller do # A minimal ActionController stand-in: it includes the real module and exposes - # settable `request`/`view_context` plus a `render` spy, so the branch logic is - # verified without booting Rails. - def controller_for(format:) + # settable `request`/`view_context`/`params` plus a `render` spy, so the branch + # logic is verified without booting Rails. + def controller_for(format:, params: {}) fmt = format + request_params = params Class.new do include DocsKit::Controller - attr_reader :rendered + attr_reader :rendered, :rendered_scope_version define_method(:request) do # Mimic ActionDispatch::Request: #format responds to md?/text? predicates. @@ -22,12 +23,17 @@ def controller_for(format:) Struct.new(:format, :base_url).new(format, "https://acme.dev") end + define_method(:params) { request_params } + def view_context = :the_view_context # ActionController#render takes an optional positional (the renderable) plus # options; capture both so the HTML branch (render view, layout: false) and # the markdown branch (render plain:, content_type:) are both observable. + # Also capture the DocsKit::Scope in effect AT render time — the render + # happens inside render_page's Scope.with wrapper, so this observes it. def render(renderable = nil, **kwargs) + @rendered_scope_version = DocsKit::Scope.version @rendered = kwargs.merge(renderable ? { renderable: renderable } : {}) end end.new @@ -87,4 +93,54 @@ def call(view_context: nil) expect(controller.rendered[:plain]).to include("## Title") end end + + describe "the version scope" do + before do + DocsKit.configure do |c| + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + end + end + + it "renders inside the scope of the requested version" do + controller = controller_for(format: :html, params: { version: "1.0" }) + + controller.render_page(page) + + expect(controller.rendered_scope_version&.id).to eq("1.0") + end + + it "falls back to the current version for an unknown :version param" do + controller = controller_for(format: :html, params: { version: "9.9" }) + + controller.render_page(page) + + expect(controller.rendered_scope_version&.id).to eq("1.1") + end + + it "scopes to the current version with no :version param" do + controller = controller_for(format: :html) + + controller.render_page(page) + + expect(controller.rendered_scope_version&.id).to eq("1.1") + end + + it "restores the empty scope after rendering" do + controller = controller_for(format: :html, params: { version: "1.0" }) + + controller.render_page(page) + + expect(DocsKit::Scope.version).to be_nil + end + + it "scopes to nil on an unversioned site (today's behavior exactly)" do + DocsKit.reset_configuration! + controller = controller_for(format: :html) + + controller.render_page(page) + + expect(controller.rendered_scope_version).to be_nil + expect(controller.rendered).to include(layout: false) + end + end end diff --git a/spec/docs_kit/doc_version_spec.rb b/spec/docs_kit/doc_version_spec.rb new file mode 100644 index 0000000..ca476c7 --- /dev/null +++ b/spec/docs_kit/doc_version_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +RSpec.describe DocsKit::DocVersion do + it "carries id/label/ref and the current/noindex flags" do + version = described_class.new(id: "1.0", label: "v1.0", ref: "v1.0.0", current: true, noindex: false) + + expect(version.id).to eq("1.0") + expect(version.label).to eq("v1.0") + expect(version.ref).to eq("v1.0.0") + expect(version.current).to be(true) + expect(version.noindex).to be(false) + end + + it "defaults label to the id" do + expect(described_class.new(id: "1.0").label).to eq("1.0") + end + + it "defaults ref to nil and current to false" do + version = described_class.new(id: "1.0") + + expect(version.ref).to be_nil + expect(version.current).to be(false) + end + + describe "#noindex" do + it "defaults to true for an archived version (the inverse of current)" do + expect(described_class.new(id: "1.0").noindex).to be(true) + end + + it "defaults to false for the current version" do + expect(described_class.new(id: "1.1", current: true).noindex).to be(false) + end + + it "is explicitly overridable to false on an archived version" do + expect(described_class.new(id: "1.0", noindex: false).noindex).to be(false) + end + end + + describe ".from" do + it "returns a DocVersion unchanged" do + version = described_class.new(id: "1.0") + + expect(described_class.from(version)).to be(version) + end + + it "builds one from a symbol-keyed Hash" do + version = described_class.from(id: "1.0", label: "v1.0", ref: "v1.0.0") + + expect(version.id).to eq("1.0") + expect(version.label).to eq("v1.0") + expect(version.ref).to eq("v1.0.0") + end + + it "builds one from a string-keyed Hash (YAML/JSON config loads cleanly)" do + version = described_class.from("id" => "1.1", "current" => true) + + expect(version.id).to eq("1.1") + expect(version.current).to be(true) + expect(version.noindex).to be(false) + end + end + + describe "#current? / #archived?" do + it "is current when marked current" do + version = described_class.new(id: "1.1", current: true) + + expect(version.current?).to be(true) + expect(version.archived?).to be(false) + end + + it "is archived otherwise" do + version = described_class.new(id: "1.0") + + expect(version.current?).to be(false) + expect(version.archived?).to be(true) + end + end + + describe "#path_prefix" do + it "is empty for the current version (unprefixed URLs, unchanged sites)" do + expect(described_class.new(id: "1.1", current: true).path_prefix).to eq("") + end + + it "is / for an archived version" do + expect(described_class.new(id: "1.0").path_prefix).to eq("/1.0") + end + end +end diff --git a/spec/docs_kit/llms_controller_spec.rb b/spec/docs_kit/llms_controller_spec.rb index 704730a..dd4d242 100644 --- a/spec/docs_kit/llms_controller_spec.rb +++ b/spec/docs_kit/llms_controller_spec.rb @@ -52,5 +52,9 @@ expect(source).not_to match(/^\s*def config\b/) expect(source).to include("def docs_config = DocsKit.configuration") end + + it "wraps every action in the request's version scope (DocsKit::Scoping)" do + expect(source).to include("include DocsKit::Scoping") + end end # rubocop:enable RSpec/DescribeClass diff --git a/spec/docs_kit/llms_text_spec.rb b/spec/docs_kit/llms_text_spec.rb index 08fee47..e275aa5 100644 --- a/spec/docs_kit/llms_text_spec.rb +++ b/spec/docs_kit/llms_text_spec.rb @@ -213,4 +213,80 @@ def configure(**opts) expect(described_class.full(configure, [])).to eq("") end end + + describe ".renderable_for" do + it "uses #renderable when the page provides it (Registry v2, snapshot entries)" do + page = Struct.new(:renderable).new(:the_renderable) + + expect(described_class.renderable_for(page)).to eq(:the_renderable) + end + + it "falls back to view_class.new for a custom registry entry predating #renderable" do + view = Class.new + page = Struct.new(:view_class).new(view) + + expect(described_class.renderable_for(page)).to be_a(view) + end + end + + describe ".pages with configured versions" do + let(:fixtures_root) { File.expand_path("../fixtures/snapshots", __dir__) } + let(:live_view) { Class.new } + + before { DocsKit::Snapshot.reset_cache! } + + def versioned_config + live = registry( + nav_items: {}, + all: [entry(title: "Live", href: "/docs/live", view_class: live_view)] + ) + config = configure(nav_registries: { "Docs" => live }) + DocsKit.configure do |c| + c.snapshots_path = fixtures_root + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + end + config + end + + it "enumerates the snapshot for an archived version argument" do + config = versioned_config + + pages = described_class.pages(config, version: config.version("1.0")) + + expect(pages.map(&:slug)).to eq(%w[installation configuration]) + expect(pages.map(&:href)).to all(start_with("/1.0/docs/")) + end + + it "keeps the live enumeration for the current version" do + config = versioned_config + + pages = described_class.pages(config, version: config.version("1.1")) + + expect(pages.map(&:title)).to eq(%w[Live]) + end + + it "consults DocsKit::Scope when no version argument is given" do + config = versioned_config + + DocsKit::Scope.with(version: config.version("1.0")) do + expect(described_class.pages(config).map(&:slug)).to eq(%w[installation configuration]) + end + end + + it "keeps the live enumeration with no scope and no argument" do + config = versioned_config + + expect(described_class.pages(config).map(&:title)).to eq(%w[Live]) + end + + it "is unchanged on an unversioned site (the backwards-compat pin)" do + live = registry( + nav_items: {}, + all: [entry(title: "Live", href: "/docs/live", view_class: live_view)] + ) + config = configure(nav_registries: { "Docs" => live }) + + expect(described_class.pages(config).map(&:title)).to eq(%w[Live]) + end + end end diff --git a/spec/docs_kit/mcp_controller_spec.rb b/spec/docs_kit/mcp_controller_spec.rb index 87ede3a..59e0c47 100644 --- a/spec/docs_kit/mcp_controller_spec.rb +++ b/spec/docs_kit/mcp_controller_spec.rb @@ -57,5 +57,9 @@ expect(source).not_to match(/^\s*def config\b/) expect(source).to include("def docs_config = DocsKit.configuration") end + + it "wraps every action in the request's version scope (DocsKit::Scoping)" do + expect(source).to include("include DocsKit::Scoping") + end end # rubocop:enable RSpec/DescribeClass diff --git a/spec/docs_kit/registry_spec.rb b/spec/docs_kit/registry_spec.rb index e1e8be1..78ab991 100644 --- a/spec/docs_kit/registry_spec.rb +++ b/spec/docs_kit/registry_spec.rb @@ -222,4 +222,28 @@ def initialize(entry) end.to raise_error(DocsKit::Registry::Error, /cannot mix/i) end end + + describe "Entry#renderable" do + it "instantiates the authored view class (the seam snapshot entries share)" do + authored = Class.new do + extend DocsKit::Registry + + view_namespace "DocsKit" + page "Configuration", group: "Guide" # → DocsKit::Configuration (exists) + end + + expect(authored.from_slug("configuration").renderable).to be_a(DocsKit::Configuration) + end + + it "is nil for an unauthored page (no resolvable view_class)" do + unwritten = Class.new do + extend DocsKit::Registry + + view_namespace "DocsKit" + page "Installation", group: "Guide" # DocsKit::Installation does not exist + end + + expect(unwritten.from_slug("installation").renderable).to be_nil + end + end end diff --git a/spec/docs_kit/scope_spec.rb b/spec/docs_kit/scope_spec.rb new file mode 100644 index 0000000..9012d51 --- /dev/null +++ b/spec/docs_kit/scope_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe DocsKit::Scope do + let(:archived) { DocsKit::DocVersion.new(id: "1.0") } + let(:current) { DocsKit::DocVersion.new(id: "1.1", current: true) } + + it "defaults to an empty scope (nil version, nil locale)" do + expect(described_class.version).to be_nil + expect(described_class.locale).to be_nil + end + + it "has an empty path_prefix by default" do + expect(described_class.path_prefix).to eq("") + end + + describe ".with" do + it "exposes the version inside the block and restores after" do + described_class.with(version: archived) do + expect(described_class.version).to be(archived) + end + + expect(described_class.version).to be_nil + end + + it "restores the previous scope when the block raises" do + expect do + described_class.with(version: archived) { raise "boom" } + end.to raise_error("boom") + + expect(described_class.version).to be_nil + end + + it "nests: the inner scope wins, then the outer is restored" do + described_class.with(version: archived) do + described_class.with(version: current) do + expect(described_class.version).to be(current) + end + + expect(described_class.version).to be(archived) + end + end + + it "returns the block's value" do + expect(described_class.with(version: archived) { :result }).to eq(:result) + end + + it "leaks nothing across sequential calls" do + described_class.with(version: archived) { nil } + described_class.with(locale: :de) { nil } + + expect(described_class.version).to be_nil + expect(described_class.locale).to be_nil + end + + it "carries the reserved locale slot (i18n M2 — nothing reads it yet)" do + described_class.with(locale: :de) do + expect(described_class.locale).to eq(:de) + end + end + end + + describe ".path_prefix" do + it "is the in-scope version's prefix" do + described_class.with(version: archived) do + expect(described_class.path_prefix).to eq("/1.0") + end + end + + it "is empty for the current version" do + described_class.with(version: current) do + expect(described_class.path_prefix).to eq("") + end + end + end +end diff --git a/spec/docs_kit/scoping_spec.rb b/spec/docs_kit/scoping_spec.rb new file mode 100644 index 0000000..51e6097 --- /dev/null +++ b/spec/docs_kit/scoping_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# DocsKit::Scoping wraps a gem controller's actions in the request's version +# scope (around_action :docs_scope). The real controllers are Rails-only, so the +# hook + the wrapper are exercised through a stand-in host that records the +# around_action registration and exposes params — the same pattern as the +# DocsKit::Controller spec. +RSpec.describe DocsKit::Scoping do + def host_for(params) + request_params = params + Class.new do + class << self + attr_reader :around_actions + + def around_action(name) + (@around_actions ||= []) << name + end + end + + include DocsKit::Scoping + + define_method(:params) { request_params } + end + end + + it "registers the docs_scope around_action on include" do + expect(host_for({}).around_actions).to eq([:docs_scope]) + end + + describe "#docs_scope" do + before do + DocsKit.configure do |c| + c.versions = [{ id: "1.1", current: true }, { id: "1.0" }] + end + end + + it "runs the action inside the requested version's scope, then restores" do + host = host_for({ version: "1.0" }).new + + seen = nil + host.send(:docs_scope) { seen = DocsKit::Scope.version } + + expect(seen.id).to eq("1.0") + expect(DocsKit::Scope.version).to be_nil + end + + it "falls back to the current version for an unknown :version param" do + host = host_for({ version: "9.9" }).new + + seen = nil + host.send(:docs_scope) { seen = DocsKit::Scope.version } + + expect(seen.id).to eq("1.1") + end + + it "scopes to nil on an unversioned site" do + DocsKit.reset_configuration! + host = host_for({}).new + + seen = :unset + host.send(:docs_scope) { seen = DocsKit::Scope.version } + + expect(seen).to be_nil + end + end +end diff --git a/spec/docs_kit/search_controller_spec.rb b/spec/docs_kit/search_controller_spec.rb index a5c96fa..60a8fee 100644 --- a/spec/docs_kit/search_controller_spec.rb +++ b/spec/docs_kit/search_controller_spec.rb @@ -65,5 +65,9 @@ expect(source).to include("DocsUI::Shell") expect(source).to include("layout: false") end + + it "wraps every action in the request's version scope (DocsKit::Scoping)" do + expect(source).to include("include DocsKit::Scoping") + end end # rubocop:enable RSpec/DescribeClass diff --git a/spec/docs_kit/snapshot_spec.rb b/spec/docs_kit/snapshot_spec.rb new file mode 100644 index 0000000..9005cec --- /dev/null +++ b/spec/docs_kit/snapshot_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "tmpdir" + +RSpec.describe DocsKit::Snapshot do + let(:fixtures_root) { File.expand_path("../fixtures/snapshots", __dir__) } + let(:version) { DocsKit::DocVersion.new(id: "1.0") } + + before do + described_class.reset_cache! + DocsKit.configure { |c| c.snapshots_path = fixtures_root } + end + + def snapshot + described_class.for(version, config: DocsKit.configuration) + end + + describe ".for" do + it "memoizes per version id (same instance across reads)" do + first = snapshot + + expect(described_class.for(version, config: DocsKit.configuration)).to be(first) + end + + it "re-reads when the manifest's mtime changes" do + tmp = File.join(Dir.mktmpdir, "snapshots") + FileUtils.mkdir_p(File.join(tmp, "1.0")) + FileUtils.cp_r(Dir[File.join(fixtures_root, "1.0", "*")], File.join(tmp, "1.0")) + DocsKit.configure { |c| c.snapshots_path = tmp } + first = snapshot + + manifest = File.join(tmp, "1.0", "manifest.json") + data = JSON.parse(File.read(manifest)) + data["registries"][0]["pages"].pop + File.write(manifest, JSON.generate(data)) + FileUtils.touch(manifest, mtime: File.mtime(manifest) + 2) + + expect(snapshot).not_to be(first) + expect(snapshot.all.map(&:slug)).to eq(%w[installation]) + end + end + + describe "#all" do + it "returns one entry per manifest page, in manifest order" do + expect(snapshot.all.map(&:slug)).to eq(%w[installation configuration]) + end + + it "exposes the manifest attributes on each entry" do + entry = snapshot.all.last + + expect(entry.title).to eq("Configuration") + expect(entry.group).to eq("Getting started") + expect(entry.icon).to eq("settings") + expect(entry.digest).to start_with("9d2f") + end + + it "prefixes every href with the version segment" do + expect(snapshot.all.map(&:href)) + .to eq(%w[/1.0/docs/installation /1.0/docs/configuration]) + end + end + + describe "#from_slug" do + it "finds an entry by slug" do + expect(snapshot.from_slug("installation").title).to eq("Installation") + end + + it "is nil for an unknown slug" do + expect(snapshot.from_slug("nope")).to be_nil + end + end + + describe "#nav_items" do + it "groups NavItems like a registry, hrefs already version-prefixed" do + items = snapshot.nav_items + + expect(items.keys).to eq(["Getting started"]) + expect(items["Getting started"].map(&:href)) + .to eq(%w[/1.0/docs/installation /1.0/docs/configuration]) + expect(items["Getting started"]).to all(be_a(DocsKit::NavItem)) + end + end + + describe "#nav_groups" do + it "keys nav_items by the manifest registry heading (the sidebar shape)" do + groups = snapshot.nav_groups + + expect(groups.keys).to eq(["Docs"]) + expect(groups["Docs"]["Getting started"].map(&:label)) + .to eq(%w[Installation Configuration]) + end + end + + describe "#path_prefix" do + it "is the version-prefixed docs prefix" do + expect(snapshot.path_prefix).to eq("/1.0/docs") + end + end + + describe "#markdown_for" do + it "returns the raw snapshot file body" do + expect(snapshot.markdown_for("installation")).to include("Add the gem to your Gemfile") + end + + it "is nil for an unknown slug" do + expect(snapshot.markdown_for("nope")).to be_nil + end + end + + describe "the archived-page duck type" do + it "gives every entry a truthy view_class (the select(&:view_class) filter passes)" do + expect(snapshot.all.map(&:view_class)).to all(eq(DocsUI::ArchivedPage)) + end + + it "builds an ArchivedPage renderable carrying the entry" do + renderable = snapshot.from_slug("installation").renderable + + expect(renderable).to be_a(DocsUI::ArchivedPage) + end + + it "reads the entry's markdown body from its snapshot file" do + expect(snapshot.from_slug("installation").markdown).to include('gem "docs_kit"') + end + end + + describe "degrading to an empty snapshot" do + it "is empty for a version with no snapshot directory (never raises)" do + missing = described_class.for(DocsKit::DocVersion.new(id: "0.9"), config: DocsKit.configuration) + + expect(missing.all).to eq([]) + expect(missing.nav_items).to eq({}) + expect(missing.nav_groups).to eq({}) + expect(missing.markdown_for("installation")).to be_nil + end + + it "is empty when no snapshots_path is configured" do + DocsKit.reset_configuration! + + expect(snapshot.all).to eq([]) + end + + it "is empty for an unreadable manifest (never takes the site down)" do + tmp = File.join(Dir.mktmpdir, "snapshots") + FileUtils.mkdir_p(File.join(tmp, "1.0")) + File.write(File.join(tmp, "1.0", "manifest.json"), "{not json") + DocsKit.configure { |c| c.snapshots_path = tmp } + + expect(snapshot.all).to eq([]) + end + end +end diff --git a/spec/docs_ui/archived_page_spec.rb b/spec/docs_ui/archived_page_spec.rb new file mode 100644 index 0000000..739d9f6 --- /dev/null +++ b/spec/docs_ui/archived_page_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# The full document wraps in DocsUI::Shell, whose needs a live Rails view +# context (csrf_meta_tags etc.) — so, like the Shell specs, exercise the page +# BODY through a subclass that renders only #body. The banner + full-page specs +# land with the version switcher (issue #61 phase 4). +RSpec.describe DocsUI::ArchivedPage do + let(:body_only) do + Class.new(described_class) do + def view_template = body + end + end + + let(:entry) do + Struct.new(:title, :markdown).new("Installation", "Add the **gem** first.") + end + + it "renders the entry's Markdown body through the chrome's Markdown island" do + html = body_only.new(entry: entry).call + + expect(html).to include("gem") + end + + it "renders the entry title as the masthead" do + html = body_only.new(entry: entry).call + + expect(html).to include("Installation") + expect(html).to include("