Problem
GFM pipe tables are painful for the table-heavy docs this engine is built for:
- Adding a column rewrites every row. Prettier (and GitHub's own diff view) pads cells to the widest value in each column, so a single long cell reformats dozens of surrounding rows into noisy diffs.
- Cells can't hold block content. No lists, no code blocks, no multiple paragraphs. The universal workarounds (
<br>, escaped \|, raw <table>) render inconsistently and fight the rest of the pipeline.
- Long cells force long source lines. Unreadable in narrow editors and in GitHub's diff view.
Every mature docs ecosystem solves this by moving rows into bullet lists: RST's list-table, MyST's {list-table}, AsciiDoc's cell-per-line, Markdoc's {% table %}. Pipe tables should stay valid — this is an escape hatch for the painful 20%.
Proposed solution
A :::table container directive where rows are bullet lists separated by ---:
:::table {caption=\"HTTP endpoints\" widths=\"1,2,4\" align=\"left,center,left\"}
- Method
- Path
- Description
---
- GET
- /users
- List users
---
- POST
- /users
- Create a user
:::
- First row group is the header by default (
header=N to extend; footer=N for trailing footer rows).
:cell{colspan=2 rowspan=2 align=\"right\" scope=\"col\"} as an inline directive for per-cell attributes.
::skip as a leaf directive for grid positions consumed by a surrounding span — self-checking, no silent inference.
- Block attributes on
:::table: caption, widths, align, header, footer, label, class.
- Rich cell content: any block content valid in a list item is valid in a cell — paragraphs, nested lists, code blocks, blockquotes, images, inline formatting.
Diff behavior (the core win)
Adding a column is N+1 line additions with zero reformatting:
:::table {label=\"http-methods\"}
- Method
- Idempotent
- Safe
+- Rate limit
---
- GET
- Yes
- Yes
+- 100/s
---
- POST
- No
- No
+- 10/s
:::
The pipe-table equivalent repads every cell in every row.
Implementation notes
The directive infrastructure already exists in crates/rw-renderer/src/directive/:
ContainerDirective trait (:::name) — implement this for tables. TabsDirective at crates/rw-renderer/src/tabs/directive.rs is the reference pattern.
InlineDirective trait (:name) — implement for :cell{…}.
LeafDirective trait (::name) — implement for ::skip.
DirectiveArgs::parse() already handles [content]{#id .class key=\"value\"} parsing.
DirectiveProcessor registers handlers with .with_container(...) / .with_inline(...) / .with_leaf(...).
- Registration goes in
rw-site::page::PageRenderer::configure_renderer alongside TabsDirective.
Open question: rich cell content through the existing pipeline
The existing pipeline is preprocess → pulldown-cmark → post-process replacements (see DirectiveProcessor::process in directive/processor.rs). Tabs work with this because tab bodies are plain block content that pulldown-cmark parses unchanged between the intermediate <rw-tabs> / <rw-tab> tags.
Tables are harder: CommonMark treats a raw <table> block as an HTML block and stops block-level markdown parsing inside it, which breaks the rich-cell-content goal. Options:
- Preprocess each
:::table into an intermediate HTML skeleton with placeholder tokens for each cell body, stash the cell markdown in the directive, parse each cell body independently, and substitute rendered cell HTML in post_process (Replacements). Cells stay markdown until render time.
- Render the whole table to HTML in
start()/end(), invoking an inner MarkdownRenderer pass per cell. Simpler, but allocates a sub-renderer per cell.
Either works; option 1 is closer to how TabsDirective already leans on Replacements. Worth prototyping before committing.
Validation
Errors (fail the render with line-accurate diagnostics):
- Row groups with unequal column counts (after span adjustment).
colspan/rowspan extending past the table edge.
- Span collisions — two cells claiming the same grid position.
::skip at a position not consumed by a span; missing ::skip at a consumed position.
- Nested
:::table inside a cell.
Warnings (log, keep rendering):
- Unknown block or cell attributes.
widths / align count mismatch with column count.
- Empty table.
Non-goals
- Replacing pipe tables. They stay valid and remain the right choice for small tables that need to render on GitHub.
- Normalizing pipe tables and
:::table into a shared internal model. Separate concern; parking it.
- Nested tables. Explicit error.
- Data-file-backed tables (
:::csv-table etc.). Out of scope.
- Sortable/filterable/interactive tables. Renderer concern, orthogonal to authoring.
Prior art
Problem
GFM pipe tables are painful for the table-heavy docs this engine is built for:
<br>, escaped\|, raw<table>) render inconsistently and fight the rest of the pipeline.Every mature docs ecosystem solves this by moving rows into bullet lists: RST's
list-table, MyST's{list-table}, AsciiDoc's cell-per-line, Markdoc's{% table %}. Pipe tables should stay valid — this is an escape hatch for the painful 20%.Proposed solution
A
:::tablecontainer directive where rows are bullet lists separated by---::::table {caption=\"HTTP endpoints\" widths=\"1,2,4\" align=\"left,center,left\"} - Method - Path - Description --- - GET - /users - List users --- - POST - /users - Create a user :::header=Nto extend;footer=Nfor trailing footer rows).:cell{colspan=2 rowspan=2 align=\"right\" scope=\"col\"}as an inline directive for per-cell attributes.::skipas a leaf directive for grid positions consumed by a surrounding span — self-checking, no silent inference.:::table:caption,widths,align,header,footer,label,class.Diff behavior (the core win)
Adding a column is N+1 line additions with zero reformatting:
:::table {label=\"http-methods\"} - Method - Idempotent - Safe +- Rate limit --- - GET - Yes - Yes +- 100/s --- - POST - No - No +- 10/s :::The pipe-table equivalent repads every cell in every row.
Implementation notes
The directive infrastructure already exists in
crates/rw-renderer/src/directive/:ContainerDirectivetrait (:::name) — implement this for tables.TabsDirectiveatcrates/rw-renderer/src/tabs/directive.rsis the reference pattern.InlineDirectivetrait (:name) — implement for:cell{…}.LeafDirectivetrait (::name) — implement for::skip.DirectiveArgs::parse()already handles[content]{#id .class key=\"value\"}parsing.DirectiveProcessorregisters handlers with.with_container(...)/.with_inline(...)/.with_leaf(...).rw-site::page::PageRenderer::configure_rendereralongsideTabsDirective.Open question: rich cell content through the existing pipeline
The existing pipeline is preprocess → pulldown-cmark → post-process replacements (see
DirectiveProcessor::processindirective/processor.rs). Tabs work with this because tab bodies are plain block content that pulldown-cmark parses unchanged between the intermediate<rw-tabs>/<rw-tab>tags.Tables are harder: CommonMark treats a raw
<table>block as an HTML block and stops block-level markdown parsing inside it, which breaks the rich-cell-content goal. Options::::tableinto an intermediate HTML skeleton with placeholder tokens for each cell body, stash the cell markdown in the directive, parse each cell body independently, and substitute rendered cell HTML inpost_process(Replacements). Cells stay markdown until render time.start()/end(), invoking an innerMarkdownRendererpass per cell. Simpler, but allocates a sub-renderer per cell.Either works; option 1 is closer to how
TabsDirectivealready leans onReplacements. Worth prototyping before committing.Validation
Errors (fail the render with line-accurate diagnostics):
colspan/rowspanextending past the table edge.::skipat a position not consumed by a span; missing::skipat a consumed position.:::tableinside a cell.Warnings (log, keep rendering):
widths/aligncount mismatch with column count.Non-goals
:::tableinto a shared internal model. Separate concern; parking it.:::csv-tableetc.). Out of scope.Prior art
{% table %}{list-table}list-table