Skip to content

Commit 9877c0c

Browse files
authored
✨ NEW: Add markdown-it-task-lists plugin (#76)
1 parent a29c8bd commit 9877c0c

File tree

7 files changed

+771
-1
lines changed

7 files changed

+771
-1
lines changed

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
# This pattern also affects html_static_path and html_extra_path.
4646
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
4747

48-
nitpick_ignore = [("py:class", "Match")]
48+
nitpick_ignore = [("py:class", "Match"), ("py:class", "x in the interval [0, 1).")]
4949

5050

5151
# -- Options for HTML output -------------------------------------------------

docs/plugins.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ html_string = md.render("some *Markdown*")
4040
4141
.. autofunction:: markdown_it.extensions.amsmath.amsmath_plugin
4242
:noindex:
43+
44+
.. autofunction:: markdown_it.extensions.tasklists.tasklists_plugin
45+
:noindex:
4346
```
4447

4548
`myst_blocks` and `myst_role` plugins are also available, for utilisation by the [MyST renderer](https://myst-parser.readthedocs.io/en/latest/using/syntax.html)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Builds task/todo lists out of markdown lists with items starting with [ ] or [x]"""
2+
3+
# Ported by Wolmar Nyberg Åkerström from https://github.com/revin/markdown-it-task-lists
4+
# ISC License
5+
# Copyright (c) 2016, Revin Guillen
6+
#
7+
# Permission to use, copy, modify, and/or distribute this software for any
8+
# purpose with or without fee is hereby granted, provided that the above
9+
# copyright notice and this permission notice appear in all copies.
10+
#
11+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18+
19+
from typing import List
20+
from uuid import uuid4
21+
22+
from markdown_it import MarkdownIt
23+
24+
from markdown_it.token import Token
25+
26+
27+
def tasklists_plugin(
28+
md: MarkdownIt,
29+
enabled: bool = False,
30+
label: bool = False,
31+
label_after: bool = False,
32+
):
33+
"""Plugin for building task/todo lists out of markdown lists with items starting with [ ] or [x]
34+
.. Nothing else
35+
36+
For example::
37+
- [ ] An item that needs doing
38+
- [x] An item that is complete
39+
40+
The rendered HTML checkboxes are disabled; to change this, pass a truthy value into the enabled
41+
property of the plugin options.
42+
43+
:param enabled: True enables the rendered checkboxes
44+
:param label: True wraps the rendered list items in a <label> element for UX purposes,
45+
:param label_after: True – adds the <label> element after the checkbox.
46+
"""
47+
disable_checkboxes = not enabled
48+
use_label_wrapper = label
49+
use_label_after = label_after
50+
51+
def fcn(state):
52+
tokens: List[Token] = state.tokens
53+
for i in range(2, len(tokens) - 1):
54+
55+
if is_todo_item(tokens, i):
56+
todoify(tokens[i], tokens[i].__class__)
57+
attr_set(
58+
tokens[i - 2],
59+
"class",
60+
"task-list-item" + (" enabled" if not disable_checkboxes else ""),
61+
)
62+
attr_set(
63+
tokens[parent_token(tokens, i - 2)], "class", "contains-task-list"
64+
)
65+
66+
md.core.ruler.after("inline", "github-tasklists", fcn)
67+
68+
def attr_set(token, name, value):
69+
index = token.attrIndex(name)
70+
attr = [name, value]
71+
if index < 0:
72+
token.attrPush(attr)
73+
else:
74+
token.attrs[index] = attr
75+
76+
def parent_token(tokens, index):
77+
target_level = tokens[index].level - 1
78+
for i in range(1, index + 1):
79+
if tokens[index - i].level == target_level:
80+
return index - i
81+
return -1
82+
83+
def is_todo_item(tokens, index):
84+
return (
85+
is_inline(tokens[index])
86+
and is_paragraph(tokens[index - 1])
87+
and is_list_item(tokens[index - 2])
88+
and starts_with_todo_markdown(tokens[index])
89+
)
90+
91+
def todoify(token: Token, token_constructor):
92+
token.children.insert(0, make_checkbox(token, token_constructor))
93+
token.children[1].content = token.children[1].content[3:]
94+
token.content = token.content[3:]
95+
96+
if use_label_wrapper:
97+
if use_label_after:
98+
token.children.pop()
99+
100+
# Replaced number generator from original plugin with uuid.
101+
checklist_id = f"task-item-{uuid4()}"
102+
token.children[0].content = (
103+
token.children[0].content[0:-1] + f' id="{checklist_id}">'
104+
)
105+
token.children.append(
106+
after_label(token.content, checklist_id, token_constructor)
107+
)
108+
else:
109+
token.children.insert(0, begin_label(token_constructor))
110+
token.children.append(end_label(token_constructor))
111+
112+
def make_checkbox(token, token_constructor):
113+
checkbox = token_constructor("html_inline", "", 0)
114+
disabled_attr = 'disabled="disabled"' if disable_checkboxes else ""
115+
if token.content.startswith("[ ] "):
116+
checkbox.content = (
117+
'<input class="task-list-item-checkbox" '
118+
f'{disabled_attr} type="checkbox">'
119+
)
120+
elif token.content.startswith("[x] ") or token.content.startswith("[X] "):
121+
checkbox.content = (
122+
'<input class="task-list-item-checkbox" checked="checked" '
123+
f'{disabled_attr} type="checkbox">'
124+
)
125+
return checkbox
126+
127+
def begin_label(token_constructor):
128+
token = token_constructor("html_inline", "", 0)
129+
token.content = "<label>"
130+
return token
131+
132+
def end_label(token_constructor):
133+
token = token_constructor("html_inline", "", 0)
134+
token.content = "</label>"
135+
return token
136+
137+
def after_label(content, checkbox_id, token_constructor):
138+
token = token_constructor("html_inline", "", 0)
139+
token.content = (
140+
f'<label class="task-list-item-label" for="{checkbox_id}">{content}</label>'
141+
)
142+
token.attrs = [{"for": checkbox_id}]
143+
return token
144+
145+
def is_inline(token):
146+
return token.type == "inline"
147+
148+
def is_paragraph(token):
149+
return token.type == "paragraph_open"
150+
151+
def is_list_item(token):
152+
return token.type == "list_item_open"
153+
154+
def starts_with_todo_markdown(token):
155+
# leading whitespace in a list item is already trimmed off by markdown-it
156+
return (
157+
token.content.startswith("[ ] ")
158+
or token.content.startswith("[x] ")
159+
or token.content.startswith("[X] ")
160+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- package: markdown-it-task-lists
2+
commit: 8233e000559fae5a6306009e55332a54a9d3f606
3+
date: 6 Mar 2018
4+
version: 2.1.1
5+
changes:
6+
- Replaced number generator from original plugin with uuid
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
2+
bullet.md:
3+
4+
.
5+
- [ ] unchecked item 1
6+
- [ ] unchecked item 2
7+
- [ ] unchecked item 3
8+
- [x] checked item 4
9+
.
10+
<ul class="contains-task-list">
11+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked item 1</li>
12+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked item 2</li>
13+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked item 3</li>
14+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> checked item 4</li>
15+
</ul>
16+
17+
.
18+
19+
dirty.md:
20+
21+
.
22+
- [ ] unchecked todo item 1
23+
- [ ]
24+
- [ ] not a todo item 2
25+
- [ x] not a todo item 3
26+
- [x ] not a todo item 4
27+
- [ x ] not a todo item 5
28+
- [x] todo item 6
29+
30+
.
31+
<ul class="contains-task-list">
32+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked todo item 1</li>
33+
<li>[ ]</li>
34+
<li>[ ] not a todo item 2</li>
35+
<li>[ x] not a todo item 3</li>
36+
<li>[x ] not a todo item 4</li>
37+
<li>[ x ] not a todo item 5</li>
38+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> todo item 6</li>
39+
</ul>
40+
41+
.
42+
43+
mixed-nested.md:
44+
45+
.
46+
# Test 1
47+
48+
1. foo
49+
* [ ] nested unchecked item 1
50+
* not a todo item 2
51+
* not a todo item 3
52+
* [x] nested checked item 4
53+
2. bar
54+
3. spam
55+
56+
# Test 2
57+
58+
- foo
59+
- [ ] nested unchecked item 1
60+
- [ ] nested unchecked item 2
61+
- [x] nested checked item 3
62+
- [X] nested checked item 4
63+
64+
65+
.
66+
<h1>Test 1</h1>
67+
<ol>
68+
<li>foo
69+
<ul class="contains-task-list">
70+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> nested unchecked item 1</li>
71+
<li>not a todo item 2</li>
72+
<li>not a todo item 3</li>
73+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> nested checked item 4</li>
74+
</ul>
75+
</li>
76+
<li>bar</li>
77+
<li>spam</li>
78+
</ol>
79+
<h1>Test 2</h1>
80+
<ul>
81+
<li>foo
82+
<ul class="contains-task-list">
83+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> nested unchecked item 1</li>
84+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> nested unchecked item 2</li>
85+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> nested checked item 3</li>
86+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> nested checked item 4</li>
87+
</ul>
88+
</li>
89+
</ul>
90+
91+
.
92+
93+
oedered.md:
94+
95+
.
96+
1. [x] checked ordered 1
97+
2. [ ] unchecked ordered 2
98+
3. [x] checked ordered 3
99+
4. [ ] unchecked ordered 4
100+
101+
.
102+
<ol class="contains-task-list">
103+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> checked ordered 1</li>
104+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked ordered 2</li>
105+
<li class="task-list-item"><input class="task-list-item-checkbox" checked="checked" disabled="disabled" type="checkbox"> checked ordered 3</li>
106+
<li class="task-list-item"><input class="task-list-item-checkbox" disabled="disabled" type="checkbox"> unchecked ordered 4</li>
107+
</ol>
108+
109+
.

tests/test_plugins/test_tasklists.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from pathlib import Path
2+
from textwrap import dedent
3+
4+
import pytest
5+
6+
from markdown_it import MarkdownIt
7+
from markdown_it.utils import read_fixture_file
8+
from markdown_it.extensions.tasklists import tasklists_plugin
9+
10+
FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures", "tasklists.md")
11+
12+
13+
def test_plugin_parse(data_regression):
14+
md = MarkdownIt().use(tasklists_plugin)
15+
tokens = md.parse(
16+
dedent(
17+
"""\
18+
* [ ] Task incomplete
19+
* [x] Task complete
20+
* [ ] Indented task incomplete
21+
* [x] Indented task complete
22+
"""
23+
)
24+
)
25+
data_regression.check([t.as_dict() for t in tokens])
26+
27+
28+
@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH))
29+
def test_all(line, title, input, expected):
30+
md = MarkdownIt("commonmark").use(tasklists_plugin)
31+
md.options["xhtmlOut"] = False
32+
text = md.render(input)
33+
print(text)
34+
assert text.rstrip() == expected.rstrip()

0 commit comments

Comments
 (0)