From 987f9eeac1d6cf520ec67fae6d619cfd8557500d Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 09:43:28 +0200 Subject: [PATCH] feat(page): ship DocsUI::Table + DocsUI::PropTable, retire the page-local one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Upstreams the dogfooded reference-table design into the kit so every docs site stops hand-rolling `table`/`tr`/`td` markup: - `DocsUI::Table.new(headers, rows)` — generic headers + rows in the kit's daisyUI look (`table table-sm table-zebra` in a `rounded-box` border, `not-prose`). Cell values: `String` (plain, escaped), `[:code, "x"]` (inline code), `[:md, "…"]` (inline GFM through `DocsUI::Markdown`). - `DocsUI::PropTable.new(rows, headers:)` — a thin preset built ON `Table` (composition, no markup duplication): name/type/default/description, first column auto code-styled, `Option/Type/Default/Description` headers by default, overridable via `headers:`. - `DocsUI::Markdown.inline` — a no-wrapper, no-`

` render for `[:md, …]` cells; adjacent top-level paragraphs are space-joined so unwrapping never fuses text. Deletes the page-local `Views::Docs::Pages::PropTable` ("Not part of the DocsUI kit") and swaps all 18 call sites across 7 docs pages to `DocsUI::PropTable`. Documents both components on the components reference page (with a live demo of all three cell types) and the README component table. ## Test Coverage - spec/docs_ui/table_spec.rb — headers→th, rows→td, [:code]→, [:md]→inline markdown, empty rows → headers only, HTML in a string cell / header escaped. - spec/docs_ui/prop_table_spec.rb — first column code-styled, default + custom headers, [:code]/[:md] honored in other columns, reuses Table's wrapper. - spec/docs_ui/markdown_spec.rb — .inline: no

/Prose wrapper, soft break → one space, multiple paragraphs separated (regression: no "onepara" fusion). ## Verification - [x] bundle exec rspec — 121 examples, 0 failures - [x] bundle exec rubocop — 48 files, no offenses - [x] cd docs && bun run build:css — clean; table-zebra/rounded-box/not-prose present - [x] Rendered /docs/configuration + all 7 swapped pages — HTTP 200, tables intact - [x] No page-local PropTable references remain Closes #11 --- README.md | 1 + app/components/docs_ui/markdown.rb | 24 +++- app/components/docs_ui/prop_table.rb | 43 +++++++ app/components/docs_ui/table.rb | 61 ++++++++++ docs/app/views/docs/pages/authoring.rb | 6 +- docs/app/views/docs/pages/components.rb | 130 +++++++++++++++------ docs/app/views/docs/pages/configuration.rb | 3 +- docs/app/views/docs/pages/deploy.rb | 6 +- docs/app/views/docs/pages/installation.rb | 6 +- docs/app/views/docs/pages/languages.rb | 6 +- docs/app/views/docs/pages/on_this_page.rb | 6 +- docs/app/views/docs/pages/prop_table.rb | 59 ---------- spec/docs_ui/markdown_spec.rb | 36 ++++++ spec/docs_ui/prop_table_spec.rb | 59 ++++++++++ spec/docs_ui/table_spec.rb | 73 ++++++++++++ 15 files changed, 407 insertions(+), 112 deletions(-) create mode 100644 app/components/docs_ui/prop_table.rb create mode 100644 app/components/docs_ui/table.rb delete mode 100644 docs/app/views/docs/pages/prop_table.rb create mode 100644 spec/docs_ui/prop_table_spec.rb create mode 100644 spec/docs_ui/table_spec.rb diff --git a/README.md b/README.md index aa8d170..5e2615f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A `DocsUI::` Phlex kit, configured once per site: | `DocsUI::Page` | Base class for a hand-authored doc page; renders inside `DocsUI::Shell`. | | `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::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/markdown.rb b/app/components/docs_ui/markdown.rb index 793711d..0db17b3 100644 --- a/app/components/docs_ui/markdown.rb +++ b/app/components/docs_ui/markdown.rb @@ -34,14 +34,24 @@ class Markdown < Phlex::HTML TABLE_WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300" TABLE_CLASSES = "table table-sm table-zebra" - def initialize(source) + # Render source as INLINE markdown: no Prose wrapper div, and a single + # top-level paragraph is unwrapped so its inline children (strong/em/code/ + # link) sit directly in the surrounding element. Used for a [:md, "…"] table + # cell — the cell's is the container, so a block

/typography div would + # be wrong there. + def self.inline(source) = new(source, inline: true) + + def initialize(source, inline: false) # commonmarker v2 raises unless the text is UTF-8. Author heredocs already # are, but nil.to_s / a US-ASCII string would crash the render — normalize # at the boundary so any input parses. @source = source.to_s.encode(Encoding::UTF_8) + @inline = inline end def view_template + return visit_inline(document) if @inline + div(class: CLASSES) { visit(document) } end @@ -51,6 +61,18 @@ def document Commonmarker.parse(@source) end + # Inline render: unwrap each top-level paragraph to its inline children (no + #

), so `[:md, "a **note**"]` becomes `a note` inside the + # cell rather than a block paragraph. Non-paragraph blocks (a stray list) still + # render as themselves. Adjacent blocks get a joining space so unwrapping never + # fuses text ("one" + "two" → "one two", not "onetwo"). + def visit_inline(node) + node.each_with_index do |child, i| + whitespace unless i.zero? + child.type == :paragraph ? visit_children(child) : visit(child) + end + end + # Emit each child of a node in order. def visit_children(node) node.each { |child| visit(child) } diff --git a/app/components/docs_ui/prop_table.rb b/app/components/docs_ui/prop_table.rb new file mode 100644 index 0000000..de8b3bc --- /dev/null +++ b/app/components/docs_ui/prop_table.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module DocsUI + # A props/options/params reference table: name · type · default · description, + # with the first column (the name) auto code-styled. A thin preset over + # DocsUI::Table — same cell conventions, same markup, no duplication. + # + # render DocsUI::PropTable.new( + # [ + # ["brand", "String", '"Docs"', "Topbar + sidebar heading."], + # ["themes", "Array", "%w[dark light]", "ThemeSwitcher options."], + # ] + # ) + # + # The default headers are Option/Type/Default/Description; pass `headers:` to + # override (e.g. `%w[Arg Type Default Description]` for a component's args). Cell + # values follow DocsUI::Table's convention (String / [:code, "x"] / [:md, "…"]); + # the first cell of each row is wrapped in automatically unless it's + # already a special-cell pair. + class PropTable < Phlex::HTML + DEFAULT_HEADERS = %w[Option Type Default Description].freeze + + def initialize(rows, headers: DEFAULT_HEADERS) + @rows = rows.map { |cells| code_first_column(cells) } + @headers = headers + end + + def view_template + render DocsUI::Table.new(@headers, @rows) + end + + private + + # Auto-code-style the name column. A plain String first cell becomes a + # [:code, …] cell; a cell that's already a typed pair ([:code, …]/[:md, …]) is + # left as the author wrote it. + def code_first_column(cells) + first, *rest = cells + first = [:code, first] unless first.is_a?(Array) + [first, *rest] + end + end +end diff --git a/app/components/docs_ui/table.rb b/app/components/docs_ui/table.rb new file mode 100644 index 0000000..98c08f1 --- /dev/null +++ b/app/components/docs_ui/table.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module DocsUI + # A generic reference table — headers + rows — in the kit's daisyUI look (a + # `table table-sm table-zebra` inside a `rounded-box` border, `not-prose` so the + # surrounding Prose typography doesn't restyle it). This is the piece every docs + # site was hand-rolling; compose it, don't write raw `table`/`tr`/`td` markup. + # + # render DocsUI::Table.new( + # ["Option", "Type", "Default", "Description"], + # [ + # ["brand", "String", '"Docs"', "Topbar + sidebar heading."], + # ["themes", [:code, "%w[dark light]"], "—", "ThemeSwitcher options."], + # ] + # ) + # + # Cell values (the same convention the dogfood PropTable proved): + # + # * String → plain text (Phlex-escaped; HTML in it is inert, never live) + # * [:code, "x"] → inline (for a type, a default literal, an identifier) + # * [:md, "…"] → inline GFM through DocsUI::Markdown (bold/links/inline code), + # opt-in so a plain String that merely *looks* like markdown + # is never surprise-parsed. + # + # PropTable is a thin preset over this (name/type/default/description, first + # column auto-code-styled). + class Table < Phlex::HTML + WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300" + TABLE = "table table-sm table-zebra" + + def initialize(headers, rows) + @headers = headers + @rows = rows + end + + def view_template + div(class: WRAPPER) do + table(class: TABLE) do + thead do + tr { @headers.each { |header| th(class: "whitespace-nowrap") { plain header.to_s } } } + end + tbody do + @rows.each { |cells| tr { cells.each { |cell| td { render_cell(cell) } } } } + end + end + end + end + + private + + # Dispatch a cell by its shape. A [type, value] pair selects an inline + # renderer; anything else is plain, Phlex-escaped text. + def render_cell(cell) + case cell + in [:code, value] then code(class: "text-sm") { plain value.to_s } + in [:md, value] then render DocsUI::Markdown.inline(value.to_s) + else plain cell.to_s + end + end + end +end diff --git a/docs/app/views/docs/pages/authoring.rb b/docs/app/views/docs/pages/authoring.rb index 2229ed7..226404d 100644 --- a/docs/app/views/docs/pages/authoring.rb +++ b/docs/app/views/docs/pages/authoring.rb @@ -111,8 +111,7 @@ class Doc def building_blocks_section DocsUI::Section("The building blocks", description: "The DocsUI kit you compose inside #content.") do - render PropTable.new( - [ "Helper", "Use for" ], + render DocsUI::PropTable.new( [ [ "DocsUI::Section(title)", "an anchored subsection with a heading (+ optional description)" ], [ "md(source)", "a block of GFM Markdown, styled like Prose" ], @@ -120,7 +119,8 @@ def building_blocks_section [ "DocsUI::Code(source)", "a syntax-highlighted code block" ], [ "example { |ex| … }", "multi-language tabbed code" ], [ "DocsUI::Callout(level)", "note / tip / warning boxes" ] - ] + ], + headers: [ "Helper", "Use for" ] ) prose do diff --git a/docs/app/views/docs/pages/components.rb b/docs/app/views/docs/pages/components.rb index df00b50..4105845 100644 --- a/docs/app/views/docs/pages/components.rb +++ b/docs/app/views/docs/pages/components.rb @@ -20,6 +20,7 @@ def content prose_section code_section example_section + table_section callout_section icon_section on_this_page_section @@ -50,12 +51,12 @@ def shell_section # page body end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "title", "String, nil", "nil", "Document + topbar title. Falls back to the site brand." ], [ "on_page", "Symbol, false", "false", "TOC placement — :panel / :toggle / :sidebar / false." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -91,15 +92,15 @@ def lead = "One-sentence summary." def content = DocsUI::Section("Hello") { prose { p { "..." } } } end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "title", "String (class DSL)", "—", "Sets the document + masthead title." ], [ "eyebrow", "String (class DSL)", "nil", "Small kicker above the h1 (e.g. the group)." ], [ "on_page", "Symbol (class DSL)", "config default", "TOC placement — :panel / :toggle / :sidebar / false." ], [ "#lead", "instance method", "nil", "Muted summary paragraph under the h1." ], [ "#content", "instance method", "—", "The page body — call kit components here." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -126,13 +127,13 @@ def header_section plain "An optional lead paragraph." end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "title", "String (positional)", "—", "The h1 text. Legacy title: kwarg still accepted." ], [ "eyebrow", "String, nil", "nil", "Small kicker above the h1." ], [ "block", "Phlex block", "nil", "Optional lead paragraph rendered under the h1." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -163,13 +164,13 @@ def section_section prose { p { "Section body." } } end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "title", "String", "—", "The h2 text; auto-slugs into the anchor id." ], [ "id", "String, nil", "slug of title", "Override the section anchor." ], [ "description", "String, callable, nil", "nil", "Muted lead paragraph under the h2." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -204,11 +205,11 @@ def prose_section code { "()" } plain "." end - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "block", "Phlex block", "—", "Hand-authored HTML — p, ul/li, code, strong, a, plain text." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -222,13 +223,13 @@ class User < ApplicationRecord RUBY prose { p { "The call that produced the block above:" } } DocsUI::Code(%(DocsUI::Code(source, lexer: :ruby, filename: "app/models/user.rb"))) - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "source", "String", "—", "The code to highlight." ], [ "lexer", "Symbol", ":ruby", "Any Rouge language — :shell, :yaml, :erb, :python, :go, etc." ], [ "filename", "String, nil", "nil", "Optional filename bar above the block." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -250,13 +251,72 @@ def example_section ex.code(:python, filename: "client.py") { python_source } end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "block", "Phlex block", "—", "Yields an object with #code — one call per language." ], [ "ex.code lang", "Symbol", "—", "The Rouge language for this tab." ], [ "ex.code filename:", "String, nil", "nil", "Optional filename bar for this tab." ], [ "ex.code lexer:", "Symbol", "lang", "Override the Rouge lexer if it differs from the tab label." ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] + ) + end + end + + def table_section + DocsUI::Section("Table & PropTable", description: "Reference tables — generic headers+rows, and a name/type/default/description preset.") do + prose do + p do + code { "DocsUI::Table" } + plain " renders headers + rows in the kit's daisyUI look. A cell is a " + code { "String" } + plain " (plain, escaped), a " + code { "[:code, \"x\"]" } + plain " pair (inline " + code { "" } + plain "), or a " + code { "[:md, \"…\"]" } + plain " pair (inline Markdown). " + code { "DocsUI::PropTable" } + plain " is the preset every args table on this page uses — the same shape, first column auto code-styled, default " + code { "Option/Type/Default/Description" } + plain " headers." + end + end + DocsUI::Table( + [ "Cell", "Renders as" ], + [ + [ "brand", "plain, escaped text" ], + [ [ :code, "%w[dark light]" ], "inline code" ], + [ [ :md, "a **bold** note" ], "inline markdown" ] + ] + ) + prose { p { "The call that produced the table above:" } } + DocsUI::Code(<<~RUBY) + DocsUI::Table( + [ "Cell", "Renders as" ], + [ + [ "brand", "plain, escaped text" ], + [ [ :code, "%w[dark light]" ], "inline code" ], + [ [ :md, "a **bold** note" ], "inline markdown" ] + ] + ) + RUBY + DocsUI::Callout(:tip) do + plain "Every args table on this page is a " + code { "DocsUI::PropTable" } + plain " — pass just the rows; the headers default to " + code { "Option/Type/Default/Description" } + plain " (override with " + code { "headers:" } + plain ")." + end + render DocsUI::PropTable.new( + [ + [ "DocsUI::Table headers", "Array", "—", "Header labels — one per column." ], + [ "DocsUI::Table rows", "Array", "—", "Rows; each a cell array (String / [:code, x] / [:md, …])." ], + [ "DocsUI::PropTable rows", "Array", "—", "Rows; the first cell is auto-wrapped in ." ], + [ "DocsUI::PropTable headers:", "Array", "Option/Type/Default/Description", "Override the header labels." ] ] ) end @@ -273,13 +333,13 @@ def callout_section DocsUI::Callout(:tip) { "A tip callout." } DocsUI::Callout(:warning) { "A warning callout." } RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "level", "Symbol", ":note", "Alert style — :note / :tip / :warning." ], [ "title", "String, nil", "nil", "Optional heading above the body." ], [ "block", "Phlex block", "—", "The callout body." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -302,12 +362,12 @@ def icon_section code { "rails_icons" } plain " isn't configured — nothing renders, no error." end - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "name", "String", "—", "The lucide icon name, e.g. \"rocket\"." ], [ "**attributes", "Hash", "{}", "Extra HTML attributes (class:, etc.) passed to the icon." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -330,12 +390,12 @@ class Views::Docs::Pages::Api < DocsUI::Page on_page :toggle # :panel | :toggle | :sidebar | false end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "mode", "Symbol", ":panel", "Placement — :panel (aside) / :toggle (button) / :sidebar." ], [ "title", "String", '"On this page"', "The TOC heading." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -358,11 +418,11 @@ def sidebar_section c.nav = -> { { "Docs" => Doc.grouped } } end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "(none)", "—", "—", "No args — reads DocsKit.configuration.nav." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end @@ -387,11 +447,11 @@ def theme_switcher_section c.themes = %w[dark light synthwave dracula night] end RUBY - render PropTable.new( - [ "Arg", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "(none)", "—", "—", "No args — reads DocsKit.configuration.themes." ] - ] + ], + headers: [ "Arg", "Type", "Default", "Description" ] ) end end diff --git a/docs/app/views/docs/pages/configuration.rb b/docs/app/views/docs/pages/configuration.rb index 904bc0b..205ba2b 100644 --- a/docs/app/views/docs/pages/configuration.rb +++ b/docs/app/views/docs/pages/configuration.rb @@ -63,8 +63,7 @@ def configure_section def all_options_section DocsUI::Section("All options", description: "Every setting on the config object, with its default.") do - render PropTable.new( - [ "Option", "Type", "Default", "Description" ], + render DocsUI::PropTable.new( [ [ "brand", "String", '"Docs"', "Topbar + sidebar heading." ], [ "title_suffix", "String", "= brand", %(Appended to ("Installation · brand").) ], diff --git a/docs/app/views/docs/pages/deploy.rb b/docs/app/views/docs/pages/deploy.rb index 4f5ea7a..e757c2b 100644 --- a/docs/app/views/docs/pages/deploy.rb +++ b/docs/app/views/docs/pages/deploy.rb @@ -84,13 +84,13 @@ def content end DocsUI::Section("Secrets") do - render PropTable.new( - [ "Secret", "Purpose" ], + render DocsUI::PropTable.new( [ [ "SSH_PRIVATE_KEY", "Deploy key for the Kamal SSH user." ], [ "DEPLOY_HOST", "The deploy host (IP or DNS)." ], [ "DEPLOY_DOMAIN", "The public host kamal-proxy routes." ] - ] + ], + headers: [ "Secret", "Purpose" ] ) prose do p do diff --git a/docs/app/views/docs/pages/installation.rb b/docs/app/views/docs/pages/installation.rb index e194001..b909d39 100644 --- a/docs/app/views/docs/pages/installation.rb +++ b/docs/app/views/docs/pages/installation.rb @@ -95,14 +95,14 @@ def existing_app_css def requirements_section DocsUI::Section("Requirements") do - render PropTable.new( - [ "Requirement", "Version/Note" ], + render DocsUI::PropTable.new( [ [ "Ruby", ">= 3.2" ], [ "Rails", ">= 7.1" ], [ "Bun", "for the Tailwind CSS build" ], [ "PostgreSQL", "not required (docs sites are stateless)" ] - ] + ], + headers: [ "Requirement", "Version/Note" ] ) end end diff --git a/docs/app/views/docs/pages/languages.rb b/docs/app/views/docs/pages/languages.rb index 6359430..f3f5bc9 100644 --- a/docs/app/views/docs/pages/languages.rb +++ b/docs/app/views/docs/pages/languages.rb @@ -137,8 +137,7 @@ def config_section end end - render PropTable.new( - [ "Option", "Purpose" ], + render DocsUI::PropTable.new( [ [ "code_lexer_aliases", "Map friendly names onto real Rouge lexers, e.g. { curl: \"console\" }. Merged over the built-in aliases." ], @@ -146,7 +145,8 @@ def config_section "Lexer used when a requested name is unknown. Defaults to \"plaintext\" — no highlighting, no error." ], [ "code_language_labels", "Override the tab caption per language in DocsUI::Example, e.g. { elixir: \"Elixir\" }." ] - ] + ], + headers: [ "Option", "Purpose" ] ) DocsUI::Callout(:tip) do diff --git a/docs/app/views/docs/pages/on_this_page.rb b/docs/app/views/docs/pages/on_this_page.rb index aabe0ba..cebc5e6 100644 --- a/docs/app/views/docs/pages/on_this_page.rb +++ b/docs/app/views/docs/pages/on_this_page.rb @@ -41,14 +41,14 @@ def automatic_toc_section def placements_section DocsUI::Section("Three placements", description: "The TOC renders in one of three spots, or not at all.") do - render PropTable.new( - [ "Mode", "Placement" ], + render DocsUI::PropTable.new( [ [ ":panel", "A sticky card top-right of the content column (default)." ], [ ":toggle", "A floating button top-right that opens a dropdown." ], [ ":sidebar", "Nested under the active nav item in the left sidebar." ], [ "false", "No auto-TOC." ] - ] + ], + headers: [ "Mode", "Placement" ] ) end end diff --git a/docs/app/views/docs/pages/prop_table.rb b/docs/app/views/docs/pages/prop_table.rb deleted file mode 100644 index 9d1b49e..0000000 --- a/docs/app/views/docs/pages/prop_table.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Views - module Docs - module Pages - # A small, page-local helper for rendering an options/props reference table - # (name · type · default · description). Used by the reference pages to - # document component args and config options consistently. Not part of the - # DocsUI kit — it's specific to these docs. - # - # render PropTable.new( - # ["Option", "Type", "Default", "Description"], - # [ - # ["brand", "String", '"Docs"', "Topbar + sidebar heading."], - # ["themes", "Array", "%w[dark light]", "ThemeSwitcher options."], - # ] - # ) - class PropTable < Phlex::HTML - def initialize(headers, rows) - @headers = headers - @rows = rows - end - - def view_template - div(class: "not-prose my-4 overflow-x-auto rounded-box border border-base-300") do - table(class: "table table-sm table-zebra") do - thead do - tr do - @headers.each { |h| th(class: "whitespace-nowrap") { h } } - end - end - tbody do - @rows.each do |cells| - tr do - cells.each_with_index do |cell, i| - # First column (the name) as inline code; the rest plain. - td { i.zero? ? code(class: "text-sm") { cell.to_s } : render_cell(cell) } - end - end - end - end - end - end - end - - private - - # A cell may be a plain String, or a [:code, "x"] pair to render as code. - def render_cell(cell) - if cell.is_a?(Array) && cell.first == :code - code(class: "text-sm") { cell.last.to_s } - else - plain cell.to_s - end - end - end - end - end -end diff --git a/spec/docs_ui/markdown_spec.rb b/spec/docs_ui/markdown_spec.rb index 43084a4..8bae313 100644 --- a/spec/docs_ui/markdown_spec.rb +++ b/spec/docs_ui/markdown_spec.rb @@ -171,6 +171,42 @@ def render_md(source) expect(html).not_to include("<td") end + # DocsUI::Markdown.inline renders inline markdown for a [:md, "…"] table cell: + # no Prose wrapper div, and a single top-level paragraph is unwrapped so its + # inline children sit directly in the surrounding element. + describe ".inline" do + def render_inline(source) + described_class.inline(source).call + end + + it "emits inline children without a <p> or the Prose wrapper div" do + html = render_inline("a **bold** note") + + expect(html).to include("a <strong>bold</strong> note") + expect(html).not_to include("<p>") + expect(html).not_to include("text-base-content/80") # no Prose wrapper + end + + it "keeps a soft line break within a paragraph as a single space" do + html = render_inline("line one\nline two") + + expect(html).to include("line one line two") + end + + it "separates multiple top-level paragraphs instead of fusing their text" do + html = render_inline("para one\n\npara two") + + # Without a separator the words would glue into "onepara". + expect(html).not_to include("onepara") + expect(html).to include("para one") + expect(html).to include("para two") + end + + it "renders an empty string without raising" do + expect { render_inline("") }.not_to raise_error + end + end + # The `md(source)` helper lives in DocsUI::PageHelpers (mixed into DocsUI::Page). # Page itself needs a Rails view context (Shell composes CSRF/url helpers) and # can't load standalone, so exercise the REAL helper module through a bare Phlex diff --git a/spec/docs_ui/prop_table_spec.rb b/spec/docs_ui/prop_table_spec.rb new file mode 100644 index 0000000..9562d05 --- /dev/null +++ b/spec/docs_ui/prop_table_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::PropTable do + def render_prop_table(...) + described_class.new(...).call + end + + it "uses the default Option/Type/Default/Description headers" do + html = render_prop_table([["brand", "String", '"Docs"', "The brand."]]) + + expect(html).to include(">Option") + expect(html).to include(">Type") + expect(html).to include(">Default") + expect(html).to include(">Description") + end + + it "renders the first column as inline code, the rest plain" do + html = render_prop_table([["brand", "String", '"Docs"', "The brand."]]) + + # The name column is code-styled. + expect(html).to include("<code") + expect(html).to include(">brand</code>") + # A later column is NOT wrapped in code. + expect(html).to include(">The brand.") + expect(html).not_to include("<code class=\"text-sm\">The brand.") + end + + it "accepts custom headers, overriding the default set" do + html = render_prop_table( + [["title", "String", "—", "Doc title."]], + headers: %w[Arg Type Default Description] + ) + + expect(html).to include(">Arg") + expect(html).not_to include(">Option") + end + + it "reuses DocsUI::Table's wrapper (composition, not duplicated markup)" do + html = render_prop_table([["brand", "String", '"Docs"', "The brand."]]) + + expect(html).to include("not-prose") + expect(html).to include("rounded-box") + expect(html).to include("table table-sm table-zebra") + end + + it "still honors [:code, …] / [:md, …] cell types in non-name columns" do + html = render_prop_table([["brand", [:code, "String"], "—", [:md, "a **note**"]]]) + + expect(html).to include(">String</code>") + expect(html).to include("<strong>note</strong>") + end + + it "renders headers only for an empty rows array" do + html = render_prop_table([]) + + expect(html).to include(">Option") + expect(html).not_to include("<td") + end +end diff --git a/spec/docs_ui/table_spec.rb b/spec/docs_ui/table_spec.rb new file mode 100644 index 0000000..12951e9 --- /dev/null +++ b/spec/docs_ui/table_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::Table do + def render_table(...) + described_class.new(...).call + end + + it "renders each header as a th" do + html = render_table(%w[Name Type], []) + + expect(html).to include("<th") + expect(html).to include(">Name") + expect(html).to include(">Type") + end + + it "renders each row cell as a td, after the headers" do + html = render_table(%w[Name Type], [%w[brand String], %w[themes Array]]) + + expect(html).to include("<td") + expect(html).to include("brand") + expect(html).to include("Array") + # Headers precede body cells (thead before tbody). + expect(html.index("Name")).to be < html.index("brand") + end + + it "wraps the table in the kit's rounded-box border + daisyUI table classes" do + html = render_table(%w[A], [%w[x]]) + + expect(html).to include("not-prose") + expect(html).to include("rounded-box") + expect(html).to include("border-base-300") + expect(html).to include("table table-sm table-zebra") + end + + it "renders a [:code, \"x\"] cell as an inline <code> element" do + html = render_table(%w[Name Type], [["brand", [:code, "String"]]]) + + expect(html).to include("<code") + expect(html).to include(">String</code>") + end + + it "renders a [:md, \"…\"] cell through DocsUI::Markdown as inline content" do + html = render_table(%w[Name Note], [["brand", [:md, "the **bold** brand"]]]) + + # Markdown emphasis is rendered (not left as literal asterisks). + expect(html).to include("<strong>bold</strong>") + # …but inline — no block <p> wrapper leaking into the cell. + expect(html).not_to include("<p>") + end + + it "renders headers only when the rows array is empty" do + html = render_table(%w[Name Type], []) + + expect(html).to include("<th") + expect(html).to include(">Name") + expect(html).not_to include("<td") + end + + it "escapes HTML in a plain string cell (Phlex escaping, no html_safe)" do + html = render_table(%w[Desc], [["Appended to <title> & such"]]) + + expect(html).to include("<title>") + expect(html).to include("&") + expect(html).not_to include("<title>") + end + + it "escapes HTML in a header" do + html = render_table(["<script>"], []) + + expect(html).to include("<script>") + expect(html).not_to include("<script>") + end +end