Skip to content

Commit 3d7dd75

Browse files
authored
feat(page): DocsUI::Endpoint + FieldTable/ErrorTable — the endpoint-reference kit (#30)
1 parent 0e21329 commit 3d7dd75

10 files changed

Lines changed: 525 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ A `DocsUI::` Phlex kit, configured once per site:
2626
| `DocsUI::Header` / `Section` / `Prose` / `Callout` | The page-authoring kit. |
2727
| `DocsUI::Markdown` | GFM Markdown island — prose as Markdown, styled like `Prose`, fenced code through Rouge. |
2828
| `DocsUI::Table` / `PropTable` | Reference tables — generic headers+rows, and a name/type/default/description preset. |
29+
| `DocsUI::Endpoint` | HTTP method badge (coloured per verb) + monospace path; renders inline (drops into a `Section` description). |
30+
| `DocsUI::FieldTable` / `ErrorTable` | API-reference presets over `Table` — an object's fields, and an endpoint's errors (Param column auto-hidden when unused). |
2931
| `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
3032

3133
Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`

app/components/docs_ui/endpoint.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module DocsUI
4+
# An HTTP endpoint reference line — a method badge followed by the path — in the
5+
# kit's daisyUI look. This is the `code(class: "badge …")` lambda every API page
6+
# was hand-rolling; compose it instead.
7+
#
8+
# render DocsUI::Endpoint.new(:post, "/v1/messages")
9+
# # => POST /v1/messages (POST as a primary badge, path monospace)
10+
#
11+
# It renders INLINE (no block wrapper), so it drops straight into a Section
12+
# description or a run of prose:
13+
#
14+
# DocsUI::Section("Create a message", description: DocsUI::Endpoint.new(:post, "/v1/messages"))
15+
#
16+
# The verb → badge-colour map is an explicit frozen Hash of LITERAL class
17+
# strings so the Tailwind scan (which reads the gem's Ruby) sees every badge
18+
# class and generates it. An unknown verb falls back to a neutral badge and
19+
# never raises — a typo degrades gracefully rather than blowing up a render.
20+
class Endpoint < Phlex::HTML
21+
# Each value is a single literal string (not interpolated) so Tailwind's
22+
# source scan generates the colour. Keep these literal — see Critical Rule 6.
23+
BADGE_CLASSES = {
24+
"GET" => "badge badge-sm badge-success",
25+
"POST" => "badge badge-sm badge-primary",
26+
"PUT" => "badge badge-sm badge-warning",
27+
"PATCH" => "badge badge-sm badge-warning",
28+
"DELETE" => "badge badge-sm badge-error"
29+
}.freeze
30+
31+
NEUTRAL_BADGE = "badge badge-sm badge-neutral"
32+
33+
def initialize(method, path)
34+
@method = method.to_s.upcase
35+
@path = path
36+
end
37+
38+
def view_template
39+
code(class: BADGE_CLASSES.fetch(@method, NEUTRAL_BADGE)) { plain @method }
40+
whitespace
41+
code { plain @path }
42+
end
43+
end
44+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module DocsUI
4+
# An error reference table for an API endpoint — a keyword-schema preset over
5+
# DocsUI::Table. Each error is a Hash:
6+
#
7+
# render DocsUI::ErrorTable.new(
8+
# [
9+
# { scenario: "Missing or invalid API key", status: "401", type: "authentication_error" },
10+
# { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" },
11+
# ]
12+
# )
13+
#
14+
# Columns: Scenario / Status / Type (auto code-styled) / Param (auto code-styled).
15+
# The Param column is shown only when at least one error names a param — an
16+
# endpoint whose errors are all param-free renders a clean three-column table.
17+
# When the column IS shown, a param-free row gets the canonical em-dash `—`.
18+
class ErrorTable < Phlex::HTML
19+
BASE_HEADERS = %w[Scenario Status Type].freeze
20+
PARAM_HEADER = "Param"
21+
22+
# Shared with FieldTable's canonical "no value" placeholder.
23+
NO_PARAM = "—"
24+
25+
def initialize(errors)
26+
@errors = errors
27+
@with_param = errors.any? { |error| error[:param] }
28+
end
29+
30+
def view_template
31+
render DocsUI::Table.new(headers, @errors.map { |error| row(error) })
32+
end
33+
34+
private
35+
36+
def headers
37+
@with_param ? [*BASE_HEADERS, PARAM_HEADER] : BASE_HEADERS
38+
end
39+
40+
def row(error)
41+
cells = [
42+
error.fetch(:scenario),
43+
error.fetch(:status),
44+
[:code, error.fetch(:type)]
45+
]
46+
cells << param_cell(error) if @with_param
47+
cells
48+
end
49+
50+
def param_cell(error)
51+
param = error[:param]
52+
param ? [:code, param] : NO_PARAM
53+
end
54+
end
55+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
module DocsUI
4+
# A parameter/field reference table for an API object or request body — a
5+
# keyword-schema preset over DocsUI::Table. Each field is a Hash:
6+
#
7+
# render DocsUI::FieldTable.new(
8+
# [
9+
# { name: "url", type: "string", required: true, description: "HTTPS destination URL." },
10+
# { name: "description", type: "string", description: "Optional internal label." },
11+
# { name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`"] },
12+
# ]
13+
# )
14+
#
15+
# Columns: Name (auto code-styled) / Type / Required (✓ or the canonical em-dash
16+
# `—`) / Description. `required:` defaults to false. The description cell follows
17+
# DocsUI::Table's convention — a plain String is escaped text, `[:code, "x"]` is
18+
# inline code, `[:md, "…"]` is inline Markdown.
19+
class FieldTable < Phlex::HTML
20+
HEADERS = %w[Name Type Required Description].freeze
21+
22+
# The ONE canonical "no value" placeholder across the whole kit — never the
23+
# ASCII hyphen "-", never a bare "—" typed ad hoc in a page.
24+
REQUIRED_YES = "✓"
25+
REQUIRED_NO = "—"
26+
27+
def initialize(fields)
28+
@fields = fields
29+
end
30+
31+
def view_template
32+
render DocsUI::Table.new(HEADERS, @fields.map { |field| row(field) })
33+
end
34+
35+
private
36+
37+
def row(field)
38+
[
39+
[:code, field.fetch(:name)],
40+
field.fetch(:type),
41+
field.fetch(:required, false) ? REQUIRED_YES : REQUIRED_NO,
42+
field.fetch(:description)
43+
]
44+
end
45+
end
46+
end

app/components/docs_ui/section.rb

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ module DocsUI
1818
# code(class: "badge badge-sm") { "POST" }; plain " /v1/messages"
1919
# }) { render DocsUI::Prose.new { … } }
2020
#
21+
# # or pass a renderable Phlex component directly (e.g. DocsUI::Endpoint)
22+
# render DocsUI::Section.new("Create a message",
23+
# description: DocsUI::Endpoint.new(:post, "/v1/messages")) { … }
24+
#
2125
# The description is rendered only when present, so plain sections are unchanged.
2226
class Section < Phlex::HTML
2327
def initialize(title, id: nil, description: nil)
@@ -45,16 +49,19 @@ def heading
4549
end
4650
end
4751

48-
# The optional description: a String is rendered as text; a callable (proc/
49-
# lambda) is instance_exec'd so it can emit rich Phlex markup (code, badges).
52+
# The optional description, rendered under the title. Three accepted forms:
53+
# * a Phlex component instance (e.g. DocsUI::Endpoint) → rendered in place;
54+
# * a proc/lambda → instance_exec'd so it can emit rich Phlex markup;
55+
# * a String → plain, Phlex-escaped text.
56+
# A Phlex component also responds to #call, so it MUST be matched before the
57+
# callable branch (else it would be instance_exec'd, not rendered).
5058
def description
5159
return unless @description
5260

5361
p(class: "mb-4 text-base leading-relaxed text-base-content/70") do
54-
if @description.respond_to?(:call)
55-
instance_exec(&@description)
56-
else
57-
plain @description
62+
case @description
63+
when Phlex::SGML then render @description
64+
else @description.respond_to?(:call) ? instance_exec(&@description) : plain(@description)
5865
end
5966
end
6067
end

docs/app/views/docs/pages/components.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def content
2121
code_section
2222
example_section
2323
table_section
24+
endpoint_section
2425
callout_section
2526
icon_section
2627
on_this_page_section
@@ -322,6 +323,87 @@ def table_section
322323
end
323324
end
324325

326+
def endpoint_section
327+
DocsUI::Section(
328+
"Endpoint, FieldTable & ErrorTable",
329+
description: "The API-reference kit — a method+path line, a fields table, and an error table."
330+
) do
331+
prose do
332+
p do
333+
code { "DocsUI::Endpoint" }
334+
plain " renders an HTTP method badge (coloured per verb) plus a monospace path, inline — so it drops straight into a "
335+
code { "Section" }
336+
plain " description. "
337+
code { "FieldTable" }
338+
plain " and "
339+
code { "ErrorTable" }
340+
plain " are keyword-schema presets over "
341+
code { "Table" }
342+
plain " for an object's fields and an endpoint's errors."
343+
end
344+
end
345+
346+
# A Section whose description IS a live Endpoint — the real component,
347+
# not a mock-up.
348+
DocsUI::Section(
349+
"Create a webhook endpoint",
350+
description: DocsUI::Endpoint.new(:post, "/api/webhook_endpoints")
351+
) do
352+
prose { p { "Registers a destination URL for outbound event notifications." } }
353+
render DocsUI::FieldTable.new(
354+
[
355+
{ name: "url", type: "string", required: true, description: "HTTPS destination URL." },
356+
{ name: "description", type: "string", description: "Optional internal label." },
357+
{ name: "events", type: "array", required: true, description: [ :md, "Event types, e.g. `payment_link.paid`." ] }
358+
]
359+
)
360+
render DocsUI::ErrorTable.new(
361+
[
362+
{ scenario: "Missing or invalid API key", status: "401", type: "authentication_error" },
363+
{ scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" },
364+
{ scenario: "Unknown event name", status: "422", type: "validation_error", param: "events" }
365+
]
366+
)
367+
end
368+
369+
prose { p { "The calls that produced the block above:" } }
370+
DocsUI::Code(<<~RUBY)
371+
DocsUI::Section("Create a webhook endpoint",
372+
description: DocsUI::Endpoint.new(:post, "/api/webhook_endpoints")) do
373+
render DocsUI::FieldTable.new([
374+
{ name: "url", type: "string", required: true, description: "HTTPS destination URL." },
375+
{ name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`."] }
376+
])
377+
render DocsUI::ErrorTable.new([
378+
{ scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" }
379+
])
380+
end
381+
RUBY
382+
383+
DocsUI::Callout(:tip) do
384+
plain "Verb → colour is a frozen Hash of literal badge classes ("
385+
code { "GET" }
386+
plain " → success, "
387+
code { "POST" }
388+
plain " → primary, "
389+
code { "PATCH/PUT" }
390+
plain " → warning, "
391+
code { "DELETE" }
392+
plain " → error). An unknown verb renders a neutral badge — no raise."
393+
end
394+
395+
render DocsUI::PropTable.new(
396+
[
397+
[ "DocsUI::Endpoint.new(method, path)", "Symbol/String, String", "—", "Method badge + monospace path; renders inline." ],
398+
[ "DocsUI::FieldTable.new(fields)", "Array<Hash>", "—", "Each: { name:, type:, required: false, description: }." ],
399+
[ "DocsUI::ErrorTable.new(errors)", "Array<Hash>", "—", "Each: { scenario:, status:, type:, param: nil }; Param column auto-hidden." ],
400+
[ "Section(description:)", "String, proc, or component", "nil", "Now also accepts a Phlex component instance." ]
401+
],
402+
headers: [ "Call", "Type", "Default", "Description" ]
403+
)
404+
end
405+
end
406+
325407
def callout_section
326408
DocsUI::Section("Callout", description: "note / tip / warning — a daisyUI alert with a lucide icon.") do
327409
DocsUI::Callout(:note) { "This is a note callout." }

spec/docs_ui/endpoint_spec.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe DocsUI::Endpoint do
4+
def render_endpoint(...)
5+
described_class.new(...).call
6+
end
7+
8+
it "renders the HTTP method as a daisyUI badge" do
9+
html = render_endpoint(:post, "/v1/messages")
10+
11+
expect(html).to include("badge")
12+
expect(html).to include("badge-sm")
13+
expect(html).to include("POST")
14+
end
15+
16+
it "renders the path in a monospace <code>" do
17+
html = render_endpoint(:post, "/v1/messages")
18+
19+
expect(html).to include("<code")
20+
expect(html).to include("/v1/messages")
21+
end
22+
23+
it "upcases a lowercase method symbol for the badge label" do
24+
html = render_endpoint(:get, "/v1/messages")
25+
26+
expect(html).to include(">GET<")
27+
end
28+
29+
it "maps GET to badge-success" do
30+
expect(render_endpoint(:get, "/x")).to include("badge-success")
31+
end
32+
33+
it "maps POST to badge-primary" do
34+
expect(render_endpoint(:post, "/x")).to include("badge-primary")
35+
end
36+
37+
it "maps PATCH to badge-warning" do
38+
expect(render_endpoint(:patch, "/x")).to include("badge-warning")
39+
end
40+
41+
it "maps PUT to badge-warning" do
42+
expect(render_endpoint(:put, "/x")).to include("badge-warning")
43+
end
44+
45+
it "maps DELETE to badge-error" do
46+
expect(render_endpoint(:delete, "/x")).to include("badge-error")
47+
end
48+
49+
it "accepts a String method (not only a Symbol)" do
50+
html = render_endpoint("POST", "/x")
51+
52+
expect(html).to include("badge-primary")
53+
expect(html).to include(">POST<")
54+
end
55+
56+
it "falls back to a neutral badge for an unknown verb, without raising" do
57+
html = nil
58+
expect { html = render_endpoint(:trace, "/x") }.not_to raise_error
59+
60+
expect(html).to include("badge-neutral")
61+
expect(html).to include(">TRACE<")
62+
# No colored verb class leaks in for an unknown method.
63+
expect(html).not_to include("badge-success")
64+
expect(html).not_to include("badge-primary")
65+
end
66+
67+
it "renders inline (no block wrapper) so it composes in a Section description" do
68+
html = render_endpoint(:get, "/x")
69+
70+
# The badge sits directly next to the path — no surrounding <div>/<p> block.
71+
expect(html).not_to include("<div")
72+
expect(html).not_to include("<p>")
73+
end
74+
75+
it "escapes HTML in the path (Phlex escaping, no html_safe)" do
76+
html = render_endpoint(:get, "/x?<script>")
77+
78+
expect(html).to include("&lt;script&gt;")
79+
expect(html).not_to include("<script>")
80+
end
81+
end

0 commit comments

Comments
 (0)