docs: record what a save preserves and what it normalizes - #348
Merged
Conversation
The entry this replaces described a direction the project stopped following at #240 and has been retiring class by class since. Rank content, closure, and form so the classes still open under #251 are decided against one rule rather than one at a time. The rule is stated ahead of its implementation. It answers the table layout class #323 raises before that work lands, and #251's completion criterion still asks for byte identity across all sixteen scoped files, which the table exception now contradicts.
A tilde fence rewritten to backticks moves a backtick in its info string to ```, which reads back as the same info string: parsing the written file returns the lang the author wrote. The propagation costs a second authored form, not content, so the class stays ranked with the forms it belongs to.
Azganoth
added a commit
that referenced
this pull request
Sep 1, 2026
## Summary - Override the GFM preset's table content expression from `table_header_row table_row+` to `table_header_row table_row*`. The `+` left a table authored without body rows with no legal representation, so ProseMirror filled a cell-less row into every one of them. - Record in `docs/specification.md` that a header-only table is kept and rendered as one, and make the `Delete row` rule say what it does now that a header-only table is a valid shape. ## Related Issue Closes #323 ## Verification `src/features/editor/plugins/tableShape.test.ts` asserts the document rather than the bytes: a header-only table holds no body rows on open, preserves its document across a save, and writes back without a row. It also pins that a body row shorter than the header is still filled, which is #254's behavior through the same code. `src/features/editor/commands/formatting/tables.test.ts` covers the command paths the relaxed schema reaches: `Add row below` from the header of a header-only table inserts the first body row, and deleting the only body row still removes the table. - `pnpm check:frontend` passes. Manually verified in the editor mount used by the plugin tests, on Windows: 1. Opening `| Header only | No body rows |` with its delimiter row renders one `<tr>`; before the change it rendered a second, empty `<tr>` holding no cells. 2. The saved file is byte-identical across the change, which is why this was never visible in the file. Not verified: no assembled-desktop run, and no manual pass in the built application. ## Notes The measurement in #323 said the save appended the row. It does not, and the issue has been corrected. The row was only ever in the document and the rendered table: the serializer writes the same two lines whether or not the empty row is present, and the document round-trips because the fill is deterministic, so neither the corpus guard nor a byte comparison could see it. The defect is document fidelity, not file content. Deleting the last body row still removes the table rather than leaving its header. That is now a choice rather than a consequence of the schema, and changing it is a command decision this fix deliberately does not take. The padding in the expected file output is the normalization `Preserve the form a file was written in` accepts, recorded in #348. The omitted outer pipes from the original #323 are #349.
Azganoth
added a commit
that referenced
this pull request
Sep 1, 2026
## 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.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Preserve Markdown semantics over exact formattingdecision. Its consequences described a direction the project stopped following at Preserve the authored autolink form on save #240: normalizing generated Markdown to an application default style, standardizing on Milkdown serializer defaults, and asserting round trips that account for known normalizations.corpus/gfm/tables.mdcannot reach byte identity.Related Issue
Refs #251, #323
Verification
Documentation-only change. No executable configuration is affected, so the application suites do not apply.
pnpm check:oxfmtpasses.](#anchor)link indocs/decisions.mdresolves against a heading in the same file, checked by script after the rename.Not verified: no frontend or backend checks were run.
Notes
The entry is deliberately ahead of its implementation. It settles the table layout class before the work in #323 lands, and #251's completion criterion is corrected separately to match.