docs-kit 1.0.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78cf5f2d5a4636b8b0c603f3d3b238aeb68c100fd0133edb16d52ce56ade4c39
4
- data.tar.gz: 50acc90365594b546ba40887c976eb6f7c6e1eca7389b834c6793cd0490bbfb9
3
+ metadata.gz: b53c8fcfcdc8d5053f9019ea56edc5a1b4fce1a8760a220eb19c76a297e72d53
4
+ data.tar.gz: 4289ad366b8462a9617db0e696250b717c21af1a30a97126367c370452156e1d
5
5
  SHA512:
6
- metadata.gz: b650c98a593eda398bafde41a07ed5ad01325d5392b113a8cfebedc06bb71519e139f846b8e6e259c106c8b23a64a2af4c9a0ab5ab1efd1b2b524764be72fa07
7
- data.tar.gz: 29c352545d1ffcd4ba93461b2df5582663b79d86db835866fc6b2d543018d5d2e0034e43f24f2f67d0ebd953b89e65788a276484901c71b693fe95040668b8ad
6
+ metadata.gz: 80f49ed3f55338303fe09ebed720679c440eaadb02a99b112b312dfebafa70b081ccbf3622f20a7a0a6b6f48771969528746465377bda9961b62ce6cf9410521
7
+ data.tar.gz: 3dc20bc085a7baa55b0c64098931f03d00553b26e477a606ce1fe878850d96058992cd7ef3e4cbc59bc67c1ea07eb9be26219f2d1c5f7b83a04eb22e82737c47
data/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # docs-kit
2
2
 
3
- [![CI](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/mhenrixon/docs-kit/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/zoolutions/docs-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/zoolutions/docs-kit/actions/workflows/ci.yml)
4
4
 
5
5
  Shared [Phlex](https://www.phlex.fun) chrome for documentation sites built on
6
6
  [daisyUI](https://daisyui.com). Extract the shell, sidebar, code blocks, theme
7
7
  switcher, and page kit into one gem so multiple docs sites look identical and are
8
8
  maintained in one place.
9
9
 
10
- Reactive demos ([phlex-reactive](https://github.com/mhenrixon/phlex-reactive))
11
- and Postgres-SSE transport ([pgbus](https://github.com/mhenrixon/pgbus)) are
10
+ Reactive demos ([phlex-reactive](https://github.com/zoolutions/phlex-reactive))
11
+ and Postgres-SSE transport ([pgbus](https://github.com/zoolutions/pgbus)) are
12
12
  **optional, runtime-detected** add-ons — docs-kit does not depend on them.
13
13
 
14
14
  ## What you get
@@ -137,7 +137,7 @@ fronts Puma with Thruster (`CMD ["./bin/thrust", "./bin/rails", "server"]`) for
137
137
  HTTP caching, compression, and X-Sendfile — and the generator scaffolds the
138
138
  `bin/thrust` binstub if the app lacks one, since the exec-form CMD needs the
139
139
  file to exist in the image. Thruster listens on the routed port
140
- (`HTTP_PORT=3000` — Kamal's `app_port`) and proxies to Puma on `TARGET_PORT=3001`.
140
+ (`HTTP_PORT=3000` — dash's `app_port`) and proxies to Puma on `TARGET_PORT=3001`.
141
141
  Without thruster in the *production* bundle (absent, or only in a
142
142
  development/test group that `BUNDLE_WITHOUT` excludes) the CMD falls back to
143
143
  plain `rails server` — never a thrust CMD that would crash at boot.
@@ -701,7 +701,7 @@ claude mcp add --transport http docs https://your-docs.example/mcp
701
701
 
702
702
  and can ask Claude to search or read your docs, which now appear as tools. The
703
703
  JSON-RPC is stateless (each `POST` is independent — no SSE session), so it works
704
- behind the existing Kamal/Cloudflare deploy unchanged; `GET`/`DELETE` return
704
+ behind the existing dash/Cloudflare deploy unchanged; `GET`/`DELETE` return
705
705
  `405`. When enabled, `/llms.txt` grows a final `## MCP` line advertising the
706
706
  endpoint so agents discover it.
707
707
 
@@ -844,7 +844,7 @@ back to a spec.
844
844
 
845
845
  ```bash
846
846
  docs-kit new my-docs # → a complete, deployable docs app
847
- docs-kit new my-docs --image mhenrixon/my-repo --service my-repo
847
+ docs-kit new my-docs --image zoolutions/my-repo --service my-repo
848
848
  ```
849
849
 
850
850
  `docs-kit new` runs `rails new` (propshaft + importmap + turbo/stimulus, no DB)
@@ -854,7 +854,7 @@ and applies docs-kit's application template, which:
854
854
  - runs `rails g docs_kit:install` (initializers, controllers, a Doc registry, a
855
855
  sample guide page, the Bun/Tailwind build, the docs-nav Stimulus wiring),
856
856
  - syncs the lucide icons and builds the CSS,
857
- - scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, an optimized
857
+ - scaffolds dash (`config/deploy.yml`, `.dash/secrets`, an optimized
858
858
  multi-stage `Dockerfile` + a `.dockerignore`) and a thin
859
859
  `.github/workflows/deploy-docs.yml` that calls the reusable workflow.
860
860
 
@@ -905,7 +905,7 @@ group. Then `bundle exec rubocop` runs the docs-kit cops.
905
905
  The build + deploy is defined **once** in this gem's reusable workflow
906
906
  (`.github/workflows/deploy.yml`). `docs-kit new` scaffolds the caller for you; to
907
907
  wire it by hand a site adds five small things and it deploys to the
908
- oss-infrastructure server (Kamal + GHCR + Cloudflare Tunnel).
908
+ oss-infrastructure server (dash + GHCR + Cloudflare Tunnel).
909
909
 
910
910
  **1. A thin caller** — `.github/workflows/deploy-docs.yml`:
911
911
 
@@ -916,46 +916,73 @@ on:
916
916
  workflow_dispatch:
917
917
  jobs:
918
918
  deploy:
919
- uses: mhenrixon/docs-kit/.github/workflows/deploy.yml@main
919
+ uses: zoolutions/docs-kit/.github/workflows/deploy.yml@main
920
920
  with:
921
- image: mhenrixon/<repo> # OWNER/REPO — see naming note below
921
+ image: zoolutions/<repo> # OWNER/REPO — see naming note below
922
922
  service: <repo>
923
923
  secrets: inherit
924
924
  ```
925
925
 
926
- **2. `docs/config/deploy.yml`** — `service:` and `image:` MUST match the caller:
926
+ **2. `docs/config/deploy.yml`** — `service:` and `image:` MUST match the caller
927
+ (the full file `docs-kit new` writes is in `lib/docs_kit/templates/new_site.rb`;
928
+ `dash docs proxy` documents every key):
927
929
 
928
930
  ```yaml
929
931
  service: <repo>
930
- image: mhenrixon/<repo>
931
- registry: { server: ghcr.io, username: mhenrixon, password: [KAMAL_REGISTRY_PASSWORD] }
932
+ image: zoolutions/<repo>
933
+ minimum_version: 4.0.0 # dash 4: dash-proxy identity + in-place host migration
934
+ retain_containers: 2
935
+ error_pages_path: public # 502/503/504.html served during a deploy gap
936
+ registry: { server: ghcr.io, username: mhenrixon, password: [DASH_REGISTRY_PASSWORD] }
932
937
  builder: { arch: amd64, context: .., dockerfile: Dockerfile } # repo root = build context
933
- proxy: { host: <%= ENV["DEPLOY_DOMAIN"] %>, app_port: 3000, ssl: false, healthcheck: { path: /up } }
934
938
  servers: { web: { hosts: [<%= ENV["DEPLOY_HOST"] %>] } }
935
939
  ssh: { user: oss }
940
+ proxy:
941
+ host: <%= ENV["DEPLOY_DOMAIN"] %>
942
+ app_port: 3000
943
+ ssl: false # TLS terminates at Cloudflare
944
+ healthcheck: { path: /up, interval: 5, timeout: 30 }
945
+ compress: true # zstd/br/gzip at the edge
946
+ cache: { enabled: true, max_ttl: 300 } # stores `Cache-Control: public` responses (assets, /llms*.txt)
947
+ headers: { response: { set: { X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin }, remove: [Server, X-Powered-By] } }
948
+ intercept_errors: [502, 503, 504]
949
+ exclude_metrics_paths: [/up]
936
950
  ```
937
951
 
952
+ Deliberately **not** set: `proxy.run` (`port_holder`, `log_format`, …) is
953
+ host-wide — every docs site on the shared host boots the same proxy, and a
954
+ `run:` block that differs between them makes each alternate deploy reboot it.
955
+ `rate_limit`/`deny_ips` need `client_ip.trusted_proxies` pinned to the tunnel's
956
+ address to key on visitors rather than on cloudflared; add them per site once
957
+ that address is known.
958
+
938
959
  **3. `docs/Dockerfile`** — end the final stage with the matching label:
939
960
 
940
961
  ```dockerfile
941
962
  LABEL service="<repo>"
942
963
  ```
943
964
 
944
- **4. `docs/.kamal/secrets`** — `KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD`.
965
+ **4. `docs/.dash/secrets`** — `DASH_REGISTRY_PASSWORD=$DASH_REGISTRY_PASSWORD`.
945
966
 
946
967
  **5. GitHub** — a `docs` environment with secrets `SSH_PRIVATE_KEY`,
947
968
  `DEPLOY_HOST`, `DEPLOY_DOMAIN`. (The registry password is the auto-provided
948
969
  `GITHUB_TOKEN` — no PAT.)
949
970
 
950
971
  > **Naming — use the repo name, not `<repo>-docs`.** `image`/`service` must be
951
- > the calling repo's `OWNER/REPO`. Pushing `ghcr.io/mhenrixon/<repo>` from the
972
+ > the calling repo's `OWNER/REPO`. Pushing `ghcr.io/zoolutions/<repo>` from the
952
973
  > repo's own Actions run auto-links the package to the repo, so `GITHUB_TOKEN`
953
974
  > can both push (build job) and pull (deploy) it. A different name becomes an
954
975
  > unlinked user-scoped package `GITHUB_TOKEN` can't pull → the deploy fails.
955
976
 
956
- **First deploy per host:** run `kamal setup` (or `bin/deploy setup`) once to boot
957
- any accessories (e.g. a Postgres accessory); the release workflow runs plain
958
- `kamal deploy`, which doesn't boot accessories.
977
+ **First deploy per host:** run `dash setup` (or `bin/deploy setup`) once to boot
978
+ any accessories (e.g. a Postgres accessory); the release workflow runs
979
+ `dash doctor` (a pre-flight of host, registry, proxy, ports and readiness gates)
980
+ and then plain `dash deploy`, which doesn't boot accessories.
981
+
982
+ **Upgrading a host to dash 4:** the first 4.x deploy renames the proxy
983
+ (`kamal-proxy` → `dash-proxy`, network `kamal` → `dash`, config volume copied)
984
+ and costs one short outage on that host while ports 80/443 change hands. It is
985
+ idempotent and shared by every site on the host — whichever deploys first pays it.
959
986
 
960
987
  ## CSS — the canonical build
961
988
 
@@ -1079,7 +1106,7 @@ them:
1079
1106
  → your profile → *Trusted Publishers* → *Create*, add a **pending** publisher
1080
1107
  (works for a gem not yet pushed) with:
1081
1108
  - Gem name: `docs-kit`
1082
- - Repository: `mhenrixon/docs-kit`
1109
+ - Repository: `zoolutions/docs-kit`
1083
1110
  - Workflow filename: `release.yml`
1084
1111
  - Environment: `rubygems`
1085
1112
  2. **GitHub `rubygems` environment.** Repo *Settings → Environments → New
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # Renders one page of an ARCHIVED documentation version — a frozen Markdown
5
+ # body (DocsKit::Snapshot::Entry) through today's live chrome, so archived
6
+ # docs get every future Shell/Sidebar/Code fix for free. The live counterpart
7
+ # is DocsUI::Page; this mirrors its shape with the content coming from the
8
+ # snapshot file instead of an authored #content method.
9
+ #
10
+ # Every kwarg defaults, so even a naive `entry.view_class.new` (a custom
11
+ # registry predating #renderable) renders an empty page rather than raising.
12
+ #
13
+ # NOTE (issue #61 phase 4): the "you are viewing the 1.0 docs" banner with a
14
+ # link to the current equivalent lands with the version switcher, not here.
15
+ #
16
+ # Deliberately does NOT include Phlex::Rails::Helpers::Routes/Request — their
17
+ # bodies run Rails.* at class load, which would make this class (and
18
+ # everything referencing it, like Snapshot::Entry#view_class) unloadable in a
19
+ # Rails-free render. Nothing here needs a request.
20
+ class ArchivedPage < Phlex::HTML
21
+ include DocsUI
22
+
23
+ def initialize(entry: nil)
24
+ @entry = entry
25
+ end
26
+
27
+ def view_template
28
+ render DocsUI::Shell.new(title: @entry&.title) { body }
29
+ end
30
+
31
+ # The masthead + Markdown body — separated from the Shell wrapper so it can
32
+ # render (and be specced) without a Rails view context, the same seam as
33
+ # Shell's own topbar/theme-script specs.
34
+ def body
35
+ render DocsUI::Header.new(@entry.title) if @entry&.title
36
+ render DocsUI::Markdown.new(markdown_source) unless markdown_source.empty?
37
+ end
38
+
39
+ private
40
+
41
+ def markdown_source
42
+ @markdown_source ||= @entry ? @entry.markdown.to_s : ""
43
+ end
44
+ end
45
+ end
@@ -19,7 +19,7 @@ module DocsUI
19
19
  # token => official Simple-Icons path data (viewBox 0 0 24 24, fill rule
20
20
  # nonzero). Sourced verbatim from simpleicons.org; each is a single <path d>.
21
21
  # The path strings are inherently one long line each — never reflow them.
22
- # rubocop:disable Layout/LineLength
22
+ # rubocop:disable-next Layout/LineLength
23
23
  BRANDS = {
24
24
  github: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
25
25
  gitlab: "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z",
@@ -36,7 +36,6 @@ module DocsUI
36
36
  reddit: "M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z",
37
37
  stackoverflow: "M15.725 0l-1.72 1.277 6.39 8.588 1.716-1.277L15.725 0zm-3.94 3.418l-1.369 1.644 8.225 6.85 1.369-1.644-8.225-6.85zm-3.15 4.465l-.905 1.94 9.702 4.517.904-1.94-9.701-4.517zm-1.85 4.86l-.44 2.093 10.473 2.201.44-2.092-10.473-2.203zM1.89 15.47V24h19.19v-8.53h-2.133v6.397H4.021v-6.396H1.89zm4.265 2.133v2.13h10.66v-2.13H6.154Z"
38
38
  }.freeze
39
- # rubocop:enable Layout/LineLength
40
39
 
41
40
  # True when `token` names a shipped brand mark (symbol or string), false for a
42
41
  # lucide name, nil, or a blank string. The Shell uses this to decide whether a
@@ -29,6 +29,11 @@ module DocsKit
29
29
  # GET-only, sessionless, public text endpoints (no token to verify).
30
30
  protect_from_forgery with: :null_session
31
31
 
32
+ # Every action runs in the request's version scope (params[:version] on the
33
+ # version-prefixed routes, else the current version), so the enumeration
34
+ # below serves the version the URL asked for.
35
+ include DocsKit::Scoping
36
+
32
37
  def index
33
38
  body = DocsKit::LlmsText.index(docs_config, base_url: request.base_url)
34
39
  render_text(body) if stale_llms?(body)
@@ -67,9 +72,11 @@ module DocsKit
67
72
  # A page's Markdown twin, rendered through this controller's view context so
68
73
  # url helpers/CSRF resolve and relative links absolutize to portable URLs —
69
74
  # the same path DocsKit::Controller#render_page takes for a `.md` request.
75
+ # renderable_for is the live-or-snapshot shim (a snapshot entry renders an
76
+ # ArchivedPage carrying its frozen Markdown).
70
77
  def render_page_markdown(page)
71
78
  DocsKit::MarkdownExport.new(
72
- page.view_class.new, view_context:, base_url: request.base_url
79
+ DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url
73
80
  ).to_md
74
81
  end
75
82
  end
@@ -15,7 +15,7 @@ module DocsKit
15
15
  # DocsKit::McpServer / DocsKit::McpTools.
16
16
  #
17
17
  # Stateless JSON-RPC: each POST is independent (no SSE session), so it works
18
- # behind the existing Kamal/Cloudflare deploy unchanged. #create delegates the
18
+ # behind the existing dash/Cloudflare deploy unchanged. #create delegates the
19
19
  # whole protocol to DocsKit::McpServer#handle_json — the SDK parses the request,
20
20
  # dispatches the tool, and serializes the response (including JSON-RPC errors),
21
21
  # so the controller never hand-rolls the protocol.
@@ -31,6 +31,10 @@ module DocsKit
31
31
  # protection outright.
32
32
  skip_forgery_protection
33
33
 
34
+ # MCP tool calls run in the request's version scope; the version-aware tool
35
+ # arguments (issue #61 phase 6) layer per-call resolution on top of this.
36
+ include DocsKit::Scoping
37
+
34
38
  def create
35
39
  return head(:not_found) unless docs_config.mcp_enabled?
36
40
 
@@ -28,6 +28,11 @@ module DocsKit
28
28
  # public endpoint.
29
29
  protect_from_forgery with: :null_session
30
30
 
31
+ # Search follows the request's version: /1.0/docs/search searches the 1.0
32
+ # snapshot, /docs/search searches current — the scope swaps the enumeration
33
+ # source underneath DocsKit::LlmsText.pages.
34
+ include DocsKit::Scoping
35
+
31
36
  def index
32
37
  hits = search_index.search(query)
33
38
 
@@ -53,7 +58,7 @@ module DocsKit
53
58
  def search_index
54
59
  triples = DocsKit::LlmsText.pages(docs_config).map do |page|
55
60
  markdown = DocsKit::MarkdownExport.new(
56
- page.view_class.new, view_context:, base_url: request.base_url
61
+ DocsKit::LlmsText.renderable_for(page), view_context:, base_url: request.base_url
57
62
  ).to_md
58
63
  [page.title, page.href, markdown]
59
64
  end
data/exe/docs-kit CHANGED
@@ -4,11 +4,11 @@
4
4
  # docs-kit CLI. One command scaffolds a complete, deployable docs site:
5
5
  #
6
6
  # docs-kit new my-docs
7
- # docs-kit new my-docs --image mhenrixon/my-repo --service my-repo
7
+ # docs-kit new my-docs --image zoolutions/my-repo --service my-repo
8
8
  #
9
9
  # It runs `rails new` with the right minimal flags and applies docs-kit's
10
10
  # application template (lib/docs_kit/templates/new_site.rb), which adds the gem,
11
- # runs `docs_kit:install`, syncs icons, builds the CSS, and scaffolds Kamal +
11
+ # runs `docs_kit:install`, syncs icons, builds the CSS, and scaffolds dash +
12
12
  # the reusable deploy workflow.
13
13
 
14
14
  require "optparse"
@@ -25,9 +25,9 @@ unless command == "new"
25
25
  docs-kit new NAME [--image OWNER/REPO] [--service NAME] [--gem-source SRC]
26
26
 
27
27
  Options:
28
- --image GHCR image (default mhenrixon/NAME) — use OWNER/REPO for the
28
+ --image GHCR image (default zoolutions/NAME) — use OWNER/REPO for the
29
29
  repo-linked package so GITHUB_TOKEN can push+pull it.
30
- --service Kamal service name (default NAME).
30
+ --service dash service name (default NAME).
31
31
  --gem-source How to depend on docs-kit: 'released' (default),
32
32
  'path:PATH', or 'github:OWNER/REPO'.
33
33
  USAGE
@@ -58,7 +58,7 @@ gem_source =
58
58
 
59
59
  env = {
60
60
  "DOCS_KIT_GEM_SOURCE" => gem_source,
61
- "DOCS_KIT_IMAGE" => opts[:image] || "mhenrixon/#{name}",
61
+ "DOCS_KIT_IMAGE" => opts[:image] || "zoolutions/#{name}",
62
62
  "DOCS_KIT_SERVICE" => opts[:service] || name
63
63
  }
64
64
 
@@ -242,6 +242,27 @@ module DocsKit
242
242
  # #openapi_document (which memoizes + reloads on file change), never @openapi.
243
243
  attr_accessor :openapi
244
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
+
245
266
  # The sentinel "no explicit nav" lambda. #nav_groups compares against this
246
267
  # identity to decide whether to derive the sidebar from #nav_registries.
247
268
  DEFAULT_NAV = -> { {} }
@@ -310,6 +331,9 @@ module DocsKit
310
331
  @brand_logo = nil
311
332
  @brand_logo_raw = nil
312
333
  @topbar_brand = :always
334
+ @versions = []
335
+ @repo_url = nil
336
+ @snapshots_path = nil
313
337
  end
314
338
 
315
339
  # The normalized App Home link (a DocsKit::TopbarLink), or nil when unset —
@@ -338,6 +362,61 @@ module DocsKit
338
362
  Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
339
363
  end
340
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
+
341
420
  # The SEO / social-share knobs (DocsKit::SeoConfig), read by DocsUI::MetaTags.
342
421
  # Lazily built and memoized so a `c.seo.description = ...` block mutates the
343
422
  # one instance the Shell later reads. A site that never touches it gets the
@@ -502,11 +581,17 @@ module DocsKit
502
581
 
503
582
  # The resolved nav Hash for this request. Always returns a Hash.
504
583
  #
505
- # An explicit #nav lambda wins. Otherwise the sidebar derives from
506
- # #nav_registries: each heading maps to its registry's .nav_items, and a
507
- # heading whose pages are all unauthored (empty nav_items) is dropped so no
508
- # 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.
509
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?
510
595
  return nav_groups_from_registries unless @nav_explicit
511
596
 
512
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
@@ -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
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module DocsKit
7
+ # Reads a committed Markdown snapshot of one documentation version back as the
8
+ # registry duck type the rest of the kit already speaks (#all / #from_slug /
9
+ # #nav_items), so an archived version renders through TODAY's chrome — only
10
+ # the content is frozen.
11
+ #
12
+ # A snapshot lives at <config.snapshots_path>/<version id>/: a manifest.json
13
+ # describing the nav structure (see the schema in the snapshot task) plus one
14
+ # .md file per page, written by the host-run `bin/rails docs_kit:snapshot[id]`
15
+ # task at release time.
16
+ #
17
+ # A missing directory or unreadable manifest degrades to an EMPTY snapshot
18
+ # (no pages) — a version configured before its snapshot is written must never
19
+ # take the site down. The install generator's --sync report warns about the
20
+ # drift instead.
21
+ class Snapshot
22
+ # The manifest format this reader understands; the writer stamps it so a
23
+ # future format change is detectable rather than silently misread.
24
+ SCHEMA = 1
25
+
26
+ class << self
27
+ # The snapshot for this version, memoized per [version id, directory] and
28
+ # invalidated when manifest.json's mtime changes — the same
29
+ # reload-on-change posture as Configuration#openapi_document, so editing
30
+ # a snapshot in development is picked up without a server restart.
31
+ def for(version, config: DocsKit.configuration)
32
+ version = DocVersion.from(version)
33
+ root = root_for(version, config)
34
+ mtime = manifest_mtime(root)
35
+ key = [version.id.to_s, root.to_s]
36
+
37
+ @cache ||= {}
38
+ cached = @cache[key]
39
+ return cached.fetch(:snapshot) if cached && cached.fetch(:mtime) == mtime
40
+
41
+ new(version: version, root: root).tap do |snapshot|
42
+ @cache[key] = { snapshot: snapshot, mtime: mtime }
43
+ end
44
+ end
45
+
46
+ def reset_cache!
47
+ @cache = {}
48
+ end
49
+
50
+ private
51
+
52
+ # <snapshots_path>/<id>, or nil when no snapshots path resolves (no
53
+ # config, no Rails) — which reads back as an empty snapshot.
54
+ def root_for(version, config)
55
+ base = config.snapshots_path
56
+ return if base.nil?
57
+
58
+ Pathname.new(base).join(version.id.to_s)
59
+ end
60
+
61
+ def manifest_mtime(root)
62
+ return if root.nil?
63
+
64
+ path = root.join("manifest.json")
65
+ path.file? ? path.mtime : nil
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+
71
+ attr_reader :version, :root
72
+
73
+ def initialize(version:, root:)
74
+ @version = version
75
+ @root = root
76
+ @manifest = read_manifest
77
+ end
78
+
79
+ # Every snapshot page across the manifest's registries, in manifest order —
80
+ # each a Snapshot::Entry quacking like a Registry::Entry.
81
+ def all
82
+ registries.flat_map { |registry| registry.fetch(:entries) }
83
+ end
84
+
85
+ def from_slug(slug)
86
+ all.find { |entry| entry.slug.to_s == slug.to_s }
87
+ end
88
+
89
+ # { group => [NavItem] }, the Registry.nav_items shape — hrefs already carry
90
+ # the version prefix, so the Sidebar's strict path == href active-matching
91
+ # works unchanged.
92
+ def nav_items
93
+ nav_items_for(all)
94
+ end
95
+
96
+ # { heading => { group => [NavItem] } }, the Configuration#nav_groups shape,
97
+ # from the manifest's per-registry headings — a heading with no pages is
98
+ # dropped so the sidebar never shows an empty group.
99
+ def nav_groups
100
+ registries.each_with_object({}) do |registry, acc|
101
+ items = nav_items_for(registry.fetch(:entries))
102
+ acc[registry.fetch(:heading)] = items unless items.empty?
103
+ end
104
+ end
105
+
106
+ # The version-prefixed docs prefix (e.g. "/1.0/docs").
107
+ def path_prefix
108
+ "#{version.path_prefix}/docs"
109
+ end
110
+
111
+ # The raw Markdown body of the page with this slug, or nil when unknown.
112
+ def markdown_for(slug)
113
+ from_slug(slug)&.markdown
114
+ end
115
+
116
+ private
117
+
118
+ # The manifest's registries as { heading:, entries: [Snapshot::Entry] }.
119
+ def registries
120
+ @registries ||= Array(@manifest && @manifest["registries"]).map do |registry|
121
+ prefix = registry["path_prefix"] || "/docs"
122
+ {
123
+ heading: registry["heading"],
124
+ entries: Array(registry["pages"]).map do |attrs|
125
+ Entry.new(attrs, version: version, root: root, registry_prefix: prefix)
126
+ end
127
+ }
128
+ end
129
+ end
130
+
131
+ def nav_items_for(entries)
132
+ entries.group_by(&:group).transform_values do |grouped|
133
+ grouped.map { |entry| NavItem.new(href: entry.href, label: entry.title, icon: entry.icon) }
134
+ end
135
+ end
136
+
137
+ # The parsed manifest Hash, or nil (→ empty snapshot) when the directory or
138
+ # manifest is missing/unreadable — degrade, never raise (the site must stay
139
+ # up with a version configured before its snapshot exists).
140
+ def read_manifest
141
+ return if root.nil?
142
+
143
+ path = root.join("manifest.json")
144
+ return unless path.file?
145
+
146
+ JSON.parse(path.read)
147
+ rescue JSON::ParserError, SystemCallError
148
+ nil
149
+ end
150
+ end
151
+ end
@@ -14,16 +14,16 @@ require "securerandom"
14
14
  # * adds docs-kit + its runtime deps to the Gemfile,
15
15
  # * runs `docs_kit:install` (all the Ruby/CSS/Stimulus wiring),
16
16
  # * syncs the lucide icon set and builds the CSS,
17
- # * scaffolds a deployable Kamal setup that calls docs-kit's reusable workflow.
17
+ # * scaffolds a deployable dash setup that calls docs-kit's reusable workflow.
18
18
  #
19
19
  # The generated app is a complete, deployable standalone docs site.
20
20
 
21
21
  # --- config the template reads ------------------------------------------------
22
22
  # DOCS_KIT_GEM_SOURCE lets the dogfood site (docs-kit/docs) depend on the gem via
23
23
  # path: ".." while a real new site depends on the released gem. Default: rubygems.
24
- gem_source = ENV.fetch("DOCS_KIT_GEM_SOURCE", "") # e.g. 'path: "..", ' or 'github: "mhenrixon/docs-kit", '
24
+ gem_source = ENV.fetch("DOCS_KIT_GEM_SOURCE", "") # e.g. 'path: "..", ' or 'github: "zoolutions/docs-kit", '
25
25
  # The GHCR image/service = the OWNER/REPO the site will live in (repo-linked pkg).
26
- image = ENV.fetch("DOCS_KIT_IMAGE", "mhenrixon/#{app_name}")
26
+ image = ENV.fetch("DOCS_KIT_IMAGE", "zoolutions/#{app_name}")
27
27
  service = ENV.fetch("DOCS_KIT_SERVICE", app_name)
28
28
 
29
29
  # --- gems ---------------------------------------------------------------------
@@ -53,14 +53,26 @@ after_bundle do
53
53
  run "bun install --silent" if system("command -v bun >/dev/null 2>&1")
54
54
  run "bun run build:css" if system("command -v bun >/dev/null 2>&1")
55
55
 
56
- # --- deploy scaffolding (Kamal + the reusable workflow) ---------------------
56
+ # --- deploy scaffolding (dash + the reusable workflow) ---------------------
57
57
  create_file "config/deploy.yml", <<~YAML
58
- # Kamal deploy → the oss-infrastructure server (Cloudflare Tunnel + kamal-proxy).
58
+ # dash deploy → the oss-infrastructure server (Cloudflare Tunnel + dash-proxy).
59
59
  # service/image = the repo OWNER/REPO so the ghcr package auto-links to the
60
60
  # repo and GITHUB_TOKEN can push + pull it (no PAT). See docs-kit's README.
61
+ # `dash docs` / `dash docs proxy` is the always-current reference for every key.
61
62
  service: #{service}
62
63
  image: #{image}
63
64
 
65
+ # dash 4 renamed the on-host proxy (kamal-proxy → dash-proxy) and migrates a
66
+ # host in place; an older CLI must not deploy this config.
67
+ minimum_version: 4.0.0
68
+
69
+ # A stateless docs site never rolls back far — keep the host tidy.
70
+ retain_containers: 2
71
+
72
+ # Status-named pages (public/502.html, 503, 504) the proxy serves in place of
73
+ # the app's during a deploy gap — paired with `proxy.intercept_errors` below.
74
+ error_pages_path: public
75
+
64
76
  servers:
65
77
  web:
66
78
  hosts:
@@ -72,17 +84,51 @@ after_bundle do
72
84
  proxy:
73
85
  host: <%= ENV["DEPLOY_DOMAIN"] %>
74
86
  app_port: 3000
87
+ # TLS terminates at Cloudflare; the tunnel reaches the proxy over plain HTTP.
75
88
  ssl: false
76
89
  healthcheck:
77
90
  path: /up
78
91
  interval: 5
79
92
  timeout: 30
80
93
 
94
+ # --- dash-proxy per-app features ------------------------------------------
95
+ # zstd / br / gzip negotiated at the edge; responses the app already encoded
96
+ # (Thruster) pass through untouched.
97
+ compress: true
98
+
99
+ # RFC 9111 shared cache. Only responses the app marks `Cache-Control: public,
100
+ # max-age` are stored (Propshaft assets, /llms*.txt) — HTML carrying a session
101
+ # cookie is refused by design. `dash proxy cache stats` shows what it holds.
102
+ cache:
103
+ enabled: true
104
+ max_ttl: 300
105
+
106
+ # Security headers set once here instead of per app; drop server fingerprints.
107
+ headers:
108
+ response:
109
+ set:
110
+ X-Content-Type-Options: nosniff
111
+ Referrer-Policy: strict-origin-when-cross-origin
112
+ remove:
113
+ - Server
114
+ - X-Powered-By
115
+
116
+ # Serve public/<status>.html instead of a bare "Bad Gateway" while a
117
+ # container is swapped or unhealthy.
118
+ intercept_errors:
119
+ - 502
120
+ - 503
121
+ - 504
122
+
123
+ # Keep the health probe out of the request histograms.
124
+ exclude_metrics_paths:
125
+ - /up
126
+
81
127
  registry:
82
128
  server: ghcr.io
83
129
  username: mhenrixon
84
130
  password:
85
- - KAMAL_REGISTRY_PASSWORD
131
+ - DASH_REGISTRY_PASSWORD
86
132
 
87
133
  builder:
88
134
  arch: amd64
@@ -98,18 +144,24 @@ after_bundle do
98
144
  SECRET_KEY_BASE: "#{SecureRandom.hex(64)}"
99
145
  YAML
100
146
 
101
- create_file ".kamal/secrets", <<~SH
147
+ # The status pages `proxy.intercept_errors` serves for a deploy gap — `rails new`
148
+ # ships 500.html; the proxy looks for the exact status it intercepted.
149
+ %w[502 503 504].each do |status|
150
+ create_file "public/#{status}.html", File.read("public/500.html") if File.exist?("public/500.html")
151
+ end
152
+
153
+ create_file ".dash/secrets", <<~SH
102
154
  # In CI the deploy workflow sets this to the job's GITHUB_TOKEN. Locally,
103
- # export it (e.g. KAMAL_REGISTRY_PASSWORD=$(gh auth token)).
104
- KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
155
+ # export it (e.g. DASH_REGISTRY_PASSWORD=$(gh auth token)).
156
+ DASH_REGISTRY_PASSWORD=$DASH_REGISTRY_PASSWORD
105
157
  SH
106
158
 
107
159
  # The Dockerfile + .dockerignore are written by `docs_kit:install` (run above in
108
160
  # after_bundle) so a scaffolded site and an upgrading site share ONE optimized,
109
161
  # version-stamped Dockerfile — no divergent copy to maintain here. The generator
110
162
  # derives the LABEL service from the app dir basename (= app_name); if the site
111
- # deploys under a DIFFERENT Kamal service (`--service`), correct the label to
112
- # match config/deploy.yml so Kamal's --skip-push validate_image passes.
163
+ # deploys under a DIFFERENT dash service (`--service`), correct the label to
164
+ # match config/deploy.yml so dash's --skip-push validate_image passes.
113
165
  gsub_file "Dockerfile", /LABEL service=".*"/, %(LABEL service="#{service}") if service != app_name
114
166
 
115
167
  create_file ".github/workflows/deploy-docs.yml", <<~YAML
@@ -120,7 +172,7 @@ after_bundle do
120
172
  workflow_dispatch:
121
173
  jobs:
122
174
  deploy:
123
- uses: mhenrixon/docs-kit/.github/workflows/deploy.yml@main
175
+ uses: zoolutions/docs-kit/.github/workflows/deploy.yml@main
124
176
  with:
125
177
  image: #{image}
126
178
  service: #{service}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.0.8"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -568,7 +568,7 @@ module DocsKit
568
568
  (name || File.basename(destination_root)).to_s.underscore.humanize
569
569
  end
570
570
 
571
- # The Kamal `service` name stamped as the Dockerfile's LABEL — the app dir
571
+ # The dash `service` name stamped as the Dockerfile's LABEL — the app dir
572
572
  # basename (a docs site's repo name), matching the `docs-kit new` default.
573
573
  # Used in Dockerfile.tt via <%= docker_service %>.
574
574
  def docker_service
@@ -7,7 +7,7 @@
7
7
  # `build` stage. Pair it with the shipped .dockerignore so the build context
8
8
  # stays small (no node_modules, .git, logs, specs, coverage).
9
9
  #
10
- # Build context is the app root; `docker build .` (Kamal: context: ".").
10
+ # Build context is the app root; `docker build .` (dash: context: ".").
11
11
 
12
12
  ARG RUBY_VERSION=<%= ruby_version_arg %>
13
13
  FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
@@ -59,9 +59,9 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \
59
59
  # --- Final stage --------------------------------------------------------------
60
60
  FROM base
61
61
 
62
- # Kamal's validate_image greps this label on a --skip-push deploy; it must equal
62
+ # dash's validate_image greps this label on a --skip-push deploy; it must equal
63
63
  # `service:` in config/deploy.yml. The reusable deploy workflow also stamps it,
64
- # but keeping it here means `docker build` alone produces a Kamal-valid image.
64
+ # but keeping it here means `docker build` alone produces a dash-valid image.
65
65
  LABEL service="<%= docker_service %>"
66
66
 
67
67
  # Copy ONLY the built artifacts from the build stage: the installed bundle and
@@ -79,9 +79,9 @@ EXPOSE 3000
79
79
  <% if thruster? -%>
80
80
  # Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on
81
81
  # HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which
82
- # config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's
82
+ # config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (dash's
83
83
  # `app_port`, the EXPOSE above) — Thruster's default is 80, which the non-root
84
- # user can't reliably bind AND which kamal-proxy (app_port: 3000) would never
84
+ # user can't reliably bind AND which dash-proxy (app_port: 3000) would never
85
85
  # route to, silently bypassing Thruster straight into Puma.
86
86
  ENV HTTP_PORT="3000" \
87
87
  TARGET_PORT="3001"
@@ -8,7 +8,7 @@ reads it through the bundled `write-docs-page` skill. Edit freely — a
8
8
  <!-- BEGIN docs-kit -->
9
9
  ## Writing docs pages (docs-kit)
10
10
 
11
- <%= app_brand %> is a [docs-kit](https://github.com/mhenrixon/docs-kit) site: a
11
+ <%= app_brand %> is a [docs-kit](https://github.com/zoolutions/docs-kit) site: a
12
12
  Phlex/daisyUI chrome where **every page is a `DocsUI::Page` subclass** and the
13
13
  sidebar, TOC, search, and Markdown twin come free. To document something, you
14
14
  scaffold a page, then write its `#content`. Never hand-write HTML or daisyUI
@@ -40,6 +40,7 @@
40
40
 
41
41
  # CI / deploy / Docker meta — not part of the runtime image.
42
42
  /.github/
43
+ /.dash/
43
44
  /.kamal/
44
45
  /Dockerfile*
45
46
  /.dockerignore
@@ -5,7 +5,7 @@ description: "Write, add, or update a documentation page in this docs-kit site.
5
5
 
6
6
  # Write a docs page
7
7
 
8
- This is a [docs-kit](https://github.com/mhenrixon/docs-kit) site (<%= app_brand %>).
8
+ This is a [docs-kit](https://github.com/zoolutions/docs-kit) site (<%= app_brand %>).
9
9
  Every page is a `DocsUI::Page` subclass; the shell, sidebar, "On this page" TOC,
10
10
  search, and the `.md` twin all come free. Your job is to scaffold a page and
11
11
  write its `#content` — never hand-write HTML or daisyUI markup.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docs-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -164,6 +164,7 @@ files:
164
164
  - CHANGELOG.md
165
165
  - LICENSE.txt
166
166
  - README.md
167
+ - app/components/docs_ui/archived_page.rb
167
168
  - app/components/docs_ui/brand_mark.rb
168
169
  - app/components/docs_ui/callout.rb
169
170
  - app/components/docs_ui/code.rb
@@ -209,6 +210,7 @@ files:
209
210
  - lib/docs_kit/brand_logo.rb
210
211
  - lib/docs_kit/configuration.rb
211
212
  - lib/docs_kit/controller.rb
213
+ - lib/docs_kit/doc_version.rb
212
214
  - lib/docs_kit/engine.rb
213
215
  - lib/docs_kit/landing_config.rb
214
216
  - lib/docs_kit/llms_text.rb
@@ -226,11 +228,15 @@ files:
226
228
  - lib/docs_kit/open_api/schema.rb
227
229
  - lib/docs_kit/registry.rb
228
230
  - lib/docs_kit/rubocop.rb
231
+ - lib/docs_kit/scope.rb
232
+ - lib/docs_kit/scoping.rb
229
233
  - lib/docs_kit/search_hit.rb
230
234
  - lib/docs_kit/search_index.rb
231
235
  - lib/docs_kit/search_index/snippet.rb
232
236
  - lib/docs_kit/seo_config.rb
233
237
  - lib/docs_kit/shortcut.rb
238
+ - lib/docs_kit/snapshot.rb
239
+ - lib/docs_kit/snapshot/entry.rb
234
240
  - lib/docs_kit/templates/new_site.rb
235
241
  - lib/docs_kit/topbar_link.rb
236
242
  - lib/docs_kit/version.rb
@@ -260,13 +266,13 @@ files:
260
266
  - lib/generators/docs_kit/page/templates/page.rb.erb
261
267
  - lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb
262
268
  - lib/rubocop/cop/docs_kit/render_component_preferred.rb
263
- homepage: https://github.com/mhenrixon/docs-kit
269
+ homepage: https://github.com/zoolutions/docs-kit
264
270
  licenses:
265
271
  - MIT
266
272
  metadata:
267
- source_code_uri: https://github.com/mhenrixon/docs-kit
268
- changelog_uri: https://github.com/mhenrixon/docs-kit/blob/main/CHANGELOG.md
269
- bug_tracker_uri: https://github.com/mhenrixon/docs-kit/issues
273
+ source_code_uri: https://github.com/zoolutions/docs-kit
274
+ changelog_uri: https://github.com/zoolutions/docs-kit/blob/main/CHANGELOG.md
275
+ bug_tracker_uri: https://github.com/zoolutions/docs-kit/issues
270
276
  rubygems_mfa_required: 'true'
271
277
  rdoc_options: []
272
278
  require_paths: