Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/components/docs_ui/archived_page.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion app/controllers/docs_kit/llms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/docs_kit/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
7 changes: 6 additions & 1 deletion app/controllers/docs_kit/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
93 changes: 89 additions & 4 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
# /<id>/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 = -> { {} }
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions lib/docs_kit/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions lib/docs_kit/doc_version.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 31 additions & 4 deletions lib/docs_kit/llms_text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions lib/docs_kit/markdown_export/blocks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion lib/docs_kit/mcp_tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/docs_kit/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading