Skip to content

Add ListTableExtension (list-as-table for block-content cells)#252

Open
dereuromark wants to merge 3 commits into
masterfrom
feature/list-table-extension
Open

Add ListTableExtension (list-as-table for block-content cells)#252
dereuromark wants to merge 3 commits into
masterfrom
feature/list-table-extension

Conversation

@dereuromark

@dereuromark dereuromark commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

What it does

Adds a ListTableExtension that renders ::: list-table divs as real HTML <table> markup. The table is authored as a nested list: each outer list item is a row, each inner list item is a cell.

The point is that cells are list items, so they can hold full block content (paragraphs, lists, code blocks) that the native pipe-table syntax cannot express.

{caption="Quarterly results" header-rows=1}
::: list-table
- - Region
  - Notes
- - EMEA
  - Strong quarter.

    Drivers:

    - new logos
    - renewals
:::

Behavior:

  • header-rows=N promotes the first N rows to <thead> with <th> cells.
  • header-cols=N promotes the first N cells of every row to row-header <th>.
  • A single-paragraph cell collapses to inline content (<td>text</td>); a multi-block cell keeps its <p>/<ul> wrappers.
  • Ragged rows pad with empty <td> to the widest row.
  • Only list-table divs whose sole block child is the table list are claimed; any other div, a div without a usable list, or a row authored without an inner cell list defers to the default div renderer, so no content is ever silently dropped. Without the extension registered the block degrades to the literal <div class="list-table"> nested list.
  • HTML output only; safe-mode attribute filtering applies.

Builds on the block-absorption fix

This relies on the marker-line nested-list block-absorption fix in #251, which lets a cell hold full block content (otherwise the nested list would not absorb the cell's block children).

Caption via attribute

djot has no ::: type "title" parse, so a quoted title on the opener would land in the class name. Instead the caption is read from a caption="..." attribute on the preceding attribute line, alongside header-rows / header-cols:

{caption="Quarterly" header-rows=1}
::: list-table
...
:::

Note jgm/djot#27 is still debating an official list-table syntax. An alternative that aligns with some of that discussion is putting the caption as content directly below the opener (a leading paragraph/heading) rather than an attribute; this PR uses the attribute form because it keeps the body purely the cell grid and round-trips cleanly. Open to switching if the upstream discussion settles on the content-below-caption shape.

Spanning rows and columns

Cells span via djot-php's existing pipe-table span markers, so the same syntax works in both table flavors: a cell that is a lone ^ merges with the cell above (rowspan); a lone < merges with the cell to the left (colspan). Continuation-style - colspan=3 is two < cells; rowspan=N is N-1 ^ cells in the following rows. An attributed or escaped marker (\^) stays literal.

{caption="Sales" header-rows=1}
::: list-table
- - Region
  - Q1
  - Q2
- - EMEA
  - 10
  - 12
- - ^
  - 14
  - 16
- - Total
  - <
  - <
:::

renders EMEA with rowspan="2" and Total with colspan="3", matching the equivalent pipe table's span markup (asserted by a parity test).

Tests and docs

24 list-table tests cover block-content cells, caption, header rows/cols, single-paragraph collapse, ragged padding, the defer-on-no-cells guard, spans (rowspan, colspan, combined, escape), and parity with the equivalent pipe table. Full suite green; PHPStan and phpcs clean. Documented in docs/extensions/index.md, including a "Spanning rows and columns" subsection.

Renders ::: list-table divs as real HTML tables, with the table authored
as a nested list so cells can hold full block content (paragraphs, lists,
code) that the native pipe-table syntax cannot express.

Outer list items are rows, inner list items are cells. The caption,
header-rows, and header-cols are read from the div's preceding attribute
line. Single-paragraph cells collapse to inline content; multi-block cells
keep their wrappers. Ragged rows pad with empty cells. Only list-table divs
whose sole block child is the table list are claimed; everything else (and
any malformed row) defers to the default div so no content is dropped.

Builds on the marker-line nested-list block-absorption fix (PR #251).
@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.98246% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.43%. Comparing base (78f8aec) to head (b6ef179).

Files with missing lines Patch % Lines
src/Extension/ListTableExtension.php 92.45% 16 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #252      +/-   ##
============================================
+ Coverage     92.34%   92.43%   +0.09%     
- Complexity     3593     3680      +87     
============================================
  Files           107      109       +2     
  Lines         10165    10393     +228     
============================================
+ Hits           9387     9607     +220     
- Misses          778      786       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A list-table cell whose sole inline content is a lone caret merges into
the cell above (rowspan) and a lone less-than merges into the cell to the
left (colspan), reusing djot-php's native pipe-table span markers and the
same continuation semantics: colspan of three is two trailing markers,
rowspan of N is N-1 markers in the rows below the origin.

The marker detection runs after the inline parse, so an escaped marker or
an attributed cell keeps its literal content and is never a span marker.
The grid is resolved into effective columns that account for colspans and
for rowspans reserved by earlier rows, padding ragged rows with empty
cells; well-formed spans produce the same table markup the equivalent pipe
table emits. No span markers leaves the previous behavior unchanged.

Span resolution lives in a small SpanDescriptors value object so the
rowspan/colspan mutations stay on one typed list.

Docs: document the marker syntax, the Sales authoring example with its
rendered HTML, and the note that it reuses the pipe-table span markers.
…wspan

Three edge-case defects in the list-table extension:

- An attributed cell (e.g. -{.x} ^) was still treated as a span marker
  because the marker check only looked at the paragraph's attributes, not
  the cell list item's. The class and the literal ^ were dropped and the
  neighbor wrongly gained a rowspan. spanMarker() now returns null when the
  cell carries its own attributes, and the cell attributes are emitted onto
  the <td>/<th> with the same safe-mode filtering the core renderer applies.

- A malformed list-table that defers to the default div renderer could
  duplicate user content: extractCells() appended a stray trailing block to
  the previous cell before the defer decision was made, leaving the mutation
  on the AST. Cells and pending appends are now collected without mutating;
  the appends are applied only once every row validates and the div is
  claimed, so a deferred render is byte-identical to the plain div.

- A rowspan authored across the header/body boundary emitted a cell inside
  <thead> with a rowspan reaching into <tbody>, which browsers misrender.
  The rowspan is now clamped at the boundary: a ^ in the first body row whose
  origin lives in the header rows degrades to a fresh empty body cell, and
  the header cell keeps its rowspan within the header rows. Rowspans entirely
  within the header or entirely within the body are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant