Skip to content

fix: keep the marker and numbers a list was authored with - #355

Merged
Azganoth merged 1 commit into
mainfrom
bug/list-marker-form
Sep 2, 2026
Merged

fix: keep the marker and numbers a list was authored with#355
Azganoth merged 1 commit into
mainfrom
bug/list-marker-form

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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-markdown picks one bullet for the whole document from options.bullet and 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 by 8. was renumbered to 3. and 4., and 4) became 4..

leafdownListForm stamps the authored form onto the mdast nodes the way leafdownTableForm stamps 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, and list and listItem stringify handlers of Leafdown's own write it.

The list handler 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 listItem handler 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's start. 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 written 1) [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:

  • The marker collision above.
  • An item opening on a blank line cannot interrupt the paragraph above its list, so where the list follows a paragraph directly, which only a tight list item does, the content is written on the marker's line. An item is written where its own list's parent is out of reach, so the list resolves that question and hands the item down without the form.

A bullet no longer gives way to a thematic break opening its item. mdast-util-to-markdown moves the bullet off the rule character it was configured with, which cannot answer for a run the node carries, while serializeThematicBreak already 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 in docs/specification.md, since it was not defined before.

joinsWithoutBlankLine moves out of thematicBreakMarkdown.ts into markdownJoins.ts, because both fallbacks ask the serializer the same question about what it will write between two blocks. withBooleanSpread and withoutFilledLeadingParagraph move out of createMilkdownEditor.ts into the list module, alongside the runners that now apply them.

Related Issue

Closes #317

Verification

markdownCompatibility.test.ts gains List 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 - and 1). Five keep their numbers, covering a consecutive run, a run that skips, a repeated 1., a 0. 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.ts already asserted the marker an inserted list writes, and List marker form asserts 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.md against its first save: every difference under Bullet-marker forms, Ordered-marker forms, Tight lists, Marker-padding boundaries, and A list item can begin with a blank line is 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.ts is 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: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 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 Tab carries 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.

@Azganoth Azganoth added the Bug Something isn't working label Sep 2, 2026
@Azganoth Azganoth self-assigned this Sep 2, 2026
@Azganoth
Azganoth merged commit 8a859de into main Sep 2, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/list-marker-form branch September 2, 2026 03:10
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.
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.
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.

A list's bullet marker is rewritten and its numbers renumbered on save

1 participant