Source code for gfm.tasklist

# coding: utf-8
# Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.

"""
:mod:`gfm.tasklist` -- Task list support
========================================

The :mod:`gfm.tasklist` module provides GitHub-like support for task lists.
Those are normal lists with a checkbox-like syntax at the beginning of items
that will be converted to actual checkbox inputs. Nested lists are supported.

Example syntax::

   - [x] milk
   - [ ] eggs
   - [x] chocolate
   - [ ] if possible:
       1. [ ] solve world peace
       2. [ ] solve world hunger

.. NOTE::
   GitHub has support for updating the Markdown source text by toggling the
   checkbox (by clicking on it). This is not supported by this extension, as it
   requires server-side processing that is out of scope of a Markdown parser.

Available configuration options
-------------------------------

================== ============== ======== ===========
Name               Type           Default  Description
================== ============== ======== ===========
``unordered``      bool           ``True`` Set to ``False`` to disable parsing of unordered lists.
``ordered``        bool           ``True`` Set to ``False`` to disable parsing of ordered lists.
``max_depth``      integer        ∞        Set to a positive integer to stop parsing nested task
                                           lists that are deeper than this limit.
``list_attrs``     dict, callable ``{}``   Attributes to be added to the ``<ul>`` or ``<ol>`` element
                                           containing the items.
``item_attrs``     dict, callable ``{}``   Attributes to be added to the ``<li>`` element containing
                                           the checkbox. See `Item attributes`_.
``checkbox_attrs`` dict, callable ``{}``   Attributes to be added to the checkbox element.
                                           See `Checkbox attributes`_.
================== ============== ======== ===========

List attributes
***************

If option ``list_attrs`` is a *dict*, the key-value pairs will be applied to
the ``<ul>`` (resp. ``<ol>``) unordered (resp. ordered) list element, that is
the parent element of the ``<li>`` elements.

.. warning::

   These attributes are applied to all nesting levels of lists, that is,
   to both the root lists and their potential sub-lists, recursively.

   You can control this behavior by using a *callable* instead (see below).

If option ``list_attrs`` is a *callable*, it should be a function that respects
the following prototype::

   def function(list, depth: int) -> dict:

where:

- ``list`` is the ``<ul>`` or ``<ol>`` element;
- ``depth`` is the depth of this list relative to its root list (root lists have
  a depth of 1).

The returned *dict* items will be applied as HTML attributes to the list
element.

.. note::

   Thanks to this feature, you could apply attributes to root lists only.
   Take this code sample::

      import markdown
      from gfm import TaskListExtension

      def list_attr_cb(list, depth):
          if depth == 1:
              return {'class': 'tasklist'}
          return {}

      tl_ext = TaskListExtension(list_attrs=list_attr_cb)

      print(markdown.markdown(\"""
      - [x] some thing
      - [ ] some other
          - [ ] sub thing
          - [ ] sub other
      \""", extensions=[tl_ext]))

   In this example, only the root list will have the ``tasklist`` class
   attribute, not the one containing “sub” items.


Item attributes
***************

If option ``item_attrs`` is a *dict*, the key-value pairs will be applied to
the ``<li>`` element as its HTML attributes.

Example::

   item_attrs={'class': 'list-item'}

will result in::

   <li class="list-item">...</li>

If option ``item_attrs`` is a *callable*, it should be a function that
respects the following prototype::

   def function(parent, element, checkbox) -> dict:

where:

- ``parent`` is the ``<li>`` parent element;
- ``element`` is the ``<li>`` element;
- ``checkbox`` is the generated ``<input type="checkbox">`` element.

The returned *dict* items will be applied as HTML attributes to the ``<li>``
element containing the checkbox.

Checkbox attributes
*******************

If option ``checkbox_attrs`` is a *dict*, the key-value pairs will be applied to
the ``<input type="checkbox">`` element as its HTML attributes.

Example::

   checkbox_attrs={'class': 'list-cb'}

will result in::

   <li><input type="checkbox" class="list-cb"> ...</li>

If option ``checkbox_attrs`` is a *callable*, it should be a function that
respects the following prototype::

   def function(parent, element) -> dict:

where:

- ``parent`` is the ``<li>`` parent element;
- ``element`` is the ``<li>`` element.

The returned *dict* items will be applied as HTML attributes to the checkbox
element.

Typical usage
-------------

.. testcode::

   import markdown
   from gfm import TaskListExtension

   print(markdown.markdown(\"""
   - [x] milk
   - [ ] eggs
   - [x] chocolate
   - not a checkbox
   \""", extensions=[TaskListExtension()]))

.. testoutput::

   <ul>
   <li><input checked="checked" disabled="disabled" type="checkbox" /> milk</li>
   <li><input disabled="disabled" type="checkbox" /> eggs</li>
   <li><input checked="checked" disabled="disabled" type="checkbox" /> chocolate</li>
   <li>not a checkbox</li>
   </ul>

"""

from __future__ import unicode_literals

import markdown
from functools import reduce
from markdown.treeprocessors import Treeprocessor
from markdown.util import etree

try:
    _string_type = unicode
except NameError:
    _string_type = str


def _to_list(obj):
    if isinstance(obj, _string_type):
        return [obj]
    if isinstance(obj, tuple):
        return list(obj)
    return obj


class TaskListProcessor(Treeprocessor):
    def __init__(self, ext):
        super(TaskListProcessor, self).__init__()
        self.ext = ext

    def run(self, root):
        ordered = self.ext.getConfig('ordered')
        unordered = self.ext.getConfig('unordered')
        if not ordered and not unordered:
            return root

        checked_pattern = _to_list(self.ext.getConfig('checked'))
        unchecked_pattern = _to_list(self.ext.getConfig('unchecked'))
        if not checked_pattern and not unchecked_pattern:
            return root

        prefix_length = reduce(max, (len(e) for e in checked_pattern +
                                     unchecked_pattern))

        item_attrs = self.ext.getConfig('item_attrs')
        list_attrs = self.ext.getConfig('list_attrs')
        base_cb_attrs = self.ext.getConfig('checkbox_attrs')
        max_depth = self.ext.getConfig('max_depth')
        if not max_depth:
            max_depth = float('inf')

        lists = set()
        stack = [(root, None, 0)]

        while stack:
            el, parent, depth = stack.pop()

            if (parent and el.tag == 'li' and
                    (parent.tag == 'ul' and unordered or
                     parent.tag == 'ol' and ordered)):
                depth += 1
                text = (el.text or '').lstrip()
                lower_text = text[:prefix_length].lower()
                found = False
                checked = False

                for p in checked_pattern:
                    if lower_text.startswith(p):
                        found = True
                        checked = True
                        text = text[len(p):]
                        break
                else:
                    for p in unchecked_pattern:
                        if lower_text.startswith(p):
                            found = True
                            text = text[len(p):]
                            break

                if found:
                    # Add root <ol> or <ul> element to the list set
                    if list_attrs:
                        lists.add((parent, depth))

                    # Checkbox attributes
                    attrs = {'type': 'checkbox', 'disabled': 'disabled'}
                    if checked:
                        attrs['checked'] = 'checked'
                    # Give user a chance to update checkbox attributes
                    attrs.update(base_cb_attrs(parent, el)
                                 if callable(base_cb_attrs)
                                 else base_cb_attrs)
                    checkbox = etree.Element('input', attrs)
                    checkbox.tail = text
                    # Prepend checkbox to <li>
                    el.text = ''
                    el.insert(0, checkbox)
                    # Give user a chance to update <li> attributes
                    for k, v in (item_attrs(parent, el, checkbox)
                                 if callable(item_attrs)
                                 else item_attrs).items():
                        el.set(k, v)

            if depth < max_depth:
                for child in el:
                    stack.append((child, el, depth))

        for list, depth in lists:
            for k, v in (list_attrs(list, depth) if callable(list_attrs)
                         else list_attrs).items():
                list.set(k, v)

        return root


[docs]class TaskListExtension(markdown.Extension): """ An extension that supports GitHub task lists. Both ordered and unordered lists are supported and can be separately enabled. Nested lists are supported. Example:: - [x] milk - [ ] eggs - [x] chocolate - [ ] if possible: 1. [ ] solve world peace 2. [ ] solve world hunger """ def __init__(self, *args, **kwargs): self.config = { 'ordered': [True, "Enable parsing of ordered lists"], 'unordered': [True, "Enable parsing of unordered lists"], 'checked': ['[x]', "The checked state pattern"], 'unchecked': ['[ ]', "The unchecked state pattern"], 'max_depth': [0, "Maximum list nesting depth (None for " "unlimited)"], 'list_attrs': [{}, "Additional attribute dict (or callable) to " "add to the <ul> or <ol> element"], 'item_attrs': [{}, "Additional attribute dict (or callable) to " "add to the <li> element"], 'checkbox_attrs': [{}, "Additional attribute dict (or callable) " "to add to the checkbox <input> element"], } super(TaskListExtension, self).__init__(*args, **kwargs) def extendMarkdown(self, md, md_globals): processor = TaskListProcessor(self) md.treeprocessors.add('gfm-tasklist', processor, '_end')