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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ A `DocsUI::` Phlex kit, configured once per site:
| `DocsUI::Code` | Rouge-highlighted code block (any of Rouge's ~200 languages) with an inline theme. |
| `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::Example` | Base for a live example with `method_source`-extracted source. |

Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
Expand Down Expand Up @@ -100,6 +101,54 @@ class Views::Docs::Pages::Installation < DocsUI::Page
end
```

## Authoring with Markdown

Prose is the most-written content type — and the noisiest to hand-build from
`p`/`code`/`plain` calls. `DocsUI::Page` gives you `md(source)`: write a block of
GitHub-Flavored Markdown and it renders styled identically to `DocsUI::Prose`,
with fenced code routed through `DocsUI::Code` (Rouge).

```ruby
def content
DocsUI::Section("Configure") do
md <<~'MD'
Set `brand` and `themes` in the initializer. Everything that differs
between two sites is **configuration**, not markup:

- `brand` — the topbar/sidebar heading,
- `themes` — the ThemeSwitcher options.

```ruby
DocsKit.configure { |c| c.brand = "My Docs" }
```

| Option | Type |
|--------|--------|
| brand | String |
| themes | Array |
MD
end
end
```

That renders paragraphs, **bold**/*italic*, inline `code`, links, bullet/ordered
lists, block quotes, GFM tables (with the kit's table classes), and
strikethrough. A fenced ` ```ruby ` block is highlighted by Rouge exactly like a
hand-written `DocsUI::Code`; an unknown fence language falls back to plaintext.

Two things to know:

- **`md` is a lowercase method, so `md <<~MD … MD` needs no parens** — unlike
`DocsUI::Prose()` / `DocsUI::Example()`, which take a block and so require the
empty-parens form.
- **Use a single-quoted heredoc, `<<~'MD'`.** Then `#{…}` in your prose is
literal text (Phlex escapes author text — no `html_safe`, no interpolation).

Markdown headings render as styled `h3`/`h4`. Document **structure and the "On
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 `<script>`, no passthrough).

## Scaffold a new docs site in one command

```bash
Expand Down
165 changes: 165 additions & 0 deletions app/components/docs_ui/markdown.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# frozen_string_literal: true

require "commonmarker"

module DocsUI
# A Markdown "island" for prose authoring inside a Phlex page. Parses GFM with
# commonmarker (v2, comrak) and walks the AST emitting Phlex nodes — it never
# `raw`s commonmarker's HTML. That buys three things:
#
# * Phlex-native escaping — author text is escaped by Phlex, so no html_safe
# on free text (Critical Rule 7) and #{} in prose renders literally.
# * Fenced code delegated to DocsUI::Code (Rouge, configured aliases,
# plaintext fallback) — highlighted identically to a hand-written Code block.
# * The exact DocsUI::Prose typography classes on the wrapper, so Markdown
# prose is visually identical to hand-authored Prose.
#
# render DocsUI::Markdown.new(<<~MD)
# Write **prose** as GFM. Fenced blocks are highlighted:
#
# ```ruby
# puts "hi"
# ```
# MD
#
# Raw HTML in the source is dropped (the AST's html_block/html_inline nodes are
# skipped) — there is no config to enable it. Headings render as styled h3/h4;
# document structure and the TOC stay with DocsUI::Section.
class Markdown < Phlex::HTML
# Reuse Prose's child-selector typography vocabulary verbatim so Markdown and
# hand-authored Prose read identically.
CLASSES = Prose::CLASSES

# The kit table wrapper + daisyUI table classes (matches DocsUI's table look).
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)
# 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)
end

def view_template
div(class: CLASSES) { visit(document) }
end

private

def document
Commonmarker.parse(@source)
end

# Emit each child of a node in order.
def visit_children(node)
node.each { |child| visit(child) }
end

# Dispatch a node to its handler (node_<type>). A node type with no handler
# just recurses into its children; html_block/html_inline have handlers that
# drop them (see below).
def visit(node)
handler = "node_#{node.type}"
respond_to?(handler, true) ? send(handler, node) : visit_children(node)
end

def node_document(node) = visit_children(node)

def node_paragraph(node)
p { visit_children(node) }
end

# Demote so Markdown headings never collide with the page masthead/section
# headings: h1→h3, h2→h4, anything deeper caps at h4.
def node_heading(node)
case node.header_level
when 1 then h3 { visit_children(node) }
else h4 { visit_children(node) }
end
end

def node_text(node) = plain(node.string_content)

def node_strong(node)
strong { visit_children(node) }
end

def node_emph(node)
em { visit_children(node) }
end

def node_strikethrough(node)
del { visit_children(node) }
end

def node_code(node)
code { node.string_content }
end

# Fenced code goes through DocsUI::Code so it is highlighted (Rouge) exactly
# like a hand-written block. No fence language falls back to plaintext.
def node_code_block(node)
lexer = node.fence_info.to_s.strip
lexer = "plaintext" if lexer.empty?
render DocsUI::Code.new(node.string_content, lexer:)
end

def node_link(node)
a(href: node.url) { visit_children(node) }
end

def node_list(node)
node.list_type == :ordered ? ol { visit_children(node) } : ul { visit_children(node) }
end

# In a tight list, GFM renders items WITHOUT a <p> wrapper (and Prose styles
# `li` directly). Unwrap the item's paragraph children when the parent list is
# tight; a loose list keeps the paragraphs (spacing between items).
def node_item(node)
tight = node.parent&.list_tight
li do
node.each do |child|
tight && child.type == :paragraph ? visit_children(child) : visit(child)
end
end
end

def node_block_quote(node)
blockquote { visit_children(node) }
end

def node_thematic_break(_node) = hr

def node_softbreak(_node) = whitespace

def node_linebreak(_node) = br

# A GFM table: the first row is the header (th), the rest are body cells (td).
def node_table(node)
rows = node.to_a
div(class: TABLE_WRAPPER) do
table(class: TABLE_CLASSES) do
thead { table_row(rows.first, header: true) } if rows.any?
tbody { rows.drop(1).each { |row| table_row(row) } } if rows.length > 1
end
end
end

def table_row(row, header: false)
tr do
row.each do |cell|
if header
th { visit_children(cell) }
else
td { visit_children(cell) }
end
end
end
end

# Raw HTML is dropped — no live tags from author Markdown.
def node_html_block(_node) = nil
def node_html_inline(_node) = nil
end
end
9 changes: 9 additions & 0 deletions app/components/docs_ui/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ def view_template
end
end

# Render a block of GFM Markdown as Prose-styled prose (see DocsUI::Markdown).
# A lowercase method + heredoc sidesteps the parens-with-blocks gotcha:
# md <<~'MD'
# Write **prose** as Markdown. Single-quoted heredoc so #{} stays literal.
# MD
def md(source)
render DocsUI::Markdown.new(source)
end

# Override in subclasses for the lead paragraph (optional).
def lead = nil

Expand Down
4 changes: 4 additions & 0 deletions docs-kit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Gem::Specification.new do |s|
s.add_dependency "rails_icons", "~> 1.1"
# Syntax highlighting for Docs::Code.
s.add_dependency "rouge", ">= 4.0"
# GFM parsing for DocsUI::Markdown (v2 = Rust/comrak, precompiled; GFM tables +
# strikethrough + autolink on by default). We walk its AST to Phlex nodes, so
# commonmarker never renders HTML we'd have to html_safe.
s.add_dependency "commonmarker", "~> 2.0"
s.add_dependency "zeitwerk", "~> 2.6"

# phlex-reactive (reactive demos) and pgbus (Postgres-SSE transport) are
Expand Down
62 changes: 25 additions & 37 deletions docs/app/views/docs/pages/installation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,18 @@ def new_site_section
DocsUI::Code(<<~SHELL, lexer: :shell)
docs-kit new my-docs --image OWNER/REPO --service my-repo
SHELL
DocsUI::Prose() do
p do
plain "This runs "
code { "rails new" }
plain " (propshaft + importmap + turbo/stimulus, no database) and applies the docs-kit template, which:"
end
ul do
li { "adds the gem and its dependencies," }
li { "runs the install generator," }
li { "syncs the lucide icons," }
li { "builds the Tailwind CSS, and" }
li { "scaffolds the Kamal deploy." }
end
p { "Then boot it:" }
end
md <<~'MD'
This runs `rails new` (propshaft + importmap + turbo/stimulus, no
database) and applies the docs-kit template, which:

- adds the gem and its dependencies,
- runs the install generator,
- syncs the lucide icons,
- builds the Tailwind CSS, and
- scaffolds the Kamal deploy.

Then boot it:
MD
DocsUI::Code(<<~SHELL, lexer: :shell)
cd my-docs && bin/dev
SHELL
Expand All @@ -58,50 +55,39 @@ def existing_app_section
end

def existing_app_gemfile
DocsUI::Prose() { p { strong { "1. Add the gems." } } }
md "**1. Add the gems.**"
DocsUI::Code(<<~RUBY, filename: "Gemfile")
gem "docs-kit"
gem "daisyui", require: "daisy_ui"
gem "phlex-rails"
gem "rails_icons", "~> 1.1"
gem "rouge"
RUBY
DocsUI::Prose() do
p do
plain "Then run "
code { "bundle install" }
plain "."
end
end
md "Then run `bundle install`."
end

def existing_app_generator
DocsUI::Prose() { p { strong { "2. Run the install generator." } } }
md "**2. Run the install generator.**"
DocsUI::Code(<<~SHELL, lexer: :shell)
rails g docs_kit:install
SHELL
DocsUI::Prose() do
p do
plain "It creates the initializers (phlex, rails_icons, docs_kit), includes "
code { "DocsKit::Controller" }
plain ", a "
code { "Doc" }
plain " registry with a sample page, the Bun/Tailwind CSS build ("
code { "bin/build-css" }
plain "), and registers the Stimulus controller. It is idempotent — safe to re-run."
end
end
md <<~'MD'
It creates the initializers (phlex, rails_icons, docs_kit), includes
`DocsKit::Controller`, a `Doc` registry with a sample page, the
Bun/Tailwind CSS build (`bin/build-css`), and registers the Stimulus
controller. It is idempotent — safe to re-run.
MD
end

def existing_app_icons
DocsUI::Prose() { p { strong { "3. Sync the icons." } } }
md "**3. Sync the icons.**"
DocsUI::Code(<<~SHELL, lexer: :shell)
rails g rails_icons:sync --library=lucide
SHELL
end

def existing_app_css
DocsUI::Prose() { p { strong { "4. Build the CSS." } } }
md "**4. Build the CSS.**"
DocsUI::Code(<<~SHELL, lexer: :shell)
bun install && bun run build:css
SHELL
Expand All @@ -121,6 +107,8 @@ def requirements_section
end
end

# Kept as a Prose() block (not md) on purpose: this page mixes both
# authoring styles to prove Prose stays fully supported alongside md.
def verify_section
DocsUI::Section("Verify") do
DocsUI::Prose() do
Expand Down
Loading
Loading