diff --git a/.rubocop.yml b/.rubocop.yml
index f5efca2..0544a6b 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -41,11 +41,29 @@ Style/OneClassPerFile:
- "lib/docs_kit.rb"
# The shell head/page templates are linear builders; their ABC is inherent to the
-# markup they emit, not accidental complexity.
+# markup they emit, not accidental complexity. Configuration#initialize is a flat
+# list of default assignments (one per knob) — its ABC is the knob count, not
+# branching complexity.
Metrics/AbcSize:
Exclude:
- "app/components/docs_ui/shell.rb"
- "app/components/docs_ui/page.rb"
+ - "lib/docs_kit/configuration.rb"
+
+# ApiRequest/ApiClient are Data value objects and RequestExample is a public
+# component; their keyword-arg constructors mirror the documented API (method:,
+# path:, body:, query:, headers:, clients:), which is inherently wide.
+Metrics/ParameterLists:
+ Exclude:
+ - "lib/docs_kit/api_request.rb"
+ - "app/components/docs_ui/request_example.rb"
+
+# ApiRequest deliberately names a member `method:` — it IS the HTTP method, the
+# documented public field templates read as `request.method`. The shadowing of
+# Object#method is intended (no reflection is done on the struct).
+Lint/DataDefineOverride:
+ Exclude:
+ - "lib/docs_kit/api_request.rb"
Style/StringLiterals:
EnforcedStyle: double_quotes
@@ -61,10 +79,13 @@ Metrics/BlockLength:
- "lib/docs_kit/templates/**/*"
# Generators are procedural wiring — one method per install step — so they're
-# naturally long and flat, not complex.
+# naturally long and flat, not complex. Configuration is the per-site config
+# surface: one accessor + one default per knob, plus small derived readers — its
+# length is the knob count, not accidental complexity.
Metrics/ClassLength:
Exclude:
- "lib/generators/**/*"
+ - "lib/docs_kit/configuration.rb"
Metrics/MethodLength:
Max: 25
diff --git a/README.md b/README.md
index d3e354f..eb326ba 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,8 @@ A `DocsUI::` Phlex kit, configured once per site:
| `DocsUI::Table` / `PropTable` | Reference tables — generic headers+rows, and a name/type/default/description preset. |
| `DocsUI::Endpoint` | HTTP method badge (coloured per verb) + monospace path; renders inline (drops into a `Section` description). |
| `DocsUI::FieldTable` / `ErrorTable` | API-reference presets over `Table` — an object's fields, and an endpoint's errors (Param column auto-hidden when unused). |
+| `DocsUI::RequestExample` | One request declaration → one code tab per configured client (curl / JS / Ruby / Python by default). |
+| `DocsUI::JsonResponse` | A Ruby Hash (or String) rendered as a pretty-printed JSON response block. |
| `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
@@ -240,6 +242,70 @@ this page" TOC still come from `DocsUI::Section`** — keep section titles as
`Section`, and use Markdown headings only for sub-headings inside a section. Raw
HTML in the Markdown source is dropped (no `" })
+
+ expect(html).not_to include("")
+ expect(html).to include("<script>")
+ end
+end
diff --git a/spec/docs_ui/request_example_spec.rb b/spec/docs_ui/request_example_spec.rb
new file mode 100644
index 0000000..640653e
--- /dev/null
+++ b/spec/docs_ui/request_example_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+RSpec.describe DocsUI::RequestExample do
+ def render_request(...)
+ described_class.new(...).call
+ end
+
+ it "renders one tab per configured client (the four defaults)" do
+ html = render_request(method: :post, path: "/v1/things", body: { name: "Acme" })
+
+ %w[curl javascript ruby python].each do |token|
+ expect(html.scan(%(data-testid="code-lang-#{token}")).size).to eq(1)
+ end
+ expect(html.scan('data-docs-nav-target="codePanel"').size).to eq(4)
+ end
+
+ it "labels each tab from the configured language labels" do
+ html = render_request(method: :get, path: "/v1/things")
+
+ expect(html).to include(">cURL<")
+ expect(html).to include(">JavaScript<")
+ expect(html).to include(">Ruby<")
+ expect(html).to include(">Python<")
+ end
+
+ it "builds the curl tab from api_base_url + path, method and a -d JSON body" do
+ DocsKit.configure { |c| c.api_base_url = "https://api.acme.test" }
+ html = render_request(method: :post, path: "/v1/things", body: { name: "Acme" })
+
+ expect(html).to include("https://api.acme.test/v1/things")
+ expect(html).to include("curl -X POST")
+ expect(html).to include("Acme") # the JSON body payload
+ end
+
+ it "injects the configured auth header into the curl snippet" do
+ DocsKit.configure { |c| c.api_auth_header = "Authorization: Bearer sk_live_..." }
+ html = render_request(method: :get, path: "/v1/things")
+
+ expect(html).to include("Authorization: Bearer sk_live_...")
+ end
+
+ it "filters and orders the tabs when clients: is given" do
+ html = render_request(method: :get, path: "/v1/things", clients: %i[ruby curl])
+
+ expect(html.scan('data-docs-nav-target="codePanel"').size).to eq(2)
+ expect(html).to include('data-testid="code-lang-ruby"')
+ expect(html).to include('data-testid="code-lang-curl"')
+ expect(html).not_to include('data-testid="code-lang-python"')
+ # order follows the clients: array (ruby tab before curl tab)
+ expect(html.index("code-lang-ruby")).to be < html.index("code-lang-curl")
+ end
+
+ it "renders a site-added custom client from config" do
+ cli = DocsKit::ApiClient.new(
+ label: "CLI", lexer: :shell, filename: "acme",
+ template: ->(req) { "acme things create --name #{req.body&.dig(:name)}" }
+ )
+ DocsKit.configure { |c| c.api_clients = { cli: cli } }
+
+ # Alongside curl so Example renders tabs (a lone snippet degrades to no tabs).
+ html = render_request(method: :post, path: "/v1/things", body: { name: "Acme" }, clients: %i[cli curl])
+
+ expect(html).to include(">CLI<") # the client's own label, not the token capitalized
+ expect(html).to include('data-testid="code-lang-cli"')
+ # The snippet ran through the client template (Rouge highlights the :shell
+ # tokens, so assert on the surviving pieces, not the whole command line).
+ expect(html).to include("acme things create")
+ expect(html).to include("Acme")
+ end
+
+ it "omits payload lines in every default template for a body-less GET" do
+ html = render_request(method: :get, path: "/v1/things")
+
+ expect(html).not_to include("-d ") # curl
+ expect(html).not_to include("body: JSON.stringify") # javascript
+ expect(html).not_to include("request.body =") # ruby
+ expect(html).not_to include("json=") # python
+ end
+
+ it "keeps Example's progressive-enhancement markup (no server-side hidden panels)" do
+ html = render_request(method: :get, path: "/v1/things")
+
+ # Example never sets the `hidden` attribute server-side; the docs-nav JS
+ # toggles it. With JS off every panel is visible (all four still present).
+ expect(html).to include('data-docs-nav-target="codeGroup"')
+ expect(html.scan('data-docs-nav-target="codePanel"').size).to eq(4)
+ expect(html).not_to match(/<[^>]*\shidden(\s|>|=)/) # no `hidden` HTML attribute
+ end
+end