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.
18from __future__ import annotations
19
20import re
21from uuid import uuid4
22
23from markdown_it import MarkdownIt
24from markdown_it.rules_core import StateCore
25from markdown_it.token import Token
26
27# Regex string to match a whitespace character, as specified in
28# https://github.github.com/gfm/#whitespace-character
29# (spec version 0.29-gfm (2019-04-06))
30_GFM_WHITESPACE_RE = r"[ \t\n\v\f\r]"
31
32
33def tasklists_plugin(
34 md: MarkdownIt,
35 enabled: bool = False,
36 label: bool = False,
37 label_after: bool = False,
38) -> None:
39 """Plugin for building task/todo lists out of markdown lists with items starting with [ ] or [x]
40 .. Nothing else
41
42 For example::
43 - [ ] An item that needs doing
44 - [x] An item that is complete
45
46 The rendered HTML checkboxes are disabled; to change this, pass a truthy value into the enabled
47 property of the plugin options.
48
49 :param enabled: True enables the rendered checkboxes
50 :param label: True wraps the rendered list items in a <label> element for UX purposes,
51 :param label_after: True adds the <label> element after the checkbox.
52 """
53 disable_checkboxes = not enabled
54 use_label_wrapper = label
55 use_label_after = label_after
56
57 def fcn(state: StateCore) -> None:
58 tokens = state.tokens
59 for i in range(2, len(tokens) - 1):
60 if is_todo_item(tokens, i):
61 todoify(tokens[i])
62 tokens[i - 2].attrSet(
63 "class",
64 "task-list-item" + (" enabled" if not disable_checkboxes else ""),
65 )
66 tokens[parent_token(tokens, i - 2)].attrSet(
67 "class", "contains-task-list"
68 )
69
70 md.core.ruler.after("inline", "github-tasklists", fcn)
71
72 def parent_token(tokens: list[Token], index: int) -> int:
73 target_level = tokens[index].level - 1
74 for i in range(1, index + 1):
75 if tokens[index - i].level == target_level:
76 return index - i
77 return -1
78
79 def is_todo_item(tokens: list[Token], index: int) -> bool:
80 return (
81 is_inline(tokens[index])
82 and is_paragraph(tokens[index - 1])
83 and is_list_item(tokens[index - 2])
84 and starts_with_todo_markdown(tokens[index])
85 )
86
87 def todoify(token: Token) -> None:
88 assert token.children is not None
89 token.children.insert(0, make_checkbox(token))
90 token.children[1].content = token.children[1].content[3:]
91 token.content = token.content[3:]
92
93 if use_label_wrapper:
94 if use_label_after:
95 token.children.pop()
96
97 # Replaced number generator from original plugin with uuid.
98 checklist_id = f"task-item-{uuid4()}"
99 token.children[0].content = (
100 token.children[0].content[0:-1] + f' id="{checklist_id}">'
101 )
102 token.children.append(after_label(token.content, checklist_id))
103 else:
104 token.children.insert(0, begin_label())
105 token.children.append(end_label())
106
107 def make_checkbox(token: Token) -> Token:
108 checkbox = Token("html_inline", "", 0)
109 disabled_attr = 'disabled="disabled"' if disable_checkboxes else ""
110 if token.content.startswith("[ ] "):
111 checkbox.content = (
112 '<input class="task-list-item-checkbox" '
113 f'{disabled_attr} type="checkbox">'
114 )
115 elif token.content.startswith("[x] ") or token.content.startswith("[X] "):
116 checkbox.content = (
117 '<input class="task-list-item-checkbox" checked="checked" '
118 f'{disabled_attr} type="checkbox">'
119 )
120 return checkbox
121
122 def begin_label() -> Token:
123 token = Token("html_inline", "", 0)
124 token.content = "<label>"
125 return token
126
127 def end_label() -> Token:
128 token = Token("html_inline", "", 0)
129 token.content = "</label>"
130 return token
131
132 def after_label(content: str, checkbox_id: str) -> Token:
133 token = Token("html_inline", "", 0)
134 token.content = (
135 f'<label class="task-list-item-label" for="{checkbox_id}">{content}</label>'
136 )
137 token.attrs = {"for": checkbox_id}
138 return token
139
140 def is_inline(token: Token) -> bool:
141 return token.type == "inline"
142
143 def is_paragraph(token: Token) -> bool:
144 return token.type == "paragraph_open"
145
146 def is_list_item(token: Token) -> bool:
147 return token.type == "list_item_open"
148
149 def starts_with_todo_markdown(token: Token) -> bool:
150 # leading whitespace in a list item is already trimmed off by markdown-it
151 return re.match(rf"\[[ xX]]{_GFM_WHITESPACE_RE}+", token.content) is not None