Skip to content

docs: record what a save preserves and what it normalizes - #348

Merged
Azganoth merged 2 commits into
mainfrom
docs/authored-form-decision
Sep 1, 2026
Merged

docs: record what a save preserves and what it normalizes#348
Azganoth merged 2 commits into
mainfrom
docs/authored-form-decision

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace the Preserve Markdown semantics over exact formatting decision. 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.
  • Record the rule the record had already reached one class at a time. Content, closure, and form are settled in that order and only form is a judgment: a form is preserved where a node or a mark can carry it and it survives an edit to the content it decorates, with a canonical fallback where the file would not read back the same, and normalized where nothing owns it or the content invalidates it.
  • Answer the table layout class A header-only table gains an empty body row #323 raises. Cell padding, delimiter row width, and alignment padding are normalized, because no node owns a layout computed across a column and a keystroke in any cell invalidates it, so corpus/gfm/tables.md cannot reach byte identity.
  • Repoint the single cross-reference to the renamed entry, and state the strikethrough delimiter run as normalized on cost, which is the term the new entry defines.

Related Issue

Refs #251, #323

Verification

Documentation-only change. No executable configuration is affected, so the application suites do not apply.

  • pnpm check:oxfmt passes.
  • Every internal ](#anchor) link in docs/decisions.md resolves 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.

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.
@Azganoth Azganoth added the Documentation Improvements or additions to documentation label Sep 1, 2026
@Azganoth Azganoth self-assigned this Sep 1, 2026
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
Azganoth merged commit 2ffa1be into main Sep 1, 2026
3 checks passed
@Azganoth
Azganoth deleted the docs/authored-form-decision branch September 1, 2026 17:59
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant