Skip to content

Keep the task list marker as literal text when item content is a setext heading - #1124

Closed
hdimer wants to merge 6 commits into
pulldown-cmark:mainfrom
hdimer:fix/1115-tasklist-setext
Closed

Keep the task list marker as literal text when item content is a setext heading#1124
hdimer wants to merge 6 commits into
pulldown-cmark:mainfrom
hdimer:fix/1115-tasklist-setext

Conversation

@hdimer

@hdimer hdimer commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #1115.

With ENABLE_TASKLISTS, a task list marker is appended as the first child of the item's paragraph before the paragraph is parsed. If that paragraph turns out to be a setext heading ( - underline), the body is rewritten to Heading but the marker stayed inside, producing <h2><input type="checkbox">a</h2>.

Per GFM a task list marker is only valid when the item content is a paragraph, so drop the leading marker when the paragraph becomes a setext heading. A marker on an earlier paragraph (when only a later block becomes a heading) is unaffected.

Regression tests cover the reported case, the earlier-paragraph case, and heading attributes.

Used AI assistance on this; I reviewed and tested it.

A task list marker is appended as the first child of a list item's
paragraph before the paragraph is parsed. If that paragraph turns out to
be a setext heading, the body is rewritten to a heading but the marker
stayed inside it, producing <h2><input type=checkbox>a</h2>.

Per GFM a task list marker is only valid when the item content is a
paragraph, so drop the leading marker when the paragraph becomes a setext
heading. A marker on an earlier paragraph is unaffected.

Fixes pulldown-cmark#1115
@ollpu

ollpu commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the contribution. +1 for AI disclosure.

This test is not what I expected:

- [ ] a
  -
.
<ul>
<li>
<h2>a</h2>
</li>
</ul>

The square brackets should be preserved (and not just reinserted, the parsing should be canceled completely in this case). That said, I'm not sure how to accomplish that cleanly.

@notriddle

Copy link
Copy Markdown
Collaborator

GitHub's own behavior seems correct, and we should try to match it, I think?

- [ ] a
  -

- [ ] b
  =

- [ ] c
  x
  • [ ] a

  • [ ] b

  • c
    x

@notriddle

notriddle commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Alternatively, we could try to match what pandoc does, and just put the task marker outside the header.

Both answers seem defensible to me. Just, please don't silently eat the [ ].

…ng it

Dropping the marker node silently ate the `[ ]` (review feedback on pulldown-cmark#1115).
Instead cancel the task-list interpretation and render the marker's own
source bytes, plus the whitespace that separated it from the text, as
literal text. `- [ ] a` over a setext rule now becomes `<h2>[ ] a</h2>`.

Only the whitespace run is absorbed (not up to the next node), so a
following backslash escape still resolves normally.
@hdimer hdimer changed the title Don't emit a task list marker when item content is a setext heading Keep the task list marker as literal text when item content is a setext heading Aug 1, 2026
@hdimer

hdimer commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks both, agreed the marker shouldn't vanish. I pushed a revision that cancels the task-list interpretation and keeps the marker's own [ ]/[x] source bytes as literal text:

- [ ] a          ->  <h2>[ ] a</h2>
  -
- [x] a {#id}    ->  <h2 id="id">[x] a</h2>
  -

It reuses the marker node's existing span (only growing it across the separating whitespace) rather than synthesizing a new string, so it's the "cancel completely" variant rather than a reinsertion. A following backslash escape isn't pulled in (- [ ] \*a\* over a rule still gives <h2>[ ] *a*</h2>), and normal task lists are untouched.

One edge worth flagging: since the brackets become a single literal text node, they no longer take part in reference-link resolution, so - [x] foo over a rule with a matching [x]: definition would render [x] literally rather than as a shortcut link ([ ] is always safe, since a whitespace label can't match a definition). If you'd rather match pandoc and put the marker outside the heading instead, I'm happy to switch, both read fine to me.

@notriddle

Copy link
Copy Markdown
Collaborator

I'm not picky about which implementation we follow, as long as we actually do it. Since GitHub resolves link defs, we should too if we're following their lead.

- [x] h1
  =

[x]: https://example.com
  • x h1

@ollpu

ollpu commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Since GitHub resolves link defs

Ah, about that...

Even if you have a perfectly normal checked task, it will prioritize the link because this is done in postprocessing.

- [x] abc

[x]: https://example.com
  • x abc

But yes, if we reject the[x] as a tasklist marker, I think it should be eligible to be parsed as a link.

@hdimer

hdimer commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Makes sense, thanks. So the agreed behavior: reject [x] as a task marker, but let those bytes go through normal inline parsing, so a matching [x]: definition still resolves as a link (matching GitHub's postprocessing order).

My current commit converts the marker to a single literal-text node, which is inert. To get link-eligibility I'll re-scan the marker's span as inline content instead, so it yields the usual MaybeLinkOpen / text / MaybeLinkClose nodes and the second pass resolves it. Then - [x] foo over a setext rule with a [x]: url definition renders the heading with a link, while - [ ] a stays literal (a whitespace-only label can't match a definition).

Sound right? If so I'll push that version.

When a list item's content turns out to be a setext heading, the task-list
marker is not valid (pulldown-cmark#1115). Instead of freezing `[x]` as inert text, split it
back into MaybeLinkOpen / text / MaybeLinkClose so a matching `[x]:` reference
resolves as a link, matching GitHub's postprocessing order (review on pulldown-cmark#1124).
A whitespace-only label (`[ ]`) can't match a definition and stays literal.
@hdimer

hdimer commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed that version. When the task-list interpretation is canceled (item content is a setext heading), [x]/[ ] now split back into the normal MaybeLinkOpen / text / MaybeLinkClose inline nodes instead of a single inert text node, so a matching [x]: url definition resolves as a link (matching GitHub's postprocessing). Added a regression example covering it. A whitespace-only label like [ ] can't match a definition, so it still renders literally. Full suite + fmt + clippy clean; the existing setext cases are unchanged when no definition is present.

@notriddle

Copy link
Copy Markdown
Collaborator

I found another problem with this PR. It turns out, you can reproduce the same bug using tables, not just headings.

- [x] | one | two | three    |
  |-----|-----|-------|------|
  | one | two | three | four |

GitHub:

  • [x] one two three
    one two three four

pulldown-cmark:

$ cargo run --release -- --enable-tables --enable-tasklists
    Finished `release` profile [optimized] target(s) in 0.07s
     Running `target/release/pulldown-cmark --enable-tables --enable-tasklists`
- [x] | one | two | three
  |-----|-----|-------
  | one | two | three
<ul>
<li><table><thead><tr><th>one</th><th>two</th><th>three</th></tr></thead><tbody>
<tr><td>one</td><td>two</td><td>three</td></tr>
</tbody></table>
</li>
</ul>

And definition lists have a slightly different, but still basically similar, bug:

$ cargo run --release -- --enable-definition-list --enable-tasklists
    Finished `release` profile [optimized] target(s) in 0.07s
     Running `target/release/pulldown-cmark --enable-tables --enable-tasklists`
- [x] a
  : b
<ul>
<li>
<dl>
<dt><input disabled="" type="checkbox" checked=""/>
a</dt>
<dd>b</dd>
</dl>
</li>
</ul>

hdimer added 3 commits August 12, 2026 04:18
A task list marker is only valid when the item content is a paragraph.
The setext heading case was handled; tables and definition list titles
have the same problem.

For a table, the marker's bytes are ordinary row content, so scan the
header row from the marker's start rather than from the paragraph's.
That counts the marker as a leading cell and makes the header and
delimiter column counts agree the way GitHub's do.

For a definition list title, reuse the setext cancellation, now
extracted into cancel_task_list_marker.
Cancelling left the marker's bytes outside the heading/title node's own
range, so offset iteration reported children starting before their
parent. The table path already grew its node; do the same in
cancel_task_list_marker so all three paths agree.

Also pin the two cases with no coverage: a leading pipe now shifts the
header count enough that no table forms at all, and a reference
definition resolving inside a table header cell.
Find the marker's `[` by scanning the leading spaces scan_task_list_marker
already bounds, rather than searching for the byte. Drop a spec example
that behaved the same before the fix, and cover the node ranges, which
nothing else asserted.
@hdimer

hdimer commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Both reproduce, both fixed.

Tables were the more interesting one. The marker's bytes are ordinary row content, so I moved the header-row scan back to the marker's start instead of the paragraph's. That means the marker counts as a leading cell, which is what makes your example agree with GitHub: four header cells ([x], one, two, three) against the four-column delimiter row, rather than three-vs-four and no table at all.

Worth flagging the flip side, since it's a behavior change beyond the bug: - [x] | a | b | over |---|---| used to be a two-column table and is now a plain task list item, because the header is three cells now. GitHub does the same, and there's a spec example pinning it.

Definition lists just reuse the setext cancellation, so - [x] a / : b gives <dt>[x] a</dt>, and a matching [x]: definition still resolves to a link there too.

One thing I noticed while doing this: cancelling left the marker's bytes outside the heading's own range, so into_offset_iter reported text events starting before the node containing them. Fixed for all three paths and added a test, since nothing asserted it.

Full suite + fmt + clippy clean. I also brute-forced ~540k generated bullet/marker/pipe/setext/deflist combinations for panics and bad ranges, and confirmed the structural table decision is now identical with ENABLE_TASKLISTS on and off, which it wasn't before.

notriddle added a commit to notriddle/pulldown-cmark that referenced this pull request Aug 15, 2026
While the test cases are based on the earlier pulldown-cmark#1124, the implementation
is different, because it avoids special-casing particular nodes where
task lists are disallowed. Instead, it includes the task list markers in
the tree, but parses as if it weren't, and removed the text from the
tree if it's not needed.
@Martin1887

Copy link
Copy Markdown
Collaborator

Closed in favor of #1135.

@Martin1887 Martin1887 closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task list marker with ENABLE_TASKLISTS can end up inside a heading

4 participants