diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:29:52 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:29:52 +0000 |
commit | fcb2f10732db61d216e2105c8154486f66b3e3ff (patch) | |
tree | efda929db4b1543eecc583e3b7d9c0bad4cd86a6 /mdit_py_plugins | |
parent | Initial commit. (diff) | |
download | mdit-py-plugins-fcb2f10732db61d216e2105c8154486f66b3e3ff.tar.xz mdit-py-plugins-fcb2f10732db61d216e2105c8154486f66b3e3ff.zip |
Adding upstream version 0.3.3.upstream/0.3.3upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mdit_py_plugins')
47 files changed, 3789 insertions, 0 deletions
diff --git a/mdit_py_plugins/__init__.py b/mdit_py_plugins/__init__.py new file mode 100644 index 0000000..e19434e --- /dev/null +++ b/mdit_py_plugins/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.3" diff --git a/mdit_py_plugins/admon/LICENSE b/mdit_py_plugins/admon/LICENSE new file mode 100644 index 0000000..eb4033e --- /dev/null +++ b/mdit_py_plugins/admon/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. +Copyright (c) 2018 jebbs +Copyright (c) 2021- commenthol + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdit_py_plugins/admon/__init__.py b/mdit_py_plugins/admon/__init__.py new file mode 100644 index 0000000..49b1c15 --- /dev/null +++ b/mdit_py_plugins/admon/__init__.py @@ -0,0 +1 @@ +from .index import admon_plugin # noqa: F401 diff --git a/mdit_py_plugins/admon/index.py b/mdit_py_plugins/admon/index.py new file mode 100644 index 0000000..8ebbe8f --- /dev/null +++ b/mdit_py_plugins/admon/index.py @@ -0,0 +1,172 @@ +# Process admonitions and pass to cb. + +import math +from typing import Callable, Optional, Tuple + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + + +def get_tag(params: str) -> Tuple[str, str]: + if not params.strip(): + return "", "" + + tag, *_title = params.strip().split(" ") + joined = " ".join(_title) + + title = "" + if not joined: + title = tag.title() + elif joined != '""': + title = joined + return tag.lower(), title + + +def validate(params: str) -> bool: + tag = params.strip().split(" ", 1)[-1] or "" + return bool(tag) + + +MIN_MARKERS = 3 +MARKER_STR = "!" +MARKER_CHAR = ord(MARKER_STR) +MARKER_LEN = len(MARKER_STR) + + +def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # Check out the first character quickly, which should filter out most of non-containers + if MARKER_CHAR != ord(state.src[start]): + return False + + # Check out the rest of the marker string + pos = start + 1 + while pos <= maximum and MARKER_STR[(pos - start) % MARKER_LEN] == state.src[pos]: + pos += 1 + + marker_count = math.floor((pos - start) / MARKER_LEN) + if marker_count < MIN_MARKERS: + return False + marker_pos = pos - ((pos - start) % MARKER_LEN) + params = state.src[marker_pos:maximum] + markup = state.src[start:marker_pos] + + if not validate(params): + return False + + # Since start is found, we can report success here in validation mode + if silent: + return True + + old_parent = state.parentType + old_line_max = state.lineMax + old_indent = state.blkIndent + + blk_start = pos + while blk_start < maximum and state.src[blk_start] == " ": + blk_start += 1 + + state.parentType = "admonition" + state.blkIndent += blk_start - start + + was_empty = False + + # Search for the end of the block + next_line = startLine + while True: + next_line += 1 + if next_line >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + pos = state.bMarks[next_line] + state.tShift[next_line] + maximum = state.eMarks[next_line] + is_empty = state.sCount[next_line] < state.blkIndent + + # two consecutive empty lines autoclose the block + if is_empty and was_empty: + break + was_empty = is_empty + + if pos < maximum and state.sCount[next_line] < state.blkIndent: + # non-empty line with negative indent should stop the block: + # - !!! + # test + break + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = next_line + + tag, title = get_tag(params) + + token = state.push("admonition_open", "div", 1) + token.markup = markup + token.block = True + token.attrs = {"class": f"admonition {tag}"} + token.meta = {"tag": tag} + token.content = title + token.info = params + token.map = [startLine, next_line] + + if title: + title_markup = f"{markup} {tag}" + token = state.push("admonition_title_open", "p", 1) + token.markup = title_markup + token.attrs = {"class": "admonition-title"} + token.map = [startLine, startLine + 1] + + token = state.push("inline", "", 0) + token.content = title + token.map = [startLine, startLine + 1] + token.children = [] + + token = state.push("admonition_title_close", "p", -1) + token.markup = title_markup + + state.md.block.tokenize(state, startLine + 1, next_line) + + token = state.push("admonition_close", "div", -1) + token.markup = state.src[start:pos] + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.blkIndent = old_indent + state.line = next_line + + return True + + +def admon_plugin(md: MarkdownIt, render: Optional[Callable] = None) -> None: + """Plugin to use + `python-markdown style admonitions + <https://python-markdown.github.io/extensions/admonition>`_. + + .. code-block:: md + + !!! note + *content* + + Note, this is ported from + `markdown-it-admon + <https://github.com/commenthol/markdown-it-admon>`_. + """ + + def renderDefault(self, tokens, idx, _options, env): + return self.renderToken(tokens, idx, _options, env) + + render = render or renderDefault + + md.add_render_rule("admonition_open", render) + md.add_render_rule("admonition_close", render) + md.add_render_rule("admonition_title_open", render) + md.add_render_rule("admonition_title_close", render) + + md.block.ruler.before( + "fence", + "admonition", + admonition, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) diff --git a/mdit_py_plugins/admon/port.yaml b/mdit_py_plugins/admon/port.yaml new file mode 100644 index 0000000..d2835bc --- /dev/null +++ b/mdit_py_plugins/admon/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-admon + commit: 9820ba89415c464a3cc18a780f222a0ceb3e18bd + date: Jul 3, 2021 + version: 1.0.0 diff --git a/mdit_py_plugins/amsmath/__init__.py b/mdit_py_plugins/amsmath/__init__.py new file mode 100644 index 0000000..0b367b5 --- /dev/null +++ b/mdit_py_plugins/amsmath/__init__.py @@ -0,0 +1,126 @@ +"""An extension to capture amsmath latex environments.""" +import re +from typing import Callable, Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml +from markdown_it.rules_block import StateBlock + +# Taken from amsmath version 2.1 +# http://anorien.csc.warwick.ac.uk/mirrors/CTAN/macros/latex/required/amsmath/amsldoc.pdf +ENVIRONMENTS = [ + # 3.2 single equation with an automatically gen-erated number + "equation", + # 3.3 variation equation, used for equations that don’t fit on a single line + "multline", + # 3.5 a group of consecutive equations when there is no alignment desired among them + "gather", + # 3.6 Used for two or more equations when vertical alignment is desired + "align", + # allows the horizontal space between equationsto be explicitly specified. + "alignat", + # stretches the space betweenthe equation columns to the maximum possible width + "flalign", + # 4.1 The pmatrix, bmatrix, Bmatrix, vmatrix and Vmatrix have (respectively) + # (),[],{},||,and ‖‖ delimiters built in. + "matrix", + "pmatrix", + "bmatrix", + "Bmatrix", + "vmatrix", + "Vmatrix", + # eqnarray is another math environment, it is not part of amsmath, + # and note that it is better to use align or equation+split instead + "eqnarray", +] +# other "non-top-level" environments: + +# 3.4 the split environment is for single equations that are too long to fit on one line +# and hence must be split into multiple lines, +# it is intended for use only inside some other displayed equation structure, +# usually an equation, align, or gather environment + +# 3.7 variants gathered, aligned,and alignedat are provided +# whose total width is the actual width of the contents; +# thus they can be used as a component in a containing expression + +RE_OPEN = re.compile(r"\\begin\{(" + "|".join(ENVIRONMENTS) + r")([\*]?)\}") + + +def amsmath_plugin(md: MarkdownIt, *, renderer: Optional[Callable[[str], str]] = None): + """Parses TeX math equations, without any surrounding delimiters, + only for top-level `amsmath <https://ctan.org/pkg/amsmath>`__ environments: + + .. code-block:: latex + + \\begin{gather*} + a_1=b_1+c_1\\\\ + a_2=b_2+c_2-d_2+e_2 + \\end{gather*} + + :param renderer: Function to render content, by default escapes HTML + + """ + md.block.ruler.before( + "blockquote", + "amsmath", + amsmath_block, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + + if renderer is None: + _renderer = lambda content: escapeHtml(content) + else: + _renderer = renderer + + def render_amsmath_block(self, tokens, idx, options, env): + content = _renderer(str(tokens[idx].content)) + return f'<div class="math amsmath">\n{content}\n</div>\n' + + md.add_render_rule("amsmath", render_amsmath_block) + + +def match_environment(string): + match_open = RE_OPEN.match(string) + if not match_open: + return None + environment = match_open.group(1) + numbered = match_open.group(2) + match_close = re.search( + r"\\end\{" + environment + numbered.replace("*", r"\*") + "\\}", string + ) + if not match_close: + return None + return (environment, numbered, match_close.end()) + + +def amsmath_block(state: StateBlock, startLine: int, endLine: int, silent: bool): + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + begin = state.bMarks[startLine] + state.tShift[startLine] + + outcome = match_environment(state.src[begin:]) + if not outcome: + return False + environment, numbered, endpos = outcome + endpos += begin + + line = startLine + while line < endLine: + if endpos >= state.bMarks[line] and endpos <= state.eMarks[line]: + # line for end of block math found ... + state.line = line + 1 + break + line += 1 + + if not silent: + token = state.push("amsmath", "math", 0) + token.block = True + token.content = state.src[begin:endpos] + token.meta = {"environment": environment, "numbered": numbered} + token.map = [startLine, line] + + return True diff --git a/mdit_py_plugins/anchors/__init__.py b/mdit_py_plugins/anchors/__init__.py new file mode 100644 index 0000000..d9c4f05 --- /dev/null +++ b/mdit_py_plugins/anchors/__init__.py @@ -0,0 +1 @@ +from .index import anchors_plugin # noqa F401 diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py new file mode 100644 index 0000000..abbd48a --- /dev/null +++ b/mdit_py_plugins/anchors/index.py @@ -0,0 +1,129 @@ +import re +from typing import Callable, List, Optional, Set + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore +from markdown_it.token import Token + + +def anchors_plugin( + md: MarkdownIt, + min_level: int = 1, + max_level: int = 2, + slug_func: Optional[Callable[[str], str]] = None, + permalink: bool = False, + permalinkSymbol: str = "¶", + permalinkBefore: bool = False, + permalinkSpace: bool = True, +): + """Plugin for adding header anchors, based on + `markdown-it-anchor <https://github.com/valeriangalliat/markdown-it-anchor>`__ + + .. code-block:: md + + # Title String + + renders as: + + .. code-block:: html + + <h1 id="title-string">Title String <a class="header-anchor" href="#title-string">¶</a></h1> + + :param min_level: minimum header level to apply anchors + :param max_level: maximum header level to apply anchors + :param slug_func: function to convert title text to id slug. + :param permalink: Add a permalink next to the title + :param permalinkSymbol: the symbol to show + :param permalinkBefore: Add the permalink before the title, otherwise after + :param permalinkSpace: Add a space between the permalink and the title + + Note, the default slug function aims to mimic the GitHub Markdown format, see: + + - https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb + - https://gist.github.com/asabaylus/3071099 + + """ + selected_levels = list(range(min_level, max_level + 1)) + md.core.ruler.push( + "anchor", + _make_anchors_func( + selected_levels, + slug_func or slugify, + permalink, + permalinkSymbol, + permalinkBefore, + permalinkSpace, + ), + ) + + +def _make_anchors_func( + selected_levels: List[int], + slug_func: Callable[[str], str], + permalink: bool, + permalinkSymbol: str, + permalinkBefore: bool, + permalinkSpace: bool, +): + def _anchor_func(state: StateCore): + slugs: Set[str] = set() + for (idx, token) in enumerate(state.tokens): + if token.type != "heading_open": + continue + level = int(token.tag[1]) + if level not in selected_levels: + continue + inline_token = state.tokens[idx + 1] + assert inline_token.children is not None + title = "".join( + child.content + for child in inline_token.children + if child.type in ["text", "code_inline"] + ) + slug = unique_slug(slug_func(title), slugs) + token.attrSet("id", slug) + + if permalink: + link_open = Token( + "link_open", + "a", + 1, + ) + link_open.attrSet("class", "header-anchor") + link_open.attrSet("href", f"#{slug}") + link_tokens = [ + link_open, + Token("html_block", "", 0, content=permalinkSymbol), + Token("link_close", "a", -1), + ] + if permalinkBefore: + inline_token.children = ( + link_tokens + + ( + [Token("text", "", 0, content=" ")] + if permalinkSpace + else [] + ) + + inline_token.children + ) + else: + inline_token.children.extend( + ([Token("text", "", 0, content=" ")] if permalinkSpace else []) + + link_tokens + ) + + return _anchor_func + + +def slugify(title: str): + return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.strip().lower().replace(" ", "-")) + + +def unique_slug(slug: str, slugs: set): + uniq = slug + i = 1 + while uniq in slugs: + uniq = f"{slug}-{i}" + i += 1 + slugs.add(uniq) + return uniq diff --git a/mdit_py_plugins/attrs/__init__.py b/mdit_py_plugins/attrs/__init__.py new file mode 100644 index 0000000..9359cf8 --- /dev/null +++ b/mdit_py_plugins/attrs/__init__.py @@ -0,0 +1 @@ +from .index import attrs_plugin # noqa: F401 diff --git a/mdit_py_plugins/attrs/index.py b/mdit_py_plugins/attrs/index.py new file mode 100644 index 0000000..1adea3f --- /dev/null +++ b/mdit_py_plugins/attrs/index.py @@ -0,0 +1,123 @@ +from typing import List, Optional + +from markdown_it import MarkdownIt +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token + +from .parse import ParseError, parse + + +def attrs_plugin( + md: MarkdownIt, + *, + after=("image", "code_inline", "link_close", "span_close"), + spans=False, + span_after="link", +): + """Parse inline attributes that immediately follow certain inline elements:: + + ![alt](https://image.com){#id .a b=c} + + This syntax is inspired by + `Djot spans + <https://htmlpreview.github.io/?https://github.com/jgm/djot/blob/master/doc/syntax.html#inline-attributes>`_. + + Inside the curly braces, the following syntax is possible: + + - `.foo` specifies foo as a class. + Multiple classes may be given in this way; they will be combined. + - `#foo` specifies foo as an identifier. + An element may have only one identifier; + if multiple identifiers are given, the last one is used. + - `key="value"` or `key=value` specifies a key-value attribute. + Quotes are not needed when the value consists entirely of + ASCII alphanumeric characters or `_` or `:` or `-`. + Backslash escapes may be used inside quoted values. + - `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`). + + Multiple attribute blocks are merged. + + :param md: The MarkdownIt instance to modify. + :param after: The names of inline elements after which attributes may be specified. + This plugin does not support attributes after emphasis, strikethrough or text elements, + which all require post-parse processing. + :param spans: If True, also parse attributes after spans of text, encapsulated by `[]`. + Note Markdown link references take precedence over this syntax. + :param span_after: The name of an inline rule after which spans may be specified. + """ + + def _attr_rule(state: StateInline, silent: bool): + if state.pending or not state.tokens: + return False + token = state.tokens[-1] + if token.type not in after: + return False + try: + new_pos, attrs = parse(state.src[state.pos :]) + except ParseError: + return False + token_index = _find_opening(state.tokens, len(state.tokens) - 1) + if token_index is None: + return False + state.pos += new_pos + 1 + if not silent: + attr_token = state.tokens[token_index] + if "class" in attrs and "class" in token.attrs: + attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}" + attr_token.attrs.update(attrs) + return True + + if spans: + md.inline.ruler.after(span_after, "span", _span_rule) + md.inline.ruler.push("attr", _attr_rule) + + +def _find_opening(tokens: List[Token], index: int) -> Optional[int]: + """Find the opening token index, if the token is closing.""" + if tokens[index].nesting != -1: + return index + level = 0 + while index >= 0: + level += tokens[index].nesting + if level == 0: + return index + index -= 1 + return None + + +def _span_rule(state: StateInline, silent: bool): + if state.srcCharCode[state.pos] != 0x5B: # /* [ */ + return False + + maximum = state.posMax + labelStart = state.pos + 1 + labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, False) + + # parser failed to find ']', so it's not a valid span + if labelEnd < 0: + return False + + pos = labelEnd + 1 + + # check not at end of inline + if pos >= maximum: + return False + + try: + new_pos, attrs = parse(state.src[pos:]) + except ParseError: + return False + + pos += new_pos + 1 + + if not silent: + state.pos = labelStart + state.posMax = labelEnd + token = state.push("span_open", "span", 1) + token.attrs = attrs + state.md.inline.tokenize(state) + token = state.push("span_close", "span", -1) + + state.pos = pos + state.posMax = maximum + return True diff --git a/mdit_py_plugins/attrs/parse.py b/mdit_py_plugins/attrs/parse.py new file mode 100644 index 0000000..4a30353 --- /dev/null +++ b/mdit_py_plugins/attrs/parse.py @@ -0,0 +1,265 @@ +"""Parser for attributes:: + + attributes { id = "foo", class = "bar baz", + key1 = "val1", key2 = "val2" } + +Adapted from: +https://github.com/jgm/djot/blob/fae7364b86bfce69bc6d5b5eede1f5196d845fd6/djot/attributes.lua#L1 + +syntax: + +attributes <- '{' whitespace* attribute (whitespace attribute)* whitespace* '}' +attribute <- identifier | class | keyval +identifier <- '#' name +class <- '.' name +name <- (nonspace, nonpunctuation other than ':', '_', '-')+ +keyval <- key '=' val +key <- (ASCII_ALPHANUM | ':' | '_' | '-')+ +val <- bareval | quotedval +bareval <- (ASCII_ALPHANUM | ':' | '_' | '-')+ +quotedval <- '"' ([^"] | '\"') '"' +""" +from __future__ import annotations + +from enum import Enum +import re +from typing import Callable + + +class State(Enum): + START = 0 + SCANNING = 1 + SCANNING_ID = 2 + SCANNING_CLASS = 3 + SCANNING_KEY = 4 + SCANNING_VALUE = 5 + SCANNING_BARE_VALUE = 6 + SCANNING_QUOTED_VALUE = 7 + SCANNING_COMMENT = 8 + SCANNING_ESCAPED = 9 + DONE = 10 + + +REGEX_SPACE = re.compile(r"\s") +REGEX_SPACE_PUNCTUATION = re.compile(r"[\s!\"#$%&'()*+,./;<=>?@[\]^`{|}~]") +REGEX_KEY_CHARACTERS = re.compile(r"[a-zA-Z\d_:-]") + + +class TokenState: + def __init__(self): + self._tokens = [] + self.start: int = 0 + + def set_start(self, start: int) -> None: + self.start = start + + def append(self, start: int, end: int, ttype: str): + self._tokens.append((start, end, ttype)) + + def compile(self, string: str) -> dict[str, str]: + """compile the tokens into a dictionary""" + attributes = {} + classes = [] + idx = 0 + while idx < len(self._tokens): + start, end, ttype = self._tokens[idx] + if ttype == "id": + attributes["id"] = string[start:end] + elif ttype == "class": + classes.append(string[start:end]) + elif ttype == "key": + key = string[start:end] + if idx + 1 < len(self._tokens): + start, end, ttype = self._tokens[idx + 1] + if ttype == "value": + if key == "class": + classes.append(string[start:end]) + else: + attributes[key] = string[start:end] + idx += 1 + idx += 1 + if classes: + attributes["class"] = " ".join(classes) + return attributes + + def __str__(self) -> str: + return str(self._tokens) + + def __repr__(self) -> str: + return repr(self._tokens) + + +class ParseError(Exception): + def __init__(self, msg: str, pos: int) -> None: + self.pos = pos + super().__init__(msg + f" at position {pos}") + + +def parse(string: str) -> tuple[int, dict[str, str]]: + """Parse attributes from start of string. + + :returns: (length of parsed string, dict of attributes) + """ + pos = 0 + state: State = State.START + tokens = TokenState() + while pos < len(string): + state = HANDLERS[state](string[pos], pos, tokens) + if state == State.DONE: + return pos, tokens.compile(string) + pos = pos + 1 + + return pos, tokens.compile(string) + + +def handle_start(char: str, pos: int, tokens: TokenState) -> State: + + if char == "{": + return State.SCANNING + raise ParseError("Attributes must start with '{'", pos) + + +def handle_scanning(char: str, pos: int, tokens: TokenState) -> State: + + if char == " " or char == "\t" or char == "\n" or char == "\r": + return State.SCANNING + if char == "}": + return State.DONE + if char == "#": + tokens.set_start(pos) + return State.SCANNING_ID + if char == "%": + tokens.set_start(pos) + return State.SCANNING_COMMENT + if char == ".": + tokens.set_start(pos) + return State.SCANNING_CLASS + if REGEX_KEY_CHARACTERS.fullmatch(char): + tokens.set_start(pos) + return State.SCANNING_KEY + + raise ParseError(f"Unexpected character whilst scanning: {char}", pos) + + +def handle_scanning_comment(char: str, pos: int, tokens: TokenState) -> State: + + if char == "%": + return State.SCANNING + + return State.SCANNING_COMMENT + + +def handle_scanning_id(char: str, pos: int, tokens: TokenState) -> State: + + if not REGEX_SPACE_PUNCTUATION.fullmatch(char): + return State.SCANNING_ID + + if char == "}": + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "id") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "id") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning id: {char}", pos) + + +def handle_scanning_class(char: str, pos: int, tokens: TokenState) -> State: + + if not REGEX_SPACE_PUNCTUATION.fullmatch(char): + return State.SCANNING_CLASS + + if char == "}": + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "class") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "class") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning class: {char}", pos) + + +def handle_scanning_key(char: str, pos: int, tokens: TokenState) -> State: + + if char == "=": + tokens.append(tokens.start, pos, "key") + return State.SCANNING_VALUE + + if REGEX_KEY_CHARACTERS.fullmatch(char): + return State.SCANNING_KEY + + raise ParseError(f"Unexpected character whilst scanning key: {char}", pos) + + +def handle_scanning_value(char: str, pos: int, tokens: TokenState) -> State: + + if char == '"': + tokens.set_start(pos) + return State.SCANNING_QUOTED_VALUE + + if REGEX_KEY_CHARACTERS.fullmatch(char): + tokens.set_start(pos) + return State.SCANNING_BARE_VALUE + + raise ParseError(f"Unexpected character whilst scanning value: {char}", pos) + + +def handle_scanning_bare_value(char: str, pos: int, tokens: TokenState) -> State: + + if REGEX_KEY_CHARACTERS.fullmatch(char): + return State.SCANNING_BARE_VALUE + + if char == "}": + tokens.append(tokens.start, pos, "value") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + tokens.append(tokens.start, pos, "value") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning bare value: {char}", pos) + + +def handle_scanning_escaped(char: str, pos: int, tokens: TokenState) -> State: + return State.SCANNING_QUOTED_VALUE + + +def handle_scanning_quoted_value(char: str, pos: int, tokens: TokenState) -> State: + + if char == '"': + tokens.append(tokens.start + 1, pos, "value") + return State.SCANNING + + if char == "\\": + return State.SCANNING_ESCAPED + + if char == "{" or char == "}": + raise ParseError( + f"Unexpected character whilst scanning quoted value: {char}", pos + ) + + if char == "\n": + tokens.append(tokens.start + 1, pos, "value") + return State.SCANNING_QUOTED_VALUE + + return State.SCANNING_QUOTED_VALUE + + +HANDLERS: dict[State, Callable[[str, int, TokenState], State]] = { + State.START: handle_start, + State.SCANNING: handle_scanning, + State.SCANNING_COMMENT: handle_scanning_comment, + State.SCANNING_ID: handle_scanning_id, + State.SCANNING_CLASS: handle_scanning_class, + State.SCANNING_KEY: handle_scanning_key, + State.SCANNING_VALUE: handle_scanning_value, + State.SCANNING_BARE_VALUE: handle_scanning_bare_value, + State.SCANNING_QUOTED_VALUE: handle_scanning_quoted_value, + State.SCANNING_ESCAPED: handle_scanning_escaped, +} diff --git a/mdit_py_plugins/colon_fence.py b/mdit_py_plugins/colon_fence.py new file mode 100644 index 0000000..e2356ed --- /dev/null +++ b/mdit_py_plugins/colon_fence.py @@ -0,0 +1,132 @@ +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, unescapeAll +from markdown_it.rules_block import StateBlock + + +def colon_fence_plugin(md: MarkdownIt): + """This plugin directly mimics regular fences, but with `:` colons. + + Example:: + + :::name + contained text + ::: + + """ + + md.block.ruler.before( + "fence", + "colon_fence", + _rule, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.add_render_rule("colon_fence", _render) + + +def _rule(state: StateBlock, startLine: int, endLine: int, silent: bool): + + haveEndMarker = False + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + if pos + 3 > maximum: + return False + + marker = state.srcCharCode[pos] + + # /* : */ + if marker != 0x3A: + return False + + # scan marker length + mem = pos + pos = state.skipChars(pos, marker) + + length = pos - mem + + if length < 3: + return False + + markup = state.src[mem:pos] + params = state.src[pos:maximum] + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # search end of block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + + pos = mem = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if pos < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if state.srcCharCode[pos] != marker: + continue + + if state.sCount[nextLine] - state.blkIndent >= 4: + # closing fence should be indented less than 4 spaces + continue + + pos = state.skipChars(pos, marker) + + # closing code fence must be at least as long as the opening one + if pos - mem < length: + continue + + # make sure tail has spaces only + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + haveEndMarker = True + # found! + break + + # If a fence has heading spaces, they should be removed from its inner block + length = state.sCount[startLine] + + state.line = nextLine + (1 if haveEndMarker else 0) + + token = state.push("colon_fence", "code", 0) + token.info = params + token.content = state.getLines(startLine + 1, nextLine, length, True) + token.markup = markup + token.map = [startLine, state.line] + + return True + + +def _render(self, tokens, idx, options, env): + token = tokens[idx] + info = unescapeAll(token.info).strip() if token.info else "" + content = escapeHtml(token.content) + block_name = "" + + if info: + block_name = info.split()[0] + + return ( + "<pre><code" + + (f' class="block-{block_name}" ' if block_name else "") + + ">" + + content + + "</code></pre>\n" + ) diff --git a/mdit_py_plugins/container/LICENSE b/mdit_py_plugins/container/LICENSE new file mode 100644 index 0000000..e6c3230 --- /dev/null +++ b/mdit_py_plugins/container/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdit_py_plugins/container/README.md b/mdit_py_plugins/container/README.md new file mode 100644 index 0000000..03868d7 --- /dev/null +++ b/mdit_py_plugins/container/README.md @@ -0,0 +1,95 @@ +# markdown-it-container + +[![Build Status](https://img.shields.io/travis/markdown-it/markdown-it-container/master.svg?style=flat)](https://travis-ci.org/markdown-it/markdown-it-container) +[![NPM version](https://img.shields.io/npm/v/markdown-it-container.svg?style=flat)](https://www.npmjs.org/package/markdown-it-container) +[![Coverage Status](https://img.shields.io/coveralls/markdown-it/markdown-it-container/master.svg?style=flat)](https://coveralls.io/r/markdown-it/markdown-it-container?branch=master) + +> Plugin for creating block-level custom containers for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. + +__v2.+ requires `markdown-it` v5.+, see changelog.__ + +With this plugin you can create block containers like: + +``` +::: warning +*here be dragons* +::: +``` + +.... and specify how they should be rendered. If no renderer defined, `<div>` with +container name class will be created: + +```html +<div class="warning"> +<em>here be dragons</em> +</div> +``` + +Markup is the same as for [fenced code blocks](http://spec.commonmark.org/0.18/#fenced-code-blocks). +Difference is, that marker use another character and content is rendered as markdown markup. + + +## Installation + +node.js, browser: + +```bash +$ npm install markdown-it-container --save +$ bower install markdown-it-container --save +``` + + +## API + +```js +var md = require('markdown-it')() + .use(require('markdown-it-container'), name [, options]); +``` + +Params: + +- __name__ - container name (mandatory) +- __options:__ + - __validate__ - optional, function to validate tail after opening marker, should + return `true` on success. + - __render__ - optional, renderer function for opening/closing tokens. + - __marker__ - optional (`:`), character to use in delimiter. + + +## Example + +```js +var md = require('markdown-it')(); + +md.use(require('markdown-it-container'), 'spoiler', { + + validate: function(params) { + return params.trim().match(/^spoiler\s+(.*)$/); + }, + + render: function (tokens, idx) { + var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/); + + if (tokens[idx].nesting === 1) { + // opening tag + return '<details><summary>' + md.utils.escapeHtml(m[1]) + '</summary>\n'; + + } else { + // closing tag + return '</details>\n'; + } + } +}); + +console.log(md.render('::: spoiler click me\n*content*\n:::\n')); + +// Output: +// +// <details><summary>click me</summary> +// <p><em>content</em></p> +// </details> +``` + +## License + +[MIT](https://github.com/markdown-it/markdown-it-container/blob/master/LICENSE) diff --git a/mdit_py_plugins/container/__init__.py b/mdit_py_plugins/container/__init__.py new file mode 100644 index 0000000..7a89c81 --- /dev/null +++ b/mdit_py_plugins/container/__init__.py @@ -0,0 +1 @@ +from .index import container_plugin # noqa F401 diff --git a/mdit_py_plugins/container/index.py b/mdit_py_plugins/container/index.py new file mode 100644 index 0000000..b6edd43 --- /dev/null +++ b/mdit_py_plugins/container/index.py @@ -0,0 +1,174 @@ +"""Process block-level custom containers.""" +from math import floor +from typing import Callable, Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import charCodeAt +from markdown_it.rules_block import StateBlock + + +def container_plugin( + md: MarkdownIt, + name: str, + marker: str = ":", + validate: Optional[Callable[[str, str], bool]] = None, + render=None, +): + """Plugin ported from + `markdown-it-container <https://github.com/markdown-it/markdown-it-container>`__. + + It is a plugin for creating block-level custom containers: + + .. code-block:: md + + :::: name + ::: name + *markdown* + ::: + :::: + + :param name: the name of the container to parse + :param marker: the marker character to use + :param validate: func(marker, param) -> bool, default matches against the name + :param render: render func + + """ + + def validateDefault(params: str, *args): + return params.strip().split(" ", 2)[0] == name + + def renderDefault(self, tokens, idx, _options, env): + # add a class to the opening tag + if tokens[idx].nesting == 1: + tokens[idx].attrJoin("class", name) + + return self.renderToken(tokens, idx, _options, env) + + min_markers = 3 + marker_str = marker + marker_char = charCodeAt(marker_str, 0) + marker_len = len(marker_str) + validate = validate or validateDefault + render = render or renderDefault + + def container_func(state: StateBlock, startLine: int, endLine: int, silent: bool): + + auto_closed = False + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # Check out the first character quickly, + # this should filter out most of non-containers + if marker_char != state.srcCharCode[start]: + return False + + # Check out the rest of the marker string + pos = start + 1 + while pos <= maximum: + try: + character = state.src[pos] + except IndexError: + break + if marker_str[(pos - start) % marker_len] != character: + break + pos += 1 + + marker_count = floor((pos - start) / marker_len) + if marker_count < min_markers: + return False + pos -= (pos - start) % marker_len + + markup = state.src[start:pos] + params = state.src[pos:maximum] + assert validate is not None + if not validate(params, markup): + return False + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # Search for the end of the block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if start < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if marker_char != state.srcCharCode[start]: + continue + + if state.sCount[nextLine] - state.blkIndent >= 4: + # closing fence should be indented less than 4 spaces + continue + + pos = start + 1 + while pos <= maximum: + try: + character = state.src[pos] + except IndexError: + break + if marker_str[(pos - start) % marker_len] != character: + break + pos += 1 + + # closing code fence must be at least as long as the opening one + if floor((pos - start) / marker_len) < marker_count: + continue + + # make sure tail has spaces only + pos -= (pos - start) % marker_len + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + # found! + auto_closed = True + break + + old_parent = state.parentType + old_line_max = state.lineMax + state.parentType = "container" + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine + + token = state.push(f"container_{name}_open", "div", 1) + token.markup = markup + token.block = True + token.info = params + token.map = [startLine, nextLine] + + state.md.block.tokenize(state, startLine + 1, nextLine) + + token = state.push(f"container_{name}_close", "div", -1) + token.markup = state.src[start:pos] + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.line = nextLine + (1 if auto_closed else 0) + + return True + + md.block.ruler.before( + "fence", + "container_" + name, + container_func, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) + md.add_render_rule(f"container_{name}_open", render) + md.add_render_rule(f"container_{name}_close", render) diff --git a/mdit_py_plugins/container/port.yaml b/mdit_py_plugins/container/port.yaml new file mode 100644 index 0000000..e47c118 --- /dev/null +++ b/mdit_py_plugins/container/port.yaml @@ -0,0 +1,5 @@ +- package: markdown-it-container + commit: adb3defde3a1c56015895b47ce4c6591b8b1e3a2 + date: Jun 2, 2020 + version: 3.0.0 + changes: diff --git a/mdit_py_plugins/deflist/LICENSE b/mdit_py_plugins/deflist/LICENSE new file mode 100644 index 0000000..2fd4e3d --- /dev/null +++ b/mdit_py_plugins/deflist/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdit_py_plugins/deflist/README.md b/mdit_py_plugins/deflist/README.md new file mode 100644 index 0000000..414157b --- /dev/null +++ b/mdit_py_plugins/deflist/README.md @@ -0,0 +1,38 @@ +# markdown-it-deflist + +[![Build Status](https://img.shields.io/travis/markdown-it/markdown-it-deflist/master.svg?style=flat)](https://travis-ci.org/markdown-it/markdown-it-deflist) +[![NPM version](https://img.shields.io/npm/v/markdown-it-deflist.svg?style=flat)](https://www.npmjs.org/package/markdown-it-deflist) +[![Coverage Status](https://img.shields.io/coveralls/markdown-it/markdown-it-deflist/master.svg?style=flat)](https://coveralls.io/r/markdown-it/markdown-it-deflist?branch=master) + +> Definition list (`<dl>`) tag plugin for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. + +__v2.+ requires `markdown-it` v5.+, see changelog.__ + +Syntax is based on [pandoc definition lists](http://johnmacfarlane.net/pandoc/README.html#definition-lists). + + +## Install + +node.js, browser: + +```bash +npm install markdown-it-deflist --save +bower install markdown-it-deflist --save +``` + +## Use + +```js +var md = require('markdown-it')() + .use(require('markdown-it-deflist')); + +md.render(/*...*/); +``` + +_Differences in browser._ If you load script directly into the page, without +package system, module will add itself globally as `window.markdownitDeflist`. + + +## License + +[MIT](https://github.com/markdown-it/markdown-it-deflist/blob/master/LICENSE) diff --git a/mdit_py_plugins/deflist/__init__.py b/mdit_py_plugins/deflist/__init__.py new file mode 100644 index 0000000..fbd9b0e --- /dev/null +++ b/mdit_py_plugins/deflist/__init__.py @@ -0,0 +1 @@ +from .index import deflist_plugin # noqa F401 diff --git a/mdit_py_plugins/deflist/index.py b/mdit_py_plugins/deflist/index.py new file mode 100644 index 0000000..0b353db --- /dev/null +++ b/mdit_py_plugins/deflist/index.py @@ -0,0 +1,253 @@ +"""Process definition lists.""" +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + + +def deflist_plugin(md: MarkdownIt): + """Plugin ported from + `markdown-it-deflist <https://github.com/markdown-it/markdown-it-deflist>`__. + + The syntax is based on + `pandoc definition lists <http://johnmacfarlane.net/pandoc/README.html#definition-lists>`__: + + .. code-block:: md + + Term 1 + : Definition 1 long form + + second paragraph + + Term 2 with *inline markup* + ~ Definition 2a compact style + ~ Definition 2b + + """ + isSpace = md.utils.isSpace # type: ignore + + def skipMarker(state: StateBlock, line: int): + """Search `[:~][\n ]`, returns next pos after marker on success or -1 on fail.""" + start = state.bMarks[line] + state.tShift[line] + maximum = state.eMarks[line] + + if start >= maximum: + return -1 + + # Check bullet + marker = state.srcCharCode[start] + start += 1 + if marker != 0x7E and marker != 0x3A: # ~ : + return -1 + + pos = state.skipSpaces(start) + + # require space after ":" + if start == pos: + return -1 + + # no empty definitions, e.g. " : " + if pos >= maximum: + return -1 + + return start + + def markTightParagraphs(state: StateBlock, idx: int): + + level = state.level + 2 + + i = idx + 2 + l2 = len(state.tokens) - 2 + while i < l2: + if ( + state.tokens[i].level == level + and state.tokens[i].type == "paragraph_open" + ): + state.tokens[i + 2].hidden = True + state.tokens[i].hidden = True + i += 2 + i += 1 + + def deflist(state: StateBlock, startLine: int, endLine: int, silent: bool): + + if silent: + # quirk: validation mode validates a dd block only, not a whole deflist + if state.ddIndent < 0: + return False + return skipMarker(state, startLine) >= 0 + + nextLine = startLine + 1 + if nextLine >= endLine: + return False + + if state.isEmpty(nextLine): + nextLine += 1 + if nextLine >= endLine: + return False + + if state.sCount[nextLine] < state.blkIndent: + return False + contentStart = skipMarker(state, nextLine) + if contentStart < 0: + return False + + # Start list + listTokIdx = len(state.tokens) + tight = True + + token = state.push("dl_open", "dl", 1) + token.map = listLines = [startLine, 0] + + # Iterate list items + dtLine = startLine + ddLine = nextLine + + # One definition list can contain multiple DTs, + # and one DT can be followed by multiple DDs. + # + # Thus, there is two loops here, and label is + # needed to break out of the second one + # + break_outer = False + + while True: + prevEmptyEnd = False + + token = state.push("dt_open", "dt", 1) + token.map = [dtLine, dtLine] + + token = state.push("inline", "", 0) + token.map = [dtLine, dtLine] + token.content = state.getLines( + dtLine, dtLine + 1, state.blkIndent, False + ).strip() + token.children = [] + + token = state.push("dt_close", "dt", -1) + + while True: + token = state.push("dd_open", "dd", 1) + token.map = itemLines = [nextLine, 0] + + pos = contentStart + maximum = state.eMarks[ddLine] + offset = ( + state.sCount[ddLine] + + contentStart + - (state.bMarks[ddLine] + state.tShift[ddLine]) + ) + + while pos < maximum: + ch = state.srcCharCode[pos] + + if isSpace(ch): + if ch == 0x09: + offset += 4 - offset % 4 + else: + offset += 1 + else: + break + + pos += 1 + + contentStart = pos + + oldTight = state.tight + oldDDIndent = state.ddIndent + oldIndent = state.blkIndent + oldTShift = state.tShift[ddLine] + oldSCount = state.sCount[ddLine] + oldParentType = state.parentType + state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2 + state.tShift[ddLine] = contentStart - state.bMarks[ddLine] + state.sCount[ddLine] = offset + state.tight = True + state.parentType = "deflist" + + state.md.block.tokenize(state, ddLine, endLine, True) + + # If any of list item is tight, mark list as tight + if not state.tight or prevEmptyEnd: + tight = False + + # Item become loose if finish with empty line, + # but we should filter last element, because it means list finish + prevEmptyEnd = (state.line - ddLine) > 1 and state.isEmpty( + state.line - 1 + ) + + state.tShift[ddLine] = oldTShift + state.sCount[ddLine] = oldSCount + state.tight = oldTight + state.parentType = oldParentType + state.blkIndent = oldIndent + state.ddIndent = oldDDIndent + + token = state.push("dd_close", "dd", -1) + + itemLines[1] = nextLine = state.line + + if nextLine >= endLine: + break_outer = True + break + + if state.sCount[nextLine] < state.blkIndent: + break_outer = True + break + + contentStart = skipMarker(state, nextLine) + if contentStart < 0: + break + + ddLine = nextLine + + # go to the next loop iteration: + # insert DD tag and repeat checking + + if break_outer: + break_outer = False + break + + if nextLine >= endLine: + break + dtLine = nextLine + + if state.isEmpty(dtLine): + break + if state.sCount[dtLine] < state.blkIndent: + break + + ddLine = dtLine + 1 + if ddLine >= endLine: + break + if state.isEmpty(ddLine): + ddLine += 1 + if ddLine >= endLine: + break + + if state.sCount[ddLine] < state.blkIndent: + break + contentStart = skipMarker(state, ddLine) + if contentStart < 0: + break + + # go to the next loop iteration: + # insert DT and DD tags and repeat checking + + # Finalise list + token = state.push("dl_close", "dl", -1) + + listLines[1] = nextLine + + state.line = nextLine + + # mark paragraphs tight if needed + if tight: + markTightParagraphs(state, listTokIdx) + + return True + + md.block.ruler.before( + "paragraph", + "deflist", + deflist, + {"alt": ["paragraph", "reference", "blockquote"]}, + ) diff --git a/mdit_py_plugins/deflist/port.yaml b/mdit_py_plugins/deflist/port.yaml new file mode 100644 index 0000000..203c772 --- /dev/null +++ b/mdit_py_plugins/deflist/port.yaml @@ -0,0 +1,5 @@ +- package: markdown-it-deflist + commit: 20db400948520308291da029a23b0751cb30f3a0 + date: July 12, 2017 + version: 2.0.3 + changes: diff --git a/mdit_py_plugins/dollarmath/__init__.py b/mdit_py_plugins/dollarmath/__init__.py new file mode 100644 index 0000000..1840543 --- /dev/null +++ b/mdit_py_plugins/dollarmath/__init__.py @@ -0,0 +1 @@ +from .index import dollarmath_plugin # noqa F401 diff --git a/mdit_py_plugins/dollarmath/index.py b/mdit_py_plugins/dollarmath/index.py new file mode 100644 index 0000000..5fe0381 --- /dev/null +++ b/mdit_py_plugins/dollarmath/index.py @@ -0,0 +1,339 @@ +import re +from typing import Any, Callable, Dict, Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, isWhiteSpace +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline + + +def dollarmath_plugin( + md: MarkdownIt, + *, + allow_labels: bool = True, + allow_space: bool = True, + allow_digits: bool = True, + double_inline: bool = False, + label_normalizer: Optional[Callable[[str], str]] = None, + renderer: Optional[Callable[[str, Dict[str, Any]], str]] = None, + label_renderer: Optional[Callable[[str], str]] = None, +) -> None: + """Plugin for parsing dollar enclosed math, + e.g. inline: ``$a=1$``, block: ``$$b=2$$`` + + This is an improved version of ``texmath``; it is more performant, + and handles ``\\`` escaping properly and allows for more configuration. + + :param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)`` + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param double_inline: Search for double-dollar math within inline contexts + :param label_normalizer: Function to normalize the label, + by default replaces whitespace with `-` + :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`, + by default escapes HTML + :param label_renderer: Function to render labels, by default creates anchor + + """ + if label_normalizer is None: + label_normalizer = lambda label: re.sub(r"\s+", "-", label) + + md.inline.ruler.before( + "escape", + "math_inline", + math_inline_dollar(allow_space, allow_digits, double_inline), + ) + md.block.ruler.before( + "fence", "math_block", math_block_dollar(allow_labels, label_normalizer) + ) + + # TODO the current render rules are really just for testing + # would be good to allow "proper" math rendering, + # e.g. https://github.com/roniemartinez/latex2mathml + + if renderer is None: + _renderer = lambda content, _: escapeHtml(content) + else: + _renderer = renderer + + if label_renderer is None: + _label_renderer = ( + lambda label: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>' # noqa: E501 + ) + else: + _label_renderer = label_renderer + + def render_math_inline(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False}) + return f'<span class="math inline">{content}</span>' + + def render_math_inline_double(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math inline">{content}</div>' + + def render_math_block(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math block">\n{content}\n</div>\n' + + def render_math_block_label(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + _id = tokens[idx].info + label = _label_renderer(tokens[idx].info) + return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n' + + md.add_render_rule("math_inline", render_math_inline) + md.add_render_rule("math_inline_double", render_math_inline_double) + + md.add_render_rule("math_block", render_math_block) + md.add_render_rule("math_block_label", render_math_block_label) + + +def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool: + """Test if dollar is escaped.""" + # count how many \ are before the current position + backslashes = 0 + while back_pos >= 0: + back_pos = back_pos - 1 + if state.srcCharCode[back_pos] == 0x5C: # /* \ */ + backslashes += 1 + else: + break + + if not backslashes: + return False + + # if an odd number of \ then ignore + if (backslashes % 2) != mod: + return True + + return False + + +def math_inline_dollar( + allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False +) -> Callable[[StateInline, bool], bool]: + """Generate inline dollar rule. + + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param allow_double: Search for double-dollar math within inline contexts + + """ + + def _math_inline_dollar(state: StateInline, silent: bool) -> bool: + """Inline dollar rule. + + - Initial check: + - check if first character is a $ + - check if the first character is escaped + - check if the next character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Advance one, if allow_double + - Find closing (advance one, if allow_double) + - Check closing: + - check if the previous character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Check empty content + """ + + # TODO options: + # even/odd backslash escaping + + if state.srcCharCode[state.pos] != 0x24: # /* $ */ + return False + + if not allow_space: + # whitespace not allowed straight after opening $ + try: + if isWhiteSpace(state.srcCharCode[state.pos + 1]): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight before opening $ + try: + if state.src[state.pos - 1].isdigit(): + return False + except IndexError: + pass + + if is_escaped(state, state.pos): + return False + + try: + is_double = allow_double and state.srcCharCode[state.pos + 1] == 0x24 + except IndexError: + return False + + # find closing $ + pos = state.pos + 1 + (1 if is_double else 0) + found_closing = False + while not found_closing: + try: + end = state.srcCharCode.index(0x24, pos) + except ValueError: + return False + + if is_escaped(state, end): + pos = end + 1 + continue + + try: + if is_double and not state.srcCharCode[end + 1] == 0x24: + pos = end + 1 + continue + except IndexError: + return False + + if is_double: + end += 1 + + found_closing = True + + if not found_closing: + return False + + if not allow_space: + # whitespace not allowed straight before closing $ + try: + if isWhiteSpace(state.srcCharCode[end - 1]): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight after closing $ + try: + if state.src[end + 1].isdigit(): + return False + except IndexError: + pass + + text = ( + state.src[state.pos + 2 : end - 1] + if is_double + else state.src[state.pos + 1 : end] + ) + + # ignore empty + if not text: + return False + + if not silent: + token = state.push( + "math_inline_double" if is_double else "math_inline", "math", 0 + ) + token.content = text + token.markup = "$$" if is_double else "$" + + state.pos = end + 1 + + return True + + return _math_inline_dollar + + +# reversed end of block dollar equation, with equation label +DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}") + + +def math_block_dollar( + allow_labels: bool = True, + label_normalizer: Optional[Callable[[str], str]] = None, +) -> Callable[[StateBlock, int, int, bool], bool]: + """Generate block dollar rule.""" + + def _math_block_dollar( + state: StateBlock, startLine: int, endLine: int, silent: bool + ) -> bool: + + # TODO internal backslash escaping + + haveEndMarker = False + startPos = state.bMarks[startLine] + state.tShift[startLine] + end = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + if startPos + 2 > end: + return False + + if ( + state.srcCharCode[startPos] != 0x24 + or state.srcCharCode[startPos + 1] != 0x24 + ): # /* $ */ + return False + + # search for end of block + nextLine = startLine + label = None + + # search for end of block on same line + lineText = state.src[startPos:end] + if len(lineText.strip()) > 3: + + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + elif allow_labels: + # reverse the line and match + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + + # search for end of block on subsequent line + if not haveEndMarker: + while True: + nextLine += 1 + if nextLine >= endLine: + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + end = state.eMarks[nextLine] + + if end - start < 2: + continue + + lineText = state.src[start:end] + + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + break + + # reverse the line and match + if allow_labels: + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + break + + if not haveEndMarker: + return False + + state.line = nextLine + (1 if haveEndMarker else 0) + + token = state.push("math_block_label" if label else "math_block", "math", 0) + token.block = True + token.content = state.src[startPos + 2 : end] + token.markup = "$$" + token.map = [startLine, state.line] + if label: + token.info = label if label_normalizer is None else label_normalizer(label) + + return True + + return _math_block_dollar diff --git a/mdit_py_plugins/field_list/__init__.py b/mdit_py_plugins/field_list/__init__.py new file mode 100644 index 0000000..9e21fb5 --- /dev/null +++ b/mdit_py_plugins/field_list/__init__.py @@ -0,0 +1,208 @@ +"""Field list plugin""" +from contextlib import contextmanager +from typing import Tuple + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + + +def fieldlist_plugin(md: MarkdownIt): + """Field lists are mappings from field names to field bodies, based on the + `reStructureText syntax + <https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists>`_. + + .. code-block:: md + + :name *markup*: + :name1: body content + :name2: paragraph 1 + + paragraph 2 + :name3: + paragraph 1 + + paragraph 2 + + A field name may consist of any characters except colons (":"). + Inline markup is parsed in field names. + + The field name is followed by whitespace and the field body. + The field body may be empty or contain multiple body elements. + The field body is aligned either by the start of the body on the first line or, + if no body content is on the first line, by 2 spaces. + """ + md.block.ruler.before( + "paragraph", + "fieldlist", + _fieldlist_rule, + {"alt": ["paragraph", "reference", "blockquote"]}, + ) + + +def parseNameMarker(state: StateBlock, startLine: int) -> Tuple[int, str]: + """Parse field name: `:name:` + + :returns: position after name marker, name text + """ + start = state.bMarks[startLine] + state.tShift[startLine] + pos = start + maximum = state.eMarks[startLine] + + # marker should have at least 3 chars (colon + character + colon) + if pos + 2 >= maximum: + return -1, "" + + # first character should be ':' + if state.src[pos] != ":": + return -1, "" + + # scan name length + name_length = 1 + found_close = False + for ch in state.src[pos + 1 :]: + if ch == "\n": + break + if ch == ":": + # TODO backslash escapes + found_close = True + break + name_length += 1 + + if not found_close: + return -1, "" + + # get name + name_text = state.src[pos + 1 : pos + name_length] + + # name should contain at least one character + if not name_text.strip(): + return -1, "" + + return pos + name_length + 1, name_text + + +@contextmanager +def set_parent_type(state: StateBlock, name: str): + """Temporarily set parent type to `name`""" + oldParentType = state.parentType + state.parentType = name + yield + state.parentType = oldParentType + + +def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: bool): + # adapted from markdown_it/rules_block/list.py::list_block + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + posAfterName, name_text = parseNameMarker(state, startLine) + if posAfterName < 0: + return False + + # For validation mode we can terminate immediately + if silent: + return True + + # start field list + token = state.push("field_list_open", "dl", 1) + token.attrSet("class", "field-list") + token.map = listLines = [startLine, 0] + + # iterate list items + nextLine = startLine + + with set_parent_type(state, "fieldlist"): + + while nextLine < endLine: + + # create name tokens + token = state.push("fieldlist_name_open", "dt", 1) + token.map = [startLine, startLine] + token = state.push("inline", "", 0) + token.map = [startLine, startLine] + token.content = name_text + token.children = [] + token = state.push("fieldlist_name_close", "dt", -1) + + # set indent positions + pos = posAfterName + maximum = state.eMarks[nextLine] + offset = ( + state.sCount[nextLine] + + posAfterName + - (state.bMarks[startLine] + state.tShift[startLine]) + ) + + # find indent to start of body on first line + while pos < maximum: + ch = state.srcCharCode[pos] + + if ch == 0x09: # \t + offset += 4 - (offset + state.bsCount[nextLine]) % 4 + elif ch == 0x20: # \s + offset += 1 + else: + break + + pos += 1 + + contentStart = pos + + # set indent for body text + if contentStart >= maximum: + # no body on first line, so use constant indentation + # TODO adapt to indentation of subsequent lines? + indent = 2 + else: + indent = offset + + # Run subparser on the field body + token = state.push("fieldlist_body_open", "dd", 1) + token.map = itemLines = [startLine, 0] + + # change current state, then restore it after parser subcall + oldTShift = state.tShift[startLine] + oldSCount = state.sCount[startLine] + oldBlkIndent = state.blkIndent + + state.tShift[startLine] = contentStart - state.bMarks[startLine] + state.sCount[startLine] = offset + state.blkIndent = indent + + state.md.block.tokenize(state, startLine, endLine) + + state.blkIndent = oldBlkIndent + state.tShift[startLine] = oldTShift + state.sCount[startLine] = oldSCount + + token = state.push("fieldlist_body_close", "dd", -1) + + nextLine = startLine = state.line + itemLines[1] = nextLine + + if nextLine >= endLine: + break + + contentStart = state.bMarks[startLine] + + # Try to check if list is terminated or continued. + if state.sCount[nextLine] < state.blkIndent: + break + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + break + + # get next field item + posAfterName, name_text = parseNameMarker(state, startLine) + if posAfterName < 0: + break + + # Finalize list + token = state.push("field_list_close", "dl", -1) + listLines[1] = nextLine + state.line = nextLine + + return True diff --git a/mdit_py_plugins/footnote/LICENSE b/mdit_py_plugins/footnote/LICENSE new file mode 100644 index 0000000..2fd4e3d --- /dev/null +++ b/mdit_py_plugins/footnote/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdit_py_plugins/footnote/__init__.py b/mdit_py_plugins/footnote/__init__.py new file mode 100644 index 0000000..d85daae --- /dev/null +++ b/mdit_py_plugins/footnote/__init__.py @@ -0,0 +1 @@ +from .index import footnote_plugin # noqa: F401 diff --git a/mdit_py_plugins/footnote/index.py b/mdit_py_plugins/footnote/index.py new file mode 100644 index 0000000..119fb71 --- /dev/null +++ b/mdit_py_plugins/footnote/index.py @@ -0,0 +1,430 @@ +# Process footnotes +# + +from typing import List, Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import isSpace +from markdown_it.helpers import parseLinkLabel +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token + + +def footnote_plugin(md: MarkdownIt): + """Plugin ported from + `markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__. + + It is based on the + `pandoc definition <http://johnmacfarlane.net/pandoc/README.html#footnotes>`__: + + .. code-block:: md + + Normal footnote: + + Here is a footnote reference,[^1] and another.[^longnote] + + [^1]: Here is the footnote. + + [^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they + belong to the previous footnote. + + """ + md.block.ruler.before( + "reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]} + ) + md.inline.ruler.after("image", "footnote_inline", footnote_inline) + md.inline.ruler.after("footnote_inline", "footnote_ref", footnote_ref) + md.core.ruler.after("inline", "footnote_tail", footnote_tail) + + md.add_render_rule("footnote_ref", render_footnote_ref) + md.add_render_rule("footnote_block_open", render_footnote_block_open) + md.add_render_rule("footnote_block_close", render_footnote_block_close) + md.add_render_rule("footnote_open", render_footnote_open) + md.add_render_rule("footnote_close", render_footnote_close) + md.add_render_rule("footnote_anchor", render_footnote_anchor) + + # helpers (only used in other rules, no tokens are attached to those) + md.add_render_rule("footnote_caption", render_footnote_caption) + md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name) + + +# ## RULES ## + + +def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool): + """Process footnote block definition""" + + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # line should be at least 5 chars - "[^x]:" + if start + 4 > maximum: + return False + + if state.srcCharCode[start] != 0x5B: # /* [ */ + return False + if state.srcCharCode[start + 1] != 0x5E: # /* ^ */ + return False + + pos = start + 2 + while pos < maximum: + if state.srcCharCode[pos] == 0x20: + return False + if state.srcCharCode[pos] == 0x5D: # /* ] */ + break + pos += 1 + + if pos == start + 2: # no empty footnote labels + return False + pos += 1 + if pos >= maximum or state.srcCharCode[pos] != 0x3A: # /* : */ + return False + if silent: + return True + pos += 1 + + label = state.src[start + 2 : pos - 2] + state.env.setdefault("footnotes", {}).setdefault("refs", {})[":" + label] = -1 + + open_token = Token("footnote_reference_open", "", 1) + open_token.meta = {"label": label} + open_token.level = state.level + state.level += 1 + state.tokens.append(open_token) + + oldBMark = state.bMarks[startLine] + oldTShift = state.tShift[startLine] + oldSCount = state.sCount[startLine] + oldParentType = state.parentType + + posAfterColon = pos + initial = offset = ( + state.sCount[startLine] + + pos + - (state.bMarks[startLine] + state.tShift[startLine]) + ) + + while pos < maximum: + ch = state.srcCharCode[pos] + + if isSpace(ch): + if ch == 0x09: + offset += 4 - offset % 4 + else: + offset += 1 + + else: + break + + pos += 1 + + state.tShift[startLine] = pos - posAfterColon + state.sCount[startLine] = offset - initial + + state.bMarks[startLine] = posAfterColon + state.blkIndent += 4 + state.parentType = "footnote" + + if state.sCount[startLine] < state.blkIndent: + state.sCount[startLine] += state.blkIndent + + state.md.block.tokenize(state, startLine, endLine, True) + + state.parentType = oldParentType + state.blkIndent -= 4 + state.tShift[startLine] = oldTShift + state.sCount[startLine] = oldSCount + state.bMarks[startLine] = oldBMark + + open_token.map = [startLine, state.line] + + token = Token("footnote_reference_close", "", -1) + state.level -= 1 + token.level = state.level + state.tokens.append(token) + + return True + + +def footnote_inline(state: StateInline, silent: bool): + """Process inline footnotes (^[...])""" + + maximum = state.posMax + start = state.pos + + if start + 2 >= maximum: + return False + if state.srcCharCode[start] != 0x5E: # /* ^ */ + return False + if state.srcCharCode[start + 1] != 0x5B: # /* [ */ + return False + + labelStart = start + 2 + labelEnd = parseLinkLabel(state, start + 1) + + # parser failed to find ']', so it's not a valid note + if labelEnd < 0: + return False + + # We found the end of the link, and know for a fact it's a valid link + # so all that's left to do is to call tokenizer. + # + if not silent: + refs = state.env.setdefault("footnotes", {}).setdefault("list", {}) + footnoteId = len(refs) + + tokens: List[Token] = [] + state.md.inline.parse( + state.src[labelStart:labelEnd], state.md, state.env, tokens + ) + + token = state.push("footnote_ref", "", 0) + token.meta = {"id": footnoteId} + + refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens} + + state.pos = labelEnd + 1 + state.posMax = maximum + return True + + +def footnote_ref(state: StateInline, silent: bool): + """Process footnote references ([^...])""" + + maximum = state.posMax + start = state.pos + + # should be at least 4 chars - "[^x]" + if start + 3 > maximum: + return False + + if "footnotes" not in state.env or "refs" not in state.env["footnotes"]: + return False + if state.srcCharCode[start] != 0x5B: # /* [ */ + return False + if state.srcCharCode[start + 1] != 0x5E: # /* ^ */ + return False + + pos = start + 2 + while pos < maximum: + if state.srcCharCode[pos] == 0x20: + return False + if state.srcCharCode[pos] == 0x0A: + return False + if state.srcCharCode[pos] == 0x5D: # /* ] */ + break + pos += 1 + + if pos == start + 2: # no empty footnote labels + return False + if pos >= maximum: + return False + pos += 1 + + label = state.src[start + 2 : pos - 1] + if (":" + label) not in state.env["footnotes"]["refs"]: + return False + + if not silent: + if "list" not in state.env["footnotes"]: + state.env["footnotes"]["list"] = {} + + if state.env["footnotes"]["refs"][":" + label] < 0: + footnoteId = len(state.env["footnotes"]["list"]) + state.env["footnotes"]["list"][footnoteId] = {"label": label, "count": 0} + state.env["footnotes"]["refs"][":" + label] = footnoteId + else: + footnoteId = state.env["footnotes"]["refs"][":" + label] + + footnoteSubId = state.env["footnotes"]["list"][footnoteId]["count"] + state.env["footnotes"]["list"][footnoteId]["count"] += 1 + + token = state.push("footnote_ref", "", 0) + token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label} + + state.pos = pos + state.posMax = maximum + return True + + +def footnote_tail(state: StateBlock, *args, **kwargs): + """Post-processing step, to move footnote tokens to end of the token stream. + + Also removes un-referenced tokens. + """ + + insideRef = False + refTokens = {} + + if "footnotes" not in state.env: + return + + current: List[Token] = [] + tok_filter = [] + for tok in state.tokens: + + if tok.type == "footnote_reference_open": + insideRef = True + current = [] + currentLabel = tok.meta["label"] + tok_filter.append(False) + continue + + if tok.type == "footnote_reference_close": + insideRef = False + # prepend ':' to avoid conflict with Object.prototype members + refTokens[":" + currentLabel] = current + tok_filter.append(False) + continue + + if insideRef: + current.append(tok) + + tok_filter.append((not insideRef)) + + state.tokens = [t for t, f in zip(state.tokens, tok_filter) if f] + + if "list" not in state.env.get("footnotes", {}): + return + foot_list = state.env["footnotes"]["list"] + + token = Token("footnote_block_open", "", 1) + state.tokens.append(token) + + for i, foot_note in foot_list.items(): + token = Token("footnote_open", "", 1) + token.meta = {"id": i, "label": foot_note.get("label", None)} + # TODO propagate line positions of original foot note + # (but don't store in token.map, because this is used for scroll syncing) + state.tokens.append(token) + + if "tokens" in foot_note: + tokens = [] + + token = Token("paragraph_open", "p", 1) + token.block = True + tokens.append(token) + + token = Token("inline", "", 0) + token.children = foot_note["tokens"] + token.content = foot_note["content"] + tokens.append(token) + + token = Token("paragraph_close", "p", -1) + token.block = True + tokens.append(token) + + elif "label" in foot_note: + tokens = refTokens[":" + foot_note["label"]] + + state.tokens.extend(tokens) + if state.tokens[len(state.tokens) - 1].type == "paragraph_close": + lastParagraph: Optional[Token] = state.tokens.pop() + else: + lastParagraph = None + + t = ( + foot_note["count"] + if (("count" in foot_note) and (foot_note["count"] > 0)) + else 1 + ) + j = 0 + while j < t: + token = Token("footnote_anchor", "", 0) + token.meta = {"id": i, "subId": j, "label": foot_note.get("label", None)} + state.tokens.append(token) + j += 1 + + if lastParagraph: + state.tokens.append(lastParagraph) + + token = Token("footnote_close", "", -1) + state.tokens.append(token) + + token = Token("footnote_block_close", "", -1) + state.tokens.append(token) + + +######################################## +# Renderer partials + + +def render_footnote_anchor_name(self, tokens, idx, options, env): + n = str(tokens[idx].meta["id"] + 1) + prefix = "" + + doc_id = env.get("docId", None) + if isinstance(doc_id, str): + prefix = f"-{doc_id}-" + + return prefix + n + + +def render_footnote_caption(self, tokens, idx, options, env): + n = str(tokens[idx].meta["id"] + 1) + + if tokens[idx].meta.get("subId", -1) > 0: + n += ":" + str(tokens[idx].meta["subId"]) + + return "[" + n + "]" + + +def render_footnote_ref(self, tokens, idx, options, env): + ident = self.rules["footnote_anchor_name"](tokens, idx, options, env) + caption = self.rules["footnote_caption"](tokens, idx, options, env) + refid = ident + + if tokens[idx].meta.get("subId", -1) > 0: + refid += ":" + str(tokens[idx].meta["subId"]) + + return ( + '<sup class="footnote-ref"><a href="#fn' + + ident + + '" id="fnref' + + refid + + '">' + + caption + + "</a></sup>" + ) + + +def render_footnote_block_open(self, tokens, idx, options, env): + return ( + ( + '<hr class="footnotes-sep" />\n' + if options.xhtmlOut + else '<hr class="footnotes-sep">\n' + ) + + '<section class="footnotes">\n' + + '<ol class="footnotes-list">\n' + ) + + +def render_footnote_block_close(self, tokens, idx, options, env): + return "</ol>\n</section>\n" + + +def render_footnote_open(self, tokens, idx, options, env): + ident = self.rules["footnote_anchor_name"](tokens, idx, options, env) + + if tokens[idx].meta.get("subId", -1) > 0: + ident += ":" + tokens[idx].meta["subId"] + + return '<li id="fn' + ident + '" class="footnote-item">' + + +def render_footnote_close(self, tokens, idx, options, env): + return "</li>\n" + + +def render_footnote_anchor(self, tokens, idx, options, env): + ident = self.rules["footnote_anchor_name"](tokens, idx, options, env) + + if tokens[idx].meta["subId"] > 0: + ident += ":" + str(tokens[idx].meta["subId"]) + + # ↩ with escape code to prevent display as Apple Emoji on iOS + return ' <a href="#fnref' + ident + '" class="footnote-backref">\u21a9\uFE0E</a>' diff --git a/mdit_py_plugins/footnote/port.yaml b/mdit_py_plugins/footnote/port.yaml new file mode 100644 index 0000000..722f5e4 --- /dev/null +++ b/mdit_py_plugins/footnote/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-footnote + commit: cab6665ba39c6eb517cbbae3baeb549004bf740c + date: Jul 9, 2019 + version: 3.0.2 diff --git a/mdit_py_plugins/front_matter/LICENSE b/mdit_py_plugins/front_matter/LICENSE new file mode 100644 index 0000000..54c0b84 --- /dev/null +++ b/mdit_py_plugins/front_matter/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2016-2020 ParkSB. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdit_py_plugins/front_matter/__init__.py b/mdit_py_plugins/front_matter/__init__.py new file mode 100644 index 0000000..26bce1d --- /dev/null +++ b/mdit_py_plugins/front_matter/__init__.py @@ -0,0 +1 @@ +from .index import front_matter_plugin # noqa: F401 diff --git a/mdit_py_plugins/front_matter/index.py b/mdit_py_plugins/front_matter/index.py new file mode 100644 index 0000000..2077925 --- /dev/null +++ b/mdit_py_plugins/front_matter/index.py @@ -0,0 +1,138 @@ +# Process front matter and pass to cb +from math import floor + +from markdown_it import MarkdownIt +from markdown_it.common.utils import charCodeAt +from markdown_it.rules_block import StateBlock + + +def front_matter_plugin(md: MarkdownIt): + """Plugin ported from + `markdown-it-front-matter <https://github.com/ParkSB/markdown-it-front-matter>`__. + + It parses initial metadata, stored between opening/closing dashes: + + .. code-block:: md + + --- + valid-front-matter: true + --- + + """ + frontMatter = make_front_matter_rule() + md.block.ruler.before( + "table", + "front_matter", + frontMatter, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) + + +def make_front_matter_rule(): + min_markers = 3 + marker_str = "-" + marker_char = charCodeAt(marker_str, 0) + marker_len = len(marker_str) + + def frontMatter(state: StateBlock, startLine: int, endLine: int, silent: bool): + auto_closed = False + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + src_len = len(state.src) + + # Check out the first character of the first line quickly, + # this should filter out non-front matter + if startLine != 0 or marker_char != state.srcCharCode[0]: + return False + + # Check out the rest of the marker string + # while pos <= 3 + pos = start + 1 + while pos <= maximum and pos < src_len: + if marker_str[(pos - start) % marker_len] != state.src[pos]: + break + pos += 1 + + marker_count = floor((pos - start) / marker_len) + + if marker_count < min_markers: + return False + + pos -= (pos - start) % marker_len + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # Search for the end of the block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + return False + + if state.src[start:maximum] == "...": + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if start < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if marker_char != state.srcCharCode[start]: + continue + + if state.sCount[nextLine] - state.blkIndent >= 4: + # closing fence should be indented less than 4 spaces + continue + + pos = start + 1 + while pos < maximum: + if marker_str[(pos - start) % marker_len] != state.src[pos]: + break + pos += 1 + + # closing code fence must be at least as long as the opening one + if floor((pos - start) / marker_len) < marker_count: + continue + + # make sure tail has spaces only + pos -= (pos - start) % marker_len + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + # found! + auto_closed = True + break + + old_parent = state.parentType + old_line_max = state.lineMax + state.parentType = "container" + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine + + token = state.push("front_matter", "", 0) + token.hidden = True + token.markup = marker_str * min_markers + token.content = state.src[ + state.bMarks[startLine + 1] : state.eMarks[nextLine - 1] + ] + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.line = nextLine + (1 if auto_closed else 0) + token.map = [startLine, state.line] + + return True + + return frontMatter diff --git a/mdit_py_plugins/front_matter/port.yaml b/mdit_py_plugins/front_matter/port.yaml new file mode 100644 index 0000000..f7d145f --- /dev/null +++ b/mdit_py_plugins/front_matter/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-front-matter + commit: b404f5d8fd536e7e9ddb276267ae0b6f76e9cf9d + date: Feb 7, 2020 + version: 0.2.1 diff --git a/mdit_py_plugins/myst_blocks/__init__.py b/mdit_py_plugins/myst_blocks/__init__.py new file mode 100644 index 0000000..434cad6 --- /dev/null +++ b/mdit_py_plugins/myst_blocks/__init__.py @@ -0,0 +1 @@ +from .index import myst_block_plugin # noqa: F401 diff --git a/mdit_py_plugins/myst_blocks/index.py b/mdit_py_plugins/myst_blocks/index.py new file mode 100644 index 0000000..d0e4cf6 --- /dev/null +++ b/mdit_py_plugins/myst_blocks/index.py @@ -0,0 +1,153 @@ +import itertools + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, isSpace +from markdown_it.rules_block import StateBlock + + +def myst_block_plugin(md: MarkdownIt): + """Parse MyST targets (``(name)=``), blockquotes (``% comment``) and block breaks (``+++``).""" + md.block.ruler.before( + "blockquote", + "myst_line_comment", + line_comment, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.block.ruler.before( + "hr", + "myst_block_break", + block_break, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.block.ruler.before( + "hr", + "myst_target", + target, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.add_render_rule("myst_target", render_myst_target) + md.add_render_rule("myst_line_comment", render_myst_line_comment) + + +def line_comment(state: StateBlock, startLine: int, endLine: int, silent: bool): + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + if state.src[pos] != "%": + return False + + if silent: + return True + + token = state.push("myst_line_comment", "", 0) + token.attrSet("class", "myst-line-comment") + token.content = state.src[pos + 1 : maximum].rstrip() + token.markup = "%" + + # search end of block while appending lines to `token.content` + for nextLine in itertools.count(startLine + 1): + if nextLine >= endLine: + break + pos = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if state.src[pos] != "%": + break + token.content += "\n" + state.src[pos + 1 : maximum].rstrip() + + state.line = nextLine + token.map = [startLine, nextLine] + + return True + + +def block_break(state: StateBlock, startLine: int, endLine: int, silent: bool): + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + marker = state.srcCharCode[pos] + pos += 1 + + # Check block marker /* + */ + if marker != 0x2B: + return False + + # markers can be mixed with spaces, but there should be at least 3 of them + + cnt = 1 + while pos < maximum: + ch = state.srcCharCode[pos] + if ch != marker and not isSpace(ch): + break + if ch == marker: + cnt += 1 + pos += 1 + + if cnt < 3: + return False + + if silent: + return True + + state.line = startLine + 1 + + token = state.push("myst_block_break", "hr", 0) + token.attrSet("class", "myst-block") + token.content = state.src[pos:maximum].strip() + token.map = [startLine, state.line] + token.markup = chr(marker) * cnt + + return True + + +def target(state: StateBlock, startLine: int, endLine: int, silent: bool): + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + text = state.src[pos:maximum].strip() + if not text.startswith("("): + return False + if not text.endswith(")="): + return False + if not text[1:-2]: + return False + + if silent: + return True + + state.line = startLine + 1 + + token = state.push("myst_target", "", 0) + token.attrSet("class", "myst-target") + token.content = text[1:-2] + token.map = [startLine, state.line] + + return True + + +def render_myst_target(self, tokens, idx, options, env): + label = tokens[idx].content + class_name = "myst-target" + target = f'<a href="#{label}">({label})=</a>' + return f'<div class="{class_name}">{target}</div>' + + +def render_myst_line_comment(self, tokens, idx, options, env): + # Strip leading whitespace from all lines + content = "\n".join(line.lstrip() for line in tokens[idx].content.split("\n")) + return f"<!-- {escapeHtml(content)} -->" diff --git a/mdit_py_plugins/myst_role/__init__.py b/mdit_py_plugins/myst_role/__init__.py new file mode 100644 index 0000000..67b87f8 --- /dev/null +++ b/mdit_py_plugins/myst_role/__init__.py @@ -0,0 +1 @@ +from .index import myst_role_plugin # noqa: F401 diff --git a/mdit_py_plugins/myst_role/index.py b/mdit_py_plugins/myst_role/index.py new file mode 100644 index 0000000..53ef60e --- /dev/null +++ b/mdit_py_plugins/myst_role/index.py @@ -0,0 +1,65 @@ +import re + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml +from markdown_it.rules_inline import StateInline + +VALID_NAME_PATTERN = re.compile(r"^\{([a-zA-Z0-9\_\-\+\:]+)\}") + + +def myst_role_plugin(md: MarkdownIt): + """Parse ``{role-name}`content```""" + md.inline.ruler.before("backticks", "myst_role", myst_role) + md.add_render_rule("myst_role", render_myst_role) + + +def myst_role(state: StateInline, silent: bool): + + # check name + match = VALID_NAME_PATTERN.match(state.src[state.pos :]) + if not match: + return False + name = match.group(1) + + # check for starting backslash escape + try: + if state.srcCharCode[state.pos - 1] == 0x5C: # /* \ */ + # escaped (this could be improved in the case of edge case '\\{') + return False + except IndexError: + pass + + # scan opening tick length + start = pos = state.pos + match.end() + try: + while state.src[pos] == "`": + pos += 1 + except IndexError: + return False + + tick_length = pos - start + if not tick_length: + return False + + # search for closing ticks + match = re.search("`" * tick_length, state.src[pos + 1 :]) + if not match: + return False + content = state.src[pos : pos + match.start() + 1].replace("\n", " ") + + if not silent: + token = state.push("myst_role", "", 0) + token.meta = {"name": name} + token.content = content + + state.pos = pos + match.end() + 1 + + return True + + +def render_myst_role(self, tokens, idx, options, env): + token = tokens[idx] + name = token.meta.get("name", "unknown") + return ( + '<code class="myst role">' f"{{{name}}}[{escapeHtml(token.content)}]" "</code>" + ) diff --git a/mdit_py_plugins/py.typed b/mdit_py_plugins/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/mdit_py_plugins/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/mdit_py_plugins/substitution.py b/mdit_py_plugins/substitution.py new file mode 100644 index 0000000..bae120b --- /dev/null +++ b/mdit_py_plugins/substitution.py @@ -0,0 +1,113 @@ +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline + + +def substitution_plugin( + md: MarkdownIt, start_delimiter: str = "{", end_delimiter: str = "}" +): + """A plugin to create substitution tokens. + + These, token should be handled by the renderer. + + Example:: + + {{ block }} + + a {{ inline }} b + + """ + + start_char = ord(start_delimiter) + end_char = ord(end_delimiter) + + def _substitution_inline(state: StateInline, silent: bool): + try: + if ( + state.srcCharCode[state.pos] != start_char + or state.srcCharCode[state.pos + 1] != start_char + ): + return False + except IndexError: + return False + + pos = state.pos + 2 + found_closing = False + while True: + try: + end = state.srcCharCode.index(end_char, pos) + except ValueError: + return False + try: + if state.srcCharCode[end + 1] == end_char: + found_closing = True + break + except IndexError: + return False + pos = end + 2 + + if not found_closing: + return False + + text = state.src[state.pos + 2 : end].strip() + state.pos = end + 2 + + if silent: + return True + + token = state.push("substitution_inline", "span", 0) + token.block = False + token.content = text + token.attrSet("class", "substitution") + token.attrSet("text", text) + token.markup = f"{start_delimiter}{end_delimiter}" + + return True + + def _substitution_block( + state: StateBlock, startLine: int, endLine: int, silent: bool + ): + startPos = state.bMarks[startLine] + state.tShift[startLine] + end = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + lineText = state.src[startPos:end].strip() + + try: + if ( + lineText[0] != start_delimiter + or lineText[1] != start_delimiter + or lineText[-1] != end_delimiter + or lineText[-2] != end_delimiter + or len(lineText) < 5 + ): + return False + except IndexError: + return False + + text = lineText[2:-2].strip() + + # special case if multiple on same line, e.g. {{a}}{{b}} + if (end_delimiter * 2) in text: + return False + + state.line = startLine + 1 + + if silent: + return True + + token = state.push("substitution_block", "div", 0) + token.block = True + token.content = text + token.attrSet("class", "substitution") + token.attrSet("text", text) + token.markup = f"{start_delimiter}{end_delimiter}" + token.map = [startLine, state.line] + + return True + + md.block.ruler.before("fence", "substitution_block", _substitution_block) + md.inline.ruler.before("escape", "substitution_inline", _substitution_inline) diff --git a/mdit_py_plugins/tasklists/__init__.py b/mdit_py_plugins/tasklists/__init__.py new file mode 100644 index 0000000..40a6d67 --- /dev/null +++ b/mdit_py_plugins/tasklists/__init__.py @@ -0,0 +1,153 @@ +"""Builds task/todo lists out of markdown lists with items starting with [ ] or [x]""" + +# Ported by Wolmar Nyberg Åkerström from https://github.com/revin/markdown-it-task-lists +# ISC License +# Copyright (c) 2016, Revin Guillen +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import re +from typing import List +from uuid import uuid4 + +from markdown_it import MarkdownIt +from markdown_it.token import Token + +# Regex string to match a whitespace character, as specified in +# https://github.github.com/gfm/#whitespace-character +# (spec version 0.29-gfm (2019-04-06)) +_GFM_WHITESPACE_RE = r"[ \t\n\v\f\r]" + + +def tasklists_plugin( + md: MarkdownIt, + enabled: bool = False, + label: bool = False, + label_after: bool = False, +): + """Plugin for building task/todo lists out of markdown lists with items starting with [ ] or [x] + .. Nothing else + + For example:: + - [ ] An item that needs doing + - [x] An item that is complete + + The rendered HTML checkboxes are disabled; to change this, pass a truthy value into the enabled + property of the plugin options. + + :param enabled: True enables the rendered checkboxes + :param label: True wraps the rendered list items in a <label> element for UX purposes, + :param label_after: True – adds the <label> element after the checkbox. + """ + disable_checkboxes = not enabled + use_label_wrapper = label + use_label_after = label_after + + def fcn(state): + tokens: List[Token] = state.tokens + for i in range(2, len(tokens) - 1): + + if is_todo_item(tokens, i): + todoify(tokens[i], tokens[i].__class__) + tokens[i - 2].attrSet( + "class", + "task-list-item" + (" enabled" if not disable_checkboxes else ""), + ) + tokens[parent_token(tokens, i - 2)].attrSet( + "class", "contains-task-list" + ) + + md.core.ruler.after("inline", "github-tasklists", fcn) + + def parent_token(tokens, index): + target_level = tokens[index].level - 1 + for i in range(1, index + 1): + if tokens[index - i].level == target_level: + return index - i + return -1 + + def is_todo_item(tokens, index): + return ( + is_inline(tokens[index]) + and is_paragraph(tokens[index - 1]) + and is_list_item(tokens[index - 2]) + and starts_with_todo_markdown(tokens[index]) + ) + + def todoify(token: Token, token_constructor): + assert token.children is not None + token.children.insert(0, make_checkbox(token, token_constructor)) + token.children[1].content = token.children[1].content[3:] + token.content = token.content[3:] + + if use_label_wrapper: + if use_label_after: + token.children.pop() + + # Replaced number generator from original plugin with uuid. + checklist_id = f"task-item-{uuid4()}" + token.children[0].content = ( + token.children[0].content[0:-1] + f' id="{checklist_id}">' + ) + token.children.append( + after_label(token.content, checklist_id, token_constructor) + ) + else: + token.children.insert(0, begin_label(token_constructor)) + token.children.append(end_label(token_constructor)) + + def make_checkbox(token, token_constructor): + checkbox = token_constructor("html_inline", "", 0) + disabled_attr = 'disabled="disabled"' if disable_checkboxes else "" + if token.content.startswith("[ ] "): + checkbox.content = ( + '<input class="task-list-item-checkbox" ' + f'{disabled_attr} type="checkbox">' + ) + elif token.content.startswith("[x] ") or token.content.startswith("[X] "): + checkbox.content = ( + '<input class="task-list-item-checkbox" checked="checked" ' + f'{disabled_attr} type="checkbox">' + ) + return checkbox + + def begin_label(token_constructor): + token = token_constructor("html_inline", "", 0) + token.content = "<label>" + return token + + def end_label(token_constructor): + token = token_constructor("html_inline", "", 0) + token.content = "</label>" + return token + + def after_label(content, checkbox_id, token_constructor): + token = token_constructor("html_inline", "", 0) + token.content = ( + f'<label class="task-list-item-label" for="{checkbox_id}">{content}</label>' + ) + token.attrs = [{"for": checkbox_id}] + return token + + def is_inline(token): + return token.type == "inline" + + def is_paragraph(token): + return token.type == "paragraph_open" + + def is_list_item(token): + return token.type == "list_item_open" + + def starts_with_todo_markdown(token): + # leading whitespace in a list item is already trimmed off by markdown-it + return re.match(rf"\[[ xX]]{_GFM_WHITESPACE_RE}+", token.content) diff --git a/mdit_py_plugins/tasklists/port.yaml b/mdit_py_plugins/tasklists/port.yaml new file mode 100644 index 0000000..4ad6da5 --- /dev/null +++ b/mdit_py_plugins/tasklists/port.yaml @@ -0,0 +1,6 @@ +- package: markdown-it-task-lists + commit: 8233e000559fae5a6306009e55332a54a9d3f606 + date: 6 Mar 2018 + version: 2.1.1 + changes: + - Replaced number generator from original plugin with uuid diff --git a/mdit_py_plugins/texmath/LICENSE b/mdit_py_plugins/texmath/LICENSE new file mode 100644 index 0000000..b88387c --- /dev/null +++ b/mdit_py_plugins/texmath/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-17 Stefan Goessner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mdit_py_plugins/texmath/README.md b/mdit_py_plugins/texmath/README.md new file mode 100644 index 0000000..f79f335 --- /dev/null +++ b/mdit_py_plugins/texmath/README.md @@ -0,0 +1,137 @@ +[![License](https://img.shields.io/github/license/goessner/markdown-it-texmath.svg)](https://github.com/goessner/markdown-it-texmath/blob/master/licence.txt) +[![npm](https://img.shields.io/npm/v/markdown-it-texmath.svg)](https://www.npmjs.com/package/markdown-it-texmath) +[![npm](https://img.shields.io/npm/dt/markdown-it-texmath.svg)](https://www.npmjs.com/package/markdown-it-texmath) + +# markdown-it-texmath + +Add TeX math equations to your Markdown documents rendered by [markdown-it](https://github.com/markdown-it/markdown-it) parser. [KaTeX](https://github.com/Khan/KaTeX) is used as a fast math renderer. + +## Features +Simplify the process of authoring markdown documents containing math formulas. +This extension is a comfortable tool for scientists, engineers and students with markdown as their first choice document format. + +* Macro support +* Simple formula numbering +* Inline math with tables, lists and blockquote. +* User setting delimiters: + * `'dollars'` (default) + * inline: `$...$` + * display: `$$...$$` + * display + equation number: `$$...$$ (1)` + * `'brackets'` + * inline: `\(...\)` + * display: `\[...\]` + * display + equation number: `\[...\] (1)` + * `'gitlab'` + * inline: ``$`...`$`` + * display: `` ```math ... ``` `` + * display + equation number: `` ```math ... ``` (1)`` + * `'julia'` + * inline: `$...$` or ``` ``...`` ``` + * display: `` ```math ... ``` `` + * display + equation number: `` ```math ... ``` (1)`` + * `'kramdown'` + * inline: ``$$...$$`` + * display: `$$...$$` + * display + equation number: `$$...$$ (1)` + +## Show me + +View a [test table](https://goessner.github.io/markdown-it-texmath/index.html). + +[try it out ...](https://goessner.github.io/markdown-it-texmath/markdown-it-texmath-demo.html) + +## Use with `node.js` + +Install the extension. Verify having `markdown-it` and `katex` already installed . +``` +npm install markdown-it-texmath +``` +Use it with JavaScript. +```js +let kt = require('katex'), + tm = require('markdown-it-texmath').use(kt), + md = require('markdown-it')().use(tm,{delimiters:'dollars',macros:{"\\RR": "\\mathbb{R}"}}); + +md.render('Euler\'s identity \(e^{i\pi}+1=0\) is a beautiful formula in $\\RR 2$.') +``` + +## Use in Browser +```html +<html> +<head> + <meta charset='utf-8'> + <link rel="stylesheet" href="katex.min.css"> + <link rel="stylesheet" href="texmath.css"> + <script src="markdown-it.min.js"></script> + <script src="katex.min.js"></script> + <script src="texmath.js"></script> +</head> +<body> + <div id="out"></div> + <script> + let md; + document.addEventListener("DOMContentLoaded", () => { + const tm = texmath.use(katex); + md = markdownit().use(tm,{delimiters:'dollars',macros:{"\\RR": "\\mathbb{R}"}}); + out.innerHTML = md.render('Euler\'s identity $e^{i\pi}+1=0$ is a beautiful formula in //RR 2.'); + }) + </script> +</body> +</html> +``` +## CDN + +Use following links for `texmath.js` and `texmath.css` +* `https://gitcdn.xyz/cdn/goessner/markdown-it-texmath/master/texmath.js` +* `https://gitcdn.xyz/cdn/goessner/markdown-it-texmath/master/texmath.css` + +## Dependencies + +* [`markdown-it`](https://github.com/markdown-it/markdown-it): Markdown parser done right. Fast and easy to extend. +* [`katex`](https://github.com/Khan/KaTeX): This is where credits for fast rendering TeX math in HTML go to. + +## ToDo + + nothing yet + +## FAQ + +* __`markdown-it-texmath` with React Native does not work, why ?__ + * `markdown-it-texmath` is using regular expressions with `y` [(sticky) property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky) and cannot avoid this. The use of the `y` flag in regular expressions means the plugin is not compatible with React Native (which as of now doesn't support it and throws an error `Invalid flags supplied to RegExp constructor`). + +## CHANGELOG + +### [0.6.0] on October 04, 2019 +* Add support for [Julia Markdown](https://docs.julialang.org/en/v1/stdlib/Markdown/) on [request](https://github.com/goessner/markdown-it-texmath/issues/15). + +### [0.5.5] on February 07, 2019 +* Remove [rendering bug with brackets delimiters](https://github.com/goessner/markdown-it-texmath/issues/9). + +### [0.5.4] on January 20, 2019 +* Remove pathological [bug within blockquotes](https://github.com/goessner/mdmath/issues/50). + +### [0.5.3] on November 11, 2018 +* Add support for Tex macros (https://katex.org/docs/supported.html#macros) . +* Bug with [brackets delimiters](https://github.com/goessner/markdown-it-texmath/issues/9) . + +### [0.5.2] on September 07, 2018 +* Add support for [Kramdown](https://kramdown.gettalong.org/) . + +### [0.5.0] on August 15, 2018 +* Fatal blockquote bug investigated. Implemented workaround to vscode bug, which has finally gone with vscode 1.26.0 . + +### [0.4.6] on January 05, 2018 +* Escaped underscore bug removed. + +### [0.4.5] on November 06, 2017 +* Backslash bug removed. + +### [0.4.4] on September 27, 2017 +* Modifying the `block` mode regular expression with `gitlab` delimiters, so removing the `newline` bug. + +## License + +`markdown-it-texmath` is licensed under the [MIT License](./license.txt) + + © [Stefan Gössner](https://github.com/goessner) diff --git a/mdit_py_plugins/texmath/__init__.py b/mdit_py_plugins/texmath/__init__.py new file mode 100644 index 0000000..f0c2588 --- /dev/null +++ b/mdit_py_plugins/texmath/__init__.py @@ -0,0 +1 @@ +from .index import texmath_plugin # noqa F401 diff --git a/mdit_py_plugins/texmath/index.py b/mdit_py_plugins/texmath/index.py new file mode 100644 index 0000000..ecf178c --- /dev/null +++ b/mdit_py_plugins/texmath/index.py @@ -0,0 +1,307 @@ +import re +from typing import Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import charCodeAt + + +def texmath_plugin(md: MarkdownIt, delimiters="dollars", macros: Optional[dict] = None): + """Plugin ported from + `markdown-it-texmath <https://github.com/goessner/markdown-it-texmath>`__. + + It parses TeX math equations set inside opening and closing delimiters: + + .. code-block:: md + + $\\alpha = \\frac{1}{2}$ + + :param delimiters: one of: brackets, dollars, gitlab, julia, kramdown + + """ + macros = macros or {} + + if delimiters in rules: + for rule_inline in rules[delimiters]["inline"]: + md.inline.ruler.before( + "escape", rule_inline["name"], make_inline_func(rule_inline) + ) + + def render_math_inline(self, tokens, idx, options, env): + return rule_inline["tmpl"].format( + render(tokens[idx].content, False, macros) + ) + + md.add_render_rule(rule_inline["name"], render_math_inline) + + for rule_block in rules[delimiters]["block"]: + md.block.ruler.before( + "fence", rule_block["name"], make_block_func(rule_block) + ) + + def render_math_block(self, tokens, idx, options, env): + return rule_block["tmpl"].format( + render(tokens[idx].content, True, macros), tokens[idx].info + ) + + md.add_render_rule(rule_block["name"], render_math_block) + + +def applyRule(rule, string: str, begin, inBlockquote): + + if not ( + string.startswith(rule["tag"], begin) + and (rule["pre"](string, begin) if "pre" in rule else True) + ): + return False + + match = rule["rex"].match(string[begin:]) # type: re.Match + + if not match or match.start() != 0: + return False + + lastIndex = match.end() + begin - 1 + if "post" in rule: + if not ( + rule["post"](string, lastIndex) # valid post-condition + # remove evil blockquote bug (https:#github.com/goessner/mdmath/issues/50) + and (not inBlockquote or "\n" not in match.group(1)) + ): + return False + return match + + +def make_inline_func(rule): + def _func(state, silent): + res = applyRule(rule, state.src, state.pos, False) + if res: + if not silent: + token = state.push(rule["name"], "math", 0) + token.content = res[1] # group 1 from regex .. + token.markup = rule["tag"] + + state.pos += res.end() + + return bool(res) + + return _func + + +def make_block_func(rule): + def _func(state, begLine, endLine, silent): + begin = state.bMarks[begLine] + state.tShift[begLine] + res = applyRule(rule, state.src, begin, state.parentType == "blockquote") + if res: + if not silent: + token = state.push(rule["name"], "math", 0) + token.block = True + token.content = res[1] + token.info = res[len(res.groups())] + token.markup = rule["tag"] + + line = begLine + endpos = begin + res.end() - 1 + + while line < endLine: + if endpos >= state.bMarks[line] and endpos <= state.eMarks[line]: + # line for end of block math found ... + state.line = line + 1 + break + line += 1 + + state.pos = begin + res.end() + + return bool(res) + + return _func + + +def dollar_pre(str, beg): + prv = charCodeAt(str[beg - 1], 0) if beg > 0 else False + return ( + (not prv) or prv != 0x5C and (prv < 0x30 or prv > 0x39) # no backslash, + ) # no decimal digit .. before opening '$' + + +def dollar_post(string, end): + try: + nxt = string[end + 1] and charCodeAt(string[end + 1], 0) + except IndexError: + return True + return ( + (not nxt) or (nxt < 0x30) or (nxt > 0x39) + ) # no decimal digit .. after closing '$' + + +def render(tex, displayMode, macros): + return tex + # TODO better HTML renderer port for math + # try: + # res = katex.renderToString(tex,{throwOnError:False,displayMode,macros}) + # except: + # res = tex+": "+err.message.replace("<","<") + # return res + + +# def use(katex): # math renderer used ... +# texmath.katex = katex; # ... katex solely at current ... +# return texmath; +# } + + +# All regexes areg global (g) and sticky (y), see: +# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky + +rules: dict = { + "brackets": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\\\((.+?)\\\)", re.DOTALL), + "tmpl": "<eq>{0}</eq>", + "tag": "\\(", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^\\\[(((?!\\\]|\\\[)[\s\S])+?)\\\]\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "\\[", + }, + { + "name": "math_block", + "rex": re.compile(r"^\\\[([\s\S]+?)\\\]", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "\\[", + }, + ], + }, + "gitlab": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\$`(.+?)`\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$`", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno">\n<eqn>{0}</eqn><span>({1})</span>\n</section>\n', # noqa: E501 + "tag": "```math", + }, + { + "name": "math_block", + "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "```math", + }, + ], + }, + "julia": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^`{2}([^`]+?)`{2}"), + "tmpl": "<eq>{0}</eq>", + "tag": "``", + }, + { + "name": "math_inline", + "rex": re.compile(r"^\$(\S[^$\r\n]*?[^\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + { + "name": "math_single", + "rex": re.compile(r"^\$([^$\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "```math", + }, + { + "name": "math_block", + "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "tmpl": "<section><eqn>{0}</eqn></section>", + "tag": "```math", + }, + ], + }, + "kramdown": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\${2}([^$\r\n]*?)\${2}"), + "tmpl": "<eq>{0}</eq>", + "tag": "$$", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile(r"^\${2}([^$]*?)\${2}\s*?\(([^)$\r\n]+?)\)", re.M), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "$$", + }, + { + "name": "math_block", + "rex": re.compile(r"^\${2}([^$]*?)\${2}", re.M), + "tmpl": "<section><eqn>{0}</eqn></section>", + "tag": "$$", + }, + ], + }, + "dollars": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\$(\S[^$]*?[^\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + { + "name": "math_single", + "rex": re.compile(r"^\$([^$\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile(r"^\${2}([^$]*?)\${2}\s*?\(([^)$\r\n]+?)\)", re.M), + "tmpl": '<section class="eqno">\n<eqn>{0}</eqn><span>({1})</span>\n</section>\n', # noqa: E501 + "tag": "$$", + }, + { + "name": "math_block", + "rex": re.compile(r"^\${2}([^$]*?)\${2}", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "$$", + }, + ], + }, +} diff --git a/mdit_py_plugins/texmath/port.yaml b/mdit_py_plugins/texmath/port.yaml new file mode 100644 index 0000000..ba47ac8 --- /dev/null +++ b/mdit_py_plugins/texmath/port.yaml @@ -0,0 +1,7 @@ +- package: markdown-it-texmath + commit: 78c548829ce2ef85c73dc71e680d01e5ae41ffbf + date: Oct 4, 2019 + version: 0.6 + changes: | + both dollars/math_inline and brackets/math_inline regexes have been changed, + to allow (single) line breaks diff --git a/mdit_py_plugins/wordcount/__init__.py b/mdit_py_plugins/wordcount/__init__.py new file mode 100644 index 0000000..577eeda --- /dev/null +++ b/mdit_py_plugins/wordcount/__init__.py @@ -0,0 +1,58 @@ +import string +from typing import Callable, List + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore + + +def basic_count(text: str) -> int: + """Split the string and ignore punctuation only elements.""" + return sum([el.strip(string.punctuation).isalpha() for el in text.split()]) + + +def wordcount_plugin( + md: MarkdownIt, + *, + per_minute: int = 200, + count_func: Callable[[str], int] = basic_count, + store_text: bool = False +): + """Plugin for computing and storing the word count. + + Stores in the ``env`` e.g.:: + + env["wordcount"] = { + "words": 200 + "minutes": 1, + } + + If "wordcount" is already in the env, it will update it. + + :param per_minute: Words per minute reading speed + :param store_text: store all text under a "text" key, as a list of strings + """ + + def _word_count_rule(state: StateCore) -> None: + text: List[str] = [] + words = 0 + for token in state.tokens: + if token.type == "text": + words += count_func(token.content) + if store_text: + text.append(token.content) + elif token.type == "inline": + for child in token.children or (): + if child.type == "text": + words += count_func(child.content) + if store_text: + text.append(child.content) + + data = state.env.setdefault("wordcount", {}) + if store_text: + data.setdefault("text", []) + data["text"] += text + data.setdefault("words", 0) + data["words"] += words + data["minutes"] = int(round(data["words"] / per_minute)) + + md.core.ruler.push("wordcount", _word_count_rule) |