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.
- checksums.yaml +4 -4
- data/README.md +86 -20
- data/app/components/docs_ui/archived_page.rb +45 -0
- data/app/components/docs_ui/brand_mark.rb +1 -2
- data/app/components/docs_ui/landing.rb +9 -18
- data/app/components/docs_ui/logo.rb +74 -0
- data/app/components/docs_ui/shell.rb +21 -1
- data/app/components/docs_ui/sidebar.rb +30 -14
- data/app/controllers/docs_kit/llms_controller.rb +8 -1
- data/app/controllers/docs_kit/mcp_controller.rb +5 -1
- data/app/controllers/docs_kit/search_controller.rb +6 -1
- data/exe/docs-kit +5 -5
- data/lib/docs_kit/brand_logo.rb +124 -0
- data/lib/docs_kit/configuration.rb +136 -4
- data/lib/docs_kit/controller.rb +11 -2
- data/lib/docs_kit/doc_version.rb +59 -0
- data/lib/docs_kit/landing_config.rb +8 -24
- data/lib/docs_kit/llms_text.rb +31 -4
- data/lib/docs_kit/markdown_export/blocks.rb +3 -2
- data/lib/docs_kit/mcp_tools.rb +2 -1
- data/lib/docs_kit/registry.rb +7 -0
- data/lib/docs_kit/scope.rb +59 -0
- data/lib/docs_kit/scoping.rb +28 -0
- data/lib/docs_kit/snapshot/entry.rb +48 -0
- data/lib/docs_kit/snapshot.rb +151 -0
- data/lib/docs_kit/templates/new_site.rb +64 -12
- data/lib/docs_kit/version.rb +1 -1
- data/lib/docs_kit.rb +3 -0
- data/lib/generators/docs_kit/install/install_generator.rb +1 -1
- data/lib/generators/docs_kit/install/templates/Dockerfile.tt +5 -5
- data/lib/generators/docs_kit/install/templates/agents_md.erb +1 -1
- data/lib/generators/docs_kit/install/templates/dockerignore +1 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +17 -0
- data/lib/generators/docs_kit/install/templates/skill.md.erb +1 -1
- 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
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
#
|
|
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
|
data/lib/docs_kit/controller.rb
CHANGED
|
@@ -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
|
-
|
|
30
|
+
DocsKit::Scope.with(version: DocsKit.configuration.resolve_version(params[:version])) do
|
|
31
|
+
return render_markdown(view) if markdown_request?
|
|
24
32
|
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
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
|
data/lib/docs_kit/llms_text.rb
CHANGED
|
@@ -54,13 +54,40 @@ module DocsKit
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# The authored pages
|
|
58
|
-
# responds to #title / #href / #view_class
|
|
59
|
-
#
|
|
60
|
-
|
|
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
|
|
24
|
-
|
|
23
|
+
node.children
|
|
24
|
+
.filter_map { |child| block(child) }
|
|
25
|
+
.reject(&:empty?)
|
|
25
26
|
.join("\n\n")
|
|
26
27
|
end
|
|
27
28
|
|
data/lib/docs_kit/mcp_tools.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/docs_kit/registry.rb
CHANGED
|
@@ -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
|