Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@
- Save operations serialize back to standard Markdown.
- File workflows remain compatible with external editor tools.

### Preserve Markdown semantics over exact formatting
### Preserve the form a file was written in

**Decision:** Leafdown prioritizes preserving Markdown meaning over byte-for-byte formatting.
**Decision:** A save writes a file back in the form it was authored in. Three properties are settled in that order, and only the last is a judgment. Content, meaning a node or a character the document would gain or lose, is never given up for form. Closure, meaning that the file the editor writes reloads as the document that wrote it, is a defect wherever it fails, repaired either by writing the authored form or by not writing the bytes at all. Form, meaning a spelling that every Markdown reader and the editor's own parser read identically, is preserved wherever a node or a mark can carry it and it survives an edit to the content it decorates, and is normalized where nothing owns it or the content invalidates it. This replaces the earlier direction to normalize generated Markdown to an application default style, which [issue #251](https://github.com/Azganoth/leafdown/issues/251) has retired class by class.

**Rationale:** A hybrid editor normalizes source markup; semantic consistency is the primary product guarantee.
**Rationale:** The earlier direction was measured in [issue #135](https://github.com/Azganoth/leafdown/issues/135) as rewriting 12 of the 16 scoped corpus files on first open. The file on disk is the only copy Leafdown keeps, so a rewrite the author did not ask for is a diff they have to account for wherever the file is versioned, and it arrives on the first save rather than on an edit they can point at. The three properties rank rather than compete: content and closure are correctness, and form is the only one where two answers are both defensible. Ordering them keeps a form argument from being spent on a defect, and keeps a defect from being settled as a matter of taste. The ownership test is not new direction but the one the record reached independently in the authored autolink form, the character reference, and the thematic break run, each of which keeps an authored spelling and gives way to a canonical one where the file would not read back the same.

**Consequences:**

- Leafdown normalizes generated Markdown to the default application style.
- Output-formatting customization remains deferred.
- Standardize on Milkdown serializer defaults unless configuration overrides are straightforward and reliable.
- Round-trip tests verify semantic preservation and account for known serializer normalizations.
- A form change is judged by everything it forces, not by the local difference it names. A tilde code fence rewritten to backticks cannot carry a backtick in its info string, so normalizing the fence character also rewrites the info string into a character reference the author did not write, which is a second authored form lost to the first. The file still reloads as the same document, so the cost is form rather than content, and the class is weighed on the whole propagation rather than on the fence alone.
- Whitespace at a line edge the parser trims is not written at all, because no spelling of it reloads. Whitespace no parse trims is content and is written back as it was read.
- Every preserved form carries a canonical fallback rather than a preference. A bare URL falls back to angle brackets where a neighbour would hide it, a character reference degrades to the character it names once its recorded source no longer matches the text, and a thematic break gives way to `***` where its run would read as a heading underline or merge with its list item's bullet. A form whose fallback is not known is not preserved until it is.
- A table's cell padding, delimiter row width, and alignment padding are normalized, which is the class [issue #323](https://github.com/Azganoth/leafdown/issues/323) raises and this decision answers. No node owns a layout computed across a column, and a keystroke in any cell invalidates it, which is what separates it from every other form under [issue #251](https://github.com/Azganoth/leafdown/issues/251): those survive an edit to the content they decorate, and a column width is a function of it. `corpus/gfm/tables.md` therefore cannot reach byte identity, and the target is identity for the files whose forms a node can hold.
- A construct the editor creates writes a defined default form, and so does one whose authored form a fallback withdraws.
- Two constructs that mean the same thing are written by one rule. A strikethrough wrapping a link keeps its wrapper outside the link because bold and italic already do.
- A form is normalized on cost only where both spellings are interchangeable to a reader, and the cost is recorded as such so it can be revisited when it changes. A form nothing can own is not revisitable on those terms: the nesting order of two marks is lost because ProseMirror holds marks as a set.
- Output-formatting customization remains deferred. Preservation is not a formatter, and a document whose form the editor holds no record of is written in the serializer's own.

### No vault or workspace model

Expand Down Expand Up @@ -142,7 +146,7 @@
- Neither autolink form has a literal state, and this is not fixed. `mdast-util-gfm-autolink-literal` contributes an escape for the characters that would otherwise read as a protocol, a `www` lead, or an email marker, but its `fromMarkdown` side runs a `findAndReplace` over already-decoded text, after escapes are resolved, so the escape never changes what a reload produces: `https\://example.com` and `\<https\://example.com>` both reopen as a link, examined in [issue #241](https://github.com/Azganoth/leafdown/issues/241). Leafdown stops emitting an escape that cannot hold rather than pursue a parser change to give a bare URL a literal state, which is declined for the same reason the canonical form above is not. Inline code is the only durable way to show a URL or an email as text, because its content is never a `text` node the autolink transform visits.
- A character reference is decoded by `micromark` before the mdast text node exists, so `©` and `&copy;` are indistinguishable to everything downstream and a file written to stay ASCII does not stay ASCII. Leafdown records the authored form, decided in [issue #262](https://github.com/Azganoth/leafdown/issues/262), and writes it back in text and in link and image destinations alike. The run is recovered by walking each text node's value against the slice of the file it was built from, and carried on a mark whose stored source is verified against the text it covers before it is written, so an edit that invalidates it degrades to the character rather than to a stale reference. References written next to each other keep one mark each, decided in [issue #305](https://github.com/Azganoth/leafdown/issues/305), so breaking one converts only that one and leaves its neighbours preserved. ProseMirror merges neighbouring text nodes carrying an equal mark set, so a repeated reference still arrives as one node holding its characters repeated; only an equal mark merges, which makes that node whole repetitions of the one source it stores, and the verification counts them rather than reading the node as a reference the source does not spell. A preserved reference is inert for escaping: it opens no construct and closes none, and the escape passes read it as the characters it will be written as. That same verified source is what a caret reaching the reference projects, decided in [issue #298](https://github.com/Azganoth/leafdown/issues/298) on the rule [Offer the escape gesture only where the conversion exists](#offer-the-escape-gesture-only-where-the-conversion-exists) states, because breaking a valid reference commits the literal text it spells and the conversion therefore exists. This is the exception the byte-identity target in [issue #251](https://github.com/Azganoth/leafdown/issues/251) would otherwise have had to admit, and it is overridden rather than accepted, unlike the strikethrough run below, because a reference and the character it names are not interchangeable to an author who chose one.
- The preset's single thematic break spelling is overridden. Its `hr` node carries no attributes, so `***`, `---`, `_ _ _`, and every other accepted run parse into the same node and are written back as `***`, rewriting every break in a file on its first save. Leafdown records the run on the node, decided in [issue #319](https://github.com/Azganoth/leafdown/issues/319), read from the slice of the file the node was built from, which is the whole of a break because it holds no children. Indentation stands outside that slice and the whitespace closing the line is trimmed off it, so what is kept is the characters and the spacing between them, tabs included. A break the editor creates carries `***`, which is also what a recorded run gives way to where the line it lands on would be read back as something other than a break. `mdast-util-to-markdown` joins a tight list item's children with a single newline, so a run of hyphens written after a paragraph there underlines it into a setext heading; and a run sharing its item's bullet character stands on the bullet's line, where the two read as one longer break with no list around them. The serializer already moves the bullet off the rule character it was configured with, but that character cannot answer for a run the node carries, so the run is what gives way rather than the bullet.
- The preset's strikethrough delimiter run is not preserved. Its strikethrough mark carries no marker attribute, unlike emphasis and strong, so a single-tilde run parses and serializes back as a double-tilde run. This is left as a known normalization under [Preserve Markdown semantics over exact formatting](#preserve-markdown-semantics-over-exact-formatting) rather than overridden as the autolink form was, because both runs mean the same thing to a GFM reader. Preserving the authored run would require carrying the marker on the mark.
- The preset's strikethrough delimiter run is not preserved. Its strikethrough mark carries no marker attribute, unlike emphasis and strong, so a single-tilde run parses and serializes back as a double-tilde run. This is normalized on cost under [Preserve the form a file was written in](#preserve-the-form-a-file-was-written-in) rather than overridden as the autolink form was, because both runs mean the same thing to a GFM reader. Preserving the authored run would require carrying the marker on the mark.
- The preset's strikethrough input rule is overridden. Its `(~{1,2})` backtracks to a one-tilde delimiter run when no two-tilde closing run exists yet, and its content group does not exclude the marker, so typing `~~text~~` created a mark over `~text` on the seventh keystroke and left a surplus tilde on each side that saved as an escaped character. Leafdown carries its own rule, decided in [issue #233](https://github.com/Azganoth/leafdown/issues/233), which excludes the marker from the content and anchors the match at the caret so a run stays literal text until the author closes it. This is the only input rule Leafdown owns; every other preset rule either anchors at the caret or excludes its own marker, and none of them can match a run this way.
- The replacement rule keeps the preset's leading word, colon, and slash guard, so a tilde run that touches one of those does not become a strikethrough as it is typed. `lead~~text~~` and `1~2~3` parse as strikethrough when a file holds them but stay literal text when typed, which is a real disagreement, examined in [issue #282](https://github.com/Azganoth/leafdown/issues/282) and left as it is. The guard does two jobs: it holds the word boundary, and it stops a one-tilde run from opening inside an unclosed two-tilde one. Removing it fixes the first case and breaks `~~a~b~~`, which types as a struck `b` between literal tildes, because an input rule reads only the text before the caret and cannot know another tilde is coming. Separating the two jobs means matching delimiter runs directly rather than through `markRule`, which reads one content group and cannot express the alternation. Leafdown prefers the conservative failure: literal text the author can see and correct, over a construct silently built around the wrong delimiters.
- A `*` or `_` run the preset input rules decline is paired by Leafdown instead, decided in [issue #232](https://github.com/Azganoth/leafdown/issues/232). Those rules read one content group between delimiters of equal length, so a run closed against an unequal or differently spelled one matched nothing and stayed literal: `***text*`, `_**text*`, `_**text**`, and `*__text_` kept every marker as text, and `_**text**_` closed an italic span over the literal asterisks its bold span should have spelled. The pairing that already answers a delimiter typed beside a mark now answers one typed beside literal text as well, over as many delimiters as the shorter run spells. It acts only where the preset declined, because a rule that matches consumes the typed character before the plugin sees it, which is why the preset's guards are reproduced rather than replaced and `lead**text**` still types literal. The underscore is the one exception: `\w` counts it as a word character where CommonMark counts it as punctuation, and `_**text**` cannot be read otherwise.
Expand Down