Authoring

# 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](https://docs-kit.zoolutions.llc/docs/components) block, so `md` prose and `prose do … end` read identically.

```ruby
md <<~'MD'
  Write **prose** as Markdown — `inline code`, [links](/docs/overview),
  lists, and tables all styled like Prose.
MD
```

This 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.

> **Tip:** Markdown is prose-only. Document structure — the section headings that feed the [On this page](https://docs-kit.zoolutions.llc/docs/on-this-page) TOC — stays with `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.

```ruby
# 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}` ...
MD
```

Even a double-quoted heredoc is still *safe* — Phlex escapes all author free text, so nothing injects markup (see [Escaping](#escaping-raw-html) 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:
  1. first ordered step,
  2. 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`](https://docs-kit.zoolutions.llc/docs/components), so it is Rouge-highlighted exactly like a hand-written Code block — same wrapper, same token spans, same configured [language aliases](https://docs-kit.zoolutions.llc/docs/languages):

```ruby
class Doc
  extend DocsKit::Registry
  page "Overview", group: "Getting started"
end
```

No fence language — or an unknown one — falls back to plaintext and never raises.

#### Links, blockquotes, rules

[Links](https://docs-kit.zoolutions.llc/docs/authoring) 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.

> **Note:** A soft line break (a single newline inside a paragraph) becomes a single space, not a `<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`](https://docs-kit.zoolutions.llc/docs/components), 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:

```ruby
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 `raw`s 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.

> **Warning:** Only the tags are dropped, not the text between them. The body of `<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`](https://docs-kit.zoolutions.llc/docs/ai) controller intercepts the click, fetches the `.md`, copies it to your clipboard, and prevents the navigation.

```ruby
# 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`.

> **Note:** The twin href is idempotent and query-preserving: `/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. |