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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 `<script>`, no passthrough).

## API docs — one request, every client tab

An endpoint example is a request shown in several clients (curl, JavaScript,
Ruby, Python, …) plus a JSON response. Writing each client by hand means a field
rename edits every language. `DocsUI::RequestExample` derives all the tabs from
**one** declaration; `DocsUI::JsonResponse` renders a Ruby Hash as a
pretty-printed response block.

```ruby
def content
DocsUI::Section("Create a payment link",
description: DocsUI::Endpoint.new(:post, "/v1/payment_links")) do

render DocsUI::RequestExample.new(
method: :post,
path: "/v1/payment_links",
body: { amount: 4900, currency: "usd", description: "Pro plan" }
)

render DocsUI::JsonResponse.new(
{ id: "plink_1a2b3c", object: "payment_link", amount: 4900,
currency: "usd", url: "https://pay.example.com/plink_1a2b3c" }
)
end
end
```

`RequestExample` renders a `DocsUI::Example`, so the global sticky language
choice works exactly as with a hand-built example (pick Ruby once, every request
on the site shows Ruby). With JS off, every client snippet is visible stacked.

**Configure the client set and host once:**

```ruby
# config/initializers/docs_kit.rb
DocsKit.configure do |c|
c.api_base_url = "https://api.acme.com" # prefixed onto every path
c.api_auth_header = "Authorization: Bearer sk_live_..." # nil ⇒ no auth line

# Swap a default for an SDK-flavored snippet, or add a new tab (e.g. a CLI):
c.api_clients = {
ruby: DocsKit::ApiClient.new(
label: "Ruby", lexer: :ruby, filename: "app.rb",
template: ->(req) { %(Acme.new.payment_links.create(#{req.pretty_body_json})) }
),
cli: DocsKit::ApiClient.new(
label: "CLI", lexer: :shell, filename: "acme",
template: ->(req) { "acme payment_links create --amount #{req.body[:amount]}" }
)
}
end
```

The gem ships four generic-HTTP clients (`curl`, `javascript`, `ruby`,
`python`). A `c.api_clients` entry merges **over** them: reuse a token
(`ruby`) to replace that client with your SDK's snippet, or use a new token
(`cli`) to append a tab. Order is stable — reused tokens keep their position, new
ones append. Each `template` is a `(DocsKit::ApiRequest) -> String` callable;
the request exposes `#http_method`, `#url`, `#url_with_query`, `#headers`,
`#body?`, and `#pretty_body_json` so a template stays one short heredoc.

Pass `clients:` to a single call to filter/order the tabs:
`DocsUI::RequestExample.new(method: :get, path: "/v1/things", clients: [:curl, :ruby])`.

## Scaffold a new docs site in one command

```bash
Expand Down
10 changes: 6 additions & 4 deletions app/components/docs_ui/example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ def initialize
# Collect one language's snippet. `lang` is the language token (e.g. :ruby,
# :python, :go) — Docs::Code resolves it against Rouge's full registry + the
# configured aliases, so any language works. The tab label comes from the
# configured language_labels (else the token capitalized). filename/lexer are
# optional; lexer defaults to the language token. The block returns the source.
def code(lang, filename: nil, lexer: nil)
# configured language_labels (else the token capitalized), or an explicit
# `label:` override (used by RequestExample so a client carries its own tab
# name). filename/lexer are optional; lexer defaults to the language token.
# The block returns the source.
def code(lang, filename: nil, lexer: nil, label: nil)
token = lang.to_sym
@snippets << {
lang: token,
label: DocsKit.configuration.language_labels.fetch(token, token.to_s.capitalize),
label: label || DocsKit.configuration.language_labels.fetch(token, token.to_s.capitalize),
filename: filename,
lexer: lexer || token,
source: yield.to_s
Expand Down
46 changes: 46 additions & 0 deletions app/components/docs_ui/json_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

require "json"

module DocsUI
# A pretty-printed JSON response block. Give it a Ruby Hash (deep-stringified and
# JSON.pretty_generate'd) or a pre-formatted String; it renders a DocsUI::Code
# with the json lexer and a filename title bar. Kills the hand-rolled
# deep_stringify + JSON.pretty_generate every API page was copy-pasting.
#
# render DocsUI::JsonResponse.new({ id: "obj_1", status: "active" })
# render DocsUI::JsonResponse.new(raw_json_string, filename: "webhook.json")
#
# A Hash with symbol keys renders as real JSON (string keys, no :symbol / =>
# leaking through). A String is passed through verbatim (already formatted).
class JsonResponse < Phlex::HTML
def initialize(body, filename: "response.json")
@body = body
@filename = filename
end

def view_template
render DocsUI::Code.new(json_source, lexer: :json, filename: @filename)
end

private

# The JSON string to highlight: a String passes through; a Hash/Array is
# deep-stringified then pretty-generated so it reads like an API response.
def json_source
return @body if @body.is_a?(String)

JSON.pretty_generate(deep_stringify(@body))
end

# Recursively stringify keys and symbol values so the output is real JSON.
def deep_stringify(value)
case value
when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
when Array then value.map { |v| deep_stringify(v) }
when Symbol then value.to_s
else value
end
end
end
end
83 changes: 83 additions & 0 deletions app/components/docs_ui/request_example.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

module DocsUI
# One structured request declaration → one code tab per configured API client.
# Declare method/path/body once and every client (curl, javascript, ruby,
# python by default; plus whatever a site adds) renders its own snippet, in a
# DocsUI::Example so the sticky global language preference keeps working.
#
# render DocsUI::RequestExample.new(
# method: :post, path: "/v1/webhook_endpoints",
# body: { url: "https://example.com/hook", events: ["payment.paid"] }
# )
#
# # only some tabs, in a chosen order:
# render DocsUI::RequestExample.new(method: :get, path: "/v1/things", clients: %i[curl ruby])
#
# The base URL and an example auth header come from config
# (DocsKit.configuration.api_base_url / #api_auth_header); the client set comes
# from #api_clients (defaults + site overrides). This replaces the per-client
# heredoc a docs page used to hand-write once per endpoint per language.
class RequestExample < Phlex::HTML
def initialize(method:, path:, body: nil, query: nil, headers: {}, clients: nil)
@method = method
@path = path
@body = body
@query = query || {}
@headers = headers || {}
@clients = clients
end

def view_template
request = build_request
selected = selected_clients

render DocsUI::Example.new do |ex|
selected.each do |token, client|
ex.code(
token,
lexer: client.lexer,
label: client.label,
filename: client.filename_for(request)
) { client.render(request) }
end
end
end

private

# The request struct handed to every client template: config base URL + path,
# config auth header merged into the headers.
def build_request
config = DocsKit.configuration
DocsKit::ApiRequest.new(
method: @method,
path: @path,
url: "#{config.api_base_url}#{@path}",
query: @query,
headers: merged_headers(config.api_auth_header),
body: @body
)
end

# The site's example Authorization header (if any) merged into the per-request
# headers. The header line is "Name: value"; split it into a { name => value }
# entry so templates can format it per language.
def merged_headers(auth_header)
return @headers if auth_header.nil? || auth_header.strip.empty?

name, value = auth_header.split(":", 2).map(&:strip)
{ name => value }.merge(@headers)
end

# The { token => ApiClient } tabs to render, in order: the configured full map,
# or just the requested `clients:` tokens (in the given order), skipping any
# unknown token so a typo degrades to fewer tabs rather than raising.
def selected_clients
configured = DocsKit.configuration.api_clients
return configured if @clients.nil?

@clients.filter_map { |token| [token.to_sym, configured[token.to_sym]] if configured.key?(token.to_sym) }.to_h
end
end
end
105 changes: 105 additions & 0 deletions docs/app/views/docs/pages/components.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def content
example_section
table_section
endpoint_section
request_example_section
callout_section
icon_section
on_this_page_section
Expand Down Expand Up @@ -404,6 +405,110 @@ def endpoint_section
end
end

def request_example_section
DocsUI::Section(
"RequestExample & JsonResponse",
description: "The API-docs kit — declare a request once, get every client tab; render a Ruby hash as a JSON response."
) do
prose do
p do
code { "DocsUI::RequestExample" }
plain " turns one structured request declaration ("
code { "method:" }
plain "/"
code { "path:" }
plain "/"
code { "body:" }
plain ") into a "
code { "DocsUI::Example" }
plain " with one tab per configured client — "
code { "curl" }
plain ", "
code { "javascript" }
plain ", "
code { "ruby" }
plain ", "
code { "python" }
plain " by default (a site adds its own, e.g. a "
code { "cli" }
plain " tab). "
code { "DocsUI::JsonResponse" }
plain " renders a Ruby Hash as pretty-printed JSON — no hand-rolled "
code { "deep_stringify" }
plain "."
end
end

# Live: one declaration → four client tabs, plus a JSON response.
DocsUI::Section(
"Create a payment link",
description: DocsUI::Endpoint.new(:post, "/v1/payment_links")
) do
prose { p { "Creates a shareable payment link for a fixed amount." } }
render DocsUI::FieldTable.new(
[
{ name: "amount", type: "integer", required: true, description: "Amount in the smallest currency unit." },
{ name: "currency", type: "string", required: true, description: "ISO 4217 currency code." },
{ name: "description", type: "string", description: "Shown to the payer at checkout." }
]
)
render DocsUI::RequestExample.new(
method: :post,
path: "/v1/payment_links",
body: { amount: 4900, currency: "usd", description: "Pro plan" }
)
prose { p { "A successful response:" } }
render DocsUI::JsonResponse.new(
{
id: "plink_1a2b3c",
object: "payment_link",
amount: 4900,
currency: "usd",
url: "https://pay.example.com/plink_1a2b3c",
active: true
}
)
end

prose { p { "The calls that produced the block above:" } }
DocsUI::Code(<<~RUBY)
render DocsUI::RequestExample.new(
method: :post,
path: "/v1/payment_links",
body: { amount: 4900, currency: "usd", description: "Pro plan" }
)
render DocsUI::JsonResponse.new(
{ id: "plink_1a2b3c", object: "payment_link", amount: 4900,
currency: "usd", url: "https://pay.example.com/plink_1a2b3c", active: true }
)
RUBY

DocsUI::Callout(:tip) do
plain "The client set, base URL, and example auth header are config: "
code { "c.api_clients" }
plain " (defaults + your overrides), "
code { "c.api_base_url" }
plain ", and "
code { "c.api_auth_header" }
plain ". Override a default token to swap in an SDK-flavored snippet; add a new token (e.g. "
code { "cli" }
plain ") to append a tab."
end

render DocsUI::PropTable.new(
[
[ "RequestExample method:/path:", "Symbol/String, String", "—", "The HTTP verb and path (path is appended to c.api_base_url)." ],
[ "RequestExample body:", "Hash, nil", "nil", "Request payload; deep-stringified into each snippet. Omit for a GET." ],
[ "RequestExample query:/headers:", "Hash", "{}", "Query params and extra headers merged into every snippet." ],
[ "RequestExample clients:", "Array<Symbol>, nil", "all configured", "Filter/order the tabs (e.g. [:curl, :ruby])." ],
[ "JsonResponse.new(body)", "Hash or String", "—", "Hash → pretty JSON with string keys; String → passed through." ],
[ "JsonResponse filename:", "String", '"response.json"', "The code block's title-bar filename." ]
],
headers: [ "Call", "Type", "Default", "Description" ]
)
end
end

def callout_section
DocsUI::Section("Callout", description: "note / tip / warning — a daisyUI alert with a lucide icon.") do
DocsUI::Callout(:note) { "This is a note callout." }
Expand Down
Loading
Loading