Markdown authoring
Write prose as Markdown with the md helper — GFM in, Prose-identical typography out, and every fence highlighted by Rouge.
The md helper#
The everyday authoring entry point — a block of GFM, styled like Prose.
md is the prose path you reach for on almost every page. Hand it a heredoc of GFM Markdown and it renders a DocsUI::Markdown island — a block styled with the exact typography of a hand-authored Prose block, so md prose and prose do … end read identically.
md <<~'MD'
Write **prose** as Markdown — `inline code`, [links](/docs/overview),
lists, and tables all styled like Prose.
MDThis whole page is written with md. Under the hood the helper is render DocsUI::Markdown.new(source) — a lowercase method so the heredoc lands without the parens-with-blocks Ruby trap. It lives on DocsUI::Page (via the PageHelpers mixin) alongside prose and example; it is not a global, so a bare Phlex component would need to include DocsUI::PageHelpers to get it.
DocsUI::Section, not with an island heading.Always single-quote the heredoc#
A single-quoted heredoc passes interpolation and backslashes through as literal author text.
Use a single-quoted heredoc — <<~'MD' — for every md block. With single quotes Ruby does no interpolation, so #{...} and backslashes reach the parser as the literal characters you typed. That matters constantly in docs prose, where you write about interpolation and escapes rather than perform them.
# Good — single-quoted: the reader sees the literal text.
md <<~'MD'
Write `#{user.name}` to interpolate, and `\d+` for a digit.
MD
# Trap — double-quoted: Ruby evaluates #{user.name} before Phlex
# ever sees it, and eats the backslash in \d.
md <<~MD
Write `#{user.name}` ...
MDEven a double-quoted heredoc is still safe — Phlex escapes all author free text, so nothing injects markup (see Escaping below). But it will silently change your words: #{user.name} becomes whatever that expression evaluates to, and \d loses its backslash. Single-quoting is the intended convention precisely so the prose you wrote is the prose the reader gets.
The GFM you can write#
Headings, lists, tables, fenced code, links, blockquotes — parsed by commonmarker.
Islands parse GitHub-Flavored Markdown with commonmarker (v2 / comrak). The full everyday vocabulary is here:
Headings, emphasis, lists
A # heading renders as an <h3>; ## and anything deeper collapse to <h4> — demoted so an island heading never collides with the page masthead or a DocsUI::Section heading. Hierarchy inside an island is intentionally flat.
Inline you get strong, emphasis, strikethrough, and inline code. Lists come tight or loose, bullet or ordered, nested:
- a bullet item,
- another, with a nested list:
- first ordered step,
- second ordered step.
Tables
A GFM pipe table renders as the kit's daisyUI table — a not-prose overflow wrapper around a table table-sm table-zebra. The first row is the header, the rest the body:
| Syntax | Renders as |
|---|---|
**bold** | strong |
~~gone~~ | strikethrough |
`code` | inline code |
Fenced code → Rouge
A fenced ```lang block routes through DocsUI::Code, so it is Rouge-highlighted exactly like a hand-written Code block — same wrapper, same token spans, same configured language aliases:
class Doc
extend DocsKit::Registry
page "Overview", group: "Getting started"
endNo fence language — or an unknown one — falls back to plaintext and never raises.
Links, blockquotes, rules
Links are ordinary [text](url). A > line is a blockquote:
Prose written as Markdown, styled with the reading rhythm.
And a line of three dashes is a thematic break — the horizontal rule just below this paragraph:
Everything above the rule was one md island.
<br>. Only a hard break — two trailing spaces or a trailing backslash — becomes a <br>.Inline markdown in a table cell#
Markdown.inline renders inline children with no Prose wrapper — for a [:md, …] cell.
DocsUI::Markdown.inline(source) is the inline sibling: no Prose wrapper div, and a single top-level paragraph is unwrapped so its inline children — strong, em, code, a link — sit directly in the surrounding element. It exists for the [:md, "…"] cell form of DocsUI::Table / PropTable / FieldTable, where the <td> is already the container and a block paragraph would be wrong.
| name | description |
|---|---|
events | Event types, e.g. payment_link.paid. |
amount | Amount in the smallest currency unit. |
The description cells above are inline markdown — the call that produced the table:
DocsUI::Table(
[ "name", "description" ],
[
[ [ :code, "events" ], [ :md, "Event types, e.g. `payment_link.paid`." ] ],
[ [ :code, "amount" ], [ :md, "Amount in the **smallest** currency unit." ] ]
]
)Adjacent top-level blocks get a joining space when unwrapped, so two paragraphs never fuse ("one" + "two" → "one two", not "onetwo"). You rarely call .inline directly — the [:md, …] cell form invokes it for you.
Escaping & raw HTML#
Author free text is Phlex-escaped; raw HTML tags are dropped entirely.
Because the island walks the commonmarker AST and emits native Phlex nodes — it never raws commonmarker's HTML string — all author free text is Phlex-escaped. There is no html_safe on prose (Critical Rule 7 holds), and <, >, & inside inline `code` are safe.
Raw HTML is dropped: html_block and html_inline AST nodes are skipped entirely, so author Markdown can never inject a live <script> or <div onclick> tag. There is no config to re-enable it.
<script>alert(1)</script> survives as a separate commonmarker text node — inert, Phlex-escaped prose that reads as the literal words alert(1). Never executable, but not erased either.Input is also normalized at the boundary: the initializer does source.to_s.encode(Encoding::UTF_8), so a nil source renders an empty wrapper (never raises) and a US-ASCII heredoc parses fine.
Markdown flows into the .md twin#
Every page has a raw-Markdown twin; the masthead links it.
Every docs page has a .md twin — the same page served as raw Markdown at its path plus .md. The Markdown button in this page's masthead points at it. With JavaScript off, the link simply opens the raw Markdown (a working no-JS fallback); with JS on, the one docs-nav controller intercepts the click, fetches the .md, copies it to your clipboard, and prevents the navigation.
# DocsUI::Page renders this automatically when
# DocsKit.configuration.page_markdown_action is true (the default).
render DocsUI::MarkdownAction.new(request.path)The affordance is a new target + action on the single docs-nav controller — the one-controller rule holds. The .md twin content itself is produced by DocsKit::Controller#render_page → DocsKit::MarkdownExport, not by this button. Disable the button site-wide with c.page_markdown_action = false.
/docs/markdown → /docs/markdown.md, /x?a=1 → /x.md?a=1, and a path already ending in .md is left untouched.DocsUI::Markdown args#
The component behind the md helper.
| Arg | Type | Default | Description |
|---|---|---|---|
source | String, nil | — | The GFM to render. nil/non-UTF-8 is normalized (never raises). |
inline: | Boolean | false | No Prose wrapper; unwrap a lone top-level paragraph (for a [:md, …] cell). |
md(source) | page helper | — | render DocsUI::Markdown.new(source) — the everyday path. |
.inline(source) | class method | — | == new(source, inline: true); used by [:md, …] table cells. |