diff --git a/src/dragonglass/mparse.py b/src/dragonglass/mparse.py index a7d26f5..d729ea3 100644 --- a/src/dragonglass/mparse.py +++ b/src/dragonglass/mparse.py @@ -382,6 +382,12 @@ class ObsidianLinks(Extension): return el, m.start(0), m.end(0) def extendMarkdown(self, md: markdown.Markdown) -> None: # noqa: N802 + """ + Registers the link inline processors with the Markdown parser. + + Args: + md (markdown.Markdown): The Markdown parser to register the patterns with. + """ md.inlinePatterns.register(ObsidianLinks.ObsidianLinksProc(OBSLINK_PATTERN, md, self), 'obsidian_links', PRIO_BASE + 110) md.inlinePatterns.register(ObsidianLinks.GenericLinksProc(GENERICLINK_PATTERN, md, self), @@ -389,9 +395,10 @@ class ObsidianLinks(Extension): class ObsidianLists(Extension): - """An extension to process Obsidian lists, including making nested lists.""" + """An extension to process Obsidian lists, including making nested lists and task lists.""" UL_RE = re.compile(r'^([ ]*)[-+*][ ]+(.*)') OL_RE = re.compile(r'^([ ]*)([1-9][0-9]*)\.[ ]+(.*)') + TASK_RE = re.compile(r'^([ ]*)-[ ]\[(.)\][ ]+(.*)') LIST_START = STX + "erbosoft:lstart" + ETX LIST_END = STX + "erbosoft:lend" + ETX @@ -419,11 +426,27 @@ class ObsidianLists(Extension): return '', -1, -1 class ObsidianListFinder(Preprocessor): + """Preprocessor that finds and marks blocks in the text that contain lists.""" def __init__(self, extref: Any) -> None: + """ + Initialize the ObsidianListFinder. + + Args: + extref (ObsidianLists): Reference to the outer object. + """ super(ObsidianLists.ObsidianListFinder, self).__init__() self._extref = extref def run(self, lines: list[str]) -> list[str]: + """ + Execute the preprocessor on the Markdown text. + + Args: + lines (list[str]): List of lines of text in the input document. + + Returns: + list[str]: The list of lines to carry forward with. + """ i = 0 in_list = False while i < len(lines): @@ -444,34 +467,43 @@ class ObsidianLists(Extension): class ObsidianListBlock(BlockProcessor): """The actual block processor that generates lists.""" def __init__(self, parser: BlockParser, extref: Any) -> None: + """ + Initialize the ObsidianListBlock. + + Args: + parser (BlockParser): Reference to the block parser. + extref (ObsidianLists): Backreference to the outer object. + """ super(ObsidianLists.ObsidianListBlock, self).__init__(parser) self._extref = extref @staticmethod - def _extract(blocks: list[str]) -> list[str]: + def _attach_checkbox(listitem: etree.Element, data_line: int, cbox: str, text: str) -> None: """ - Extract all lines up to the next blank line, which terminates the list. + Attach a checkbox and its associated text to the ``li`` element. Args: - blocks (list[str]): List of blocks to be parsed. - - Returns: - list[str]: The block of lines to be parsed for a list. + listitem (etree.Element): The ``li`` element that's being worked on. + data_line (int): Data line index for this list item. + cbox (str): The checkbox marker character. + text (str): The text to include after the checkbox. """ - out_lines: list[str] = [] - while len(blocks) > 0: - curblk = blocks.pop(0) - cur_lines = curblk.split('\n') - while len(cur_lines) > 0: - line = cur_lines.pop(0) - if line.strip() == '': - cur_lines.insert(0, line) - blocks.insert(0, '\n'.join(cur_lines)) - return out_lines - out_lines.append(line) - return out_lines + checkbox = etree.SubElement(listitem, 'input') + checkbox.attrib['type'] = 'checkbox' + checkbox.attrib['class'] = 'task-list-item-checkbox' + checkbox.attrib['aria-label'] = 'Task' + checkbox.attrib['data-line'] = str(data_line) + checkbox.attrib['data-task'] = cbox + if not cbox.isspace(): + checkbox.attrib['checked'] = 'checked' + if cbox == 'x': + span = etree.SubElement(listitem, 'span') + span.attrib['class'] = 'task-list-item-checked' + span.text = text + else: + checkbox.tail = text - def _build_element(self, parent: etree.Element, listtype: str, indent: int, lines: list[str]) -> None: + def _build_element(self, parent: etree.Element, listtype: str, indent: int, data_line: int, lines: list[str]) -> None: """ Builds a list element and adds it to the specified parent list. @@ -479,9 +511,11 @@ class ObsidianLists(Extension): parent (etree.element): The parent list element to insert "li" items under. listtype (str): The list type, either "ol" or "ul". indent (int): The indent level of the list, which is greater than or equal to 0. + data_line (int): The data line index of the element. lines (list[str]): The lines comprising the list element. """ textdata: list[str] = [] + cbox: str | None = None # Parse the header line to get the first bit of text. if listtype == 'ol': @@ -489,12 +523,24 @@ class ObsidianLists(Extension): assert len(m.group(1)) == indent textdata.append(m.group(3)) elif listtype == 'ul': - m = self._extref.UL_RE.match(lines[0]) - assert len(m.group(1)) == indent - textdata.append(m.group(2)) + m = self._extref.TASK_RE.match(lines[0]) + if m: + assert len(m.group(1)) == indent + cbox = m.group(2) + textdata.append(m.group(3)) + else: + m = self._extref.UL_RE.match(lines[0]) + assert len(m.group(1)) == indent + textdata.append(m.group(2)) # Build the list element listelement = etree.SubElement(parent, 'li') + listelement.attrib['data-line'] = str(data_line) + if cbox is not None: + classname = 'task-list-item' + if not cbox.isspace(): + classname = f"{classname} is-checked" + listelement.attrib['class'] = classname my_lines = list(lines) i = 1 last_sublist: etree.Element | None = None @@ -510,6 +556,8 @@ class ObsidianLists(Extension): # flush text data if last_sublist is not None: last_sublist.tail = '\n'.join(textdata) + elif cbox is not None: + self._attach_checkbox(listelement, data_line, cbox, '\n'.join(textdata)) else: listelement.text = '\n'.join(textdata) textdata = [] @@ -521,6 +569,8 @@ class ObsidianLists(Extension): if len(textdata) > 0: if last_sublist is not None: last_sublist.tail = '\n'.join(textdata) + elif cbox is not None: + self._attach_checkbox(listelement, data_line, cbox, '\n'.join(textdata)) else: listelement.text = '\n'.join(textdata) @@ -543,27 +593,34 @@ class ObsidianLists(Extension): list_top = etree.SubElement(parent, listtype) if listtype == 'ol' and start_index > 1: list_top.attrib['start'] = str(start_index) + if listtype == 'ul': + m = self._extref.TASK_RE.match(lines[0]) + if m: + list_top.attrib['class'] = 'contains-task-list' # The start index is set to -1 so, the first time around, we'll pick it up and set the start point. st = -1 + data_line = 0 for i, line in enumerate(lines): new_type, new_indent, _ = self._extref._find_listhead(lines[i]) if len(new_type) > 0: if new_indent < indent_level: # if the list head is lesser indented, this is the end of this element and the list if st >= 0: - self._build_element(list_top, listtype, indent_level, lines[st:i]) + self._build_element(list_top, listtype, indent_level, data_line, lines[st:i]) + data_line += 1 return list_top, lines[i:] if new_indent == indent_level: # this is the end of this element if st >= 0: - self._build_element(list_top, listtype, indent_level, lines[st:i]) + self._build_element(list_top, listtype, indent_level, data_line, lines[st:i]) + data_line += 1 if new_type != listtype: # this is also the end of the list return list_top, lines[i:] st = i # start of next element if st >= 0: # end the final element - self._build_element(list_top, listtype, indent_level, lines[st:]) + self._build_element(list_top, listtype, indent_level, data_line, lines[st:]) return list_top, [] def test(self, parent: etree.Element, block: str) -> bool: diff --git a/src/dragonglass/style.py b/src/dragonglass/style.py index 93a60ef..cfe126b 100644 --- a/src/dragonglass/style.py +++ b/src/dragonglass/style.py @@ -28,6 +28,9 @@ blockquote { margin-right: 1em; border-left: 2px solid #9873f7; } +span.task-list-item-checked { + text-decoration-line: line-through; +} """