Skip to content

fix: keep the marker a task list item was authored with - #370

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

fix: keep the marker a task list item was authored with#370
Azganoth merged 1 commit into
mainfrom
bug/task-marker-form

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

A task list item is now written with the marker it was authored with: x or X for a
checked 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] done came 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.offset
indexes 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.

leafdownListForm stamps the marker onto the mdast item the way it already stamps the
bullet, the number, and the padding, and list_item carries it as an attribute. It is read
off 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.md records the boundary, because the rule under #359 read alone argues for
restoring the authored marker.

Related Issue

Closes #318

Verification

markdownCompatibility.test.ts gains a Task marker form group.

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.ts is unchanged and still passes; corpus/gfm/task-lists.md loses
both differences this issue names, which is the fourth Done when item.

Manually verified in Typora 1.14.9 on Windows 11:

  1. Opened a file holding every case above and saved it with no edit. It came back
    byte-identical, so the authored marker is what the established editor in this category
    preserves as well.
  2. Unchecked an item authored [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>] as
a 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-item uses markdownSpace, [\t ], with that
reason recorded beside it.

Notes

  • corpus/gfm/task-lists.md stays on the convergence assertion rather than moving to byte
    identity. One difference remains and belongs to another class: [ ] not a list item is a
    lazy continuation, A lazy continuation line gains its block marker on save #322, and gains the explicit indentation the file left out.
  • The malformed markers the issue puts out of scope — [], [y], and [x] with no
    following 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.
  • Filed while measuring this change and left alone: A nine-digit ordered marker's four-space padding is dropped on save #369, where an item written with a
    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.

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 Azganoth added the Bug Something isn't working label Sep 2, 2026
@Azganoth Azganoth self-assigned this Sep 2, 2026
@Azganoth
Azganoth enabled auto-merge (squash) September 2, 2026 18:03
@Azganoth
Azganoth merged commit d2e0b3d into main Sep 2, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/task-marker-form branch September 2, 2026 18:05
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 task marker's authored form is rewritten on save

1 participant