Skip to content

fix: keep the outer pipes a table's rows were authored with - #351

Merged
Azganoth merged 1 commit into
mainfrom
bug/table-outer-pipes
Sep 1, 2026
Merged

fix: keep the outer pipes a table's rows were authored with#351
Azganoth merged 1 commit into
mainfrom
bug/table-outer-pipes

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

A table is now written with the outer pipes its rows were authored with. mdast-util-gfm-table calls markdown-table with the alignment, the padding, and the cell width it was configured with and never with delimiterStart or delimiterEnd, and exposes neither as a setting, so every table came back with an outer pipe on both sides of every row and a file using GFM's pipe-optional form was rewritten on its first save.

leafdownTableForm stamps the authored form onto the mdast node the way leafdownThematicBreak stamps a break's run, the table schema carries it as an attribute, and a table stringify handler of Leafdown's own reaches the two options. The form is read off the rows rather than off the table's own slice: a row's position already has its container prefix and its indentation taken off, while the lines after the first inside a blockquote or a list item keep theirs. The delimiter row is no node of its own, so it follows the rows that are.

All four forms GFM reads alike are kept — both pipes, a leading one, a trailing one, and neither. A pipe is dropped only where every row was authored without it, because taking it off the rows that carry one to match a row that does not would rewrite more of the file than the form keeps.

Two positions read a row written without an outer pipe back as something other than the row it was written from, and both give way to both pipes rather than write a row the next open would disagree with:

  • A blank cell at either end leaves the row opening or closing on a pipe of its own, which GFM strips before it splits the row, so the cell is gone and every cell after it has moved a column.
  • A delimiter cell is as wide as its column, so a first column one character wide is written -, and that hyphen and the space after it open a bullet list item where no pipe precedes it. An alignment marker widens the cell past a lone hyphen, so only a column carrying none reaches this.

A table the editor inserts writes both pipes, which is also both fallbacks: it is the one form every row reads back as the row it was written from. That default is recorded in docs/decisions.md and docs/specification.md, since it was not defined before.

markdown-table is added as a direct dependency, pinned to the version mdast-util-gfm-table already resolves, so the handler writes what the extension would have.

Related Issue

Closes #349

Verification

markdownCompatibility.test.ts gains Table outer pipe form, which pins the written bytes across a save: seven sources kept as authored, covering all four forms, a header-only table, a one-character first column carrying an alignment marker, and a one-character first column behind a leading pipe; two containers, a blockquote and a list item; one row for a table whose rows disagree; and one row per fallback. The three fallback rows empty a first cell, empty a last cell, and narrow a first column to one character through an edit, which is the arrangement no source can author — a blank edge cell or a lone-hyphen delimiter row is what stops the lines being read as a table in the first place.

blocks.test.ts asserts the form an inserted table writes, since a table read from a file now writes its own and one the editor creates needs a defined one.

Measured by diffing corpus/gfm/tables.md against its first save: the four rows under Alignment markers and outer pipes are optional no longer gain outer pipes. What still differs in that file is cell padding, delimiter row width, and the padding an alignment marker redistributes, which Preserve the form a file was written in normalizes and #348 recorded. No corpus fixture is added: the round-trip assertions compare a save against the save before it and converge either way, while Table outer pipe form compares the written bytes, which is what distinguishes the two behaviors.

pnpm check:frontend passes. The backend is untouched, so pnpm check:backend was not run.

Not verified: the manual pass over corpus/ in the running application, which covers rendering, interaction, and navigator behavior beyond the automated round-trip.

Notes

A table that reaches the document without a parse — a pasted one — carries the default rather than the form of the document it came from. The rendered table would have to carry the form the way a thematic break carries its run in data-marker, which is clipboard scope and is not what this issue measures.

A fallback rewrites the recorded form as well as the bytes, so a table whose form was withdrawn reopens holding the form it was written with rather than the one it was authored with. The content is identical; only the recorded attribute differs. A thematic break behaves the same way where its run gives way.

The form is carried by a plugin of its own rather than by tableShape, which the issue named as the local seam. tableShape answers for how many cells a row holds and repairs the ones that disagree; the outer pipes are a property of the whole table that no repair touches, and the two walks share nothing but the tree.

The delimiter row's own pipes are not recorded separately. It is no node, so a table authored with pipes on its rows and none on its delimiter row is written with pipes throughout.

`mdast-util-gfm-table` never passes `delimiterStart` or `delimiterEnd` to
`markdown-table` and exposes neither as a setting, so the option is only
reachable from a `table` stringify handler of Leafdown's own.

The form is read off the rows rather than the table's own slice: a row's
position already has its container prefix and its indentation taken off,
while the lines after the first inside a blockquote or a list item keep
theirs. The delimiter row is no node, so it follows the rows that are.

A pipe is dropped only where every row was authored without it. Taking
it off the rows that carry one to match a row that does not would rewrite
more of the file than the form keeps.

Two positions read a bare row back as something other than the row it was
written from, and both give way to both pipes:

- A blank cell at either end leaves the row opening or closing on a pipe
  of its own, which GFM strips before it splits the row, so the cell is
  gone and every cell after it has moved a column.
- A delimiter cell is as wide as its column, so a first column one
  character wide is written `-`, which opens a bullet list item where no
  pipe precedes it. An alignment marker widens the cell past a lone
  hyphen, so only a column carrying none reaches this.

`markdown-table` is pinned to the version `mdast-util-gfm-table` already
resolves, so the handler writes what the extension would have.
@Azganoth Azganoth added the Bug Something isn't working label Sep 1, 2026
@Azganoth Azganoth self-assigned this Sep 1, 2026
@Azganoth
Azganoth enabled auto-merge (squash) September 1, 2026 19:55
@Azganoth
Azganoth merged commit 83033d4 into main Sep 1, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/table-outer-pipes branch September 1, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A table's omitted outer pipes are added on save

1 participant