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
/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 ")
+ 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(" wrapper leaking into the cell.
+ expect(html).not_to include(" ")
+ end
+
+ it "renders headers only when the rows array is empty" do
+ html = render_table(%w[Name Type], [])
+
+ expect(html).to include(" 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 or the Prose wrapper div" do
+ html = render_inline("a **bold** note")
+
+ expect(html).to include("a bold note")
+ expect(html).not_to include(" brand")
+ # A later column is NOT wrapped in code.
+ expect(html).to include(">The brand.")
+ expect(html).not_to include("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")
+ expect(html).to include("note")
+ 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("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(" element" do
+ html = render_table(%w[Name Type], [["brand", [:code, "String"]]])
+
+ expect(html).to include(" String")
+ 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("bold")
+ # …but inline — no block Name")
+ expect(html).not_to include(" & such"]])
+
+ expect(html).to include("<title>")
+ expect(html).to include("&")
+ expect(html).not_to include("