fix: keep the marker a task list item was authored with - #370
Merged
Conversation
The marker is read off the whole slice standing between the item's own marker and the content it opens with, rather than off a head of a fixed length as the bullet and the padding are. GFM consumes the checkbox and the whitespace after it, so that slice ends exactly where the marker does, and no bound computed from the list marker and its padding covers every item: the widest of them is a nine-digit ordered marker whose content opens on the line after it, indented past the marker's own width. A marker spells the state it was written for rather than decorating both states, so an item the editor moves to another state carries none and is written in the default form from then on, including when it returns to the state it was authored in. That is the limit of the rule under #359, where a heading keeps its runs through a level change: a closing sequence decorates a heading whatever its level, while a task marker is the state it would decorate. Typora 1.14.9 writes `[x]` for an item authored `[X]`, unchecked and checked again, and preserves `[X]` across a save that changes nothing. Removing the checkbox moves the item the same way, so an item made a task item again is written in the default form too, and the click path and the command path are held to one rule because they make the same move.
Azganoth
enabled auto-merge (squash)
September 2, 2026 18:03
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 task list item is now written with the marker it was authored with:
xorXfor achecked item, and a space or a tab for an unchecked one. GFM reads each pair as the same
state, so the state always survived a save and only the authored spelling was lost —
- [X] donecame back- [x] done, and a tab between the brackets became a space.The issue asked which of the two rows was reachable, since tabs are expanded to tab stops
during parsing and a read-time difference no serializer change reaches would have narrowed
this to the marker case. Both are reachable. The mdast item's
position.start.offsetindexes the original source, so the slice the parse plugin reads carries the tab verbatim;
the expansion happens inside micromark's token stream and does not move the offsets the
tree reports.
leafdownListFormstamps the marker onto the mdast item the way it already stamps thebullet, the number, and the padding, and
list_itemcarries it as an attribute. It is readoff the whole slice standing between the item's own marker and the content it opens with,
rather than off the fixed-length head the other three use. GFM consumes the checkbox and
the whitespace after it, so that slice ends exactly where the marker does, and no bound
computed from the list marker and its padding covers every item: the widest is a nine-digit
ordered marker whose content opens on the line after it, indented past the marker's own
width.
A marker spells the state it was written for rather than decorating both, so an item the
editor moves to another state carries none and is written in the default form from then on,
including when it returns to the state it was authored in. That is the limit of the rule
#359 settled: a heading's closing sequence decorates a heading whatever its level, while a
task marker is the state it would otherwise decorate. Removing the checkbox is the same
move, so an item made a task item again is written in the default form too. The command
path and the checkbox-click path go through one helper because they make the same move.
docs/decisions.mdrecords the boundary, because the rule under #359 read alone argues forrestoring the authored marker.
Related Issue
Closes #318
Verification
markdownCompatibility.test.tsgains aTask marker formgroup.Nine rows assert the authored marker across a save and a reopen, covering both checked
spellings and both unchecked ones under padding, nesting, an ordered marker, a blockquote,
and an item whose content opens on the line after its marker. Each asserts the written
bytes and the reopened document together, because a marker written in a form the next read
does not answer for costs the state rather than the form, and the bytes alone cannot tell
the two apart.
Five more cover the editor's own moves, where the authored marker gives way: a command
checking or unchecking an item, a click on its checkbox, an item moved off its state and
back, an item that stops being a task item and is made one again, and one the editor
creates.
corpusRoundTrip.test.tsis unchanged and still passes;corpus/gfm/task-lists.mdlosesboth differences this issue names, which is the fourth
Done whenitem.Manually verified in Typora 1.14.9 on Windows 11:
byte-identical, so the authored marker is what the established editor in this category
preserves as well.
[X], saved, checked it again, saved. Typora writes[ ]and then
[x], which is the rule this branch implements.Not verified: Typora is not evidence for the unchecked pair. It does not read
[<tab>]asa checkbox at all, rendering literal brackets and preserving them as ordinary text. That
case rests on GFM instead, whose reference implementation accepts a tab explicitly to match
GitHub —
micromark-extension-gfm-task-list-itemusesmarkdownSpace,[\t ], with thatreason recorded beside it.
Notes
corpus/gfm/task-lists.mdstays on the convergence assertion rather than moving to byteidentity. One difference remains and belongs to another class:
[ ] not a list itemis alazy continuation, A lazy continuation line gains its block marker on save #322, and gains the explicit indentation the file left out.
[],[y], and[x]with nofollowing whitespace — are unaffected. GFM leaves them as ordinary list content, so the
slice the marker is read from holds only the item's marker and padding and the pattern
cannot match.
nine-digit ordered marker and four spaces of padding comes back with one space. It is a
boundary of the padding class A list's bullet marker is rewritten and its numbers renumbered on save #317 delivered, reproduces with no task marker involved,
and is the one row of the manual Typora file that Leafdown still rewrites.