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