fix: keep two blocks adjacent where the file wrote them that way - #378
Merged
Conversation
The document held no record of the difference. A blank-line run is carried as blank paragraphs, but only the surplus over the separator the serializer writes, so two adjacent blocks and two blocks a single blank line apart both restore nothing, and a blank paragraph spends two blank lines and cannot spell the one-line case either. Deriving the separator instead would delete every blank line the author chose wherever the second block interrupts the first, which is the rewrite this class exists to prevent, so the pair is recorded on the block that follows. The separator is decided in the assembled document rather than at the node. `mdast-util-to-markdown` settles a join before the block on its right is serialized, so no handler can weigh the blank line against the block it separates; the block is marked as it is written and the root handler takes the line back out. Marking through `state.handle` reaches every block in every container without a wrapper per handler, and the marker is resolved ahead of the deferred escapes so an escape is decided against the lines the file is actually written with. Dropping a separator merges two blocks, so it is withdrawn against the document rather than against the file it came from: a heading edited into a paragraph still carries the separator the heading was authored with. The block the pair is measured against is the innermost one before it, because a blockquote and a list item both continue their last paragraph lazily, and the test is the line the block was actually written with rather than the construct it is — a setext heading opens on ordinary text, an item whose content begins below its marker leaves that marker alone, and a run of hyphens underlines the paragraph above instead of breaking it.
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
A save inserted a blank line between blocks the author wrote on consecutive lines. CommonMark does not require one wherever the second block interrupts the first, so the file grew lines nobody wrote while the document stayed the same. This was the widest of the classes under #251 by file count, and the only remaining difference in
corpus/commonmark/html.md.The document held no record of the difference to write back.
countEmptyParagraphsBetweenrestores only the surplus over the blank line the serializer already writes, so two adjacent blocks and two blocks one blank line apart both restore nothing, and a blank paragraph spends two blank lines and cannot spell the one-line case either. Deriving the separator instead — writing blocks adjacent wherever the second interrupts the first — would delete every blank line the author did choose, which is the rewrite #251 exists to prevent. So the pair is recorded:restoreBlockStructuremarks a block opening on the line the block before it closes on, and anadjacentattribute carries that through everygroup: "block"node type. UnderPreserve the form a file was written inthe separator decorates the boundary, the second block owns it, and the blank line is the canonical fallback the guard below withdraws to.The separator is settled in the assembled document rather than at the node, because
mdast-util-to-markdowndecides a join before the block on its right is serialized and no handler can weigh the blank line against the block it separates. Each block is marked as it is written andserializeMarkdownRoottakes the line back out. The mark is applied by wrappingstate.handlefor the length of the root handler, which reaches every block in every container without a wrapper per handler and keeps the marker out of the one charactercontainerPhrasingpeeks; the zwitch's own fields are carried onto the wrapper because that peek reads them directly. Separators resolve ahead of the deferred escapes, so an escape is still decided against the lines the file is actually written with rather than against blank ones a separator removes.Dropping a separator merges two blocks, so it is withdrawn against the document rather than against the file it came from — a heading edited into a paragraph still carries the separator the heading was authored with. The block the pair is measured against is the innermost one before it, not the sibling, because a blockquote and a list item both continue their last paragraph lazily. The blank line stays after a raw HTML block, which runs to the next blank line; it goes after a heading, thematic break, or fence, which close on their own line; after a definition it goes unless the following line opens a title the definition would swallow; and otherwise only where the line interrupts a paragraph. That last test reads the line the block was actually written with rather than the construct it is, which is what makes three cases fall out of one rule: a setext heading opens on ordinary text, an item whose content begins below its marker leaves that marker alone on the line, and a run of hyphens underlines the paragraph above instead of breaking it.
Related Issue
Closes #324
Verification
blockSeparator.test.tscovers every pairing #324 measured, plus the nested case a blockquote holds, and asserts the written bytes and the reopened document together for each. Byte equality alone cannot see a separator that merges two blocks, which is the direction that costs content.Nineteen sources come back unchanged: a paragraph before a block-level tag, two reference definitions, a paragraph before
***, two headings, a quoted paragraph before a heading and before a list, a paragraph before a bullet list, an ordered list and a table, a table before the blockquote that terminates it, a paragraph before a fence, two footnote definitions, a paragraph under a heading, a rule and a fence, a setext heading under a heading, a paragraph and a quoted line under a definition, and two blocks written adjacent inside a blockquote.Five sources keep the blank line they were written with, including a blank-line run and a blank line inside a blockquote. Four more cover the withdrawal: a heading turned into a paragraph by
format.paragraphis separated again, a definition whose title is cleared is separated from the quoted line below it, and a raw HTML block and a---run keep their blank line.blockSeparatorMarkdown.test.tspins the guard line by line — fourteen lines that interrupt a paragraph and eleven that do not — along with the four classes a preceding block falls into and the marker resolution at the root, inside a blockquote, and inside an indented list item.corpusRoundTrip.test.tsmovescommonmark/html.mdtobyteIdenticalFiles, which is the file #324 named as reaching identity on this class alone.Measured by saving each scoped corpus file once at
130ffcdfand diffing it against itself: every blank line this class inserted is gone fromcommonmark/blocks.md,commonmark/code.md,commonmark/lists-and-blockquotes.md, andgfm/tables.md. What still differs in those four belongs to other classes — escapes, fence form, indented code rewritten as fenced, a lazy continuation gaining its marker, and table cell padding.pnpm check:frontendpasses.Not verified: no manual pass in the assembled application, and
pnpm check:backendwas not run. The change is serializer and parse-time behavior with no rendered surface of its own, and it touches no Rust or Tauri code.Notes
list_itemdeliberately carries no separator. The gap between two list items is tightness, whichspreadalready owns, and is a different class from the gap between two blocks.parseDOMandtoDOM, so a block copied out of one document and pasted into another is written with the blank line rather than with the separator its source file used. That matches how a heading's authored form already behaves, and the fallback is the safe direction.interruptsParagraphAsHtmlBlockis exported fromrawHtmlMarkdown.tsrather than written again. The HTML block start conditions that interrupt a paragraph were already spelled there against micromark's own tag list, and the guard needs exactly that question.",', or(.main, and the reopened document is identical, so it costs form rather than content. It looks like an unfiled form class under Preserve the authored form of a Markdown file on save #251 rather than a defect, and is left alone here.oxlint.config.tsregisters the two assertion wrappers this suite asserts through, using theassertFunctionNameslist already kept there for the same reason.