fix: keep the marker and numbers a list was authored with - #355
Merged
Conversation
Azganoth
added a commit
that referenced
this pull request
Sep 2, 2026
## Summary A heading now keeps the form it was authored in. The preset's `heading` node carries only the level, which is all an ATX and a setext heading have in common, so both parsed into the same node and were written back as an ATX heading with nothing closing it: `# Level one #` saved as `# Level one`, and `Level one` over `=========` saved as `# Level one`. The form is recorded on the node at parse and read back at serialization, which is what `thematicBreak` (#319) and `list` (#355) already do. A heading's slice opens at its opening sequence, or at its first content character where it has none, and closes at the end of the line that ends it. An ATX heading is that one line and a setext heading ends on its underline, so only the second spans more than one and the slice names which form it holds without a second discriminator. Three runs reach the node. `closingSequence` is the `#` run closing an ATX heading together with the spacing before it, because the two are the whole of what the line holds after its content and neither is read as anything else; it carries the length the file wrote rather than the length of the opening sequence, so `# Trailing hashes ####` keeps four. `separator` is the spaces or tabs between the opening sequence and the content. `underline` is the setext run, whose length is kept but whose character is not: the character answers for the level, so a heading moved between one and two is underlined by the character that level reads back as. `mdast-util-to-markdown` settles both forms from `setext` and `closeAtx`, which are one answer for the whole document, and sizes each run from the content it just wrote. `serializeHeading` therefore carries the authored form in those options for the length of the heading and puts the runs back on the handler's own output, restoring the enclosing options afterwards the way `serializeList` does. The handler's own output names which branch it took: a line break inside an ATX heading is written as a character reference, so an underline standing on a line of its own is a tail only the setext branch produces. A heading the editor creates writes ATX with one space and nothing closing it, which is also what a recorded form gives way to where the lines it lands on would not be read back as the heading: - A setext underline carries only levels one and two, so a heading moved past them is written ATX. The run stays on the node, which is what returns the form to a heading moved back into reach of it. - A setext heading written after a paragraph in a tight list item is joined to it by a single newline, which leaves its content read as more of that paragraph and the underline covering both. `continuesPrecedingParagraph` reads the join off the serializer through `joinsWithoutBlankLine`, as `serializeThematicBreak` reads the mirrored case where a run of hyphens underlines the paragraph it follows. The schema override replaces the preset's runners rather than wrapping them, because each opens the mdast node itself and carries only the level. That also means reproducing the trailing hard break the preset's serializer drops, since a heading has no line to break onto. ## Related Issue Closes #316 ## Verification `markdownCompatibility.test.ts` gains a `Heading form` group. Sixteen rows assert the ATX form written back as authored: closing sequences at levels one, three, and six; the same headings without one, so preserving a closing sequence cannot introduce one; a closing run longer than the opening sequence; an empty heading, and an empty heading a run closes rather than fills; a backslash that keeps the hash literal; and a tab, three spaces, and a two-space separator. Seven assert the setext form, including an underline longer than its content and one shorter, which is what the sizing from written content could not express. Three more cover a blockquote and a list item, and three assert that indentation and line-closing whitespace are not part of the form. One row writes a document holding a closed ATX heading, an open one, and two setext headings together, which is what shows that preserving either form cannot force it onto its neighbours, since the serializer settles both from one document-wide option. Four rows cover the fallbacks: a heading made with `format.heading2` writes ATX with nothing closing it; a setext heading raised past level two writes ATX and returns to setext when lowered back, at the length the file wrote and the character its level reads back as; and a setext heading written after a paragraph in a tight list item writes ATX. That last one is reachable only by editing, so the row inserts text into the leading paragraph of `- Item setext` / ` ----` and asserts the item writes `- Lead` / ` ## Item setext`. Four further rows reopen a saved heading and compare the document against the one that wrote it. Measured by diffing each corpus file against its own first save: `corpus/commonmark/blocks.md` loses every heading-form difference, which is the three closed ATX headings and the four setext headings under `Setext underlines form level-one and level-two headings` and `A Setext underline takes precedence after paragraph text`. `corpus/commonmark/lists-and-blockquotes.md` loses the setext heading under `An empty list item cannot interrupt a paragraph`. Every other corpus file differs from its first save on exactly the lines it did before. `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. The desktop E2E suite was not run locally; its fixtures hold no closed or underlined heading. ## Notes Neither named corpus file reaches byte identity, so both stay among the converging files. What remains in each is the blank line the serializer writes between two blocks, which the issue puts in the blank-line class, along with escaped block markers, indented code, and definition destinations in `blocks.md`. I re-checked all twelve converging files and none became byte-identical. The tab separator the issue asked to be settled before including is not a read-time difference. The heading's position covers `#<tab>Heading after a tab separator` intact, so the separator is reachable from the serializer, and it is preserved here as the issue directed. It is the one part of this change with no `Done when` bullet and no corpus instance — no corpus file uses a tab or a multi-space heading separator — so it is the piece to split out if it should be its own issue. `corpus/practical/round-trip.md` opens by saying every section below is written in a form the editor is expected to normalize, and its `Setext Underlines` section is no longer one of them. Measured against its own first save, only four of its eight sections still normalize: indented code, inline-code delimiter length, table cell padding, and hard-break spelling. Emphasis delimiters, bullet and ordered markers, and reference links went stale under #240, #261, and #355 without the file being touched, so correcting only the section this change supersedes would leave it self-contradictory. It is left for a rewrite of its own. `format.heading2` replaces a heading's attributes and so resets its recorded form, while `format.increaseHeading` and `format.decreaseHeading` carry the attributes through and keep it. That is a command-behavior difference this issue does not name and it is left as it is. The form is not carried through the DOM, so a heading copied inside the editor and pasted elsewhere writes the default. `withBulletListMarker` and `withListItemForm` behave the same way; `withThematicBreakMarker` carries its run through `data-marker` because an `<hr>` has nothing else to rebuild the break from, while an `<h1>` through `<h6>` already names the level.
This was referenced Sep 2, 2026
Azganoth
added a commit
that referenced
this pull request
Sep 2, 2026
## Summary An ordered list item written with a nine-digit marker and four spaces before its content came back with one space. \ That is the single marker-and-padding combination `leafdownListForm` could not read, \ and it is a boundary of the padding preservation #317 delivered rather than a class of its own. `LIST_ITEM_HEAD_LENGTH` was 14: nine digits, a delimiter, and four spaces, \ which is the longest marker CommonMark reads together with the widest run it reads as padding after it. \ Reading that run needs one character more than writing it does. \ `LIST_ITEM_PADDING_PATTERN` measures the run against the character that ends it, `/^ {1,4}(?=[^\t\n\r ])/u`, \ because five or more spaces open indented code inside the item and leave the content one space past the marker; \ the lookahead is what separates the two. \ At the boundary the head stopped on the last space of the run, the lookahead had nothing to look at, \ and the padding fell back to the default of one space. \ The head now reaches one character past the widest marker and padding together. ## Related Issue Closes #369 ## Verification `markdownCompatibility.test.ts` gains two rows to the group asserting an item's marker padding. \ The widest marker carrying the widest padding fails on the pre-change source; \ the row one space short of it passes, which is what makes the pair a guard rather than a restatement of the fix. The extra character can only reach an item whose marker and padding together are exactly the old head length, \ and a nine-digit ordered marker is the only marker long enough to get there. \ That was measured rather than reasoned, by driving the editor mount used by the plugin tests over the neighbouring cases, none of which moved: 1. Three spaces under the widest marker; four spaces under the shortest; four spaces under an eight-digit marker; and ten digits, which CommonMark does not read as a marker at all. 2. The widest marker with its content on the line after it, and with a second block written at the padding column. 3. Five spaces under either marker, which still read as one space of padding, because the run opens indented code inside the item and CommonMark puts the content one space past the marker there. Those neighbours are covered by the existing rows in the same group and by `corpusRoundTrip.test.ts`, \ so they are not added again as committed rows; the list above records what the boundary change was checked against. The frontend checks pass. Not verified: the five-space rows still round-trip differently, because indented code inside a list item is rewritten as a fenced block. \ That is an open class under #251 and is untouched here — \ only the padding those items are written at was in question, and it is correct. ## Notes - No changelog entry. \ The behavior this restores is promised by an entry already sitting in `Unreleased`, from #355: \ "The spaces between a marker and its content are kept too." \ No released version has the working behavior, so no user met the defect, \ and a second entry would describe a fix to something that never shipped. \ `docs/specification.md` already states the rule correctly under `Serialization And Output` and needed no edit. - #370 touches the same two files in disjoint hunks, and the two changes do not interact: \ the task marker is read from the slice between an item's marker and its content, \ while the head length this changes bounds only the marker and padding read.
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 list is now written with the marker it was authored with,
-,+, or*for a bullet list and.or)for an ordered one, with the numbers its items were written with, the spaces between each marker and its content, and the blank line an item opening on a later line was written with.mdast-util-to-markdownpicks one bullet for the whole document fromoptions.bulletand moves each list off whatever the list before it used, so a file written with-came back as a mixture of*and-that followed the order the lists appeared in,3.followed by8.was renumbered to3.and4., and4)became4..leafdownListFormstamps the authored form onto the mdast nodes the wayleafdownTableFormstamps a table's outer pipes. It reads the head of each item's own slice, which opens at the marker whatever the container indented it by. CommonMark reads a change of marker as the start of another list, so the first item that carries a position answers for the whole list, while the number and the spacing are each item's own. The three list schemas carry the form as attributes, andlistandlistItemstringify handlers of Leafdown's own write it.The
listhandler holds the authored marker in the options the upstream handler reads, for the length of that list, rather than replacing it. Two adjacent lists sharing a marker are read back as one list, and that guard is upstream's; keeping it means the marker gives way exactly where the file would not reload as the document that wrote it. No file holds two adjacent lists with one marker, because a repeated marker opens one list to begin with, so only an edit that deletes what stood between two lists reaches the collision: a bullet list then moves to*, or to-where*is what collided, and an ordered list to the other delimiter.The
listItemhandler is written out rather than delegated. The upstream handler sizes every item by one space after its marker, or by the tab stop a document-wide option names, and neither can answer for the spaces a single item was written with; an item's own number would have to be forged through its list'sstart. Owning the handler is also what keeps a task marker GFM's own handler drops, since it inserts the checkbox by matching a.delimiter followed by at most three spaces: an ordered item written1) [x], or any item written with four spaces of padding, lost its checkbox before this.Two positions give way rather than write a list the next open reads differently:
A bullet no longer gives way to a thematic break opening its item.
mdast-util-to-markdownmoves the bullet off the rule character it was configured with, which cannot answer for a run the node carries, whileserializeThematicBreakalready answers it from the break itself. The option is now held to_, a character no bullet can be, so only the run gives way:* ---was written- ***, giving way at both ends, and is now written as authored. The run still moves where an edit puts it on its bullet's character.The first item of an ordered list is written with the list's own start rather than the number recorded on the item, because that is the number the file is read back with and the list is what holds it. A list the editor makes carries no authored marker and writes
*, or.when ordered, with one space after it; that default is recorded indocs/specification.md, since it was not defined before.joinsWithoutBlankLinemoves out ofthematicBreakMarkdown.tsintomarkdownJoins.ts, because both fallbacks ask the serializer the same question about what it will write between two blocks.withBooleanSpreadandwithoutFilledLeadingParagraphmove out ofcreateMilkdownEditor.tsinto the list module, alongside the runners that now apply them.Related Issue
Closes #317
Verification
markdownCompatibility.test.tsgainsList marker form, which pins the written bytes across a save. Twelve sources keep their markers, covering each bullet, each ordered delimiter, adjacent lists of both kinds, a list nested under either kind, and task items under-and1). Five keep their numbers, covering a consecutive run, a run that skips, a repeated1., a0.start, and the nine-digit marker beside a short one. Eight keep their marker padding, covering one to four spaces, a wider ordered marker, a task marker after the padding, and the two places the padding sets a column: a second paragraph in the item, and a nested list. Five keep an item opening on the line after its marker, including a task marker and a block other than a paragraph.One row per fallback covers what no source can author. Two adjacent lists are built by deleting the
<!---->between two lists that share a marker, for a bullet pair and an ordered pair; each asserts the written bytes and that reopening the save gives two lists rather than one. The blank first line is built by tightening the item that holds the list, which is the only way a list comes to follow a paragraph with no blank line between them.blocks.test.tsalready asserted the marker an inserted list writes, andList marker formasserts the same default through the command, since a list read from a file now writes its own.Measured by diffing
corpus/commonmark/lists-and-blockquotes.mdagainst its first save: every difference underBullet-marker forms,Ordered-marker forms,Tight lists,Marker-padding boundaries, andA list item can begin with a blank lineis gone. What still differs in that file is the lazy continuation gaining its marker, the blank lines written between blocks, the setext heading rewritten as ATX, and the indented code rewritten as fenced, each of which belongs to a class #251 tracks separately.Existing expectations that recorded the old rewriting were updated rather than kept. The fixture in
markdownCompatibility.test.tsis now derived from its source, since the save differs from it only by the final newline; four rows there that asserted*for a-source now assert-, as do the task-checkbox, command-keymap, and keyboard tests that open a-list. The thematic-break test that asserted- ***for* ---now asserts the authored bytes, and the collision it covered moved to two rows that edit a run onto its bullet's character.pnpm check:frontendpasses. The backend is untouched, sopnpm check:backendwas 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. The desktop E2E suite was not run locally; its fixtures hold no lists.Notes
A list 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 list would have to carry the marker the way a thematic break carries its run in
data-marker, which is clipboard scope. A table's outer pipes are held the same way.A nested list made with
Tabcarries the default marker rather than the marker of the list around it, because the command builds the list from the schema's own attributes. It is a list the editor made, so the recorded default applies, but a marker inherited from the enclosing list would read better in the file and is worth its own issue.A fallback rewrites the recorded form as well as the bytes, so a list whose marker was withdrawn reopens holding the marker it was written with rather than the one it was authored with. The content is identical; only the recorded attribute differs. A thematic break and a table behave the same way.
An item written with five or more spaces after its marker keeps one, because CommonMark reads the rest as the indented code inside it. That item is rewritten as a fenced block, which is the indented-code class rather than this one.