From 429a0d094dfd3c7082a477f9863d86d101356aec Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 10:14:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(page):=20DocsUI::Endpoint=20+=20FieldTable?= =?UTF-8?q?/ErrorTable=20=E2=80=94=20the=20endpoint-reference=20kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The gem had zero API-documentation vocabulary, so every consuming API page hand-rolled a method+path badge, a fields table, and an error table with drifting per-page row helpers. This adds three thin, composable pieces plus a Section extension so documenting an endpoint's reference material is declarative and renders identically across every site. - `DocsUI::Endpoint.new(method, path)` — verb badge (GET→success, POST→primary, PATCH/PUT→warning, DELETE→error via a frozen Hash of LITERAL class strings the CSS scan sees; unknown verb → neutral badge, no raise) + monospace path, rendered inline so it drops into a Section description or a run of prose. - `Section#description` now also accepts a Phlex component instance (matched before the callable branch, since a component also responds to #call). The existing String/proc forms are untouched. - `DocsUI::FieldTable` / `DocsUI::ErrorTable` — keyword-schema presets over Table. FieldTable: Name (code) / Type / Required (✓ or canonical em-dash —) / Description. ErrorTable: Scenario / Status / Type (code) / Param (code); the Param column is auto-hidden when no row names a param. Descriptions honour Table's cell convention, so `[:md, …]` renders inline Markdown today. ## Test Coverage - spec/docs_ui/endpoint_spec.rb — badge class per verb, path monospace, upcasing, unknown verb → neutral (no raise), inline rendering, HTML escaping. - spec/docs_ui/field_table_spec.rb — name code-styled, required tick vs canonical em-dash, [:md, …] description, composition over Table, escaping. - spec/docs_ui/error_table_spec.rb — type code-styled, Param column present iff any row has a param (td-count asserted), composition, escaping. - spec/docs_ui/section_spec.rb — a Phlex component instance description renders in place; existing String/proc specs untouched (backwards compatible). ## Verification - [x] bundle exec rspec — 187 examples, 0 failures (92% line coverage) - [x] bundle exec rubocop — no offenses - [x] bun run build:css — badge-success/primary/warning/error/neutral all present in the compiled CSS (the literal frozen-Hash classes were scanned) Closes #14 --- README.md | 2 + app/components/docs_ui/endpoint.rb | 44 +++++++++++ app/components/docs_ui/error_table.rb | 55 +++++++++++++ app/components/docs_ui/field_table.rb | 46 +++++++++++ app/components/docs_ui/section.rb | 19 +++-- docs/app/views/docs/pages/components.rb | 82 +++++++++++++++++++ spec/docs_ui/endpoint_spec.rb | 81 +++++++++++++++++++ spec/docs_ui/error_table_spec.rb | 100 ++++++++++++++++++++++++ spec/docs_ui/field_table_spec.rb | 90 +++++++++++++++++++++ spec/docs_ui/section_spec.rb | 12 +++ 10 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 app/components/docs_ui/endpoint.rb create mode 100644 app/components/docs_ui/error_table.rb create mode 100644 app/components/docs_ui/field_table.rb create mode 100644 spec/docs_ui/endpoint_spec.rb create mode 100644 spec/docs_ui/error_table_spec.rb create mode 100644 spec/docs_ui/field_table_spec.rb diff --git a/README.md b/README.md index 01a8115..d3e354f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ A `DocsUI::` Phlex kit, configured once per site: | `DocsUI::Header` / `Section` / `Prose` / `Callout` | The page-authoring kit. | | `DocsUI::Markdown` | GFM Markdown island — prose as Markdown, styled like `Prose`, fenced code through Rouge. | | `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::Example` | Base for a live example with `method_source`-extracted source. | Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem` diff --git a/app/components/docs_ui/endpoint.rb b/app/components/docs_ui/endpoint.rb new file mode 100644 index 0000000..ee8d9d9 --- /dev/null +++ b/app/components/docs_ui/endpoint.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module DocsUI + # An HTTP endpoint reference line — a method badge followed by the path — in the + # kit's daisyUI look. This is the `code(class: "badge …")` lambda every API page + # was hand-rolling; compose it instead. + # + # render DocsUI::Endpoint.new(:post, "/v1/messages") + # # => POST /v1/messages (POST as a primary badge, path monospace) + # + # It renders INLINE (no block wrapper), so it drops straight into a Section + # description or a run of prose: + # + # DocsUI::Section("Create a message", description: DocsUI::Endpoint.new(:post, "/v1/messages")) + # + # The verb → badge-colour map is an explicit frozen Hash of LITERAL class + # strings so the Tailwind scan (which reads the gem's Ruby) sees every badge + # class and generates it. An unknown verb falls back to a neutral badge and + # never raises — a typo degrades gracefully rather than blowing up a render. + class Endpoint < Phlex::HTML + # Each value is a single literal string (not interpolated) so Tailwind's + # source scan generates the colour. Keep these literal — see Critical Rule 6. + BADGE_CLASSES = { + "GET" => "badge badge-sm badge-success", + "POST" => "badge badge-sm badge-primary", + "PUT" => "badge badge-sm badge-warning", + "PATCH" => "badge badge-sm badge-warning", + "DELETE" => "badge badge-sm badge-error" + }.freeze + + NEUTRAL_BADGE = "badge badge-sm badge-neutral" + + def initialize(method, path) + @method = method.to_s.upcase + @path = path + end + + def view_template + code(class: BADGE_CLASSES.fetch(@method, NEUTRAL_BADGE)) { plain @method } + whitespace + code { plain @path } + end + end +end diff --git a/app/components/docs_ui/error_table.rb b/app/components/docs_ui/error_table.rb new file mode 100644 index 0000000..36f3032 --- /dev/null +++ b/app/components/docs_ui/error_table.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module DocsUI + # An error reference table for an API endpoint — a keyword-schema preset over + # DocsUI::Table. Each error is a Hash: + # + # render DocsUI::ErrorTable.new( + # [ + # { scenario: "Missing or invalid API key", status: "401", type: "authentication_error" }, + # { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" }, + # ] + # ) + # + # Columns: Scenario / Status / Type (auto code-styled) / Param (auto code-styled). + # The Param column is shown only when at least one error names a param — an + # endpoint whose errors are all param-free renders a clean three-column table. + # When the column IS shown, a param-free row gets the canonical em-dash `—`. + class ErrorTable < Phlex::HTML + BASE_HEADERS = %w[Scenario Status Type].freeze + PARAM_HEADER = "Param" + + # Shared with FieldTable's canonical "no value" placeholder. + NO_PARAM = "—" + + def initialize(errors) + @errors = errors + @with_param = errors.any? { |error| error[:param] } + end + + def view_template + render DocsUI::Table.new(headers, @errors.map { |error| row(error) }) + end + + private + + def headers + @with_param ? [*BASE_HEADERS, PARAM_HEADER] : BASE_HEADERS + end + + def row(error) + cells = [ + error.fetch(:scenario), + error.fetch(:status), + [:code, error.fetch(:type)] + ] + cells << param_cell(error) if @with_param + cells + end + + def param_cell(error) + param = error[:param] + param ? [:code, param] : NO_PARAM + end + end +end diff --git a/app/components/docs_ui/field_table.rb b/app/components/docs_ui/field_table.rb new file mode 100644 index 0000000..4476344 --- /dev/null +++ b/app/components/docs_ui/field_table.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module DocsUI + # A parameter/field reference table for an API object or request body — a + # keyword-schema preset over DocsUI::Table. Each field is a Hash: + # + # render DocsUI::FieldTable.new( + # [ + # { name: "url", type: "string", required: true, description: "HTTPS destination URL." }, + # { name: "description", type: "string", description: "Optional internal label." }, + # { name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`"] }, + # ] + # ) + # + # Columns: Name (auto code-styled) / Type / Required (✓ or the canonical em-dash + # `—`) / Description. `required:` defaults to false. The description cell follows + # DocsUI::Table's convention — a plain String is escaped text, `[:code, "x"]` is + # inline code, `[:md, "…"]` is inline Markdown. + class FieldTable < Phlex::HTML + HEADERS = %w[Name Type Required Description].freeze + + # The ONE canonical "no value" placeholder across the whole kit — never the + # ASCII hyphen "-", never a bare "—" typed ad hoc in a page. + REQUIRED_YES = "✓" + REQUIRED_NO = "—" + + def initialize(fields) + @fields = fields + end + + def view_template + render DocsUI::Table.new(HEADERS, @fields.map { |field| row(field) }) + end + + private + + def row(field) + [ + [:code, field.fetch(:name)], + field.fetch(:type), + field.fetch(:required, false) ? REQUIRED_YES : REQUIRED_NO, + field.fetch(:description) + ] + end + end +end diff --git a/app/components/docs_ui/section.rb b/app/components/docs_ui/section.rb index b3bf70b..0110bd4 100644 --- a/app/components/docs_ui/section.rb +++ b/app/components/docs_ui/section.rb @@ -18,6 +18,10 @@ module DocsUI # code(class: "badge badge-sm") { "POST" }; plain " /v1/messages" # }) { render DocsUI::Prose.new { … } } # + # # or pass a renderable Phlex component directly (e.g. DocsUI::Endpoint) + # render DocsUI::Section.new("Create a message", + # description: DocsUI::Endpoint.new(:post, "/v1/messages")) { … } + # # The description is rendered only when present, so plain sections are unchanged. class Section < Phlex::HTML def initialize(title, id: nil, description: nil) @@ -45,16 +49,19 @@ def heading end end - # The optional description: a String is rendered as text; a callable (proc/ - # lambda) is instance_exec'd so it can emit rich Phlex markup (code, badges). + # The optional description, rendered under the title. Three accepted forms: + # * a Phlex component instance (e.g. DocsUI::Endpoint) → rendered in place; + # * a proc/lambda → instance_exec'd so it can emit rich Phlex markup; + # * a String → plain, Phlex-escaped text. + # A Phlex component also responds to #call, so it MUST be matched before the + # callable branch (else it would be instance_exec'd, not rendered). def description return unless @description p(class: "mb-4 text-base leading-relaxed text-base-content/70") do - if @description.respond_to?(:call) - instance_exec(&@description) - else - plain @description + case @description + when Phlex::SGML then render @description + else @description.respond_to?(:call) ? instance_exec(&@description) : plain(@description) end end end diff --git a/docs/app/views/docs/pages/components.rb b/docs/app/views/docs/pages/components.rb index 4105845..cff7746 100644 --- a/docs/app/views/docs/pages/components.rb +++ b/docs/app/views/docs/pages/components.rb @@ -21,6 +21,7 @@ def content code_section example_section table_section + endpoint_section callout_section icon_section on_this_page_section @@ -322,6 +323,87 @@ def table_section end end + def endpoint_section + DocsUI::Section( + "Endpoint, FieldTable & ErrorTable", + description: "The API-reference kit — a method+path line, a fields table, and an error table." + ) do + prose do + p do + code { "DocsUI::Endpoint" } + plain " renders an HTTP method badge (coloured per verb) plus a monospace path, inline — so it drops straight into a " + code { "Section" } + plain " description. " + code { "FieldTable" } + plain " and " + code { "ErrorTable" } + plain " are keyword-schema presets over " + code { "Table" } + plain " for an object's fields and an endpoint's errors." + end + end + + # A Section whose description IS a live Endpoint — the real component, + # not a mock-up. + DocsUI::Section( + "Create a webhook endpoint", + description: DocsUI::Endpoint.new(:post, "/api/webhook_endpoints") + ) do + prose { p { "Registers a destination URL for outbound event notifications." } } + render DocsUI::FieldTable.new( + [ + { name: "url", type: "string", required: true, description: "HTTPS destination URL." }, + { name: "description", type: "string", description: "Optional internal label." }, + { name: "events", type: "array", required: true, description: [ :md, "Event types, e.g. `payment_link.paid`." ] } + ] + ) + render DocsUI::ErrorTable.new( + [ + { scenario: "Missing or invalid API key", status: "401", type: "authentication_error" }, + { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" }, + { scenario: "Unknown event name", status: "422", type: "validation_error", param: "events" } + ] + ) + end + + prose { p { "The calls that produced the block above:" } } + DocsUI::Code(<<~RUBY) + DocsUI::Section("Create a webhook endpoint", + description: DocsUI::Endpoint.new(:post, "/api/webhook_endpoints")) do + render DocsUI::FieldTable.new([ + { name: "url", type: "string", required: true, description: "HTTPS destination URL." }, + { name: "events", type: "array", required: true, description: [:md, "e.g. `payment_link.paid`."] } + ]) + render DocsUI::ErrorTable.new([ + { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" } + ]) + end + RUBY + + DocsUI::Callout(:tip) do + plain "Verb → colour is a frozen Hash of literal badge classes (" + code { "GET" } + plain " → success, " + code { "POST" } + plain " → primary, " + code { "PATCH/PUT" } + plain " → warning, " + code { "DELETE" } + plain " → error). An unknown verb renders a neutral badge — no raise." + end + + render DocsUI::PropTable.new( + [ + [ "DocsUI::Endpoint.new(method, path)", "Symbol/String, String", "—", "Method badge + monospace path; renders inline." ], + [ "DocsUI::FieldTable.new(fields)", "Array", "—", "Each: { name:, type:, required: false, description: }." ], + [ "DocsUI::ErrorTable.new(errors)", "Array", "—", "Each: { scenario:, status:, type:, param: nil }; Param column auto-hidden." ], + [ "Section(description:)", "String, proc, or component", "nil", "Now also accepts a Phlex component instance." ] + ], + 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." } diff --git a/spec/docs_ui/endpoint_spec.rb b/spec/docs_ui/endpoint_spec.rb new file mode 100644 index 0000000..977b1c8 --- /dev/null +++ b/spec/docs_ui/endpoint_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::Endpoint do + def render_endpoint(...) + described_class.new(...).call + end + + it "renders the HTTP method as a daisyUI badge" do + html = render_endpoint(:post, "/v1/messages") + + expect(html).to include("badge") + expect(html).to include("badge-sm") + expect(html).to include("POST") + end + + it "renders the path in a monospace " do + html = render_endpoint(:post, "/v1/messages") + + expect(html).to include("GET<") + end + + it "maps GET to badge-success" do + expect(render_endpoint(:get, "/x")).to include("badge-success") + end + + it "maps POST to badge-primary" do + expect(render_endpoint(:post, "/x")).to include("badge-primary") + end + + it "maps PATCH to badge-warning" do + expect(render_endpoint(:patch, "/x")).to include("badge-warning") + end + + it "maps PUT to badge-warning" do + expect(render_endpoint(:put, "/x")).to include("badge-warning") + end + + it "maps DELETE to badge-error" do + expect(render_endpoint(:delete, "/x")).to include("badge-error") + end + + it "accepts a String method (not only a Symbol)" do + html = render_endpoint("POST", "/x") + + expect(html).to include("badge-primary") + expect(html).to include(">POST<") + end + + it "falls back to a neutral badge for an unknown verb, without raising" do + html = nil + expect { html = render_endpoint(:trace, "/x") }.not_to raise_error + + expect(html).to include("badge-neutral") + expect(html).to include(">TRACE<") + # No colored verb class leaks in for an unknown method. + expect(html).not_to include("badge-success") + expect(html).not_to include("badge-primary") + end + + it "renders inline (no block wrapper) so it composes in a Section description" do + html = render_endpoint(:get, "/x") + + # The badge sits directly next to the path — no surrounding
/

block. + expect(html).not_to include("") + end + + it "escapes HTML in the path (Phlex escaping, no html_safe)" do + html = render_endpoint(:get, "/x?