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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
44 changes: 44 additions & 0 deletions app/components/docs_ui/endpoint.rb
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions app/components/docs_ui/error_table.rb
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions app/components/docs_ui/field_table.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 13 additions & 6 deletions app/components/docs_ui/section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions docs/app/views/docs/pages/components.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def content
code_section
example_section
table_section
endpoint_section
callout_section
icon_section
on_this_page_section
Expand Down Expand Up @@ -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<Hash>", "—", "Each: { name:, type:, required: false, description: }." ],
[ "DocsUI::ErrorTable.new(errors)", "Array<Hash>", "—", "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." }
Expand Down
81 changes: 81 additions & 0 deletions spec/docs_ui/endpoint_spec.rb
Original file line number Diff line number Diff line change
@@ -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 <code>" do
html = render_endpoint(:post, "/v1/messages")

expect(html).to include("<code")
expect(html).to include("/v1/messages")
end

it "upcases a lowercase method symbol for the badge label" do
html = render_endpoint(:get, "/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 <div>/<p> block.
expect(html).not_to include("<div")
expect(html).not_to include("<p>")
end

it "escapes HTML in the path (Phlex escaping, no html_safe)" do
html = render_endpoint(:get, "/x?<script>")

expect(html).to include("&lt;script&gt;")
expect(html).not_to include("<script>")
end
end
Loading
Loading