Skip to content

fix: keep two blocks adjacent where the file wrote them that way - #378

Merged
Azganoth merged 1 commit into
mainfrom
bug/adjacent-block-separator
Sep 3, 2026
Merged

fix: keep two blocks adjacent where the file wrote them that way#378
Azganoth merged 1 commit into
mainfrom
bug/adjacent-block-separator

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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. countEmptyParagraphsBetween restores 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: restoreBlockStructure marks a block opening on the line the block before it closes on, and an adjacent attribute carries that through every group: "block" node type. Under Preserve the form a file was written in the 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-markdown decides 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 and serializeMarkdownRoot takes the line back out. The mark is applied by wrapping state.handle for 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 character containerPhrasing peeks; 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.ts covers 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.paragraph is 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.ts pins 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.ts moves commonmark/html.md to byteIdenticalFiles, which is the file #324 named as reaching identity on this class alone.

Measured by saving each scoped corpus file once at 130ffcdf and diffing it against itself: every blank line this class inserted is gone from commonmark/blocks.md, commonmark/code.md, commonmark/lists-and-blockquotes.md, and gfm/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:frontend passes.

Not verified: no manual pass in the assembled application, and pnpm check:backend was 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_item deliberately carries no separator. The gap between two list items is tightness, which spread already owns, and is a different class from the gap between two blocks.
  • The attribute is not carried through parseDOM and toDOM, 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.
  • interruptsParagraphAsHtmlBlock is exported from rawHtmlMarkdown.ts rather 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.
  • Two behaviors were measured rather than read off the spec, because the guard has to agree with the parser that reads the file back: a bullet list, an ordered list, and a fenced code block each terminate a GFM table rather than extending it as a row, and a definition holding no title absorbs a following line opening with ", ', or (.
  • A paragraph written directly under a footnote definition is saved with four spaces of indentation. This is unchanged by this pull request — it reproduces on 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.ts registers the two assertion wrappers this suite asserts through, using the assertFunctionNames list already kept there for the same reason.

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.
@Azganoth Azganoth added the Bug Something isn't working label Sep 3, 2026
@Azganoth Azganoth self-assigned this Sep 3, 2026
@Azganoth
Azganoth enabled auto-merge (squash) September 3, 2026 08:17
@Azganoth
Azganoth merged commit 15e6a74 into main Sep 3, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/adjacent-block-separator branch September 3, 2026 08:19
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.

Saving inserts a blank line between blocks the author wrote adjacent

1 participant