summaryrefslogtreecommitdiff
path: root/src/mistune/directives
diff options
context:
space:
mode:
Diffstat (limited to 'src/mistune/directives')
-rw-r--r--src/mistune/directives/__init__.py31
-rw-r--r--src/mistune/directives/_base.py121
-rw-r--r--src/mistune/directives/_fenced.py142
-rw-r--r--src/mistune/directives/_rst.py73
-rw-r--r--src/mistune/directives/admonition.py61
-rw-r--r--src/mistune/directives/image.py152
-rw-r--r--src/mistune/directives/include.py65
-rw-r--r--src/mistune/directives/toc.py105
8 files changed, 750 insertions, 0 deletions
diff --git a/src/mistune/directives/__init__.py b/src/mistune/directives/__init__.py
new file mode 100644
index 0000000..660c4c8
--- /dev/null
+++ b/src/mistune/directives/__init__.py
@@ -0,0 +1,31 @@
+from ._base import DirectiveParser, BaseDirective, DirectivePlugin
+from ._rst import RSTDirective
+from ._fenced import FencedDirective
+from .admonition import Admonition
+from .toc import TableOfContents
+from .include import Include
+from .image import Image, Figure
+
+
+class RstDirective(RSTDirective): # pragma: no cover
+ def __init__(self, plugins):
+ super(RstDirective, self).__init__(plugins)
+ import warnings
+ warnings.warn(
+ "'RstDirective' is deprecated, please use 'RSTDirective' instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
+
+__all__ = [
+ 'DirectiveParser',
+ 'BaseDirective',
+ 'DirectivePlugin',
+ 'RSTDirective',
+ 'FencedDirective',
+ 'Admonition',
+ 'TableOfContents',
+ 'Include',
+ 'Image', 'Figure',
+]
diff --git a/src/mistune/directives/_base.py b/src/mistune/directives/_base.py
new file mode 100644
index 0000000..ad326c6
--- /dev/null
+++ b/src/mistune/directives/_base.py
@@ -0,0 +1,121 @@
+import re
+
+
+class DirectiveParser:
+ name = 'directive'
+
+ @staticmethod
+ def parse_type(m: re.Match):
+ raise NotImplementedError()
+
+ @staticmethod
+ def parse_title(m: re.Match):
+ raise NotImplementedError()
+
+ @staticmethod
+ def parse_content(m: re.Match):
+ raise NotImplementedError()
+
+ @classmethod
+ def parse_tokens(cls, block, text, state):
+ if state.depth() >= block.max_nested_level - 1 and cls.name in block.rules:
+ rules = list(block.rules)
+ rules.remove(cls.name)
+ else:
+ rules = block.rules
+ child = state.child_state(text)
+ block.parse(child, rules)
+ return child.tokens
+
+ @staticmethod
+ def parse_options(m: re.Match):
+ text = m.group('options')
+ if not text.strip():
+ return []
+
+ options = []
+ for line in re.split(r'\n+', text):
+ line = line.strip()[1:]
+ if not line:
+ continue
+ i = line.find(':')
+ k = line[:i]
+ v = line[i + 1:].strip()
+ options.append((k, v))
+ return options
+
+
+class BaseDirective:
+ parser = DirectiveParser
+ directive_pattern = None
+
+ def __init__(self, plugins):
+ self._methods = {}
+ self.__plugins = plugins
+
+ def register(self, name, fn):
+ self._methods[name] = fn
+
+ def parse_method(self, block, m, state):
+ _type = self.parser.parse_type(m)
+ method = self._methods.get(_type)
+ if method:
+ try:
+ token = method(block, m, state)
+ except ValueError as e:
+ token = {'type': 'block_error', 'raw': str(e)}
+ else:
+ text = m.group(0)
+ token = {
+ 'type': 'block_error',
+ 'raw': text,
+ }
+
+ if isinstance(token, list):
+ for tok in token:
+ state.append_token(tok)
+ else:
+ state.append_token(token)
+ return token
+
+ def parse_directive(self, block, m, state):
+ raise NotImplementedError()
+
+ def register_block_parser(self, md, before=None):
+ md.block.register(
+ self.parser.name,
+ self.directive_pattern,
+ self.parse_directive,
+ before=before,
+ )
+
+ def __call__(self, md):
+ for plugin in self.__plugins:
+ plugin.parser = self.parser
+ plugin(self, md)
+
+
+class DirectivePlugin:
+ def __init__(self):
+ self.parser = None
+
+ def parse_options(self, m: re.Match):
+ return self.parser.parse_options(m)
+
+ def parse_type(self, m: re.Match):
+ return self.parser.parse_type(m)
+
+ def parse_title(self, m: re.Match):
+ return self.parser.parse_title(m)
+
+ def parse_content(self, m: re.Match):
+ return self.parser.parse_content(m)
+
+ def parse_tokens(self, block, text, state):
+ return self.parser.parse_tokens(block, text, state)
+
+ def parse(self, block, m, state):
+ raise NotImplementedError()
+
+ def __call__(self, md):
+ raise NotImplementedError()
diff --git a/src/mistune/directives/_fenced.py b/src/mistune/directives/_fenced.py
new file mode 100644
index 0000000..818f130
--- /dev/null
+++ b/src/mistune/directives/_fenced.py
@@ -0,0 +1,142 @@
+import re
+from ._base import DirectiveParser, BaseDirective
+
+__all__ = ['FencedDirective']
+
+
+_type_re = re.compile(r'^ *\{[a-zA-Z0-9_-]+\}')
+_directive_re = re.compile(
+ r'\{(?P<type>[a-zA-Z0-9_-]+)\} *(?P<title>[^\n]*)(?:\n|$)'
+ r'(?P<options>(?:\:[a-zA-Z0-9_-]+\: *[^\n]*\n+)*)'
+ r'\n*(?P<text>(?:[^\n]*\n+)*)'
+)
+
+
+class FencedParser(DirectiveParser):
+ name = 'fenced_directive'
+
+ @staticmethod
+ def parse_type(m: re.Match):
+ return m.group('type')
+
+ @staticmethod
+ def parse_title(m: re.Match):
+ return m.group('title')
+
+ @staticmethod
+ def parse_content(m: re.Match):
+ return m.group('text')
+
+
+class FencedDirective(BaseDirective):
+ """A **fenced** style of directive looks like a fenced code block, it is
+ inspired by markdown-it-docutils. The syntax looks like:
+
+ .. code-block:: text
+
+ ```{directive-type} title
+ :option-key: option value
+ :option-key: option value
+
+ content text here
+ ```
+
+ To use ``FencedDirective``, developers can add it into plugin list in
+ the :class:`Markdown` instance:
+
+ .. code-block:: python
+
+ import mistune
+ from mistune.directives import FencedDirective, Admonition
+
+ md = mistune.create_markdown(plugins=[
+ # ...
+ FencedDirective([Admonition()]),
+ ])
+
+ FencedDirective is using >= 3 backticks or curly-brackets for the fenced
+ syntax. Developers can change it to other characters, e.g. colon:
+
+ .. code-block:: python
+
+ directive = FencedDirective([Admonition()], ':')
+
+ And then the directive syntax would look like:
+
+ .. code-block:: text
+
+ ::::{note} Nesting directives
+ You can nest directives by ensuring the start and end fence matching
+ the length. For instance, in this example, the admonition is started
+ with 4 colons, then it should end with 4 colons.
+
+ You can nest another admonition with other length of colons except 4.
+
+ :::{tip} Longer outermost fence
+ It would be better that you put longer markers for the outer fence,
+ and shorter markers for the inner fence. In this example, we put 4
+ colons outsie, and 3 colons inside.
+ :::
+ ::::
+
+ :param plugins: list of directive plugins
+ :param markers: characters to determine the fence, default is backtick
+ and curly-bracket
+ """
+ parser = FencedParser
+
+ def __init__(self, plugins, markers='`~'):
+ super(FencedDirective, self).__init__(plugins)
+ self.markers = markers
+ _marker_pattern = '|'.join(re.escape(c) for c in markers)
+ self.directive_pattern = (
+ r'^(?P<fenced_directive_mark>(?:' + _marker_pattern + r'){3,})'
+ r'\{[a-zA-Z0-9_-]+\}'
+ )
+
+ def _process_directive(self, block, marker, start, state):
+ mlen = len(marker)
+ cursor_start = start + len(marker)
+
+ _end_pattern = (
+ r'^ {0,3}' + marker[0] + '{' + str(mlen) + r',}'
+ r'[ \t]*(?:\n|$)'
+ )
+ _end_re = re.compile(_end_pattern, re.M)
+
+ _end_m = _end_re.search(state.src, cursor_start)
+ if _end_m:
+ text = state.src[cursor_start:_end_m.start()]
+ end_pos = _end_m.end()
+ else:
+ text = state.src[cursor_start:]
+ end_pos = state.cursor_max
+
+ m = _directive_re.match(text)
+ if not m:
+ return
+
+ self.parse_method(block, m, state)
+ return end_pos
+
+ def parse_directive(self, block, m, state):
+ marker = m.group('fenced_directive_mark')
+ return self._process_directive(block, marker, m.start(), state)
+
+ def parse_fenced_code(self, block, m, state):
+ info = m.group('fenced_3')
+ if not info or not _type_re.match(info):
+ return block.parse_fenced_code(m, state)
+
+ if state.depth() >= block.max_nested_level:
+ return block.parse_fenced_code(m, state)
+
+ marker = m.group('fenced_2')
+ return self._process_directive(block, marker, m.start(), state)
+
+ def __call__(self, md):
+ super(FencedDirective, self).__call__(md)
+ if self.markers == '`~':
+ md.block.register('fenced_code', None, self.parse_fenced_code)
+ else:
+ self.register_block_parser(md, 'fenced_code')
diff --git a/src/mistune/directives/_rst.py b/src/mistune/directives/_rst.py
new file mode 100644
index 0000000..6e054cf
--- /dev/null
+++ b/src/mistune/directives/_rst.py
@@ -0,0 +1,73 @@
+import re
+from ._base import DirectiveParser, BaseDirective
+
+__all__ = ['RSTDirective']
+
+
+_directive_re = re.compile(
+ r'\.\.( +)(?P<type>[a-zA-Z0-9_-]+)\:\: *(?P<title>[^\n]*)(?:\n|$)'
+ r'(?P<options>(?: \1 {0,3}\:[a-zA-Z0-9_-]+\: *[^\n]*\n+)*)'
+ r'\n*(?P<text>(?: \1 {0,3}[^\n]*\n+)*)'
+)
+
+
+class RSTParser(DirectiveParser):
+ name = 'rst_directive'
+
+ @staticmethod
+ def parse_type(m: re.Match):
+ return m.group('type')
+
+ @staticmethod
+ def parse_title(m: re.Match):
+ return m.group('title')
+
+ @staticmethod
+ def parse_content(m: re.Match):
+ full_content = m.group(0)
+ text = m.group('text')
+ pretext = full_content[:-len(text)]
+ leading = len(m.group(1)) + 2
+ return '\n'.join(line[leading:] for line in text.splitlines()) + '\n'
+
+
+class RSTDirective(BaseDirective):
+ """A RST style of directive syntax is inspired by reStructuredText.
+ The syntax is very powerful that you can define a lot of custom
+ features on your own. The syntax looks like:
+
+ .. code-block:: text
+
+ .. directive-type:: directive value
+ :option-key: option value
+ :option-key: option value
+
+ content text here
+
+ To use ``RSTDirective``, developers can add it into plugin list in
+ the :class:`Markdown` instance:
+
+ .. code-block:: python
+
+ import mistune
+ from mistune.directives import RSTDirective, Admonition
+
+ md = mistune.create_markdown(plugins=[
+ # ...
+ RSTDirective([Admonition()]),
+ ])
+ """
+ parser = RSTParser
+ directive_pattern = r'^\.\. +[a-zA-Z0-9_-]+\:\:'
+
+ def parse_directive(self, block, m, state):
+ m = _directive_re.match(state.src, state.cursor)
+ if not m:
+ return
+
+ self.parse_method(block, m, state)
+ return m.end()
+
+ def __call__(self, md):
+ super(RSTDirective, self).__call__(md)
+ self.register_block_parser(md)
diff --git a/src/mistune/directives/admonition.py b/src/mistune/directives/admonition.py
new file mode 100644
index 0000000..b380611
--- /dev/null
+++ b/src/mistune/directives/admonition.py
@@ -0,0 +1,61 @@
+from ._base import DirectivePlugin
+
+
+class Admonition(DirectivePlugin):
+ SUPPORTED_NAMES = {
+ "attention", "caution", "danger", "error", "hint",
+ "important", "note", "tip", "warning",
+ }
+
+ def parse(self, block, m, state):
+ name = self.parse_type(m)
+ attrs = {'name': name}
+ options = dict(self.parse_options(m))
+ if 'class' in options:
+ attrs['class'] = options['class']
+
+ title = self.parse_title(m)
+ if not title:
+ title = name.capitalize()
+
+ content = self.parse_content(m)
+ children = [
+ {
+ 'type': 'admonition_title',
+ 'text': title,
+ },
+ {
+ 'type': 'admonition_content',
+ 'children': self.parse_tokens(block, content, state),
+ }
+ ]
+ return {
+ 'type': 'admonition',
+ 'children': children,
+ 'attrs': attrs,
+ }
+
+ def __call__(self, directive, md):
+ for name in self.SUPPORTED_NAMES:
+ directive.register(name, self.parse)
+
+ if md.renderer.NAME == 'html':
+ md.renderer.register('admonition', render_admonition)
+ md.renderer.register('admonition_title', render_admonition_title)
+ md.renderer.register('admonition_content', render_admonition_content)
+
+
+def render_admonition(self, text, name, **attrs):
+ html = '<section class="admonition ' + name
+ _cls = attrs.get('class')
+ if _cls:
+ html += ' ' + _cls
+ return html + '">\n' + text + '</section>\n'
+
+
+def render_admonition_title(self, text):
+ return '<p class="admonition-title">' + text + '</p>\n'
+
+
+def render_admonition_content(self, text):
+ return text
diff --git a/src/mistune/directives/image.py b/src/mistune/directives/image.py
new file mode 100644
index 0000000..5d9d40a
--- /dev/null
+++ b/src/mistune/directives/image.py
@@ -0,0 +1,152 @@
+import re
+from ._base import DirectivePlugin
+from ..util import escape as escape_text, escape_url
+
+__all__ = ['Image', 'Figure']
+
+_num_re = re.compile(r'^\d+(?:\.\d*)?')
+_allowed_aligns = ["top", "middle", "bottom", "left", "center", "right"]
+
+
+def _parse_attrs(options):
+ attrs = {}
+ if 'alt' in options:
+ attrs['alt'] = options['alt']
+
+ # validate align
+ align = options.get('align')
+ if align and align in _allowed_aligns:
+ attrs['align'] = align
+
+ height = options.get('height')
+ width = options.get('width')
+ if height and _num_re.match(height):
+ attrs['height'] = height
+ if width and _num_re.match(width):
+ attrs['width'] = width
+ if 'target' in options:
+ attrs['target'] = escape_url(options['target'])
+ return attrs
+
+
+class Image(DirectivePlugin):
+ NAME = 'image'
+
+ def parse(self, block, m, state):
+ options = dict(self.parse_options(m))
+ attrs = _parse_attrs(options)
+ attrs['src'] = self.parse_title(m)
+ return {'type': 'block_image', 'attrs': attrs}
+
+ def __call__(self, directive, md):
+ directive.register(self.NAME, self.parse)
+ if md.renderer.NAME == 'html':
+ md.renderer.register('block_image', render_block_image)
+
+
+def render_block_image(self, src: str, alt=None, width=None, height=None, **attrs):
+ img = '<img src="' + src + '"'
+ style = ''
+ if alt:
+ img += ' alt="' + escape_text(alt) + '"'
+ if width:
+ if width.isdigit():
+ img += ' width="' + width + '"'
+ else:
+ style += 'width:' + width + ';'
+ if height:
+ if height.isdigit():
+ img += ' height="' + height + '"'
+ else:
+ style += 'height:' + height + ';'
+ if style:
+ img += ' style="' + escape_text(style) + '"'
+
+ img += ' />'
+
+ _cls = 'block-image'
+ align = attrs.get('align')
+ if align:
+ _cls += ' align-' + align
+
+ target = attrs.get('target')
+ if target:
+ href = escape_text(self.safe_url(target))
+ outer = '<a class="' + _cls + '" href="' + href + '">'
+ return outer + img + '</a>\n'
+ else:
+ return '<div class="' + _cls + '">' + img + '</div>\n'
+
+
+class Figure(DirectivePlugin):
+ NAME = 'figure'
+
+ def parse_directive_content(self, block, m, state):
+ content = self.parse_content(m)
+ if not content:
+ return
+
+ tokens = self.parse_tokens(block, content, state)
+ caption = tokens[0]
+ if caption['type'] == 'paragraph':
+ caption['type'] = 'figcaption'
+ children = [caption]
+ if len(tokens) > 1:
+ children.append({
+ 'type': 'legend',
+ 'children': tokens[1:]
+ })
+ return children
+
+ def parse(self, block, m, state):
+ options = dict(self.parse_options(m))
+ image_attrs = _parse_attrs(options)
+ image_attrs['src'] = self.parse_title(m)
+
+ align = image_attrs.pop('align', None)
+ fig_attrs = {}
+ if align:
+ fig_attrs['align'] = align
+ for k in ['figwidth', 'figclass']:
+ if k in options:
+ fig_attrs[k] = options[k]
+
+ children = [{'type': 'block_image', 'attrs': image_attrs}]
+ content = self.parse_directive_content(block, m, state)
+ if content:
+ children.extend(content)
+ return {
+ 'type': 'figure',
+ 'attrs': fig_attrs,
+ 'children': children,
+ }
+
+ def __call__(self, directive, md):
+ directive.register(self.NAME, self.parse)
+
+ if md.renderer.NAME == 'html':
+ md.renderer.register('figure', render_figure)
+ md.renderer.register('block_image', render_block_image)
+ md.renderer.register('figcaption', render_figcaption)
+ md.renderer.register('legend', render_legend)
+
+
+def render_figure(self, text, align=None, figwidth=None, figclass=None):
+ _cls = 'figure'
+ if align:
+ _cls += ' align-' + align
+ if figclass:
+ _cls += ' ' + figclass
+
+ html = '<figure class="' + _cls + '"'
+ if figwidth:
+ html += ' style="width:' + figwidth + '"'
+ return html + '>\n' + text + '</figure>\n'
+
+
+def render_figcaption(self, text):
+ return '<figcaption>' + text + '</figcaption>\n'
+
+
+def render_legend(self, text):
+ return '<div class="legend">\n' + text + '</div>\n'
diff --git a/src/mistune/directives/include.py b/src/mistune/directives/include.py
new file mode 100644
index 0000000..d2180ba
--- /dev/null
+++ b/src/mistune/directives/include.py
@@ -0,0 +1,65 @@
+import os
+from ._base import DirectivePlugin
+
+
+class Include(DirectivePlugin):
+ def parse(self, block, m, state):
+ source_file = state.env.get('__file__')
+ if not source_file:
+ return {'type': 'block_error', 'raw': 'Missing source file'}
+
+ encoding = 'utf-8'
+ options = self.parse_options(m)
+ if options:
+ attrs = dict(options)
+ if 'encoding' in attrs:
+ encoding = attrs['encoding']
+ else:
+ attrs = {}
+
+ relpath = self.parse_title(m)
+ dest = os.path.join(os.path.dirname(source_file), relpath)
+ dest = os.path.normpath(dest)
+
+ if dest == source_file:
+ return {
+ 'type': 'block_error',
+ 'raw': 'Could not include self: ' + relpath,
+ }
+
+ if not os.path.isfile(dest):
+ return {
+ 'type': 'block_error',
+ 'raw': 'Could not find file: ' + relpath,
+ }
+
+ with open(dest, 'rb') as f:
+ content = f.read()
+ content = content.decode(encoding)
+
+ ext = os.path.splitext(relpath)[1]
+ if ext in {'.md', '.markdown', '.mkd'}:
+ new_state = block.state_cls()
+ new_state.env['__file__'] = dest
+ new_state.process(content)
+ block.parse(new_state)
+ return new_state.tokens
+
+ elif ext in {'.html', '.xhtml', '.htm'}:
+ return {'type': 'block_html', 'raw': content}
+
+ attrs['filepath'] = dest
+ return {
+ 'type': 'include',
+ 'raw': content,
+ 'attrs': attrs,
+ }
+
+ def __call__(self, directive, md):
+ directive.register('include', self.parse)
+ if md.renderer and md.renderer.NAME == 'html':
+ md.renderer.register('include', render_html_include)
+
+
+def render_html_include(renderer, text, **attrs):
+ return '<pre class="directive-include">\n' + text + '</pre>\n'
diff --git a/src/mistune/directives/toc.py b/src/mistune/directives/toc.py
new file mode 100644
index 0000000..4084f43
--- /dev/null
+++ b/src/mistune/directives/toc.py
@@ -0,0 +1,105 @@
+"""
+ TOC directive
+ ~~~~~~~~~~~~~
+
+ The TOC directive syntax looks like::
+
+ .. toc:: Title
+ :min-level: 1
+ :max-level: 3
+
+ "Title", "min-level", and "max-level" option can be empty. "min-level"
+ and "max-level" are integers >= 1 and <= 6, which define the allowed
+ heading levels writers want to include in the table of contents.
+"""
+
+from ._base import DirectivePlugin
+from ..toc import normalize_toc_item, render_toc_ul
+
+
+class TableOfContents(DirectivePlugin):
+ def __init__(self, min_level=1, max_level=3):
+ self.min_level = min_level
+ self.max_level = max_level
+
+ def generate_heading_id(self, token, index):
+ return 'toc_' + str(index + 1)
+
+ def parse(self, block, m, state):
+ title = self.parse_title(m)
+ options = self.parse_options(m)
+ if options:
+ d_options = dict(options)
+ collapse = 'collapse' in d_options
+ min_level = _normalize_level(d_options, 'min-level', self.min_level)
+ max_level = _normalize_level(d_options, 'max-level', self.max_level)
+ if min_level < self.min_level:
+ raise ValueError(f'"min-level" option MUST be >= {self.min_level}')
+ if max_level > self.max_level:
+ raise ValueError(f'"max-level" option MUST be <= {self.max_level}')
+ if min_level > max_level:
+ raise ValueError('"min-level" option MUST be less than "max-level" option')
+ else:
+ collapse = False
+ min_level = self.min_level
+ max_level = self.max_level
+
+ attrs = {
+ 'min_level': min_level,
+ 'max_level': max_level,
+ 'collapse': collapse,
+ }
+ return {'type': 'toc', 'text': title or '', 'attrs': attrs}
+
+ def toc_hook(self, md, state):
+ sections = []
+ headings = []
+
+ for tok in state.tokens:
+ if tok['type'] == 'toc':
+ sections.append(tok)
+ elif tok['type'] == 'heading':
+ headings.append(tok)
+
+ if sections:
+ toc_items = []
+ # adding ID for each heading
+ for i, tok in enumerate(headings):
+ tok['attrs']['id'] = self.generate_heading_id(tok, i)
+ toc_items.append(normalize_toc_item(md, tok))
+
+ for sec in sections:
+ _min = sec['attrs']['min_level']
+ _max = sec['attrs']['max_level']
+ toc = [item for item in toc_items if _min <= item[0] <= _max]
+ sec['attrs']['toc'] = toc
+
+ def __call__(self, directive, md):
+ if md.renderer and md.renderer.NAME == 'html':
+ # only works with HTML renderer
+ directive.register('toc', self.parse)
+ md.before_render_hooks.append(self.toc_hook)
+ md.renderer.register('toc', render_html_toc)
+
+
+def render_html_toc(renderer, title, collapse=False, **attrs):
+ if not title:
+ title = 'Table of Contents'
+ toc = attrs['toc']
+ content = render_toc_ul(attrs['toc'])
+
+ html = '<details class="toc"'
+ if not collapse:
+ html += ' open'
+ html += '>\n<summary>' + title + '</summary>\n'
+ return html + content + '</details>\n'
+
+
+def _normalize_level(options, name, default):
+ level = options.get(name)
+ if not level:
+ return default
+ try:
+ return int(level)
+ except (ValueError, TypeError):
+ raise ValueError(f'"{name}" option MUST be integer')