diff options
Diffstat (limited to 'src/mistune/directives')
-rw-r--r-- | src/mistune/directives/__init__.py | 31 | ||||
-rw-r--r-- | src/mistune/directives/_base.py | 121 | ||||
-rw-r--r-- | src/mistune/directives/_fenced.py | 142 | ||||
-rw-r--r-- | src/mistune/directives/_rst.py | 73 | ||||
-rw-r--r-- | src/mistune/directives/admonition.py | 61 | ||||
-rw-r--r-- | src/mistune/directives/image.py | 152 | ||||
-rw-r--r-- | src/mistune/directives/include.py | 65 | ||||
-rw-r--r-- | src/mistune/directives/toc.py | 105 |
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') |