docs-kit 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -20
  3. data/app/components/docs_ui/archived_page.rb +45 -0
  4. data/app/components/docs_ui/brand_mark.rb +1 -2
  5. data/app/components/docs_ui/landing.rb +9 -18
  6. data/app/components/docs_ui/logo.rb +74 -0
  7. data/app/components/docs_ui/shell.rb +21 -1
  8. data/app/components/docs_ui/sidebar.rb +30 -14
  9. data/app/controllers/docs_kit/llms_controller.rb +8 -1
  10. data/app/controllers/docs_kit/mcp_controller.rb +5 -1
  11. data/app/controllers/docs_kit/search_controller.rb +6 -1
  12. data/exe/docs-kit +5 -5
  13. data/lib/docs_kit/brand_logo.rb +124 -0
  14. data/lib/docs_kit/configuration.rb +136 -4
  15. data/lib/docs_kit/controller.rb +11 -2
  16. data/lib/docs_kit/doc_version.rb +59 -0
  17. data/lib/docs_kit/landing_config.rb +8 -24
  18. data/lib/docs_kit/llms_text.rb +31 -4
  19. data/lib/docs_kit/markdown_export/blocks.rb +3 -2
  20. data/lib/docs_kit/mcp_tools.rb +2 -1
  21. data/lib/docs_kit/registry.rb +7 -0
  22. data/lib/docs_kit/scope.rb +59 -0
  23. data/lib/docs_kit/scoping.rb +28 -0
  24. data/lib/docs_kit/snapshot/entry.rb +48 -0
  25. data/lib/docs_kit/snapshot.rb +151 -0
  26. data/lib/docs_kit/templates/new_site.rb +64 -12
  27. data/lib/docs_kit/version.rb +1 -1
  28. data/lib/docs_kit.rb +3 -0
  29. data/lib/generators/docs_kit/install/install_generator.rb +1 -1
  30. data/lib/generators/docs_kit/install/templates/Dockerfile.tt +5 -5
  31. data/lib/generators/docs_kit/install/templates/agents_md.erb +1 -1
  32. data/lib/generators/docs_kit/install/templates/dockerignore +1 -0
  33. data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +17 -0
  34. data/lib/generators/docs_kit/install/templates/skill.md.erb +1 -1
  35. metadata +13 -5
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The normalized brand mark for the shell chrome (config.brand_logo) and the
5
+ # landing hero (config.landing.logo). A site configures a Hash in exactly one
6
+ # of five forms; DocsUI::Logo renders the result:
7
+ #
8
+ # { svg: "M0 0Z", viewbox: "0 0 24 24", label: "Acme" } # one path-d (landing-compat)
9
+ # { paths: ["M0 0Z", "M4 4Z"], viewbox: "…", label: "…" } # multi-path wordmark
10
+ # { markup: "<svg …>…</svg>", label: "Acme" } # raw SVG markup, embedded verbatim
11
+ # { file: "app/assets/images/mark.svg", label: "Acme" } # a .svg file, embedded inline
12
+ # { src: "logo.png", alt: "Acme" } # an <img> (not theme-adaptive)
13
+ #
14
+ # The svg:/paths: forms render each `d` as an ordinary Phlex-escaped attribute.
15
+ # The markup:/file: forms embed SITE-AUTHORED markup verbatim (see DocsUI::Logo
16
+ # for the trust rationale); both are shape-checked here — the content must be an
17
+ # <svg> element — so a mis-pasted snippet fails loudly at config time, never as
18
+ # a silently broken (or script-bearing) header. Mixing forms, or giving none,
19
+ # is ambiguous config and raises. `label`/`alt` fall back to each other so
20
+ # either knob names the mark for assistive tech.
21
+ #
22
+ # A file: mark memoizes its content and re-reads on an mtime change (the
23
+ # Configuration#openapi_document posture), so editing the SVG in development
24
+ # shows up without a server restart.
25
+ class BrandLogo
26
+ # The config keys that each select a render form — exactly one must be given.
27
+ FORM_KEYS = %i[svg paths markup file src].freeze
28
+
29
+ # A loose "is this an <svg> element" shape check for the markup:/file: forms.
30
+ SVG_SHAPE = /\A\s*<svg[\s>]/i
31
+
32
+ DEFAULT_VIEWBOX = "0 0 24 24"
33
+
34
+ attr_reader :paths, :viewbox, :markup, :file, :src
35
+
36
+ # Coerce a config value (Hash with symbol or string keys, or an
37
+ # already-normalized BrandLogo) into a BrandLogo.
38
+ def self.from(logo)
39
+ return logo if logo.is_a?(self)
40
+
41
+ new(logo.to_h)
42
+ end
43
+
44
+ def initialize(attrs = {})
45
+ attrs = attrs.transform_keys(&:to_sym)
46
+ given = attrs.slice(*FORM_KEYS).compact
47
+ unless given.size == 1
48
+ raise ArgumentError,
49
+ "brand_logo takes exactly one of #{FORM_KEYS.inspect} (got #{given.keys.inspect})"
50
+ end
51
+
52
+ @viewbox = attrs[:viewbox] || DEFAULT_VIEWBOX
53
+ @label = attrs[:label]
54
+ @alt = attrs[:alt]
55
+ build_form(given.keys.first, attrs)
56
+ end
57
+
58
+ # The single path-d, for the landing-compat svg: shape (first of #paths).
59
+ def svg = paths&.first
60
+
61
+ def inline? = !paths.nil?
62
+ def markup? = !markup.nil?
63
+ def file? = !file.nil?
64
+ def image? = !src.nil?
65
+
66
+ # Whether the mark embeds site-authored markup verbatim (markup: or file:).
67
+ def embed? = markup? || file?
68
+
69
+ # The accessible name — label falls back to alt (and vice versa) so a site
70
+ # setting either names the mark; nil defers to the render-time brand fallback.
71
+ def label = @label || @alt
72
+ def alt = @alt || @label
73
+
74
+ # The markup to embed: the literal markup: string, or the file's content —
75
+ # memoized per mtime so a dev edit re-reads without a restart.
76
+ def svg_markup
77
+ return @markup if markup?
78
+
79
+ mtime = begin
80
+ @file.mtime
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ return @file_content if defined?(@file_content) && @file_mtime == mtime
85
+
86
+ @file_mtime = mtime
87
+ @file_content = check_svg_shape!(@file.read, "file #{@file}")
88
+ end
89
+
90
+ private
91
+
92
+ # Store the one given form. file: primes #svg_markup immediately so a bad
93
+ # file fails at config time (boot), not on first render.
94
+ def build_form(form, attrs)
95
+ case form
96
+ when :svg, :paths then @paths = Array(attrs[:paths] || attrs[:svg]).map(&:to_s)
97
+ when :markup then @markup = check_svg_shape!(attrs[:markup].to_s, "markup")
98
+ when :src then @src = attrs[:src]
99
+ when :file
100
+ @file = resolve_file!(attrs[:file])
101
+ svg_markup
102
+ end
103
+ end
104
+
105
+ # Validate + resolve the file: form eagerly, so a bad path fails at config
106
+ # time (boot), not on first render. Relative paths resolve against Rails.root
107
+ # when Rails is loaded, else the process working directory.
108
+ def resolve_file!(file)
109
+ path = Pathname.new(file.to_s)
110
+ path = Rails.root.join(path) if path.relative? && defined?(Rails) && Rails.respond_to?(:root) && Rails.root
111
+ raise ArgumentError, "brand_logo file must be a .svg (got #{path.basename})" unless path.extname.casecmp?(".svg")
112
+ raise ArgumentError, "brand_logo file not found: #{path}" unless path.file?
113
+
114
+ path
115
+ end
116
+
117
+ # The markup:/file: shape guard — the content must BE an <svg> element.
118
+ def check_svg_shape!(content, source)
119
+ return content if content.match?(SVG_SHAPE)
120
+
121
+ raise ArgumentError, "brand_logo #{source} must be an <svg> element (got #{content[0, 40].inspect})"
122
+ end
123
+ end
124
+ end
@@ -193,6 +193,39 @@ module DocsKit
193
193
  # sidebar, and page-masthead links). Read via #app_link, never @app_link.
194
194
  attr_writer :app_link
195
195
 
196
+ # The opt-in shell brand mark, rendered by BOTH the topbar and the sidebar
197
+ # header in place of the text #brand (which stays the accessible-name
198
+ # fallback). A Hash in exactly one of the DocsKit::BrandLogo forms —
199
+ # svg:/paths: (inline path-d, theme-adaptive via currentColor), markup:/file:
200
+ # (site-authored <svg> embedded verbatim), or src: (an <img>, NOT
201
+ # theme-adaptive) — or an already-built BrandLogo. Defaults to nil → the
202
+ # text brand renders and the chrome is byte-identical to before. Sibling of
203
+ # c.landing.logo, which is the landing-hero mark. Read via #brand_logo,
204
+ # never @brand_logo.
205
+ def brand_logo=(value)
206
+ @brand_logo = nil
207
+ @brand_logo_raw = value
208
+ end
209
+
210
+ # Where the topbar renders the brand: :always (the default — byte-compat),
211
+ # or :mobile_only, which hides it at the drawer-pinned breakpoint (lg:)
212
+ # where the sidebar brand is already visible, deduplicating the mark.
213
+ attr_reader :topbar_brand
214
+
215
+ # The topbar-brand placements. At lg: the sidebar (with its own brand) is
216
+ # pinned open, so :mobile_only drops the duplicate; :always keeps it.
217
+ TOPBAR_BRAND_MODES = %i[always mobile_only].freeze
218
+
219
+ def topbar_brand=(value)
220
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
221
+ unless TOPBAR_BRAND_MODES.include?(mode)
222
+ raise ArgumentError,
223
+ "topbar_brand must be one of #{TOPBAR_BRAND_MODES.inspect} (got #{value.inspect})"
224
+ end
225
+
226
+ @topbar_brand = mode
227
+ end
228
+
196
229
  # External links rendered in the topbar next to the theme switcher — a repo
197
230
  # link, a chat invite, a social profile. Each entry is a Hash
198
231
  # ({ href:, label:, icon: }) or a DocsKit::TopbarLink; #topbar_links
@@ -209,6 +242,27 @@ module DocsKit
209
242
  # #openapi_document (which memoizes + reloads on file change), never @openapi.
210
243
  attr_accessor :openapi
211
244
 
245
+ # The documentation versions this site serves — a list of Hashes
246
+ # ({ id:, label:, ref:, current:, noindex: }) or DocsKit::DocVersion objects;
247
+ # #versions normalizes them. Defaults to [] → versioning is off and the site
248
+ # is byte-identical to before. The `current` entry keeps serving unprefixed
249
+ # at /docs; every other entry serves a committed Markdown snapshot at
250
+ # /<id>/docs (see DocsKit::Snapshot). A version id must match v?\d+(\.\d+)*
251
+ # so the host's static version route constraint recognizes it. Read via
252
+ # #versions, never @versions.
253
+ attr_writer :versions
254
+
255
+ # The site's source repository root (e.g. "https://github.com/me/repo"),
256
+ # used for the GitHub compare link between two versions' refs
257
+ # (#compare_url). Defaults to nil → no compare link renders.
258
+ attr_accessor :repo_url
259
+
260
+ # Where committed version snapshots live. Defaults to nil, which the reader
261
+ # resolves to Rails.root/"docs_snapshots" under Rails (nil outside Rails —
262
+ # the standalone suite points at fixtures explicitly). Read via
263
+ # #snapshots_path, never @snapshots_path.
264
+ attr_writer :snapshots_path
265
+
212
266
  # The sentinel "no explicit nav" lambda. #nav_groups compares against this
213
267
  # identity to decide whether to derive the sidebar from #nav_registries.
214
268
  DEFAULT_NAV = -> { {} }
@@ -274,6 +328,12 @@ module DocsKit
274
328
  @app_link = nil
275
329
  @topbar_links = []
276
330
  @openapi = nil
331
+ @brand_logo = nil
332
+ @brand_logo_raw = nil
333
+ @topbar_brand = :always
334
+ @versions = []
335
+ @repo_url = nil
336
+ @snapshots_path = nil
277
337
  end
278
338
 
279
339
  # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -284,6 +344,17 @@ module DocsKit
284
344
  DocsKit::TopbarLink.from(@app_link)
285
345
  end
286
346
 
347
+ # The normalized shell brand mark (a DocsKit::BrandLogo), or nil when unset.
348
+ # Memoized (and invalidated on reassignment) — unlike #app_link's rebuild-
349
+ # per-read, because a file: mark shape-checks and reads its SVG on build;
350
+ # per-render re-normalization would repeat that IO. A malformed value raises
351
+ # here, on first read — loud, never a silently broken header.
352
+ def brand_logo
353
+ return if @brand_logo_raw.nil?
354
+
355
+ @brand_logo ||= DocsKit::BrandLogo.from(@brand_logo_raw)
356
+ end
357
+
287
358
  # The normalized topbar links (DocsKit::TopbarLink list), in declaration
288
359
  # order. Each configured Hash/TopbarLink is coerced via TopbarLink.from, so
289
360
  # the Shell only ever sees value objects. Blank/nil config yields [].
@@ -291,6 +362,61 @@ module DocsKit
291
362
  Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
292
363
  end
293
364
 
365
+ # The normalized version list (DocsKit::DocVersion list), in declaration
366
+ # order. Each configured Hash/DocVersion is coerced via DocVersion.from, so
367
+ # the switcher and the snapshot reader only ever see value objects.
368
+ # Blank/nil config yields [].
369
+ def versions
370
+ Array(@versions).map { |version| DocsKit::DocVersion.from(version) }
371
+ end
372
+
373
+ # The version serving unprefixed at /docs: the entry marked current: true,
374
+ # else the first configured entry, else nil (an unversioned site).
375
+ def current_version
376
+ versions.find(&:current?) || versions.first
377
+ end
378
+
379
+ # The configured version with this id, or nil when unknown (or nil id).
380
+ def version(id)
381
+ return if id.nil?
382
+
383
+ versions.find { |version| version.id.to_s == id.to_s }
384
+ end
385
+
386
+ # The version a request's :version param resolves to: the strict #version
387
+ # lookup, falling back to #current_version for an unknown or missing id —
388
+ # one rule shared by DocsKit::Controller#render_page and the gem's own
389
+ # controllers (DocsKit::Scoping), so a bad param degrades to the current
390
+ # docs instead of 500ing.
391
+ def resolve_version(id)
392
+ version(id) || current_version
393
+ end
394
+
395
+ # Whether the version chrome (switcher, llms.txt Versions block) renders.
396
+ # A single configured version is not worth a switcher, so this needs two —
397
+ # and an unconfigured site stays byte-identical to before.
398
+ def versioning_enabled?
399
+ versions.size > 1
400
+ end
401
+
402
+ # The resolved snapshots directory: the configured value verbatim, else
403
+ # Rails.root/"docs_snapshots" under Rails, else nil (no Rails, no default —
404
+ # the standalone suite passes explicit paths).
405
+ def snapshots_path
406
+ return @snapshots_path if @snapshots_path
407
+
408
+ Rails.root.join("docs_snapshots") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
409
+ end
410
+
411
+ # The GitHub compare URL between two versions' refs
412
+ # ("{repo_url}/compare/{from.ref}...{to.ref}"), or nil unless #repo_url and
413
+ # BOTH refs are present — absent value, absent link, never a broken one.
414
+ def compare_url(from, to)
415
+ return if repo_url.nil? || from&.ref.nil? || to&.ref.nil?
416
+
417
+ "#{repo_url.chomp('/')}/compare/#{from.ref}...#{to.ref}"
418
+ end
419
+
294
420
  # The SEO / social-share knobs (DocsKit::SeoConfig), read by DocsUI::MetaTags.
295
421
  # Lazily built and memoized so a `c.seo.description = ...` block mutates the
296
422
  # one instance the Shell later reads. A site that never touches it gets the
@@ -455,11 +581,17 @@ module DocsKit
455
581
 
456
582
  # The resolved nav Hash for this request. Always returns a Hash.
457
583
  #
458
- # An explicit #nav lambda wins. Otherwise the sidebar derives from
459
- # #nav_registries: each heading maps to its registry's .nav_items, and a
460
- # heading whose pages are all unauthored (empty nav_items) is dropped so no
461
- # empty group renders.
584
+ # An ARCHIVED version in DocsKit::Scope wins outright: the sidebar derives
585
+ # from that version's snapshot manifest (hrefs already version-prefixed), so
586
+ # an archived page never links into the live docs even a site's explicit
587
+ # #nav lambda describes the live pages, not the frozen ones. With no scope
588
+ # (or the current version) nothing changes: an explicit #nav lambda wins,
589
+ # else the sidebar derives from #nav_registries — each heading maps to its
590
+ # registry's .nav_items, and a heading whose pages are all unauthored
591
+ # (empty nav_items) is dropped so no empty group renders.
462
592
  def nav_groups
593
+ scope_version = DocsKit::Scope.version
594
+ return DocsKit::Snapshot.for(scope_version, config: self).nav_groups if scope_version&.archived?
463
595
  return nav_groups_from_registries unless @nav_explicit
464
596
 
465
597
  result = @nav.respond_to?(:call) ? @nav.call : @nav
@@ -19,10 +19,19 @@ module DocsKit
19
19
  # from the SAME render (DocsKit::MarkdownExport walks the rendered HTML). So
20
20
  # `GET /docs/x.md` is faithful GFM of exactly what `/docs/x` shows — the
21
21
  # author writes nothing extra, and the two never drift.
22
+ #
23
+ # The render runs inside the request's DocsKit::Scope (the version resolved
24
+ # from params[:version], falling back to the current version), so the
25
+ # sidebar/meta tags/enumeration all see the version the URL asked for.
26
+ # `render` renders synchronously inside the action, so this block wrapper is
27
+ # sufficient — no around_action, no host code changes. On an unversioned
28
+ # site the scope is nil: today's behavior exactly.
22
29
  def render_page(view)
23
- return render_markdown(view) if markdown_request?
30
+ DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version])) do
31
+ return render_markdown(view) if markdown_request?
24
32
 
25
- render view, layout: false
33
+ render view, layout: false
34
+ end
26
35
  end
27
36
 
28
37
  private
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # One documentation version a site serves. Sites declare these in config as
5
+ # plain Hashes; #versions normalizes each into a DocVersion so the chrome and
6
+ # the AI surfaces stay value-object-driven (like DocsKit::TopbarLink):
7
+ #
8
+ # c.versions = [
9
+ # { id: "1.1", ref: "v1.1.0", current: true },
10
+ # { id: "1.0", ref: "v1.0.0" },
11
+ # ]
12
+ #
13
+ # #id is the URL segment (an archived version serves at "/#{id}/docs/...");
14
+ # #label is the switcher text (defaults to the id); #ref is the git ref backing
15
+ # the GitHub compare link (optional); #current marks the version serving
16
+ # unprefixed at /docs (exactly today's URLs); #noindex defaults to the inverse
17
+ # of #current — archived copies are noindex'd so search engines keep pointing
18
+ # at the current docs, overridable per version with `noindex: false`.
19
+ #
20
+ # Named DocVersion, not Version — lib/docs_kit/version.rb already owns that
21
+ # file slot and defines DocsKit::VERSION.
22
+ DocVersion = Data.define(:id, :label, :ref, :current, :noindex) do
23
+ def initialize(id:, label: nil, ref: nil, current: false, noindex: nil)
24
+ super(
25
+ id: id,
26
+ label: label || id.to_s,
27
+ ref: ref,
28
+ current: current,
29
+ noindex: noindex.nil? ? !current : noindex
30
+ )
31
+ end
32
+
33
+ # Build a DocVersion from a Hash (symbol- OR string-keyed, so a YAML/JSON
34
+ # config loads cleanly) or pass an existing DocVersion through unchanged.
35
+ def self.from(version)
36
+ return version if version.is_a?(self)
37
+
38
+ attrs = version.to_h.transform_keys(&:to_sym)
39
+ new(
40
+ id: attrs[:id],
41
+ label: attrs[:label],
42
+ ref: attrs[:ref],
43
+ current: attrs.fetch(:current, false),
44
+ noindex: attrs[:noindex]
45
+ )
46
+ end
47
+
48
+ def current? = !!current
49
+
50
+ def archived? = !current?
51
+
52
+ # The root URL segment this version contributes: "" for the current version
53
+ # (existing sites and their SEO untouched), "/#{id}" for an archived one.
54
+ # Stacks with the i18n locale prefix later ("/de/1.0/docs/...").
55
+ def path_prefix
56
+ current? ? "" : "/#{id}"
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "brand_logo"
4
+
3
5
  module DocsKit
4
6
  # The per-site landing-page knobs, read by DocsUI::Landing to render a marketing
5
7
  # home page (hero + feature grid + doc index) without a site hand-rolling one.
@@ -90,11 +92,11 @@ module DocsKit
90
92
  { code: attrs[:code].to_s, filename: attrs[:filename], lexer: (attrs[:lexer] || :shell).to_sym }
91
93
  end
92
94
 
93
- # The hero logo as a normalized Logo value object, or nil when unset.
95
+ # The hero logo as a normalized DocsKit::BrandLogo, or nil when unset.
94
96
  def hero_logo
95
97
  return if @logo.nil?
96
98
 
97
- Logo.from(@logo)
99
+ DocsKit::BrandLogo.from(@logo)
98
100
  end
99
101
 
100
102
  # One hero call-to-action button. `style` maps to a daisyUI btn variant
@@ -134,27 +136,9 @@ module DocsKit
134
136
  end
135
137
  end
136
138
 
137
- # The hero brand logo either an inline single-path SVG mark (`svg` = the
138
- # `<path d>` data, `viewbox` = its viewBox) rendered with fill: currentColor so
139
- # it adapts to the theme, OR an image (`src` = an asset path/URL, `alt` = its
140
- # accessible name). `label` is the accessible name for the inline mark.
141
- Logo = Data.define(:svg, :viewbox, :src, :alt, :label) do
142
- def initialize(svg: nil, viewbox: "0 0 24 24", src: nil, alt: nil, label: nil)
143
- super
144
- end
145
-
146
- def self.from(logo)
147
- return logo if logo.is_a?(self)
148
-
149
- attrs = logo.to_h.transform_keys(&:to_sym)
150
- new(
151
- svg: attrs[:svg], viewbox: attrs[:viewbox] || "0 0 24 24",
152
- src: attrs[:src], alt: attrs[:alt], label: attrs[:label]
153
- )
154
- end
155
-
156
- # An inline SVG mark (vs. an <img>). True when `svg` path data is present.
157
- def inline? = !svg.to_s.empty?
158
- end
139
+ # The hero brand logo shape now lives in DocsKit::BrandLogo (shared with the
140
+ # shell's config.brand_logo); the old nested name stays as an alias so any
141
+ # site referencing LandingConfig::Logo keeps working.
142
+ Logo = DocsKit::BrandLogo
159
143
  end
160
144
  end
@@ -54,13 +54,40 @@ module DocsKit
54
54
  end
55
55
  end
56
56
 
57
- # The authored pages across every registry, in config/registry order — each
58
- # responds to #title / #href / #view_class. The controller renders these to
59
- # Markdown for .full.
60
- def pages(config)
57
+ # The authored pages for one version of the docs, in config/registry order —
58
+ # each responds to #title / #href / #view_class (render via .renderable_for).
59
+ # This is the ONE enumeration seam every AI surface funnels through, so
60
+ # making IT version-aware makes llms-full.txt, search, and MCP follow the
61
+ # request's version for free.
62
+ #
63
+ # version: nil resolves through DocsKit::Scope (set per request by the
64
+ # controllers), then config.current_version — so an unversioned site, or the
65
+ # current version, enumerates the live registries exactly as before. An
66
+ # ARCHIVED version enumerates its Markdown snapshot instead
67
+ # (DocsKit::Snapshot — every entry is authored by definition).
68
+ def pages(config, version: nil)
69
+ version ||= DocsKit::Scope.version || config.current_version
70
+ return snapshot_pages(config, version) if version&.archived?
71
+
61
72
  config.nav_registries.values.flat_map { |registry| registry.all.select(&:view_class) }
62
73
  end
63
74
 
75
+ # An archived version's pages, from its committed snapshot. Every entry has
76
+ # a view_class by construction; the select keeps the authored-pages contract
77
+ # symmetric with the live branch.
78
+ def snapshot_pages(config, version)
79
+ DocsKit::Snapshot.for(version, config: config).all.select(&:view_class)
80
+ end
81
+
82
+ # The Phlex renderable for a page returned by .pages: the page's own
83
+ # #renderable (Registry v2 Entry, Snapshot::Entry) with a backwards-
84
+ # compatible fallback to view_class.new for a site's custom `entries`-style
85
+ # registry class that predates #renderable. The ONE shim — the controllers
86
+ # and MCP tools all call this rather than repeating the respond_to? check.
87
+ def renderable_for(page)
88
+ page.respond_to?(:renderable) ? page.renderable : page.view_class.new
89
+ end
90
+
64
91
  # The llms-full.txt body: each [title, markdown] pair as `# {title}` + body,
65
92
  # separated by a `---` rule. Empty pairs → "".
66
93
  def full(_config, title_markdown_pairs)
@@ -20,8 +20,9 @@ module DocsKit
20
20
  # yield nothing (whitespace-only text nodes) are dropped so no stray blank
21
21
  # lines accumulate.
22
22
  def render(node)
23
- node.children.filter_map { |child| block(child) }
24
- .reject(&:empty?)
23
+ node.children
24
+ .filter_map { |child| block(child) }
25
+ .reject(&:empty?)
25
26
  .join("\n\n")
26
27
  end
27
28
 
@@ -87,8 +87,9 @@ module DocsKit
87
87
 
88
88
  # A page's GFM Markdown twin, rendered through the view context so url helpers
89
89
  # and relative-link absolutization resolve — the LlmsController#full seam.
90
+ # renderable_for is the live-or-snapshot shim (see LlmsText.renderable_for).
90
91
  def render_markdown(page, base_url:, view_context:)
91
- MarkdownExport.new(page.view_class.new, view_context:, base_url:).to_md
92
+ MarkdownExport.new(LlmsText.renderable_for(page), view_context:, base_url:).to_md
92
93
  end
93
94
 
94
95
  # A DocsKit::SearchIndex over every authored page's twin — the same triples
@@ -147,6 +147,13 @@ module DocsKit
147
147
 
148
148
  "#{@view_namespace}::#{@view_name}".safe_constantize
149
149
  end
150
+
151
+ # The renderable instance for this page (nil when unauthored) — the seam
152
+ # DocsKit::Snapshot::Entry shares, so consumers render live pages and
153
+ # snapshot pages identically (see LlmsText.renderable_for).
154
+ def renderable
155
+ view_class&.new
156
+ end
150
157
  end
151
158
  end
152
159
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The ONE request-scoped content scope: which documentation version (and,
5
+ # come i18n M2, which locale) the current render serves. Controllers set it
6
+ # around an action (DocsKit::Controller#render_page, DocsKit::Scoping); the
7
+ # config and the components consult it (Configuration#nav_groups,
8
+ # LlmsText.pages) — so "which content tree?" is asked once per request, not
9
+ # threaded through every component.
10
+ #
11
+ # DocsKit::Scope.with(version: v) { ... } # block-scoped, restores in an ensure
12
+ # DocsKit::Scope.version # the DocVersion in scope, or nil
13
+ # DocsKit::Scope.locale # reserved for i18n M2 — nil today
14
+ # DocsKit::Scope.path_prefix # "" or "/1.0"
15
+ #
16
+ # Backed by Thread.current[] — fiber-local in Ruby, which is what a fibered
17
+ # server wants — and deliberately Rails-free (NOT CurrentAttributes), so bare
18
+ # Phlex component specs can set a scope without booting Rails. An empty scope
19
+ # (no `with` in flight) reads as nil version / nil locale, which every
20
+ # consumer treats as "the current version" — today's behavior exactly.
21
+ module Scope
22
+ KEY = :docs_kit_scope
23
+
24
+ EMPTY = { version: nil, locale: nil }.freeze
25
+ private_constant :EMPTY
26
+
27
+ module_function
28
+
29
+ # Run the block with this version/locale in scope, restoring the previous
30
+ # scope on the way out — even when the block raises — so nothing leaks
31
+ # across requests sharing a thread.
32
+ def with(version: nil, locale: nil)
33
+ previous = Thread.current[KEY]
34
+ Thread.current[KEY] = { version: version, locale: locale }
35
+ yield
36
+ ensure
37
+ Thread.current[KEY] = previous
38
+ end
39
+
40
+ # The DocsKit::DocVersion in scope, or nil (treated as the current version).
41
+ def version
42
+ current[:version]
43
+ end
44
+
45
+ # Reserved for i18n M2 — always nil until the locale axis is wired.
46
+ def locale
47
+ current[:locale]
48
+ end
49
+
50
+ # The root URL prefix the in-scope version contributes ("" when none/current).
51
+ def path_prefix
52
+ version&.path_prefix || ""
53
+ end
54
+
55
+ def current
56
+ Thread.current[KEY] || EMPTY
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # Wraps a controller's actions in the request's DocsKit::Scope, so everything
5
+ # rendered or enumerated during the action (Configuration#nav_groups,
6
+ # LlmsText.pages, the search index) sees the same version the URL asked for.
7
+ # Included by the gem's own controllers (Llms, Search, Mcp); a host's docs
8
+ # controller gets the same behavior from DocsKit::Controller#render_page's own
9
+ # wrapper instead — including this module there would around_action every host
10
+ # action, which is not docs-kit's call to make.
11
+ #
12
+ # A plain module with an included hook, not an ActiveSupport::Concern — it has
13
+ # no dependency chain and stays loadable in the Rails-free suite.
14
+ module Scoping
15
+ def self.included(base)
16
+ base.around_action :docs_scope
17
+ end
18
+
19
+ private
20
+
21
+ # The requested version (params[:version], falling back to the current
22
+ # version — an unknown id degrades, never 500s) held in scope for the whole
23
+ # action. nil on an unversioned site: today's behavior exactly.
24
+ def docs_scope(&)
25
+ DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version]), &)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ class Snapshot
5
+ # One snapshot page — the duck type of DocsKit::Registry::Entry (#slug /
6
+ # #title / #group / #icon / #href / #view_class / #renderable), so the
7
+ # enumeration seam (LlmsText.pages) and its consumers treat a frozen
8
+ # Markdown page exactly like a live Ruby one. #view_class is the truthy
9
+ # DocsUI::ArchivedPage constant, so the `select(&:view_class)` authored-page
10
+ # filter passes unchanged.
11
+ class Entry
12
+ attr_reader :slug, :title, :group, :icon, :file, :digest, :href
13
+
14
+ def initialize(attrs, version:, root:, registry_prefix:)
15
+ @slug = attrs["slug"]
16
+ @title = attrs["title"]
17
+ @group = attrs["group"]
18
+ @icon = attrs["icon"]
19
+ @file = attrs["file"]
20
+ @digest = attrs["digest"]
21
+ @root = root
22
+ @href = "#{version.path_prefix}#{registry_prefix}/#{@slug}"
23
+ end
24
+
25
+ # The renderer for every archived page. Truthy (never nil): a snapshot
26
+ # page is by definition authored — its content is the committed .md file.
27
+ def view_class
28
+ DocsUI::ArchivedPage
29
+ end
30
+
31
+ # The renderable the controllers hand to Phlex — an ArchivedPage carrying
32
+ # this entry, where a live Registry::Entry builds `view_class.new`.
33
+ def renderable
34
+ DocsUI::ArchivedPage.new(entry: self)
35
+ end
36
+
37
+ # The raw Markdown body from the snapshot file. A missing/unreadable file
38
+ # degrades to "" — the page renders empty rather than 500ing.
39
+ def markdown
40
+ return "" if @root.nil? || @file.nil?
41
+
42
+ @root.join(@file).read
43
+ rescue SystemCallError
44
+ ""
45
+ end
46
+ end
47
+ end
48
+ end