This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

@@ -0,0 +1,5 @@
"""PyMdown extra extensions."""
from .__meta__ import __version__, __version_info__ # noqa: F401
# Nothing to import with all
__all__ = ()
@@ -0,0 +1,197 @@
"""Meta related things."""
from __future__ import annotations
from collections import namedtuple
import re
RE_VER = re.compile(
r'''(?x)
(?P<major>\d+)(?:\.(?P<minor>\d+))?(?:\.(?P<micro>\d+))?
(?:(?P<type>a|b|rc)(?P<pre>\d+))?
(?:\.post(?P<post>\d+))?
(?:\.dev(?P<dev>\d+))?
'''
)
REL_MAP = {
".dev": "",
".dev-alpha": "a",
".dev-beta": "b",
".dev-candidate": "rc",
"alpha": "a",
"beta": "b",
"candidate": "rc",
"final": ""
}
DEV_STATUS = {
".dev": "2 - Pre-Alpha",
".dev-alpha": "2 - Pre-Alpha",
".dev-beta": "2 - Pre-Alpha",
".dev-candidate": "2 - Pre-Alpha",
"alpha": "3 - Alpha",
"beta": "4 - Beta",
"candidate": "4 - Beta",
"final": "5 - Production/Stable"
}
PRE_REL_MAP = {"a": 'alpha', "b": 'beta', "rc": 'candidate'}
class Version(namedtuple("Version", ["major", "minor", "micro", "release", "pre", "post", "dev"])):
"""
Get the version (PEP 440).
A biased approach to the PEP 440 semantic version.
Provides a tuple structure which is sorted for comparisons `v1 > v2` etc.
(major, minor, micro, release type, pre-release build, post-release build, development release build)
Release types are named in is such a way they are comparable with ease.
Accessors to check if a development, pre-release, or post-release build. Also provides accessor to get
development status for setup files.
How it works (currently):
- You must specify a release type as either `final`, `alpha`, `beta`, or `candidate`.
- To define a development release, you can use either `.dev`, `.dev-alpha`, `.dev-beta`, or `.dev-candidate`.
The dot is used to ensure all development specifiers are sorted before `alpha`.
You can specify a `dev` number for development builds, but do not have to as implicit development releases
are allowed.
- You must specify a `pre` value greater than zero if using a prerelease as this project (not PEP 440) does not
allow implicit prereleases.
- You can optionally set `post` to a value greater than zero to make the build a post release. While post releases
are technically allowed in prereleases, it is strongly discouraged, so we are rejecting them. It should be
noted that we do not allow `post0` even though PEP 440 does not restrict this. This project specifically
does not allow implicit post releases.
- It should be noted that we do not support epochs `1!` or local versions `+some-custom.version-1`.
Acceptable version releases:
```
Version(1, 0, 0, "final") 1.0
Version(1, 2, 0, "final") 1.2
Version(1, 2, 3, "final") 1.2.3
Version(1, 2, 0, "alpha", pre=4) 1.2a4
Version(1, 2, 0, "beta", pre=4) 1.2b4
Version(1, 2, 0, "candidate", pre=4) 1.2rc4
Version(1, 2, 0, "final", post=1) 1.2.post1
Version(1, 2, 3, ".dev") 1.2.3.dev0
Version(1, 2, 3, ".dev", dev=1) 1.2.3.dev1
```
"""
def __new__(
cls,
major: int, minor: int, micro: int, release: str = "final",
pre: int = 0, post: int = 0, dev: int = 0
) -> Version:
"""Validate version info."""
# Ensure all parts are positive integers.
for value in (major, minor, micro, pre, post):
if not (isinstance(value, int) and value >= 0):
raise ValueError("All version parts except 'release' should be integers.")
if release not in REL_MAP:
raise ValueError(f"'{release}' is not a valid release type.")
# Ensure valid pre-release (we do not allow implicit pre-releases).
if ".dev-candidate" < release < "final":
if pre == 0:
raise ValueError("Implicit pre-releases not allowed.")
elif dev:
raise ValueError("Version is not a development release.")
elif post:
raise ValueError("Post-releases are not allowed with pre-releases.")
# Ensure valid development or development/pre release
elif release < "alpha":
if release > ".dev" and pre == 0:
raise ValueError("Implicit pre-release not allowed.")
elif post:
raise ValueError("Post-releases are not allowed with pre-releases.")
# Ensure a valid normal release
else:
if pre:
raise ValueError("Version is not a pre-release.")
elif dev:
raise ValueError("Version is not a development release.")
return super().__new__(cls, major, minor, micro, release, pre, post, dev)
def _is_pre(self) -> bool:
"""Is prerelease."""
return bool(self.pre > 0)
def _is_dev(self) -> bool:
"""Is development."""
return bool(self.release < "alpha")
def _is_post(self) -> bool:
"""Is post."""
return bool(self.post > 0)
def _get_dev_status(self) -> str: # pragma: no cover
"""Get development status string."""
return DEV_STATUS[self.release]
def _get_canonical(self) -> str:
"""Get the canonical output string."""
# Assemble major, minor, micro version and append `pre`, `post`, or `dev` if needed..
if self.micro == 0 and self.major != 0:
ver = f"{self.major}.{self.minor}"
else:
ver = f"{self.major}.{self.minor}.{self.micro}"
if self._is_pre():
ver += f'{REL_MAP[self.release]}{self.pre}'
if self._is_post():
ver += f".post{self.post}"
if self._is_dev():
ver += f".dev{self.dev}"
return ver
def parse_version(ver: str) -> Version:
"""Parse version into a comparable Version tuple."""
m = RE_VER.match(ver)
if m is None:
raise ValueError(f"'{ver}' is not a valid version")
# Handle major, minor, micro
major = int(m.group('major'))
minor = int(m.group('minor')) if m.group('minor') else 0
micro = int(m.group('micro')) if m.group('micro') else 0
# Handle pre releases
if m.group('type'):
release = PRE_REL_MAP[m.group('type')]
pre = int(m.group('pre'))
else:
release = "final"
pre = 0
# Handle development releases
dev = m.group('dev') if m.group('dev') else 0
if m.group('dev'):
dev = int(m.group('dev'))
release = '.dev-' + release if pre else '.dev'
else:
dev = 0
# Handle post
post = int(m.group('post')) if m.group('post') else 0
return Version(major, minor, micro, release, pre, post, dev)
__version_info__ = Version(10, 21, 0, "final")
__version__ = __version_info__._get_canonical()
@@ -0,0 +1,66 @@
"""
Bypass whitespace normalization.
pymdownx._bypassnorm
Strips `SOH` and `EOT` characters before whitespace normalization
allowing other extensions to then create preprocessors that stash HTML
with `SOH` and `EOT` After whitespace normalization, all `SOH` and
`EOT` characters will be converted to the Python Markdown standard
`STX` and `ETX` convention since whitespace normalization usually
strips out the `STX` and `ETX` characters.
Copyright 2014 - 2018 Isaac Muse <isaacmuse@gmail.com>
"""
from markdown import Extension
from markdown.util import STX, ETX
from markdown.preprocessors import Preprocessor
SOH = '\u0001' # start
EOT = '\u0004' # end
class PreNormalizePreprocessor(Preprocessor):
"""Preprocessor to remove workaround symbols."""
def run(self, lines):
"""Remove workaround placeholder markers before adding actual workaround placeholders."""
source = '\n'.join(lines)
source = source.replace(SOH, '').replace(EOT, '')
return source.split('\n')
class PostNormalizePreprocessor(Preprocessor):
"""Preprocessor to clean up normalization bypass hack."""
def run(self, lines):
"""Convert alternate placeholder symbols to actual placeholder symbols."""
source = '\n'.join(lines)
source = source.replace(SOH, STX).replace(EOT, ETX)
return source.split('\n')
class BypassNormExtension(Extension):
"""Bypass whitespace normalization."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.inlinehilite = []
self.config = {}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add extensions that help with bypassing whitespace normalization."""
md.preprocessors.register(PreNormalizePreprocessor(md), "pymdownx-pre-norm-ws", 35)
md.preprocessors.register(PostNormalizePreprocessor(md), "pymdownx-post-norm-ws", 29.9)
def makeExtension(*args, **kwargs):
"""Return extension."""
return BypassNormExtension(*args, **kwargs)
@@ -0,0 +1,409 @@
r"""
Arithmatex.
pymdownx.arithmatex
Extension that preserves the following for MathJax use:
```
$Equation$, \(Equation\)
$$
Display Equations
$$
\[
Display Equations
\]
\begin{align}
Display Equations
\end{align}
```
and `$Inline MathJax Equations$`
Inline and display equations are converted to scripts tags. You can optionally generate previews.
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown.blockprocessors import BlockProcessor
from markdown import util as md_util
from functools import partial
import xml.etree.ElementTree as etree
from . import util
import re
RE_SMART_DOLLAR_INLINE = r'(?:(?<!\\)((?:\\{2})+)(?=\$)|(?<!\\)(\$)(?!\s)((?:\\.|[^\\$\x02\x03])+?)(?<!\s)(?:\$))'
RE_DOLLAR_INLINE = r'(?:(?<!\\)((?:\\{2})+)(?=\$)|(?<!\\)(\$)((?:\\.|[^\\$\x02\x03])+?)(?:\$))'
RE_BRACKET_INLINE = r'(?:(?<!\\)((?:\\{2})+?)(?=\\\()|(?<!\\)(\\\()((?:\\[^)]|[^\\\x02\x03])+?)(?:\\\)))'
RE_DOLLAR_BLOCK = r'(?P<dollar>[$]{2})(?P<math>((?:\\.|[^\\])+?))(?P=dollar)'
RE_TEX_BLOCK = r'(?P<math2>\\begin\{(?P<env>[a-z]+\*?)\}(?:\\.|[^\\])+?\\end\{(?P=env)\})'
RE_BRACKET_BLOCK = r'\\\[(?P<math3>(?:\\[^\]]|[^\\])+?)\\\]'
def _escape(txt):
"""Basic html escaping."""
txt = txt.replace('&', '&amp;')
txt = txt.replace('<', '&lt;')
txt = txt.replace('>', '&gt;')
txt = txt.replace('"', '&quot;')
return txt
# Formatters usable with InlineHilite
@util.deprecated(
"The inline MathJax Preview formatter has been deprecated in favor of the configurable 'arithmatex_fenced_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def inline_mathjax_preview_format(math, language='math', class_name='arithmatex', md=None):
"""Inline math formatter with preview."""
return _inline_mathjax_format(math, preview=True)
@util.deprecated(
"The inline MathJax formatter has been deprecated in favor of the configurable 'arithmatex_fenced_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def inline_mathjax_format(math, language='math', class_name='arithmatex', md=None):
"""Inline math formatter."""
return _inline_mathjax_format(math, preview=False)
@util.deprecated(
"The inline generic math formatter has been deprecated in favor of the configurable 'arithmatex_inline_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def inline_generic_format(math, language='math', class_name='arithmatex', md=None, **kwargs):
"""Inline generic formatter."""
return _inline_generic_format(math, language, class_name, md, **kwargs)
def _inline_mathjax_format(math, language='math', class_name='arithmatex', md=None, tag='span', preview=False):
"""Inline math formatter."""
el = etree.Element(tag, {'class': 'arithmatex'})
if preview:
pre = etree.SubElement(el, 'span', {'class': 'MathJax_Preview'})
pre.text = md_util.AtomicString(math)
script = etree.SubElement(el, 'script', {'type': 'math/tex'})
script.text = md_util.AtomicString(math)
return el
def _inline_generic_format(math, language='math', class_name='arithmatex', md=None, wrap='\\({}\\)', tag='span'):
"""Inline generic formatter."""
el = etree.Element(tag, {'class': class_name})
el.text = md_util.AtomicString(wrap.format(math))
return el
def arithmatex_inline_format(**kwargs):
"""Specify which type of formatter you want and the wrapping tag."""
mode = kwargs.get('mode', 'generic')
tag = kwargs.get('tag', 'span')
preview = kwargs.get('preview', False)
if mode == 'generic':
return partial(_inline_generic_format, tag=tag)
elif mode == 'mathjax':
return partial(_inline_mathjax_format, preview=preview)
# Formatters usable with SuperFences
@util.deprecated(
"The fenced MathJax preview formatter has been deprecated in favor of the configurable 'arithmatex_fenced_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def fence_mathjax_preview_format(math, language='math', class_name='arithmatex', options=None, md=None, **kwargs):
"""Block MathJax formatter with preview."""
return _fence_mathjax_format(math, preview=True)
@util.deprecated(
"The fenced MathJax preview formatter has been deprecated in favor of the configurable 'arithmatex_fenced_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def fence_mathjax_format(math, language='math', class_name='arithmatex', options=None, md=None, **kwargs):
"""Block MathJax formatter."""
return _fence_mathjax_format(math, preview=False)
@util.deprecated(
"The generic math formatter has been deprecated in favor of the configurable 'arithmatex_fenced_format'. "
"Please see relevant documentation for more information on how to switch before this function is "
"removed in the future."
)
def fence_generic_format(math, language='math', class_name='arithmatex', options=None, md=None, **kwargs):
"""Generic block formatter."""
return _fence_generic_format(math, language, class_name, options, md, **kwargs)
def _fence_mathjax_format(
math, language='math', class_name='arithmatex', options=None, md=None, preview=False, tag="div", **kwargs
):
"""Block math formatter."""
text = f'<{tag} class="arithmatex">\n'
if preview:
text += (
'<div class="MathJax_Preview">\n' +
_escape(math) +
'\n</div>\n'
)
text += (
'<script type="math/tex; mode=display">\n' +
math +
'\n</script>\n'
)
text += '</div>'
return text
def _fence_generic_format(
math, language='math', class_name='arithmatex', options=None, md=None, wrap='\\[\n{}\n\\]', tag='div', **kwargs
):
"""Generic block formatter."""
classes = kwargs['classes']
id_value = kwargs['id_value']
attrs = kwargs['attrs']
classes.insert(0, class_name)
id_value = f' id="{id_value}"' if id_value else ''
classes = ' class="{}"'.format(' '.join(classes))
attrs = ' ' + ' '.join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ''
return f'<{tag}{id_value}{classes}{attrs}>{wrap.format(math)}</{tag}>'
def arithmatex_fenced_format(**kwargs):
"""Specify which type of formatter you want and the wrapping tag."""
mode = kwargs.get('mode', 'generic')
tag = kwargs.get('tag', 'div')
preview = kwargs.get('preview', False)
if mode == 'generic':
return partial(_fence_generic_format, tag=tag)
elif mode == 'mathjax':
return partial(_fence_mathjax_format, tag=tag, preview=preview)
class InlineArithmatexPattern(InlineProcessor):
"""Arithmatex inline pattern handler."""
ESCAPED_BSLASH = '{}{}{}'.format(md_util.STX, ord('\\'), md_util.ETX)
def __init__(self, pattern, config):
"""Initialize."""
# Generic setup
self.generic = config.get('generic', False)
wrap = config.get('tex_inline_wrap', ["\\(", "\\)"])
self.wrap = (
wrap[0].replace('{', '}}').replace('}', '}}') + '{}' + wrap[1].replace('{', '}}').replace('}', '}}')
)
self.inline_tag = config.get('inline_tag', 'span')
# Default setup
self.preview = config.get('preview', True)
InlineProcessor.__init__(self, pattern)
def handleMatch(self, m, data):
"""Handle notations and switch them to something that will be more detectable in HTML."""
# Handle escapes
groups = m.groups()
escapes = groups[0]
if not escapes and len(groups) > 3:
escapes = groups[3]
if escapes:
return escapes.replace('\\\\', self.ESCAPED_BSLASH), m.start(0), m.end(0)
# Handle Tex
math = groups[2]
if not math and len(groups) > 3:
math = groups[5]
if self.generic:
return _inline_generic_format(math, wrap=self.wrap, tag=self.inline_tag), m.start(0), m.end(0)
else:
return _inline_mathjax_format(math, tag=self.inline_tag, preview=self.preview), m.start(0), m.end(0)
class BlockArithmatexProcessor(BlockProcessor):
"""MathJax block processor to find $$MathJax$$ content."""
def __init__(self, pattern, config, md):
"""Initialize."""
# Generic setup
self.generic = config.get('generic', False)
wrap = config.get('tex_block_wrap', ['\\[', '\\]'])
self.wrap = (
wrap[0].replace('{', '}}').replace('}', '}}') + '{}' + wrap[1].replace('{', '}}').replace('}', '}}')
)
self.block_tag = config.get('block_tag', 'div')
# Default setup
self.preview = config.get('preview', False)
self.match = None
self.pattern = re.compile(pattern)
BlockProcessor.__init__(self, md.parser)
def test(self, parent, block):
"""Return 'True' for future Python Markdown block compatibility."""
self.match = self.pattern.match(block) if self.pattern is not None else None
return self.match is not None
def mathjax_output(self, parent, math):
"""Default MathJax output."""
grandparent = parent
parent = etree.SubElement(grandparent, self.block_tag, {'class': 'arithmatex'})
if self.preview:
preview = etree.SubElement(parent, 'div', {'class': 'MathJax_Preview'})
preview.text = md_util.AtomicString(math)
el = etree.SubElement(parent, 'script', {'type': 'math/tex; mode=display'})
el.text = md_util.AtomicString(math)
def generic_output(self, parent, math):
"""Generic output."""
el = etree.SubElement(parent, self.block_tag, {'class': 'arithmatex'})
el.text = md_util.AtomicString(self.wrap.format(math))
def run(self, parent, blocks):
"""Find and handle block content."""
blocks.pop(0)
groups = self.match.groupdict()
math = groups.get('math', '')
if not math:
math = groups.get('math2', '')
if not math:
math = groups.get('math3', '')
if self.generic:
self.generic_output(parent, math)
else:
self.mathjax_output(parent, math)
return True
class ArithmatexExtension(Extension):
"""Adds delete extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'tex_inline_wrap': [
["\\(", "\\)"],
"Wrap inline content with the provided text ['open', 'close'] - Default: ['', '']"
],
'tex_block_wrap': [
["\\[", "\\]"],
"Wrap blick content with the provided text ['open', 'close'] - Default: ['', '']"
],
"smart_dollar": [True, "Use Arithmatex's smart dollars - Default True"],
"block_syntax": [
['dollar', 'square', 'begin'],
'Enable block syntax: "dollar" ($$...$$), "square" (\\[...\\]), and '
'"begin" (\\begin{env}...\\end{env}). - Default: ["dollar", "square", "begin"]'
],
"inline_syntax": [
['dollar', 'round'],
'Enable block syntax: "dollar" ($$...$$), "bracket" (\\(...\\)) '
' - Default: ["dollar", "round"]'
],
'generic': [False, "Output in a generic format for non MathJax libraries - Default: False"],
'preview': [
True,
"Insert a preview for scripts. - Default: False"
],
'block_tag': ['div', "Specify wrapper tag - Default 'div'"],
'inline_tag': ['span', "Specify wrapper tag - Default 'span'"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Extend the inline and block processor objects."""
md.registerExtension(self)
util.escape_chars(md, ['$'])
config = self.getConfigs()
# Inline patterns
allowed_inline = set(config.get('inline_syntax', ['dollar', 'round']))
smart_dollar = config.get('smart_dollar', True)
inline_patterns = []
if 'dollar' in allowed_inline:
inline_patterns.append(RE_SMART_DOLLAR_INLINE if smart_dollar else RE_DOLLAR_INLINE)
if 'round' in allowed_inline:
inline_patterns.append(RE_BRACKET_INLINE)
if inline_patterns:
inline = InlineArithmatexPattern('(?:%s)' % '|'.join(inline_patterns), config)
md.inlinePatterns.register(inline, 'arithmatex-inline', 189.9)
# Block patterns
allowed_block = set(config.get('block_syntax', ['dollar', 'square', 'begin']))
block_pattern = []
if 'dollar' in allowed_block:
block_pattern.append(RE_DOLLAR_BLOCK)
if 'square' in allowed_block:
block_pattern.append(RE_BRACKET_BLOCK)
if 'begin' in allowed_block:
block_pattern.append(RE_TEX_BLOCK)
if block_pattern:
block = BlockArithmatexProcessor(r'(?s)^(?:%s)[ ]*$' % '|'.join(block_pattern), config, md)
md.parser.blockprocessors.register(block, "arithmatex-block", 79.9)
def makeExtension(*args, **kwargs):
"""Return extension."""
return ArithmatexExtension(*args, **kwargs)
@@ -0,0 +1,146 @@
"""
B64.
An extension for Python Markdown.
Given an absolute base path, this extension searches for image tags,
and if the images are local, will embed the images in base64.
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.postprocessors import Postprocessor
from . import util
import os
import base64
import re
RE_SLASH_WIN_DRIVE = re.compile(r"^/[A-Za-z]{1}:/.*")
file_types = {
(".png",): "image/png",
(".jpg", ".jpeg"): "image/jpeg",
(".gif",): "image/gif",
(".svg",): "image/svg+xml",
}
RE_TAG_HTML = re.compile(
r'''(?xus)
(?:
(?P<avoid>
<\s*(?P<script_name>script|style)[^>]*>.*?</\s*(?P=script_name)\s*> |
(?:(\r?\n?\s*)<!--[\s\S]*?-->(\s*)(?=\r?\n)|<!--[\s\S]*?-->)
)|
(?P<open><\s*(?P<tag>img))
(?P<attr>(?:\s+[\w\-:]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)
(?P<close>\s*(?:\/?)>)
)
'''
)
RE_TAG_LINK_ATTR = re.compile(
r'''(?xus)
(?P<attr>
(?:
(?P<name>\s+src\s*=\s*)
(?P<path>"[^"]*"|'[^']*')
)
)
'''
)
def repl_path(m, base_path):
"""Replace path with b64 encoded data."""
link = m.group(0)
try:
_, _, path, _, _, _, is_url, is_absolute = util.parse_url(m.group('path')[1:-1])
if not is_url:
path = util.url2path(path)
if is_absolute:
file_name = os.path.normpath(path)
else:
file_name = os.path.normpath(os.path.join(base_path, path))
if os.path.exists(file_name):
ext = os.path.splitext(file_name)[1].lower()
for b64_ext in file_types:
if ext in b64_ext:
with open(file_name, "rb") as f:
link = " src=\"data:{};base64,{}\"".format(
file_types[b64_ext],
base64.b64encode(f.read()).decode('ascii')
)
break
except Exception: # pragma: no cover
# Parsing crashed and burned; no need to continue.
pass
return link
def repl(m, base_path):
"""Replace."""
if m.group('avoid'):
tag = m.group('avoid')
else:
tag = m.group('open')
tag += RE_TAG_LINK_ATTR.sub(lambda m2: repl_path(m2, base_path), m.group('attr'))
tag += m.group('close')
return tag
class B64Postprocessor(Postprocessor):
"""Post processor for B64."""
def run(self, text):
"""Find and replace paths with base64 encoded file."""
basepath = self.config['base_path']
text = RE_TAG_HTML.sub(lambda m: repl(m, basepath), text)
return text
class B64Extension(Extension):
"""B64 extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'base_path': [".", "Base path for b64 to use to resolve paths - Default: \".\""]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add base 64 tree processor to Markdown instance."""
b64 = B64Postprocessor(md)
b64.config = self.getConfigs()
md.postprocessors.register(b64, "b64", 2)
md.registerExtension(self)
def makeExtension(*args, **kwargs):
"""Return extension."""
return B64Extension(*args, **kwargs)
@@ -0,0 +1,252 @@
"""
Better Emphasis.
pymdownx.betterem
Add intelligent handling of to em and strong notations
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import re
from markdown import Extension
from markdown.inlinepatterns import SimpleTextInlineProcessor
from . import util
SMART_UNDER_CONTENT = r'(.+?_*?)'
SMART_STAR_CONTENT = r'(.+?\**?)'
SMART_STAR_LIMITED_CONTENT = r'((?:[^\*]|(?<=\w)\*+?(?=\w)|(?<=\s)\*+?(?=\s))+?)'
SMART_UNDER_LIMITED_CONTENT = r'((?:[^_]|(?<=[^\W_])_+?(?=[^\W_])|(?<=\s)_+?(?=\s))+?)'
UNDER_CONTENT = r'(_|(?:(?<=\s)_|[^_])+?)'
UNDER_CONTENT2 = r'((?:[^_]|(?<!_{2})_)+?)'
STAR_CONTENT = r'(\*|(?:(?<=\s)\*|[^\*])+?)'
STAR_CONTENT2 = r'((?:[^\*]|(?<!\*{2})\*)+?)'
# Avoid starting a pattern with asterisk or underscore tokens that are surrounded by white space.
NOT_STRONG = r'((^|(?<=\s))(\*+|_+)(?=\s|$))'
# ***strong,em***
STAR_STRONG_EM = r'(\*{3})(?!\s)(\*{1,2}|[^\*]+?)(?<!\s)\1'
# ___strong,em___
UNDER_STRONG_EM = r'(_{3})(?!\s)(_{1,2}|[^_]+?)(?<!\s)\1'
# ***strong,em*strong**
STAR_STRONG_EM2 = r'(\*{{3}})(?![\s\*]){}(?<!\s)\*{}(?<!\s)\*{{2}}'.format(STAR_CONTENT, STAR_CONTENT)
# ___strong,em_strong__
UNDER_STRONG_EM2 = r'(_{{3}})(?![\s_]){}(?<!\s)_{}(?<!\s)_{{2}}'.format(UNDER_CONTENT, UNDER_CONTENT)
# ***em,strong**em*
STAR_EM_STRONG = r'(\*{{3}})(?![\s\*]){}(?<!\s)\*{{2}}{}(?<!\s)\*'.format(STAR_CONTENT, STAR_CONTENT)
# **strong*em,strong***
STAR_STRONG_EM3 = r'(\*{{2}})(?![\s\*]){}\*(?![\s\*]){}(?<!\s)\*{{3}}'.format(STAR_CONTENT, STAR_CONTENT)
# ___em,strong__em_
UNDER_EM_STRONG = r'(_{{3}})(?![\s_]){}(?<!\s)_{{2}}{}(?<!\s)_'.format(UNDER_CONTENT, UNDER_CONTENT)
# __strong_em,strong___
UNDER_STRONG_EM3 = r'(_{{2}})(?![\s_]){}_(?![\s_]){}(?<!\s)_{{3}}'.format(UNDER_CONTENT, UNDER_CONTENT)
# **strong**
STAR_STRONG = r'(\*{{2}})(?!\s){}(?<!\s)\1'.format(STAR_CONTENT2)
# __strong__
UNDER_STRONG = r'(_{{2}})(?!\s){}(?<!\s)\1'.format(UNDER_CONTENT2)
# *em **strong***
STAR_EM_STRONG2 = r'(?<!\*)(\*)(?![\*\s]){}\*{{2}}{}\*{{3}}'.format(STAR_CONTENT, STAR_CONTENT)
# _em __strong___
UNDER_EM_STRONG2 = r'(?<!_)(_)(?![_\s]){}_{{2}}{}_{{3}}'.format(UNDER_CONTENT, UNDER_CONTENT)
# Prioritize *value* when **value** is nested within
STAR_EM2 = r'(?<!\*)(\*)(?![\*\s])((?:[^\*]|\*{2,})+?)(?<![\*\s])(\*)(?!\*)'
# Prioritize _value_ when __value__ is nested within
UNDER_EM2 = r'(?<!_)(_)(?![_\s])((?:[^_]|_{2,})+?)(?<![_\s])(_)(?!_)'
# *emphasis*
STAR_EM = r'(\*)(?!\s){}(?<!\s)\1'.format(STAR_CONTENT)
# _emphasis_
UNDER_EM = r'(_)(?!\s){}(?<!\s)\1'.format(UNDER_CONTENT)
# Smart rules for when "smart underscore" is enabled
# SMART: ___strong,em___
SMART_UNDER_STRONG_EM = r'(?<!\w)(_{{3}})(?![\s_]){}(?<!\s)\1(?!\w)'.format(SMART_UNDER_CONTENT)
# SMART: ___strong,em_ strong__
SMART_UNDER_STRONG_EM2 = \
r'(?<!\w)(_{{3}})(?![\s_]){}(?<!\s)_(?!\w){}(?<!\s)_{{2}}(?!\w)'.format(
SMART_UNDER_LIMITED_CONTENT, SMART_UNDER_LIMITED_CONTENT
)
# SMART: ___em,strong__ em_
SMART_UNDER_EM_STRONG = \
r'(?<!\w)(_{{3}})(?![\s_]){}(?<!\s)_{{2}}(?!\w){}(?<!\s)_(?!\w)'.format(
SMART_UNDER_LIMITED_CONTENT, SMART_UNDER_LIMITED_CONTENT
)
# SMART: __strong__
SMART_UNDER_STRONG = r'(?<!\w)(_{{2}})(?![\s_]){}(?<!\s)\1(?!\w)'.format(SMART_UNDER_CONTENT)
# SMART: _em_
SMART_UNDER_EM = r'(?<!\w)(_)(?![\s_]){}(?<!\s)\1(?!\w)'.format(SMART_UNDER_CONTENT)
# SMART: Prioritize _value_ when __value__ is nested within
SMART_UNDER_EM2 = r'(?<![\w_])(_)(?![_\s])((?:[^_]|_{2,})+?)(?<![_\s])(_)(?!\w)'
# SMART: _em __strong___
SMART_UNDER_EM_STRONG2 = \
r'(?<!\w)(_)(?![\s_]){}(?<!\w)_{{2}}(?![\s_]){}(?<!\s)_{{3}}(?!\w)'.format(
SMART_UNDER_LIMITED_CONTENT, SMART_UNDER_LIMITED_CONTENT
)
SMART_UNDER_STRONG_EM3 = \
r'(?<!\w)(_{{2}})(?![\s_]){}(?<!\w)_(?![\s_]){}(?<!\s)_{{3}}(?!\w)'.format(
SMART_UNDER_LIMITED_CONTENT, SMART_UNDER_LIMITED_CONTENT
)
# Smart rules for when "smart asterisk" is enabled
# SMART: ***strong,em***
SMART_STAR_STRONG_EM = r'(?:(?<=_)|(?<![\w\*]))(\*{{3}})(?![\s\*]){}(?<!\s)\1(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_CONTENT
)
# SMART: ***strong,em* strong**
SMART_STAR_STRONG_EM2 = \
r'(?:(?<=_)|(?<![\w\*]))(\*{{3}})(?![\s\*]){}(?<![\s\*])\*(?:(?=_)|(?![\w\*])){}(?<![\s\*])\*{{2}}(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_LIMITED_CONTENT, SMART_STAR_LIMITED_CONTENT
)
# SMART: ***em,strong** em*
SMART_STAR_EM_STRONG = \
r'(?:(?<=_)|(?<![\w\*]))(\*{{3}})(?![\s\*]){}(?<![\s\*])\*{{2}}(?:(?=_)|(?![\w\*])){}(?<![\s\*])\*(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_LIMITED_CONTENT, SMART_STAR_LIMITED_CONTENT
)
# SMART: **strong**
SMART_STAR_STRONG = r'(?:(?<=_)|(?<![\w\*]))(\*{{2}})(?![\s\*]){}(?<!\s)\1(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_CONTENT
)
# SMART: *em*
SMART_STAR_EM = r'(?:(?<=_)|(?<![\w\*]))(\*)(?![\s\*]){}(?<!\s)\1(?:(?=_)|(?![\w\*]))'.format(SMART_STAR_CONTENT)
# SMART: Prioritize *value* when **value** is nested within
SMART_STAR_EM2 = r'(?:(?<=_)|(?<![\w\*]))(\*)(?![\*\s])((?:[^\*]|\*{2,})+?)(?<![\*\s])(\*)(?:(?=_)|(?![\w\*]))'
# SMART: *em **strong***
SMART_STAR_EM_STRONG2 = \
r'(?:(?<=_)|(?<![\w\*]))(\*)(?![\s\*]){}(?:(?<=_)|(?<![\w\*]))\*{{2}}(?![\s\*]){}(?<!\s)\*{{3}}(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_LIMITED_CONTENT, SMART_STAR_LIMITED_CONTENT
)
# SMART: **em *strong***
SMART_STAR_STRONG_EM3 = \
r'(?:(?<=_)|(?<![\w\*]))(\*{{2}})(?![\s\*]){}(?:(?<=_)|(?<![\w\*]))\*(?![\s\*]){}(?<![\s\*])\*{{3}}(?:(?=_)|(?![\w\*]))'.format(
SMART_STAR_LIMITED_CONTENT, SMART_STAR_LIMITED_CONTENT
)
class AsteriskProcessor(util.PatternSequenceProcessor):
"""Emphasis processor for handling strong and em matches."""
PATTERNS = [
util.PatSeqItem(re.compile(STAR_STRONG_EM, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(STAR_EM_STRONG, re.DOTALL | re.UNICODE), 'double', 'em,strong'),
util.PatSeqItem(re.compile(STAR_STRONG_EM2, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(STAR_STRONG_EM3, re.DOTALL | re.UNICODE), 'double2', 'strong,em'),
util.PatSeqItem(re.compile(STAR_STRONG, re.DOTALL | re.UNICODE), 'single', 'strong'),
util.PatSeqItem(re.compile(STAR_EM_STRONG2, re.DOTALL | re.UNICODE), 'double2', 'em,strong'),
util.PatSeqItem(re.compile(STAR_EM2, re.DOTALL | re.UNICODE), 'single', 'em', True),
util.PatSeqItem(re.compile(STAR_EM, re.DOTALL | re.UNICODE), 'single', 'em')
]
class SmartAsteriskProcessor(util.PatternSequenceProcessor):
"""Smart emphasis and strong processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_STAR_STRONG_EM, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(SMART_STAR_EM_STRONG, re.DOTALL | re.UNICODE), 'double', 'em,strong'),
util.PatSeqItem(re.compile(SMART_STAR_STRONG_EM2, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(SMART_STAR_STRONG_EM3, re.DOTALL | re.UNICODE), 'double2', 'strong,em'),
util.PatSeqItem(re.compile(SMART_STAR_STRONG, re.DOTALL | re.UNICODE), 'single', 'strong'),
util.PatSeqItem(re.compile(SMART_STAR_EM_STRONG2, re.DOTALL | re.UNICODE), 'double2', 'em,strong'),
util.PatSeqItem(re.compile(SMART_STAR_EM2, re.DOTALL | re.UNICODE), 'single', 'em', True),
util.PatSeqItem(re.compile(SMART_STAR_EM, re.DOTALL | re.UNICODE), 'single', 'em')
]
class UnderscoreProcessor(util.PatternSequenceProcessor):
"""Emphasis processor for handling strong and em matches."""
PATTERNS = [
util.PatSeqItem(re.compile(UNDER_STRONG_EM, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(UNDER_EM_STRONG, re.DOTALL | re.UNICODE), 'double', 'em,strong'),
util.PatSeqItem(re.compile(UNDER_STRONG_EM2, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(UNDER_STRONG_EM3, re.DOTALL | re.UNICODE), 'double2', 'strong,em'),
util.PatSeqItem(re.compile(UNDER_STRONG, re.DOTALL | re.UNICODE), 'single', 'strong'),
util.PatSeqItem(re.compile(UNDER_EM_STRONG2, re.DOTALL | re.UNICODE), 'double2', 'em,strong'),
util.PatSeqItem(re.compile(UNDER_EM2, re.DOTALL | re.UNICODE), 'single', 'em', True),
util.PatSeqItem(re.compile(UNDER_EM, re.DOTALL | re.UNICODE), 'single', 'em')
]
class SmartUnderscoreProcessor(util.PatternSequenceProcessor):
"""Emphasis processor for handling strong and em matches."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_UNDER_STRONG_EM, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(SMART_UNDER_EM_STRONG, re.DOTALL | re.UNICODE), 'double', 'em,strong'),
util.PatSeqItem(re.compile(SMART_UNDER_STRONG_EM2, re.DOTALL | re.UNICODE), 'double', 'strong,em'),
util.PatSeqItem(re.compile(SMART_UNDER_STRONG_EM3, re.DOTALL | re.UNICODE), 'double2', 'strong,em'),
util.PatSeqItem(re.compile(SMART_UNDER_STRONG, re.DOTALL | re.UNICODE), 'single', 'strong'),
util.PatSeqItem(re.compile(SMART_UNDER_EM_STRONG2, re.DOTALL | re.UNICODE), 'double2', 'em,strong'),
util.PatSeqItem(re.compile(SMART_UNDER_EM2, re.DOTALL | re.UNICODE), 'single', 'em', True),
util.PatSeqItem(re.compile(SMART_UNDER_EM, re.DOTALL | re.UNICODE), 'single', 'em')
]
class BetterEmExtension(Extension):
"""Add extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'smart_enable': ["underscore", "Treat connected words intelligently - Default: underscore"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Modify inline patterns."""
# Not better yet, so let's make it better
md.registerExtension(self)
self.make_better(md)
def make_better(self, md):
"""
Configure all the pattern rules.
This should be used instead of smart_strong package.
pymdownx.extra should be used in place of markdown.extensions.extra.
"""
config = self.getConfigs()
enabled = config["smart_enable"]
enable_all = enabled == "all"
enable_under = enabled == "underscore" or enable_all
enable_star = enabled == "asterisk" or enable_all
# If we don't have to move an existing extension, use the same priority,
# but if we do have to, move it closely to the relative needed position.
md.inlinePatterns.deregister('not_strong', False)
md.inlinePatterns.deregister('strong_em', False)
md.inlinePatterns.deregister('em_strong', False)
md.inlinePatterns.deregister('em_strong2', False)
md.inlinePatterns.deregister('strong', False)
md.inlinePatterns.deregister('emphasis', False)
md.inlinePatterns.deregister('strong2', False)
md.inlinePatterns.deregister('emphasis2', False)
md.inlinePatterns.register(SimpleTextInlineProcessor(NOT_STRONG), 'not_strong', 70)
asterisk = SmartAsteriskProcessor(r'\*') if enable_star else AsteriskProcessor(r'\*')
md.inlinePatterns.register(asterisk, "strong_em", 50)
underscore = SmartUnderscoreProcessor('_') if enable_under else UnderscoreProcessor('_')
md.inlinePatterns.register(underscore, "strong_em2", 40)
def makeExtension(*args, **kwargs):
"""Return extension."""
return BetterEmExtension(*args, **kwargs)
@@ -0,0 +1,564 @@
"""Generic blocks extension."""
from __future__ import annotations
from markdown import Extension, Markdown
from markdown.blockprocessors import BlockProcessor
from markdown.treeprocessors import Treeprocessor
from markdown.blockparser import BlockParser
from markdown import util as mutil
from .. import util
import xml.etree.ElementTree as etree
import re
import yaml
import textwrap
from typing import cast, Any, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from .block import Block
# Fenced block placeholder for SuperFences
FENCED_BLOCK_RE = re.compile(
r'^([\> ]*){}({}){}$'.format(
mutil.HTML_PLACEHOLDER[0],
mutil.HTML_PLACEHOLDER[1:-1] % r'([0-9]+)',
mutil.HTML_PLACEHOLDER[-1]
)
)
# Block start/end
RE_START = re.compile(
r'(?:^|\n)[ ]{0,3}(/{3,})[ ]*([\w-]+)[ ]*(?:\|[ ]*(.*?)[ ]*)?(?:\n|$)'
)
RE_END = re.compile(
r'(?m)(?:^|\n)[ ]{0,3}(/{3,})[ ]*(?:\n|$)'
)
# Frontmatter patterns
RE_YAML_START = re.compile(r'(?m)^[ ]{0,3}(-{3})[ ]*(?:\n|$)')
RE_YAML_END = re.compile(
r'(?m)^[ ]{0,3}(-{3})[ ]*(?:\n|$)'
)
RE_INDENT_YAML_LINE = re.compile(r'(?m)^(?:[ ]{4,}(?!\s).*?(?:\n|$))+')
class BlockEntry:
"""Track Block entries."""
def __init__(self, block: Block, el: etree.Element, parent: etree.Element) -> None:
"""Block entry."""
self.block: 'Block' = block
self.el: etree.Element = el
self.parent: etree.Element = parent
self.hungry: bool = False
def get_frontmatter(string: str) -> dict[str, Any] | None:
"""
Get frontmatter from string.
YAML-ish key value pairs.
"""
frontmatter = None
try:
frontmatter = yaml.safe_load(string)
if frontmatter is None:
frontmatter = {}
if not isinstance(frontmatter, dict):
frontmatter = None
except Exception:
pass
return cast('dict[str, Any]', frontmatter)
def reindent(text: str, pos: int, level: int) -> list[str]:
"""Reindent the code to where it is supposed to be."""
indented = []
for line in text.split('\n'):
index = pos - level
indented.append(line[index:])
return indented
def unescape_markdown(md: Markdown, blocks: list[str], is_raw: bool) -> list[str]:
"""Look for SuperFences code placeholders and other HTML stash placeholders and revert them back to plain text."""
superfences = None
try:
from ..superfences import SuperFencesBlockPreprocessor
processor = md.preprocessors['fenced_code_block']
if isinstance(processor, SuperFencesBlockPreprocessor):
superfences = processor.extension # type: ignore[attr-defined]
except Exception:
pass
new_blocks = []
for block in blocks:
new_lines = []
for line in block.split('\n'):
m = FENCED_BLOCK_RE.match(line)
if m:
key = m.group(2)
# Extract SuperFences content
indent_level = len(m.group(1))
original = None
if superfences is not None:
original, pos = superfences.stash.get(key, (None, None))
if original is not None:
code = reindent(original, pos, indent_level)
new_lines.extend(code)
superfences.stash.remove(key)
# Extract other HTML stashed content
if original is None and is_raw:
index = int(key.split(':')[1])
if index < len(md.htmlStash.rawHtmlBlocks):
original = md.htmlStash.rawHtmlBlocks[index]
if isinstance(original, etree.Element):
original = etree.tostring(original, encoding='unicode', method='html')
new_lines.append(original)
# Couldn't find anything to extract
if original is None: # pragma: no cover
new_lines.append(line)
else:
new_lines.append(line)
new_blocks.append('\n'.join(new_lines))
return new_blocks
class BlocksTreeprocessor(Treeprocessor):
"""Blocks tree processor."""
def __init__(self, md: Markdown, blocks: BlocksProcessor):
"""Initialize."""
super().__init__(md)
self.blocks = blocks
def run(self, root: etree.Element) -> None:
"""Update tab IDs."""
while self.blocks.inline_stack:
entry = self.blocks.inline_stack.pop(0)
entry.block.on_inline_end(entry.el)
class BlocksProcessor(BlockProcessor):
"""Generic block processor."""
def __init__(self, parser: BlockParser, md: Markdown) -> None:
"""Initialization."""
self.md = md
# The Block classes indexable by name
self.blocks: dict[str, type[Block]] = {}
self.config: dict[str, dict[str, Any]] = {}
self.empty_tags = {'hr',}
self.block_level_tags = set(md.block_level_elements.copy())
self.block_level_tags.add('html')
# Block-level tags in which the content only gets span level parsing
self.span_tags = {
'address', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'legend', 'li', 'p', 'summary', 'td', 'th'
}
# Block-level tags which never get their content parsed.
self.raw_tags = {'canvas', 'math', 'option', 'pre', 'script', 'style', 'textarea', 'code'}
# Block-level tags in which the content gets parsed as blocks
self.block_tags = set(self.block_level_tags) - (self.span_tags | self.raw_tags | self.empty_tags)
self.span_and_blocks_tags = self.block_tags | self.span_tags
super().__init__(parser)
# Persistent storage across a document for blocks
self.trackers: dict[str, dict[str, Any]] = {}
# Currently queued up blocks
self.stack: list[BlockEntry] = []
# Blocks that should be processed after inline.
self.inline_stack: list[BlockEntry] = []
# When set, the assigned block is actively parsing blocks.
self.working: BlockEntry | None = None
# Cached the found parent when testing
# so we can quickly retrieve it when running
self.cached_parent: etree.Element | None = None
self.cached_block: tuple['Block', str] | None = None
# Used during the alpha/beta stage
self.start = RE_START
self.end = RE_END
self.yaml_line = RE_INDENT_YAML_LINE
def detab_by_length(self, text: str, length: int) -> tuple[str, str]:
"""Remove a tab from the front of each line of the given text."""
newtext = []
lines = text.split('\n')
for line in lines:
if line.startswith(' ' * length):
newtext.append(line[length:])
elif not line.strip():
newtext.append('') # pragma: no cover
else:
break
if newtext:
return '\n'.join(newtext), '\n'.join(lines[len(newtext):])
return '\n'.join(lines[len(newtext):]), ''
def register(self, b: type[Block], config: dict[str, Any]) -> None:
"""Register a block."""
if b.NAME in self.blocks:
raise ValueError(f'The block name {b.NAME} is already registered!')
self.blocks[b.NAME] = b
self.config[b.NAME] = config
self.trackers[b.NAME] = {}
def test(self, parent: etree.Element, block: str) -> bool:
"""Test to see if we should process the block."""
# Are we hungry for more?
if self.get_parent(parent) is not None:
return True
# Is this the start of a new block?
m = self.start.search(block)
if m:
pre_text = block[:m.start()] if m.start() > 0 else None
# Create a block object
name = m.group(2).lower()
if name in self.blocks:
generic_block = self.blocks[name](len(m.group(1)), self.trackers[name], self, self.config[name])
# Remove first line
block = block[m.end():]
# Get frontmatter and argument(s)
options, the_rest = self.split_header(block, generic_block.length)
arguments = m.group(3)
# Options must be valid
status = options is not None
# Update the config for the Block
if status:
status = generic_block._validate(parent, arguments, **options) # type: ignore[arg-type]
# Cache the found Block and any remaining content
if status:
self.cached_block = (generic_block, the_rest)
# Any text before the block should get handled
if pre_text is not None:
self.parser.parseBlocks(parent, [pre_text])
return status
return False
def _reset(self) -> None:
"""Reset."""
self.stack.clear()
self.inline_stack.clear()
self.working = None
self.trackers = {d: {} for d in self.blocks.keys()}
def split_end(self, block: str, length: int) -> tuple[str | None, str | None, bool]:
"""Search for end and split the blocks while removing the end."""
good = None
bad = None
end = False
# Find the end of the Block
m = None
for match in self.end.finditer(block):
if len(match.group(1)) == length:
m = match
break
# Separate everything from before the "end" and after
if m:
temp = block[:m.start(0)]
if temp:
good = temp[:-1] if temp.endswith('\n') else temp
end = True
# Since we found our end, everything after is unwanted
temp = block[m.end(0):]
if temp:
bad = temp
else:
# Gather blocks until we find our end
good = block
# Send back the new list of blocks to parse and note whether we found our end
return good, bad, end
def split_header(self, block: str, length: int) -> tuple[dict[str, Any] | None, str]:
"""Split, YAML-ish header out."""
# Search for end in first block
m = None
blocks: list[str] = []
for match in self.end.finditer(block):
if len(match.group(1)) == length:
m = match
break
# Move block ending to be parsed later
if m:
end = block[m.start(0):]
blocks.insert(0, end)
block = block[:m.start(0)]
m = self.yaml_line.match(block)
if m is not None:
config = textwrap.dedent(m.group(0))
blocks.insert(0, block[m.end():])
if config.strip():
return get_frontmatter(config), '\n'.join(blocks)
blocks.insert(0, block)
return {}, '\n'.join(blocks)
def get_parent(self, parent: etree.Element) -> etree.Element | None:
"""Get parent."""
# Returned the cached parent from our last attempt
if self.cached_parent is not None:
parent = self.cached_parent
self.cached_parent = None
return parent
temp: etree.Element | None = parent
while temp is not None:
if not self.stack:
break
if self.stack[-1].hungry and self.stack[-1].parent is temp:
self.cached_parent = temp
return temp
if temp is not None:
temp = self.lastChild(temp)
return None
def is_raw(self, tag: etree.Element) -> bool:
"""Is tag raw."""
return tag.tag in self.raw_tags
def is_block(self, tag: etree.Element) -> bool:
"""Is tag block."""
return tag.tag in self.block_tags
def parse_blocks(self, blocks: list[str], current_parent: etree.Element) -> None:
"""Parse the blocks."""
# Get the target element and parse
while blocks and self.stack:
b: str | None = blocks.pop(0)
# Get the latest block on the stack
# This is required to avoid some issues with `md_in_html`
entry = self.stack[-1]
target = entry.block.on_add(entry.el)
# Since we are juggling the block parsers on the stack, the pipeline
# has not fully adjusted list indentation, so look at how many
# list item parents we have on the stack and adjust the content
# accordingly.
parent_map = {c: p for p in current_parent.iter() for c in p}
# Only need to count lists between nested blocks
parent = self.stack[-1].el if len(self.stack) > 1 else None
li = 0
while parent is not None:
parent = parent_map.get(parent, None)
if parent is not None:
if parent.tag in ('li', 'dd'):
li += 1
continue
break
b, a = self.detab_by_length(cast(str, b), li * self.tab_length)
if a:
blocks.insert(0, a)
# Split out blocks we care about
b, bad, end = self.split_end(b, entry.block.length)
if bad is not None:
blocks.insert(0, bad)
# Parse the block under the given target
if b is not None and target is not None:
# Resolve modes
mode = entry.block.on_markdown()
if mode not in ('block', 'inline', 'raw'):
mode = 'auto'
is_block = mode == 'block' or (mode == 'auto' and self.is_block(target))
is_atomic = mode == 'raw' or (mode == 'auto' and self.is_raw(target))
# We should revert fenced code in spans or atomic tags.
# Make sure atomic tags have content wrapped as `AtomicString`.
if is_atomic or not is_block:
child = list(target)[-1] if len(target) else None
text = target.text if child is None else child.tail
b = '\n\n'.join(unescape_markdown(self.md, [b], is_atomic)).strip('\n')
if text:
text += b if not b else '\n\n' + b
else:
text = b
if child is None:
target.text = mutil.AtomicString(text) if is_atomic else text
else: # pragma: no cover
# TODO: We would need to build a special plugin to test this,
# as none of the default ones do this, but we have verified this
# locally. Once we've written a test, we can remove this.
child.tail = mutil.AtomicString(text) if is_atomic else text
# Block tags should have content go through the normal block processor
else:
self.parser.state.set('blocks')
working = self.working
self.working = entry
self.parser.parseChunk(target, b)
self.parser.state.reset()
self.working = working
# Run "on end" event when we finish a block
if end:
entry.block._end(entry.el)
self.inline_stack.append(entry)
del self.stack[-1]
# The Block does not or no longer accepts more content
if target is None: # pragma: no cover
break
if self.stack:
self.stack[-1].hungry = True
def capture_leaked_content(self, parent: etree.Element, entry: BlockEntry) -> None:
"""
Capture leaked content.
Old school, non-block admonitions, details,
and content tabs strongly control where there content is inserted and
can cause content leakage outside of the Blocks container.
Look for such content and pull it back into the container if found.
"""
last_child = self.lastChild(parent)
if last_child is not None and last_child is not entry.el:
target = entry.block.on_add(entry.el)
parent.remove(last_child)
target.append(last_child)
def run(self, parent: etree.Element, blocks: list[str]) -> None:
"""Convert to details/summary block."""
# Get the appropriate parent for this Block
temp = self.get_parent(parent)
if temp is not None:
parent = temp
# Did we find a new Block?
if self.cached_block:
# Get cached Block and reset the cache
generic_block, block = self.cached_block
self.cached_block = None
# Discard first block as we've already processed what we need from it
blocks.pop(0)
if block:
blocks.insert(0, block)
# Ensure a "tight" parent list item is converted to "loose".
if parent is not None and parent.tag in ('li', 'dd'): # pragma: no cover
text = parent.text
if parent.text:
parent.text = ''
p = etree.SubElement(parent, 'p')
p.text = text
# Create the block element
el = generic_block._create(parent)
# Push a Block entry on the stack.
self.stack.append(BlockEntry(generic_block, el, parent))
# Parse the text blocks under the Block
self.parse_blocks(blocks, parent)
else:
for r in range(len(self.stack)):
entry = self.stack[r]
if entry.hungry and parent is entry.parent:
# Capture leaked content from old-school extensions: admonition, details, tabbed, etc.
self.capture_leaked_content(parent, entry)
# Get the target element and parse
entry.hungry = False
self.parse_blocks(blocks, parent)
break
class BlocksMgrExtension(Extension):
"""Add generic Blocks extension."""
def extendMarkdown(self, md: Markdown) -> None:
"""Add Blocks to Markdown instance."""
md.registerExtension(self)
util.escape_chars(md, ['/'])
self.extension = BlocksProcessor(md.parser, md)
# We want to be right after list indentations are processed
md.parser.blockprocessors.register(self.extension, "blocks", 89.99)
tree = BlocksTreeprocessor(md, self.extension)
md.treeprocessors.register(tree, 'blocks_on_inline_end', 19.99)
def reset(self) -> None:
"""Reset."""
self.extension._reset()
class BlocksExtension(Extension):
"""Blocks Extension."""
def register_block_mgr(self, md: Markdown) -> BlocksProcessor:
"""Add Blocks to Markdown instance."""
if 'blocks' not in md.parser.blockprocessors:
ext = BlocksMgrExtension()
ext.extendMarkdown(md)
mgr = ext.extension
else:
mgr = cast('BlocksProcessor', md.parser.blockprocessors['blocks'])
return mgr
def extendMarkdown(self, md: Markdown) -> None:
"""Extend markdown."""
mgr = self.register_block_mgr(md)
self.extendMarkdownBlocks(md, mgr)
def extendMarkdownBlocks(self, md: Markdown, block_mgr: BlocksProcessor) -> None:
"""Extend Markdown blocks."""
@@ -0,0 +1,125 @@
"""Admonitions."""
import xml.etree.ElementTree as etree
from .block import Block, type_html_identifier
from .. blocks import BlocksExtension
import re
RE_SEP = re.compile(r'[_-]+')
class Admonition(Block):
"""
Admonition.
Arguments (1 optional):
- A title.
Options:
- `type` (string): Attach a single special class for styling purposes. If more are needed,
use the built-in `attributes` options to apply as many classes as desired.
Content:
Detail body.
"""
NAME = 'admonition'
ARGUMENT = None
OPTIONS = {
'type': ('', type_html_identifier),
}
DEF_TITLE = None
DEF_CLASS = None
def on_validate(self, parent):
"""Handle on validate event."""
if self.NAME != 'admonition':
self.options['type'] = {'name': self.NAME}
if self.DEF_TITLE:
self.options['type']['title'] = self.DEF_TITLE
if self.DEF_TITLE:
self.options['type']['class'] = self.DEF_CLASS
return True
def on_create(self, parent):
"""Create the element."""
# Set classes
classes = ['admonition']
obj = self.options['type']
atype = def_title = class_name = ''
if isinstance(obj, dict):
atype = obj['name']
class_name = obj.get('class', atype)
def_title = obj.get('title', RE_SEP.sub(' ', class_name).title())
elif isinstance(obj, str):
atype = obj
class_name = atype
def_title = RE_SEP.sub(' ', atype).title()
if atype and atype != 'admonition':
classes.append(class_name)
# Create the admonition
el = etree.SubElement(parent, 'div', {'class': ' '.join(classes)})
# Create the title
title = None
if self.argument is None:
if atype:
title = def_title
elif self.argument:
title = self.argument
if title is not None:
ad_title = etree.SubElement(el, 'p', {'class': 'admonition-title'})
ad_title.text = title
return el
class AdmonitionExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
['note', 'attention', 'caution', 'danger', 'error', 'tip', 'hint', 'warning', 'important'],
"Generate Admonition block extensions for the given types."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Admonition, self.getConfigs())
# Generate an admonition subclass based on the given names.
for obj in self.getConfig('types', []):
if isinstance(obj, dict):
name = obj['name']
class_name = obj.get('class', name)
title = obj.get('title', RE_SEP.sub(' ', class_name).title())
else:
name = obj
class_name = name
title = RE_SEP.sub(' ', class_name).title()
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Admonition,),
{'OPTIONS': {}, 'NAME': name, 'DEF_TITLE': title, 'DEF_CLASS': class_name}
),
{}
)
def makeExtension(*args, **kwargs):
"""Return extension."""
return AdmonitionExtension(*args, **kwargs)
@@ -0,0 +1,401 @@
"""Block class."""
from __future__ import annotations
from abc import ABCMeta, abstractmethod
import functools
import copy
import re
import sys
from markdown import util as mutil
import xml.etree.ElementTree as etree
from typing import Any, Callable, TypeVar, TYPE_CHECKING
from collections.abc import Iterable
if TYPE_CHECKING: # pragma: no cover
from ..blocks import BlocksProcessor
RE_IDENT = re.compile(
r'''
(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f])+|--)
(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f])*)
''',
re.I | re.X
)
RE_INDENT = re.compile(r'(?m)^([ ]*)[^ \n]')
RE_DEDENT = re.compile(r'(?m)^([ ]*)($)?')
_T = TypeVar("_T")
def _type_multi(value: Any, types: Iterable[Callable[[Any], _T]] = ()) -> _T:
"""Multi types."""
for t in types:
try:
return t(value)
except ValueError: # noqa: PERF203
pass
raise ValueError(f"Type '{type(value)}' did not match any of the provided types")
def type_multi(*args: Callable[[Any], _T]) -> Callable[[Any], _T]:
"""Validate a type with multiple type functions."""
return functools.partial(_type_multi, types=args)
def type_any(value: _T) -> _T:
"""Accepts any type."""
return value
def type_none(value: Any) -> None:
"""Ensure type None or fail."""
if value is not None:
raise ValueError(f'{type(value)} is not None')
def _ranged_number(
value: Any,
minimum: int | float | None,
maximum: int | float | None,
number_type: Callable[[Any], int | float]
) -> int | float:
"""Check the range of the given number type."""
_value = number_type(value)
if minimum is not None and _value < minimum:
raise ValueError(f'{_value} is not greater than {minimum}')
if maximum is not None and _value > maximum:
raise ValueError(f'{_value} is not greater than {minimum}')
return _value
def type_number(value: Any) -> int | float:
"""Ensure type number or fail."""
if not isinstance(value, (float, int)):
raise ValueError(f"Could not convert type {type(value)} to a number")
return value
def type_integer(value: Any) -> int:
"""Ensure type integer or fail."""
if isinstance(value, int):
return value
if not isinstance(value, float) or not value.is_integer():
raise ValueError(f"Could not convert type {type(value)} to an integer")
return int(value)
def type_ranged_number(minimum: int | None = None, maximum: int | None = None) -> Callable[[Any], int | float]:
"""Ensure typed number is within range."""
return functools.partial(_ranged_number, minimum=minimum, maximum=maximum, number_type=type_number)
def type_ranged_integer(minimum: int | None = None, maximum: int | None = None) -> Callable[[Any], int | float]:
"""Ensured type integer is within range."""
return functools.partial(_ranged_number, minimum=minimum, maximum=maximum, number_type=type_integer)
def type_boolean(value: Any) -> bool:
"""Ensure type boolean or fail."""
if not isinstance(value, bool):
raise ValueError(f"Could not convert type {type(value)} to a boolean")
return value
type_ternary = type_multi(type_none, type_boolean)
def type_string(value: Any) -> str:
"""Ensure type string or fail."""
if isinstance(value, str):
return value
raise ValueError(f"Could not convert type {type(value)} to a string")
def type_string_insensitive(value: Any) -> str:
"""Ensure type string and normalize case."""
return type_string(value).lower()
def type_html_identifier(value: Any) -> str:
"""Ensure type HTML attribute name or fail."""
value = type_string(value)
m = RE_IDENT.fullmatch(value)
if m is None:
raise ValueError('A valid attribute name must be provided')
return m.group(0)
def _delimiter(string: Any, split: str, string_type: Callable[[Any], str]) -> list[str]:
"""Split the string by the delimiter and then parse with the parser."""
l = []
# Ensure input is a string
_string = type_string(string)
for s in _string.split(split):
s = s.strip()
if not s:
continue
# Ensure each part conforms to the desired string type
s = string_type(s)
l.append(s)
return l
def _string_in(value: Any, accepted: Iterable[str], string_type: Callable[[Any], str]) -> str:
"""Ensure type string is within the accepted values."""
_value = string_type(value)
if _value not in accepted:
raise ValueError(f'{_value} not found in {accepted!s}')
return _value
def type_string_in(accepted: Iterable[str], insensitive: bool = True) -> Callable[[Any], str]:
"""Ensure type string is within the accepted list."""
return functools.partial(
_string_in,
accepted=accepted,
string_type=type_string_insensitive if insensitive else type_string
)
def type_string_delimiter(split: str, string_type: Callable[[Any], str] = type_string) -> Callable[[Any], list[str]]:
"""String delimiter function."""
return functools.partial(_delimiter, split=split, string_type=string_type)
def type_html_attribute_dict(value: Any) -> dict[str, str | list[str]]:
"""Attribute dictionary."""
if not isinstance(value, dict):
raise ValueError('Attributes should be contained within a dictionary')
attributes = {}
for k, v in value.items():
k = type_html_identifier(k)
if k.lower() == 'class':
k = 'class'
v = type_html_classes(v)
elif k.lower() == 'id':
k = 'id'
v = type_html_identifier(v)
else:
v = type_string(v)
attributes[k] = v
return attributes
# Ensure class(es) or fail
type_html_classes = type_string_delimiter(' ', type_html_identifier)
class Block(metaclass=ABCMeta):
"""Block."""
# Set to something if argument should be split.
# Arguments will be split and white space stripped.
NAME = ''
# Instance arguments and options
ARGUMENT: bool | None = False
OPTIONS: dict[str, tuple[Any, Callable[[Any], Any]]] = {}
def __init__(self, length: int, tracker: Any, block_mgr: BlocksProcessor, config: Any):
"""
Initialize.
- `length` specifies the length (number of slashes) that the header used
- `tracker` is a persistent storage for the life of the current Markdown page.
It is a dictionary where we can keep references until the parent extension is reset.
- `md` is the Markdown object just in case access is needed to something we
didn't think about.
"""
# Setup up the argument and options spec
# Note that `attributes` is handled special and we always override it
self.arg_spec = self.ARGUMENT
self.option_spec = copy.deepcopy(self.OPTIONS)
if 'attrs' in self.option_spec: # pragma: no cover
raise ValueError("'attrs' is a reserved option name and cannot be overriden")
self.option_spec['attrs'] = ({}, type_html_attribute_dict)
self._block_mgr = block_mgr
self.length = length
self.tracker = tracker
self.md = block_mgr.md
self.arguments: list[Any] = []
self.options: dict[str, Any] = {}
self.config = config
self.on_init()
def is_raw(self, tag: etree.Element) -> bool:
"""Is raw element."""
return self._block_mgr.is_raw(tag)
def is_block(self, tag: etree.Element) -> bool: # pragma: no cover
"""Is block element."""
return self._block_mgr.is_block(tag)
def html_escape(self, text: str) -> str:
"""Basic html escaping."""
text = text.replace('&', '&amp;')
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
return text
def dedent(self, text: str, length: int | None = None) -> str:
"""Dedent raw text."""
if length is None:
length = self.md.tab_length
min_length = sys.maxsize
for x in RE_INDENT.findall(text):
min_length = min(len(x), min_length)
min_length = min(min_length, length)
def on_match(m: re.Match[str], l: int = min_length) -> str:
return '' if m.group(2) is not None else m.group(1)[l:]
return RE_DEDENT.sub(on_match, text)
def on_init(self) -> None:
"""On initialize."""
return
def on_markdown(self) -> str:
"""Check how element should be treated by the Markdown parser."""
return "auto"
def _validate(self, parent: etree.Element, arg: Any, **options: Any) -> bool:
"""Parse configuration."""
# Check argument
if (self.arg_spec is not None and ((arg and not self.arg_spec) or (not arg and self.arg_spec))):
return False
self.argument = arg
# Fill in defaults options
spec = self.option_spec
parsed = {}
for k, v in spec.items():
parsed[k] = v[0]
# Parse provided options
for k, v in options.items():
# Parameter not in spec
if k not in spec:
# Unrecognized parameter name
return False
# Spec explicitly handles parameter
else:
parser = spec[k][1]
if parser is not None:
try:
v = parser(v)
except Exception:
# Invalid parameter value
return False
parsed[k] = v
# Add parsed options to options
self.options = parsed
return self.on_validate(parent)
def on_validate(self, parent: etree.Element) -> bool:
"""
Handle validation event.
Run after config parsing completes and allows for the opportunity
to invalidate the block if argument, options, or even the parent
element do not meet certain criteria.
Return `False` to invalidate the block.
"""
return True
@abstractmethod
def on_create(self, parent: etree.Element) -> etree.Element:
"""Create the needed element and return it."""
def _create(self, parent: etree.Element) -> etree.Element:
"""Create the element."""
el = self.on_create(parent)
# Handle general HTML attributes
attrib = el.attrib
for k, v in self.options['attrs'].items():
if k == 'class':
if k in attrib:
# Don't validate what the developer as already attached
v = type_string_delimiter(' ')(attrib['class']) + v
attrib['class'] = ' '.join(v)
else:
attrib[k] = v
return el
def _end(self, block: etree.Element) -> None:
"""Reached end of the block, dedent raw blocks and call `on_end` hook."""
mode = self.on_markdown()
add = self.on_add(block)
if mode == 'raw' or (mode == 'auto' and self.is_raw(add)):
text = add.text if add.text is not None else ''
add.text = mutil.AtomicString(self.dedent(text))
self.on_end(block)
def on_end(self, block: etree.Element) -> None:
"""Perform any action on end."""
return
def on_add(self, block: etree.Element) -> etree.Element:
"""
Adjust where the content is added and return the desired element.
Is there a sub-element where this content should go?
This runs before processing every new block.
"""
return block
def on_inline_end(self, block: etree.Element) -> None:
"""Perform action on the block after inline parsing."""
return
@@ -0,0 +1,417 @@
"""
Captions.
Captions should be placed after a block, that block will be wrapped in a `figure`
and captions will be inserted either at the end of the figure or at the beginning.
If the preceding block happens to be a `figure`, if no `figcaption` is detected
within, the caption will be injected into that figure instead of wrapping.
Keep in mind that when `md_in_html` is used and raw HTML is used, if `markdown=1`
is not present on the caption, the caption will be invisible to this extension.
Class, IDs, or other attributes will be attached to the figure, not the caption.
`types`:
A dictionary with figure type names and prefix templates. A template will be
used depending on whether the current type is assumed or directly specified.
`prepend`:
Will prepend `figcaption` at the start of a `figure` instead of the end.
`auto`:
Will generate IDs and prefixes via the provided template for all figures of
a given type as long as they also define a prefix template.
`auto_level`:
Auto number will not be shown below the given level depth. A value of 0, the
default, disables the feature, 1 would show only auto-generate IDs and
prefixes for the outermost figures with prefixes, etc. This level is only
considered for each figure type individually.
"""
import xml.etree.ElementTree as etree
from .block import Block, type_html_identifier
from .. blocks import BlocksExtension
from .html import parse_selectors
from markdown.treeprocessors import Treeprocessor
import re
RE_FIG_NUM = re.compile(r'^(\^)?([1-9][0-9]*(?:\.[1-9][0-9]*)*)(?= |$)')
RE_SEP = re.compile(r'[_-]+')
def update_tag(el, fig_type, fig_num, template, prepend, md):
"""Update tag ID and caption prefix."""
# Auto add an ID
if 'id' not in el.attrib:
el.attrib['id'] = f'__{fig_type}_' + '_'.join(str(x) for x in fig_num.split('.'))
# Prefix the caption with a given numbered prefix
if template:
for child in list(el) if prepend else reversed(el):
if child.tag == 'figcaption':
children = list(child)
value = md.htmlStash.store(template.format(fig_num))
if not len(children) or children[0].tag != 'p':
p = etree.Element('p')
span = etree.SubElement(p, 'span', {'class': 'caption-prefix'})
span.text = value
p.tail = child.text
child.text = None
child.insert(0, p)
else:
p = children[0]
span = etree.Element('span', {'class': 'caption-prefix'})
span.text = value
empty = not bool(p.text)
span.tail = (' ' + p.text) if not empty else p.text
p.text = None
p.insert(0, span)
class CaptionTreeprocessor(Treeprocessor):
"""Caption tree processor."""
def __init__(self, md, types, config):
"""Initialize."""
super().__init__(md)
self.auto = config['auto']
self.prepend = config['prepend']
self.type = ''
self.auto_level = max(0, config['auto_level'])
self.fig_types = types
def run(self, doc):
"""Update caption IDs and prefixes."""
parent_map = {c: p for p in doc.iter() for c in p}
last = dict.fromkeys(self.fig_types, 0)
counters = {k: [0] for k in self.fig_types}
fig_type = last_type = self.type
figs = []
fig_num = ''
# Calculate the depth and iteration at that depth of the given figure.
for el in doc.iter():
fig_num = ''
stack = -1
if el.tag == 'figure':
fig_type = last_type
prepend = False
skip = False
# Find caption appended or prepended
if '__figure_prepend' in el.attrib:
prepend = True
del el.attrib['__figure_prepend']
# Determine figure type
if '__figure_type' in el.attrib:
fig_type = el.attrib['__figure_type']
figs.append(el)
# See if we have an unknown type or the type has no prefix template.
if fig_type not in self.fig_types or not self.fig_types[fig_type]:
continue
else:
# Found a figure that was not generated by this plugin.
continue
# Handle a specified relative nesting depth
if '__figure_level' in el.attrib:
stack += int(el.attrib['__figure_level']) + 1
if self.auto_level and stack >= self.auto_level:
continue
else:
stack += 1
current = el
while True:
parent = parent_map.get(current, None)
# No more parents
if parent is None:
break
# Check if parent element is a figure of the current type
if parent.tag == 'figure' and parent.attrib['__figure_type'] == fig_type:
# See if position in stack is manually specified
level = '__figure_level' in parent.attrib
if level:
stack += int(parent.attrib['__figure_level']) + 1
else:
stack += 1
if level:
el.attrib['__figure_level'] = str(stack + 1)
# Ensure position in stack is not deeper than the specified level
if self.auto_level and stack >= self.auto_level:
skip = True
break
current = parent
if skip:
# Parent has been skipped so all children are also skipped
continue
# Found an appropriate figure at an acceptable depth
if stack > -1:
# Handle a manual number
if '__figure_num' in el.attrib:
fig_num = [int(x) for x in el.attrib['__figure_num'].split('.')]
del el.attrib['__figure_num']
new_stack = len(fig_num) - 1
el.attrib['__figure_level'] = new_stack - stack
stack = new_stack
# Increment counter
l = last[fig_type]
counter = counters[fig_type]
if stack > l:
counter.extend([1] * (stack - l))
elif stack == l:
counter[stack] += 1
else:
del counter[stack + 1:]
counter[-1] += 1
last[fig_type] = stack
last_type = fig_type
# Determine if manual number is not smaller than existing figure numbers at that depth
if fig_num and fig_num > counter:
counter[:] = fig_num[:]
# Apply prefix and ID
update_tag(
el,
fig_type,
'.'.join(str(x) for x in counter[:stack + 1]),
self.fig_types.get(fig_type, ''),
prepend,
self.md
)
# Clean up attributes
for fig in figs:
del fig.attrib['__figure_type']
if '__figure_level' in fig.attrib:
del fig.attrib['__figure_level']
class Caption(Block):
"""Figure captions."""
NAME = ''
PREFIX = ''
CLASSES = ''
ARGUMENT = None
OPTIONS = {
'type': ('', type_html_identifier)
}
def on_init(self):
"""Initialize."""
self.auto = self.config['auto']
self.prepend = self.config['prepend']
self.caption = None
self.fig_num = ''
self.level = ''
self.classes = self.CLASSES.split()
def on_validate(self, parent):
"""Handle on validate event."""
argument = self.argument
if argument:
if argument.startswith('>'):
self.prepend = False
argument = argument[1:].lstrip()
elif argument.startswith('<'):
self.prepend = True
argument = argument[1:].lstrip()
m = RE_FIG_NUM.match(argument)
if m:
if m.group(1):
self.level = m.group(2)
else:
self.fig_num = m.group(2)
argument = argument[m.end():].lstrip()
if argument:
try:
_, attrs = parse_selectors(argument, require_tag=False)
except ValueError:
return False
attrs_original = dict(self.options['attrs'])
for k, v in attrs.items():
if k == 'class':
classes = {x for x in attrs_original.get('class', []) if x}
classes |= {x for x in v.split(' ') if x}
attrs_original['class'] = sorted(classes)
elif k not in attrs_original:
attrs_original[k] = v
self.options['attrs'] = attrs_original
return True
return True
def on_create(self, parent):
"""Create the element."""
# Find sibling to add caption to.
fig = None
child = None
children = list(parent)
if children:
child = children[-1]
# Do we have a figure with no caption?
if child.tag == 'figure':
fig = child
for c in list(child):
if c.tag == 'figcaption':
fig = None
break
# Create a new figure if sibling is not a figure or already has a caption.
# Add sibling to the new figure.
if fig is None:
attrib = {} if not self.classes else {'class': ' '.join(self.classes)}
fig = etree.SubElement(parent, 'figure', attrib)
if child is not None:
fig.append(child)
parent.remove(child)
# Add classes to existing figure
elif self.CLASSES:
classes = fig.attrib.get('class', '').strip()
if classes:
class_list = classes.split()
for c in self.classes:
if c not in class_list:
classes += " " + c
else:
classes = ' '.join(self.classes)
fig.attrib['class'] = classes
if self.auto:
fig.attrib['__figure_type'] = self.NAME
if self.level:
fig.attrib['__figure_level'] = self.level
if self.fig_num:
fig.attrib['__figure_num'] = self.fig_num
# Add caption to the target figure.
if self.prepend:
if self.auto:
fig.attrib['__figure_prepend'] = "1"
self.caption = etree.Element('figcaption')
fig.insert(0, self.caption)
else:
self.caption = etree.SubElement(fig, 'figcaption')
return fig
def on_add(self, block):
"""Return caption as the target container for content."""
return self.caption
def on_end(self, block):
"""Handle explicit, manual prefixes on block end."""
prefix = self.PREFIX
if prefix and not self.auto:
# Levels should not be used in manual mode, but if they are, give a generic result.
if self.level:
self.fig_num = '.'.join(['1'] * (int(self.level) + 1))
if self.fig_num:
update_tag(
block,
self.NAME,
self.fig_num,
prefix,
self.prepend,
self.md
)
class CaptionExtension(BlocksExtension):
"""Caption Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
[
'caption',
{
'name': 'figure-caption',
'prefix': 'Figure {}.'
},
{
'name': 'table-caption',
'prefix': 'Table {}.'
}
],
"Configure types a list of types, each type is a dictionary that defines a 'name' and 'prefix' "
"A template must contain '{}' for numerical insertions unless the template is an empty string "
"which will assume no prefix should be used."
],
"auto_level": [
0,
"Depth of children to add prefixes to - Default: 0"
],
"auto": [
True,
"Auto add IDs with prefixes (prefixes are only added if prefix template is defined) - Default: False"
],
"prepend": [
False,
"Prepend captions opposed to appending - Default: False"
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
config = self.getConfigs()
# Generate an details subclass based on the given names.
types = {}
for obj in config['types']:
if isinstance(obj, dict):
name = obj['name']
prefix = obj.get('prefix', '')
classes = obj.get('classes', '')
else:
name = obj
prefix = ''
classes = ''
types[name] = prefix
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Caption,),
{
'OPTIONS': {},
'NAME': name,
'PREFIX': prefix,
'CLASSES': classes
}
),
{'auto_level': config['auto_level'], 'auto': config['auto'], 'prepend': config['prepend']}
)
if config['auto']:
md.treeprocessors.register(CaptionTreeprocessor(md, types, config), 'caption-auto', 4)
def makeExtension(*args, **kwargs):
"""Return extension."""
return CaptionExtension(*args, **kwargs)
@@ -0,0 +1,65 @@
"""Definition."""
import xml.etree.ElementTree as etree
from .block import Block
from ..blocks import BlocksExtension
class Definition(Block):
"""
Definition.
Converts non `ul`, `ol` blocks (ideally `p` tags) into `dt`
and will convert first level `li` elements of `ul` and `ol`
elements to `dd` tags. When done, the `ul`, and `ol` elements
will be removed.
"""
NAME = 'define'
def on_create(self, parent):
"""Create the element."""
return etree.SubElement(parent, 'dl')
def on_end(self, block):
"""Convert non list items to details."""
remove = []
offset = 0
for i, child in enumerate(list(block)):
if child.tag.lower() in ('dt', 'dd'):
continue
elif child.tag.lower() not in ('ul', 'ol'):
if child.tag.lower() == 'p':
child.tag = 'dt'
else:
dt = etree.Element('dt')
dt.append(child)
block.insert(i + offset, dt)
block.remove(child)
else:
for li in list(child):
offset += 1
li.tag = 'dd'
block.insert(i + offset, li)
child.remove(li)
remove.append(child)
for el in remove:
block.remove(el)
class DefinitionExtension(BlocksExtension):
"""Definition Blocks Extension."""
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Definition, self.getConfigs())
def makeExtension(*args, **kwargs):
"""Return extension."""
return DefinitionExtension(*args, **kwargs)
@@ -0,0 +1,138 @@
"""Details."""
import xml.etree.ElementTree as etree
from .block import Block, type_boolean, type_html_identifier
from ..blocks import BlocksExtension
import re
RE_SEP = re.compile(r'[_-]+')
class Details(Block):
"""
Details.
Arguments (1 optional):
- A summary.
Options:
- `open` (boolean): force the details block to be in an open state opposed to collapsed.
- `type` (string): Attach a single special class for styling purposes. If more are needed,
use the built-in `attributes` options to apply as many classes as desired.
Content:
Detail body.
"""
NAME = 'details'
ARGUMENT = None
OPTIONS = {
'open': (False, type_boolean),
'type': ('', type_html_identifier)
}
DEF_TITLE = None
DEF_CLASS = None
def on_validate(self, parent):
"""Handle on validate event."""
if self.NAME != 'details':
self.options['type'] = {'name': self.NAME}
if self.DEF_TITLE:
self.options['type']['title'] = self.DEF_TITLE
if self.DEF_TITLE:
self.options['type']['class'] = self.DEF_CLASS
return True
def on_create(self, parent):
"""Create the element."""
# Is it open?
attributes = {}
if self.options['open']:
attributes['open'] = 'open'
# Set classes
obj = self.options['type']
dtype = def_title = class_name = ''
if isinstance(obj, dict):
dtype = obj['name']
class_name = obj.get('class', dtype)
def_title = obj.get('title', RE_SEP.sub(' ', class_name).title())
elif isinstance(obj, str):
dtype = obj
class_name = dtype
def_title = RE_SEP.sub(' ', class_name).title()
if dtype:
attributes['class'] = class_name
# Create Detail element
el = etree.SubElement(parent, 'details', attributes)
# Create the summary
summary = None
if self.argument is None:
if dtype:
summary = def_title
elif self.argument:
summary = self.argument
# Create the summary
if summary is not None:
s = etree.SubElement(el, 'summary')
s.text = summary
return el
class DetailsExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
[],
"Generate Admonition block extensions for the given types."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Details, self.getConfigs())
# Generate an details subclass based on the given names.
for obj in self.getConfig('types', []):
if isinstance(obj, dict):
name = obj['name']
class_name = obj.get('class', name)
title = obj.get('title', RE_SEP.sub(' ', class_name).title())
else:
name = obj
class_name = name
title = RE_SEP.sub(' ', class_name).title()
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Details,),
{
'OPTIONS': {'open': [False, type_boolean]},
'NAME': name,
'DEF_TITLE': title,
'DEF_CLASS': class_name
}
),
{}
)
def makeExtension(*args, **kwargs):
"""Return extension."""
return DetailsExtension(*args, **kwargs)
@@ -0,0 +1,219 @@
"""HTML."""
import xml.etree.ElementTree as etree
from .block import Block, type_string_in
from ..blocks import BlocksExtension
import re
# Sub-patterns parts
# Whitespace
WS = r'(?:[ \t])'
# CSS escapes
CSS_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}{WS}?|[^\r\n\f]|$))'
# CSS Identifier
IDENTIFIER = r'''
(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f])+|--)
(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f])*)
'''
# Value: quoted string or identifier
VALUE = r'''
(?:"(?:\\(?:.)|[^\\"\r\n\f]+)*?"|'(?:\\(?:.)|[^\\'\r\n\f]+)*?'|{ident}+)
'''.format(ident=IDENTIFIER)
# Attribute value comparison.
ATTR = r'''
(?:{ws}*(?P<cmp>=){ws}*(?P<value>{value}))?
'''.format(ws=WS, value=VALUE)
# Selector patterns
# IDs (`#id`)
PAT_ID = fr'\#{IDENTIFIER}'
# Classes (`.class`)
PAT_CLASS = fr'\.{IDENTIFIER}'
# Attributes (`[attr]`, `[attr=value]`, etc.)
PAT_ATTR = r'''
\[(?:{ws}*(?P<attr_name>{ident}){attr})+{ws}*\]
'''.format(ws=WS, ident=IDENTIFIER, attr=ATTR)
RE_IDENT = re.compile(IDENTIFIER, flags=re.I | re.X)
RE_ID = re.compile(PAT_ID, flags=re.I | re.X)
RE_CLASS = re.compile(PAT_CLASS, flags=re.I | re.X)
RE_ATTRS = re.compile(PAT_ATTR, flags=re.I | re.X)
RE_ATTR = re.compile(fr'(?P<attr_name>{IDENTIFIER}){ATTR}', flags=re.I | re.X)
ATTRIBUTES = {'id': RE_ID, 'class': RE_CLASS, 'attr': RE_ATTRS}
VALID_MODES = {'auto', 'inline', 'block', 'raw', 'html'}
def parse_selectors(selector, require_tag=True):
"""Parse the selector."""
eol = len(selector)
tag = None
attrs = {}
end = 0
m = None
if require_tag:
m = RE_IDENT.match(selector)
if m is None:
raise ValueError('No defined tag')
tag = m.group(0)
end = m.end()
found_id = False
while end < eol:
for atype, pat in ATTRIBUTES.items():
m = pat.match(selector, end)
if m is not None:
if atype == 'id':
if not found_id:
attrs[atype] = m.group(0)[1:]
end = m.end()
found_id = True
else:
raise ValueError('Only one ID is allowed')
elif atype == 'class':
if atype not in attrs:
attrs[atype] = [m.group(0)[1:]]
else:
attrs[atype].append(m.group(0)[1:])
end = m.end()
else:
results = m.group(0)
m2 = RE_ATTR.search(results)
while m2 is not None:
pos = m2.end()
name = m2.group('attr_name').lower()
value = m2.group('value')
if value is None:
value = name if name != 'class' else ''
elif value.startswith(('"', "'")):
value = value[1:-1]
if name == 'class':
value = [v for v in value.split(' ') if v]
if value:
if name in attrs:
attrs[name].extend(value)
else:
attrs[name] = value
else:
value = value
attrs[name] = value
m2 = RE_ATTR.search(results, pos)
end = m.end()
break
if m is None:
raise ValueError('Invalid selector')
if 'class' in attrs:
attrs['class'] = ' '.join(sorted(attrs['class']))
return tag, attrs
class HTML(Block):
"""
HTML.
Arguments (1 required):
- HTML tag name
Options:
- `markdown` (string): specify how content inside the element should be treated:
- `auto`: will automatically determine how an element's content should be handled.
- `inline`: treat content as an inline element's content.
- `block`: treat content as a block element's content.
- `raw`: treat the content as raw content (atomic).
Content:
HTML element content.
"""
NAME = 'html'
ARGUMENT = True
OPTIONS = {
'markdown': ('auto', type_string_in(VALID_MODES))
}
def __init__(self, length, tracker, md, config):
"""Initialize."""
self.markdown = None
self.custom = {}
for entry in config.get('custom'):
mode = entry.get('mode', 'auto')
self.custom[entry['tag']] = mode if mode in VALID_MODES else 'auto'
super().__init__(length, tracker, md, config)
def on_validate(self, parent):
"""Handle argument parsing."""
try:
self.tag, self.attr = parse_selectors(self.argument)
except ValueError:
return False
return True
def on_markdown(self):
"""Check if this is atomic."""
mode = self.options['markdown']
if mode == 'auto':
tag = self.tag.lower()
mode = self.custom.get(tag, mode)
if mode == 'html':
mode = 'raw'
return mode
def on_create(self, parent):
"""Create the element."""
# Create element
return etree.SubElement(parent, self.tag.lower(), self.attr)
def is_html(self, tag):
"""Does tag require no processing and no HTML escaping."""
return tag.tag in ('script', 'style')
def on_end(self, block):
"""On end event."""
mode = self.options['markdown']
if mode == 'auto':
tag = self.tag.lower()
mode = self.custom.get(tag, mode)
if (mode == 'auto' and self.is_html(block)) or mode == 'html':
block.text = self.md.htmlStash.store(block.text)
elif (mode == 'auto' and self.is_raw(block)) or mode == 'raw':
block.text = self.md.htmlStash.store(self.html_escape(block.text))
class HTMLExtension(BlocksExtension):
"""HTML Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"custom": [
[],
"Specify handling for custom blocks."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(HTML, self.getConfigs())
def makeExtension(*args, **kwargs):
"""Return extension."""
return HTMLExtension(*args, **kwargs)
@@ -0,0 +1,303 @@
"""Tabs."""
import xml.etree.ElementTree as etree
from markdown.extensions import toc
from markdown.treeprocessors import Treeprocessor
from .block import Block, type_boolean
from ..blocks import BlocksExtension
import html
HEADERS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
class TabbedTreeprocessor(Treeprocessor):
"""Tab tree processor."""
def __init__(self, md, config):
"""Initialize."""
super().__init__(md)
self.alternate = config['alternate_style']
self.slugify = config['slugify']
self.combine_header_slug = config['combine_header_slug']
self.sep = config["separator"]
def get_parent_header_slug(self, root, header_map, parent_map, el):
"""Attempt retrieval of parent header slug."""
parent = el
last_parent = parent
while parent is not root:
last_parent = parent
parent = parent_map[parent]
if parent in header_map:
headers = header_map[parent]
header = None
for i in list(parent):
if i is el and header is None:
break
if i is last_parent and header is not None:
return header.attrib.get("id", '')
if i in headers:
header = i
return ''
def run(self, doc):
"""Update tab IDs."""
# Get a list of id attributes
used_ids = set()
parent_map = {}
header_map = {}
if self.combine_header_slug:
parent_map = {c: p for p in doc.iter() for c in p}
for el in doc.iter():
if "id" in el.attrib:
if self.combine_header_slug and el.tag in HEADERS:
parent = parent_map[el]
if parent in header_map:
header_map[parent].append(el)
else:
header_map[parent] = [el]
used_ids.add(el.attrib["id"])
for el in doc.iter():
if isinstance(el.tag, str) and el.tag.lower() == 'div':
classes = el.attrib.get('class', '').split()
if 'tabbed-set' in classes and (not self.alternate or 'tabbed-alternate' in classes):
inputs = []
labels = []
if self.alternate:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'div' and i.attrib.get('class', '') == 'tabbed-labels':
labels = [j for j in list(i) if j.tag == 'label']
else:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'label':
labels.append(i)
# Generate slugged IDs
for inpt, label in zip(inputs, labels):
innerhtml = toc.render_inner_html(toc.remove_fnrefs(label), self.md)
innertext = html.unescape(toc.strip_tags(innerhtml))
if self.combine_header_slug:
parent_slug = self.get_parent_header_slug(doc, header_map, parent_map, el)
else:
parent_slug = ''
slug = self.slugify(innertext, self.sep)
if parent_slug:
slug = parent_slug + self.sep + slug
slug = toc.unique(slug, used_ids)
inpt.attrib["id"] = slug
label.attrib["for"] = slug
class Tab(Block):
"""
Tabbed container.
Arguments (1 required):
- A tab title.
Options:
- `new` (boolean): since consecutive tabs are automatically grouped, `new` can force a tab
to start a new tab container.
Content:
Detail body.
"""
NAME = 'tab'
ARGUMENT = True
OPTIONS = {
'new': (False, type_boolean),
'select': (False, type_boolean)
}
def on_init(self):
"""Handle initialization."""
self.alternate_style = self.config['alternate_style']
self.slugify = callable(self.config['slugify'])
# Track tab group count across the entire page.
if 'tab_group_count' not in self.tracker:
self.tracker['tab_group_count'] = 0
self.tab_content = None
def last_child(self, parent):
"""Return the last child of an `etree` element."""
if len(parent):
return parent[-1]
else:
return None
def on_add(self, block):
"""Adjust where the content is added."""
if self.tab_content is None:
if self.alternate_style:
for d in block.findall('div'):
c = d.attrib['class']
if c == 'tabbed-content' or c.startswith('tabbed-content '):
self.tab_content = list(d)[-1]
break
else:
self.tab_content = list(block)[-1]
return self.tab_content
def on_create(self, parent):
"""Create the element."""
new_group = self.options['new']
select = self.options['select']
title = self.argument
sibling = self.last_child(parent)
tabbed_set = 'tabbed-set' if not self.alternate_style else 'tabbed-set tabbed-alternate'
index = 0
labels = None
content = None
if (
sibling is not None and sibling.tag.lower() == 'div' and
sibling.attrib.get('class', '') == tabbed_set and
not new_group
):
first = False
tab_group = sibling
if self.alternate_style:
index = [index for index, _ in enumerate(tab_group.findall('input'), 1)][-1]
for d in tab_group.findall('div'):
if d.attrib['class'] == 'tabbed-labels':
labels = d
elif d.attrib['class'] == 'tabbed-content':
content = d
if labels is not None and content is not None:
break
else:
first = True
self.tracker['tab_group_count'] += 1
tab_group = etree.SubElement(
parent,
'div',
{'class': tabbed_set, 'data-tabs': '%d:0' % self.tracker['tab_group_count']}
)
if self.alternate_style:
labels = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-labels'}
)
content = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-content'}
)
data = tab_group.attrib['data-tabs'].split(':')
tab_set = int(data[0])
tab_count = int(data[1]) + 1
attributes = {
"name": "__tabbed_%d" % tab_set,
"type": "radio"
}
if not self.slugify:
attributes['id'] = "__tabbed_%d_%d" % (tab_set, tab_count)
attributes2 = {"for": "__tabbed_%d_%d" % (tab_set, tab_count)} if not self.slugify else {}
if first or select:
attributes['checked'] = 'checked'
# Remove any previously assigned "checked states" to siblings
for i in tab_group.findall('input'):
if i.attrib.get('name', '') == f'__tabbed_{tab_set}':
if 'checked' in i.attrib:
del i.attrib['checked']
if self.alternate_style:
input_el = etree.Element(
'input',
attributes
)
tab_group.insert(index, input_el)
lab = etree.SubElement(
labels,
"label",
attributes2
)
lab.text = title
attrib = {'class': 'tabbed-block'}
etree.SubElement(
content,
"div",
attrib
)
else:
etree.SubElement(
tab_group,
'input',
attributes
)
lab = etree.SubElement(
tab_group,
"label",
attributes2
)
lab.text = title
etree.SubElement(
tab_group,
"div",
{
"class": "tabbed-content"
}
)
tab_group.attrib['data-tabs'] = '%d:%d' % (tab_set, tab_count)
return tab_group
class TabExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'alternate_style': [False, "Use alternate style - Default: False"],
'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
'combine_header_slug': [False, "Combine the tab slug with the slug of the parent header - Default: False"],
'separator': ['-', "Slug separator - Default: '-'"]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Tab, self.getConfigs())
if callable(self.getConfig('slugify')):
slugs = TabbedTreeprocessor(md, self.getConfigs())
md.treeprocessors.register(slugs, 'tab_slugs', 4)
def makeExtension(*args, **kwargs):
"""Return extension."""
return TabExtension(*args, **kwargs)
@@ -0,0 +1,186 @@
"""
Caret.
pymdownx.caret
Really simple plugin to add support for
`<ins>test</ins>` tags as `^^test^^` and
`<sup>test</sup>` tags as `^test^`
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import re
from markdown import Extension
from markdown.inlinepatterns import SimpleTextInlineProcessor
from . import util
SMART_CONTENT = r'(.+?\^*?)'
SMART_LIMITED_CONTENT = r'((?:[^\^]|(?<=\w)\^+?(?=\w)|(?<=\s)\^+?(?=\s))+?)'
CONTENT = r'(\^|[^\s]+?)'
CONTENT2 = r'((?:[^\^]|(?<!\^{2})\^)+?)'
# Avoid starting a pattern with caret tokens that are surrounded by white space.
NOT_CARET = r'((^|(?<=\s))(\^+)(?=\s|$))'
# `^^^ins,sup^^^`
INS_SUP = r'(\^{3})(?!\s)(\^{1,2}|[^\^\s]+?)(?<!\s)\1'
# `^^^ins,sup^ins^^`
INS_SUP2 = r'(\^{{3}})(?![\s\^]){}(?<!\s)\^{}(?<!\s)\^{{2}}'.format(CONTENT, CONTENT2)
# `^^^sup,ins^^sup^`
SUP_INS = r'(\^{{3}})(?![\s\^]){}(?<!\s)\^{{2}}{}(?<!\s)\^'.format(CONTENT, CONTENT)
# `^^ins^sup,ins^^^`
INS_SUP3 = r'(\^{{2}})(?![\s\^]){}\^(?![\s\^]){}(?<!\s)\^{{3}}'.format(CONTENT2, CONTENT)
# `^^ins^^`
INS = r'(\^{{2}})(?!\s){}(?<!\s)\1'.format(CONTENT2)
# `^sup^`
SUP = r'(\^)(?!\s){}(?<!\s)\1'.format(CONTENT)
# `^sup ^^sup,ins^^^`
SUP_INS2 = r'(?<!\^)(\^)(?![\^\s]){}\^{{2}}{}\^{{3}}'.format(CONTENT, CONTENT)
# Prioritize ^value^ when ^^value^^ is nested within
SUP2 = r'(?<!\^)(\^)(?![\^\s])((?:[^\^\s]|\^{2,})+?)(?<![\^\s])(\^)(?!\^)'
# Smart rules for when "smart caret" is enabled
# SMART: `^^^ins,sup^^^`
SMART_INS_SUP = r'(\^{{3}})(?![\s\^]){}(?<!\s)\1'.format(CONTENT)
# SMART: `^^^ins,sup^ ins^^`
SMART_INS_SUP2 = \
r'(\^{{3}})(?![\s\^]){}(?<!\s)\^(?:(?=_)|(?![\w\^])){}(?<!\s)\^{{2}}'.format(
CONTENT, SMART_LIMITED_CONTENT
)
# SMART: `^^^sup,ins^^ sup^`
SMART_SUP_INS = \
r'(\^{{3}})(?![\s\^]){}(?<!\s)\^{{2}}(?:(?=_)|(?![\w\^])){}(?<!\s)\^'.format(
CONTENT, CONTENT
)
# SMART: `^^ins^^`
SMART_INS = r'(?:(?<=_)|(?<![\w\^]))(\^{{2}})(?![\s\^]){}(?<!\s)\1(?:(?=_)|(?![\w\^]))'.format(SMART_CONTENT)
# SMART: `^sup ^^sup,ins^^^`
SMART_SUP_INS2 = \
r'(?<!\^)(\^)(?![\s\^]){}(?:(?<=_)|(?<![\w\^]))\^{{2}}(?![\s\^]){}(?<!\s)\^{{3}}'.format(
CONTENT, CONTENT
)
# SMART: `^^sup ^sup,ins^^^`
SMART_INS_SUP3 = \
r'(?<!\^)(\^{{2}})(?![\s\^]){}(?:(?<=_)|(?<![\w\^]))\^(?![\s\^]){}(?<!\s)\^{{3}}'.format(
SMART_LIMITED_CONTENT, CONTENT
)
class CaretProcessor(util.PatternSequenceProcessor):
"""Emphasis processor for handling insert and superscript matches."""
PATTERNS = [
util.PatSeqItem(re.compile(INS_SUP, re.DOTALL | re.UNICODE), 'double', 'ins,sup'),
util.PatSeqItem(re.compile(SUP_INS, re.DOTALL | re.UNICODE), 'double', 'sup,ins'),
util.PatSeqItem(re.compile(INS_SUP2, re.DOTALL | re.UNICODE), 'double', 'ins,sup'),
util.PatSeqItem(re.compile(INS_SUP3, re.DOTALL | re.UNICODE), 'double2', 'ins,sup'),
util.PatSeqItem(re.compile(INS, re.DOTALL | re.UNICODE), 'single', 'ins'),
util.PatSeqItem(re.compile(SUP_INS2, re.DOTALL | re.UNICODE), 'double2', 'sup,ins'),
util.PatSeqItem(re.compile(SUP2, re.DOTALL | re.UNICODE), 'single', 'sup', True),
util.PatSeqItem(re.compile(SUP, re.DOTALL | re.UNICODE), 'single', 'sup')
]
class CaretSmartProcessor(util.PatternSequenceProcessor):
"""Smart insert and sup processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_INS_SUP, re.DOTALL | re.UNICODE), 'double', 'ins,sup'),
util.PatSeqItem(re.compile(SMART_SUP_INS, re.DOTALL | re.UNICODE), 'double', 'sup,ins'),
util.PatSeqItem(re.compile(SMART_INS_SUP2, re.DOTALL | re.UNICODE), 'double', 'ins,sup'),
util.PatSeqItem(re.compile(SMART_INS_SUP3, re.DOTALL | re.UNICODE), 'double2', 'ins,sup'),
util.PatSeqItem(re.compile(SMART_INS, re.DOTALL | re.UNICODE), 'single', 'ins'),
util.PatSeqItem(re.compile(SMART_SUP_INS2, re.DOTALL | re.UNICODE), 'double2', 'sup,ins'),
util.PatSeqItem(re.compile(SUP2, re.DOTALL | re.UNICODE), 'single', 'sup', True),
util.PatSeqItem(re.compile(SUP, re.DOTALL | re.UNICODE), 'single', 'sup')
]
class CaretSupProcessor(util.PatternSequenceProcessor):
"""Just superscript processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SUP, re.DOTALL | re.UNICODE), 'single', 'sup')
]
class CaretInsertProcessor(util.PatternSequenceProcessor):
"""Just insert processor."""
PATTERNS = [
util.PatSeqItem(re.compile(INS, re.DOTALL | re.UNICODE), 'single', 'ins')
]
class CaretSmartInsertProcessor(util.PatternSequenceProcessor):
"""Just smart insert processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_INS, re.DOTALL | re.UNICODE), 'single', 'ins')
]
class InsertSupExtension(Extension):
"""Add insert and/or superscript extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'smart_insert': [True, "Treat ^^connected^^words^^ intelligently - Default: True"],
'insert': [True, "Enable insert - Default: True"],
'superscript': [True, "Enable superscript - Default: True"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Insert `<ins>test</ins>` tags as `^^test^^` and `<sup>test</sup>` tags as `^test^`."""
config = self.getConfigs()
insert = bool(config.get('insert', True))
superscript = bool(config.get('superscript', True))
smart = bool(config.get('smart_insert', True))
md.registerExtension(self)
escape_chars = []
if insert or superscript:
escape_chars.append('^')
if superscript:
escape_chars.append(' ')
util.escape_chars(md, escape_chars)
caret = None
md.inlinePatterns.register(SimpleTextInlineProcessor(NOT_CARET), 'not_tilde', 70)
if insert and superscript:
caret = CaretSmartProcessor(r'\^') if smart else CaretProcessor(r'\^')
elif insert:
caret = CaretSmartInsertProcessor(r'\^') if smart else CaretInsertProcessor(r'\^')
elif superscript:
caret = CaretSupProcessor(r'\^')
if caret is not None:
md.inlinePatterns.register(caret, "sup_ins", 65)
def makeExtension(*args, **kwargs):
"""Return extension."""
return InsertSupExtension(*args, **kwargs)
@@ -0,0 +1,327 @@
"""
Critic.
pymdownx.critic
Parses critic markup and outputs the file in a more visual HTML.
Must be the last extension loaded.
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.preprocessors import Preprocessor
from markdown.postprocessors import Postprocessor
from markdown.util import STX, ETX
import re
SOH = '\u0001' # start
EOT = '\u0004' # end
CRITIC_KEY = "czjqqkd:%s"
CRITIC_PLACEHOLDER = CRITIC_KEY % r'[0-9]+'
SINGLE_CRITIC_PLACEHOLDER = r'{stx}(?P<key>{key}){etx}'.format(
key=CRITIC_PLACEHOLDER, stx=STX, etx=ETX
)
CRITIC_PLACEHOLDERS = r'''(?x)
(?:
(?P<block>\<p\>(?P<block_keys>(?:{stx}{key}{etx})+)\</p\>) |
{single}
)
'''.format(
key=CRITIC_PLACEHOLDER, single=SINGLE_CRITIC_PLACEHOLDER,
stx=STX, etx=ETX
)
ALL_CRITICS = r'''(?x)
((?P<critic>(?P<open>\{)
(?:
(?P<ins_open>\+{2})
(?P<ins_text>.*?)
(?P<ins_close>\+{2})
| (?P<del_open>\-{2})
(?P<del_text>.*?)
(?P<del_close>\-{2})
| (?P<mark_open>\={2})
(?P<mark_text>.*?)
(?P<mark_close>\={2})
| (?P<comment>
(?P<com_open>\>{2})
(?P<com_text>.*?)
(?P<com_close>\<{2})
)
| (?P<sub_open>\~{2})
(?P<sub_del_text>.*?)
(?P<sub_mid>\~\>)
(?P<sub_ins_text>.*?)
(?P<sub_close>\~{2})
)
(?P<close>\})))
'''
RE_CRITIC = re.compile(ALL_CRITICS, re.DOTALL)
RE_CRITIC_PLACEHOLDER = re.compile(CRITIC_PLACEHOLDERS)
RE_CRITIC_SUB_PLACEHOLDER = re.compile(SINGLE_CRITIC_PLACEHOLDER)
RE_CRITIC_BLOCK = re.compile(r'((?:ins|del|mark)\s+)(class=([\'"]))(.*?)(\3)')
RE_BLOCK_SEP = re.compile(r'^(?:\r?\n){2,}$')
class CriticStash:
"""Stash critic marks until ready."""
def __init__(self, stash_key):
"""Initialize."""
self.stash_key = stash_key
self.stash = {}
self.count = 0
def __len__(self): # pragma: no cover
"""Get length of stash."""
return len(self.stash)
def get(self, key, default=None):
"""Get the specified item from the stash."""
code = self.stash.get(key, default)
return code
def remove(self, key): # pragma: no cover
"""Remove the specified item from the stash."""
del self.stash[key]
def store(self, code):
"""
Store the code in the stash with the placeholder.
Return placeholder.
"""
key = self.stash_key % str(self.count)
self.stash[key] = code
self.count += 1
return SOH + key + EOT
def clear(self):
"""Clear the stash."""
self.stash = {}
self.count = 0
class CriticsPostprocessor(Postprocessor):
"""Handle cleanup on post process for viewing critic marks."""
def __init__(self, critic_stash):
"""Initialize."""
super().__init__()
self.critic_stash = critic_stash
def subrestore(self, m):
"""Replace all critic tags in the paragraph block `<p>(critic del close)(critic ins close)</p>` etc."""
content = None
key = m.group('key')
if key is not None:
content = self.critic_stash.get(key)
return content
def block_edit(self, m):
"""Handle block edits."""
if 'break' in m.group(4).split(' '):
return m.group(0)
else:
return m.group(1) + m.group(2) + m.group(4) + ' block' + m.group(5)
def restore(self, m):
"""Replace placeholders with actual critic tags."""
content = None
if m.group('block_keys') is not None:
content = RE_CRITIC_SUB_PLACEHOLDER.sub(
self.subrestore, m.group('block_keys')
)
if content is not None:
content = RE_CRITIC_BLOCK.sub(self.block_edit, content)
else:
text = self.critic_stash.get(m.group('key'))
if text is not None:
content = text
return content if content is not None else m.group(0)
def run(self, text):
"""Replace critic placeholders."""
text = RE_CRITIC_PLACEHOLDER.sub(self.restore, text)
return text
class CriticViewPreprocessor(Preprocessor):
"""Handle viewing critic marks in Markdown content."""
def __init__(self, critic_stash):
"""Initialize."""
super().__init__()
self.critic_stash = critic_stash
def _ins(self, text):
"""Handle critic inserts."""
if RE_BLOCK_SEP.match(text):
return '\n\n%s\n\n' % self.critic_stash.store('<ins class="critic break">&nbsp;</ins>')
return (
self.critic_stash.store('<ins class="critic">') +
text +
self.critic_stash.store('</ins>')
)
def _del(self, text):
"""Handle critic deletes."""
if RE_BLOCK_SEP.match(text):
return self.critic_stash.store('<del class="critic break">&nbsp;</del>')
return (
self.critic_stash.store('<del class="critic">') +
text +
self.critic_stash.store('</del>')
)
def _mark(self, text):
"""Handle critic marks."""
return (
self.critic_stash.store('<mark class="critic">') +
text +
self.critic_stash.store('</mark>')
)
def _comment(self, text):
"""Handle critic comments."""
return (
self.critic_stash.store(
'<span class="critic comment">' +
self.html_escape(text, strip_nl=True) +
'</span>'
)
)
def critic_view(self, m):
"""Insert appropriate HTML to tags to visualize Critic marks."""
if m.group('ins_open'):
return self._ins(m.group('ins_text'))
elif m.group('del_open'):
return self._del(m.group('del_text'))
elif m.group('sub_open'):
return (
self._del(m.group('sub_del_text')) +
self._ins(m.group('sub_ins_text'))
)
elif m.group('mark_open'):
return self._mark(m.group('mark_text'))
elif m.group('com_open'):
return self._comment(m.group('com_text'))
def critic_parse(self, m):
"""
Normal critic parser.
Either removes accepted or rejected critic marks and replaces with the opposite.
Comments are removed and marks are replaced with their content.
"""
accept = self.config["mode"] == 'accept'
if m.group('ins_open'):
return m.group('ins_text') if accept else ''
elif m.group('del_open'):
return '' if accept else m.group('del_text')
elif m.group('mark_open'):
return m.group('mark_text')
elif m.group('com_open'):
return ''
elif m.group('sub_open'):
return m.group('sub_ins_text') if accept else m.group('sub_del_text')
def html_escape(self, txt, strip_nl=False):
"""Basic html escaping."""
txt = txt.replace('&', '&amp;')
txt = txt.replace('<', '&lt;')
txt = txt.replace('>', '&gt;')
txt = txt.replace('"', '&quot;')
txt = txt.replace("\n", "<br>" if not strip_nl else ' ')
return txt
def run(self, lines):
"""Process critic marks."""
# Determine processor type to use
if self.config['mode'] == "view":
processor = self.critic_view
else:
processor = self.critic_parse
# Find and process critic marks
text = RE_CRITIC.sub(processor, '\n'.join(lines))
return text.split('\n')
class CriticExtension(Extension):
"""Critic extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'mode': [
'view',
"Critic mode to run in: 'view', 'accept', or 'reject' - Default: view "
],
'raw_view': [False, "Raw view keeps the output as the raw markup for view mode - Default False"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Register the extension."""
md.registerExtension(self)
self.critic_stash = CriticStash(CRITIC_KEY)
post = CriticsPostprocessor(self.critic_stash)
critic = CriticViewPreprocessor(self.critic_stash)
critic.config = self.getConfigs()
md.preprocessors.register(critic, "critic", 31.1)
md.postprocessors.register(post, "critic-post", 25)
md.registerExtensions(["pymdownx._bypassnorm"], {})
def reset(self):
"""Clear stash."""
self.critic_stash.clear()
def makeExtension(*args, **kwargs):
"""Return extension."""
return CriticExtension(*args, **kwargs)
@@ -0,0 +1,189 @@
"""
Details.
pymdownx.details
MIT license.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.blockprocessors import BlockProcessor
import xml.etree.ElementTree as etree
import re
class DetailsProcessor(BlockProcessor):
"""Details block processor."""
START = re.compile(
r'(?:^|\n)\?{3}(\+)? ?(?:([\w\-]+(?: +[\w\-]+)*?)?(?: +"(.*?)")|([\w\-]+(?: +[\w\-]+)*?)) *(?:\n|$)'
)
COMPRESS_SPACES = re.compile(r' {2,}')
def __init__(self, parser):
"""Initialization."""
super().__init__(parser)
self.current_sibling = None
self.content_indention = 0
def detab_by_length(self, text, length):
"""Remove a tab from the front of each line of the given text."""
newtext = []
lines = text.split('\n')
for line in lines:
if line.startswith(' ' * length):
newtext.append(line[length:])
elif not line.strip():
newtext.append('') # pragma: no cover
else:
break
return '\n'.join(newtext), '\n'.join(lines[len(newtext):])
def parse_content(self, parent, block):
"""
Get sibling details.
Retrieve the appropriate sibling element. This can get tricky when
dealing with lists.
"""
old_block = block
non_details = ''
# We already acquired the block via test
if self.current_sibling is not None:
sibling = self.current_sibling
block, non_details = self.detab_by_length(block, self.content_indent)
self.current_sibling = None
self.content_indent = 0
return sibling, block, non_details
sibling = self.lastChild(parent)
if sibling is None or sibling.tag.lower() != 'details':
sibling = None
else:
# If the last child is a list and the content is indented sufficient
# to be under it, then the content's is sibling is in the list.
last_child = self.lastChild(sibling)
indent = 0
while last_child is not None:
if (
sibling is not None and block.startswith(' ' * self.tab_length * 2) and
last_child is not None and last_child.tag in ('ul', 'ol', 'dl')
):
# The expectation is that we'll find an `<li>`.
# We should get it's last child as well.
sibling = self.lastChild(last_child)
last_child = self.lastChild(sibling) if sibling is not None else None
# Context has been lost at this point, so we must adjust the
# text's indentation level so it will be evaluated correctly
# under the list.
block = block[self.tab_length:]
indent += self.tab_length
else:
last_child = None
if not block.startswith(' ' * self.tab_length):
sibling = None
if sibling is not None:
indent += self.tab_length
block, non_details = self.detab_by_length(old_block, indent)
self.current_sibling = sibling
self.content_indent = indent
return sibling, block, non_details
def test(self, parent, block):
"""Test block."""
if self.START.search(block):
return True
else:
return self.parse_content(parent, block)[0] is not None
def run(self, parent, blocks):
"""Convert to details/summary block."""
block = blocks.pop(0)
m = self.START.search(block)
if m:
# remove the first line
if m.start() > 0:
self.parser.parseBlocks(parent, [block[:m.start()]])
block = block[m.end():]
block, non_details = self.detab(block)
else:
sibling, block, non_details = self.parse_content(parent, block)
if m:
state = m.group(1)
is_open = state is not None
if m.group(4):
class_name = self.COMPRESS_SPACES.sub(' ', m.group(4).lower())
title = class_name.split(' ')[0].capitalize()
else:
classes = m.group(2)
class_name = '' if classes is None else self.COMPRESS_SPACES.sub(' ', classes.lower())
title = m.group(3)
div = etree.SubElement(parent, 'details', ({'open': 'open'} if is_open else {}))
if class_name:
div.set('class', class_name)
summary = etree.SubElement(div, 'summary')
summary.text = title
else:
# Sibling is a list item, but we need to wrap it's content should be wrapped in <p>
if sibling.tag in ('li', 'dd') and sibling.text:
text = sibling.text
sibling.text = ''
p = etree.SubElement(sibling, 'p')
p.text = text
div = sibling
self.parser.parseChunk(div, block)
if non_details:
# Insert the non-details content back into blocks
blocks.insert(0, non_details)
class DetailsExtension(Extension):
"""Add Details extension."""
def extendMarkdown(self, md):
"""Add Details to Markdown instance."""
md.registerExtension(self)
md.parser.blockprocessors.register(DetailsProcessor(md.parser), "details", 105)
def makeExtension(*args, **kwargs):
"""Return extension."""
return DetailsExtension(*args, **kwargs)
@@ -0,0 +1,441 @@
"""
Emoji.
pymdownx.emoji
Emoji extension for EmojiOne's, GitHub's, or Twemoji's gemoji.
MIT license.
Copyright (c) 2016 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown.postprocessors import Postprocessor
from markdown import util as md_util
import xml.etree.ElementTree as etree
import inspect
import copy
from . import util
RE_EMOJI = r'(:[+\-\w]+:)'
SUPPORTED_INDEXES = ('emojione', 'gemoji', 'twemoji')
UNICODE_VARIATION_SELECTOR_16 = 'fe0f'
EMOJIONE_SVG_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.2.7/assets/svg/'
EMOJIONE_PNG_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.2.7/assets/png/'
TWEMOJI_SVG_CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@16.0.1/assets/svg/'
TWEMOJI_PNG_CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@16.0.1/assets/72x72/'
GITHUB_UNICODE_CDN = 'https://github.githubassets.com/images/icons/emoji/unicode/'
GITHUB_CDN = 'https://github.githubassets.com/images/icons/emoji/'
NO_TITLE = 'none'
LONG_TITLE = 'long'
SHORT_TITLE = 'short'
VALID_TITLE = (LONG_TITLE, SHORT_TITLE, NO_TITLE)
UNICODE_ENTITY = 'html_entity'
UNICODE_ALT = ('unicode', UNICODE_ENTITY)
LEGACY_ARG_COUNT = 8
MSG_INDEX_WARN = """Using emoji indexes with no arguments is now deprecated.
Emoji indexes now take 2 arguments: 'options' and 'md'.
Please update your custom index accordingly.
"""
MSG_BAD_EMOJI = """
Emoji Extension (strict mode): The following emoji were detected and either had
their name change, were removed, or have never existed.
{}
"""
def add_attributes(options, attributes):
"""Add additional attributes from options."""
attr = options.get('attributes', {})
if attr:
for k, v in attr.items():
attributes[k] = v
# Exists for backwards compatibility as this function
# was initially spelled incorrectly.
add_attriubtes = add_attributes
def emojione(options, md):
"""The EmojiOne index."""
from . import emoji1_db as emoji_map
return {
"name": emoji_map.name,
"emoji": copy.deepcopy(emoji_map.emoji),
"aliases": copy.deepcopy(emoji_map.aliases)
}
def gemoji(options, md):
"""The Gemoji index."""
from . import gemoji_db as emoji_map
return {
"name": emoji_map.name,
"emoji": copy.deepcopy(emoji_map.emoji),
"aliases": copy.deepcopy(emoji_map.aliases)
}
def twemoji(options, md):
"""The Twemoji index."""
from . import twemoji_db as emoji_map
return {
"name": emoji_map.name,
"emoji": copy.deepcopy(emoji_map.emoji),
"aliases": copy.deepcopy(emoji_map.aliases)
}
###################
# Converters
###################
def to_png(index, shortname, alias, uc, alt, title, category, options, md):
"""Return PNG element."""
if index == 'gemoji':
def_image_path = GITHUB_UNICODE_CDN
def_non_std_image_path = GITHUB_CDN
elif index == 'twemoji':
def_image_path = TWEMOJI_PNG_CDN
def_non_std_image_path = TWEMOJI_PNG_CDN
else:
def_image_path = EMOJIONE_PNG_CDN
def_non_std_image_path = EMOJIONE_PNG_CDN
is_unicode = uc is not None
classes = options.get('classes', index)
# In general we can use the alias, but github specific images don't have one for each alias.
# We can tell we have a github specific if there is no Unicode value.
if is_unicode:
image_path = options.get('image_path', def_image_path)
else: # pragma: no cover
image_path = options.get('non_standard_image_path', def_non_std_image_path)
src = "{}{}.png".format(
image_path,
uc if is_unicode else shortname[1:-1]
)
attributes = {
"class": classes,
"alt": alt,
"src": src
}
if title:
attributes['title'] = title
add_attributes(options, attributes)
return etree.Element("img", attributes)
def to_svg(index, shortname, alias, uc, alt, title, category, options, md):
"""Return SVG element."""
if index == 'twemoji':
svg_path = TWEMOJI_SVG_CDN
else:
svg_path = EMOJIONE_SVG_CDN
attributes = {
"class": options.get('classes', index),
"alt": alt,
"src": "{}{}.svg".format(
options.get('image_path', svg_path),
uc
)
}
if title:
attributes['title'] = title
add_attributes(options, attributes)
return etree.Element("img", attributes)
def to_png_sprite(index, shortname, alias, uc, alt, title, category, options, md):
"""Return PNG sprite element."""
attributes = {
"class": '%(class)s-%(size)s-%(category)s _%(unicode)s' % {
"class": options.get('classes', index),
"size": options.get('size', '64'),
"category": (category if category else ''),
"unicode": uc
}
}
if title:
attributes['title'] = title
add_attributes(options, attributes)
el = etree.Element("span", attributes)
el.text = md_util.AtomicString(alt)
return el
def to_svg_sprite(index, shortname, alias, uc, alt, title, category, options, md):
"""
Return SVG sprite element.
```
<svg class="%(classes)s"><description>%(alt)s</description>
<use xlink:href="%(sprite)s#emoji-%(unicode)s"></use></svg>
```
"""
xlink_href = '{}#emoji-{}'.format(
options.get('image_path', './../assets/sprites/emojione.sprites.svg'), uc
)
svg = etree.Element("svg", {"class": options.get('classes', index)})
desc = etree.SubElement(svg, 'description')
desc.text = md_util.AtomicString(alt)
etree.SubElement(svg, 'use', {'xlink:href': xlink_href})
return svg
def to_alt(index, shortname, alias, uc, alt, title, category, options, md):
"""Return html entities."""
return md.htmlStash.store(alt)
###################
# Classes
###################
class EmojiPattern(InlineProcessor):
"""Return element of type `tag` with a text attribute of group(2) of an `InlineProcessor`."""
def __init__(self, pattern, config, strict_mode, md):
"""Initialize."""
InlineProcessor.__init__(self, pattern, md)
title = config['title']
alt = config['alt']
self.options = config['options']
self._set_index(config["emoji_index"])
self.unicode_alt = alt in UNICODE_ALT
self.encoded_alt = alt == UNICODE_ENTITY
self.remove_var_sel = config['remove_variation_selector']
self.title = title if title in VALID_TITLE else NO_TITLE
self.generator = config['emoji_generator']
self.strict = config['strict']
self.strict_cache = strict_mode
def _set_index(self, index):
"""Set the index."""
if len(inspect.getfullargspec(index).args):
self.emoji_index = index(self.options, self.md)
else:
util.warn_deprecated(MSG_INDEX_WARN)
self.emoji_index = index()
def _remove_variation_selector(self, value):
"""Remove variation selectors."""
return value.replace('-' + UNICODE_VARIATION_SELECTOR_16, '')
def _get_unicode_char(self, value):
"""Get the Unicode char."""
return ''.join([util.get_char(int(c, 16)) for c in value.split('-')])
def _get_unicode(self, emoji):
"""
Get Unicode and Unicode alt.
Unicode: This is the stripped down form of the Unicode, no joining chars and no variation chars.
Unicode code points are not always valid. If this is present and there is no 'unicode_alt',
Unicode code points can be counted on as valid. For the most part, the returned `uc` should
be used to reference image files, or create classes, but for inserting actual Unicode, 'uc_alt'
should be used.
Unicode Alt: When present, this will always be valid Unicode points. This contains not just the
needed characters to identify the Unicode emoji, but the formatting as well. Joining characters
and variation characters will be present. If you don't want variation chars, enable the global
'remove_variation_selector' option.
"""
uc = emoji.get('unicode')
uc_alt = emoji.get('unicode_alt', uc)
if uc_alt and self.remove_var_sel:
uc_alt = self._remove_variation_selector(uc_alt)
return uc, uc_alt
def _get_title(self, shortname, emoji):
"""Get the title."""
if self.title == LONG_TITLE:
title = emoji['name']
elif self.title == SHORT_TITLE:
title = shortname
else:
title = None
return title
def _get_alt(self, shortname, uc_alt):
"""Get alt form."""
if uc_alt is None or not self.unicode_alt:
alt = shortname
else:
alt = self._get_unicode_char(uc_alt)
if self.encoded_alt:
alt = ''.join(
[md_util.AMP_SUBSTITUTE + ('#x%04x;' % util.get_ord(point)) for point in util.get_code_points(alt)]
)
return alt
def _get_category(self, emoji):
"""Get the category."""
return emoji.get('category')
def handleMatch(self, m, data):
"""Handle emoji pattern matches."""
el = m.group(1)
shortname = self.emoji_index['aliases'].get(el, el)
alias = None if shortname == el else el
emoji = self.emoji_index['emoji'].get(shortname, None)
if emoji:
uc, uc_alt = self._get_unicode(emoji)
title = self._get_title(el, emoji)
alt = self._get_alt(el, uc_alt)
category = self._get_category(emoji)
el = self.generator(
self.emoji_index['name'],
shortname,
alias,
uc,
alt,
title,
category,
self.options,
self.md
)
elif self.strict:
self.strict_cache.add(shortname)
return el, m.start(0), m.end(0)
class EmojiAlertPostprocessor(Postprocessor):
"""Post processor to strip out unwanted content."""
def __init__(self, strict_cache, md):
"""Initialize."""
self.strict_cache = strict_cache
def run(self, text):
"""Strip out ids and classes for a simplified HTML output."""
if len(self.strict_cache):
raise RuntimeError(
MSG_BAD_EMOJI.format('\n'.join([f'- {x}' for x in sorted(self.strict_cache)]))
)
return text
class EmojiExtension(Extension):
"""Add emoji extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'emoji_index': [
emojione,
"Function that returns the desired emoji index. - Default: 'pymdownx.emoji.emojione'"
],
'emoji_generator': [
to_png,
"Emoji generator method. - Default: pymdownx.emoji.to_png"
],
'title': [
'short',
"What title to use on images. You can use 'long' which shows the long name, "
"'short' which shows the shortname (:short:), or 'none' which shows no title. "
"- Default: 'short'"
],
'alt': [
'unicode',
"Control alt form. 'short' sets alt to the shortname (:short:), 'uniocde' sets "
"alt to the raw Unicode value, and 'html_entity' sets alt to the HTML entity. "
"- Default: 'unicode'"
],
'remove_variation_selector': [
False,
"Remove variation selector 16 from unicode. - Default: False"
],
'strict': [
False,
"When enabled, if an emoji with a missing name is detected, an exception will be raised."
],
'options': [
{},
"Emoji options see documentation for options for github and emojione."
]
}
super().__init__(*args, **kwargs)
def reset(self):
"""Reset."""
self.strict_cache.clear()
def extendMarkdown(self, md):
"""Add support for emoji."""
md.registerExtension(self)
config = self.getConfigs()
util.escape_chars(md, [':'])
self.strict_cache = set()
md.inlinePatterns.register(EmojiPattern(RE_EMOJI, config, self.strict_cache, md), "emoji", 75)
if config['strict']:
md.postprocessors.register(EmojiAlertPostprocessor(self.strict_cache, md), "emoji-alert", 50)
###################
# Make Available
###################
def makeExtension(*args, **kwargs):
"""Return extension."""
return EmojiExtension(*args, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
"""
EscapeAll.
pymdownx.escapeall
Escape everything.
MIT license.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor, SubstituteTagInlineProcessor
from markdown import util as md_util
from . import util
# We need to ignore these as they are used in Markdown processing
STX = '\u0002'
ETX = '\u0003'
ESCAPE_RE = r'\\(.)'
ESCAPE_NO_NL_RE = r'\\([^\n])'
HARDBREAK_RE = r'\\\n'
class EscapeAllPattern(InlineProcessor):
"""Return an escaped character."""
def __init__(self, pattern, nbsp, md):
"""Initialize."""
self.nbsp = nbsp
InlineProcessor.__init__(self, pattern, md)
def handleMatch(self, m, data):
"""Convert the char to an escaped character."""
char = m.group(1)
if char in ('<', '>', '&'):
if char == '<':
char = '&lt;'
elif char == '>':
char = '&gt;'
elif char == '&':
char = '&amp;'
escape = self.md.htmlStash.store(char)
elif self.nbsp and char == ' ':
escape = self.md.htmlStash.store('&nbsp;')
elif char in (STX, ETX):
escape = char
else:
escape = '{}{}{}'.format(md_util.STX, util.get_ord(char), md_util.ETX)
return escape, m.start(0), m.end(0)
class EscapeAllExtension(Extension):
"""Extension that allows you to escape everything."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'hardbreak': [
False,
"Turn escaped newlines to hardbreaks - Default: False"
],
'nbsp': [
False,
"Turn escaped spaces to non-breaking spaces - Default: False"
]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Escape all."""
config = self.getConfigs()
hardbreak = config['hardbreak']
md.inlinePatterns.register(
EscapeAllPattern(ESCAPE_NO_NL_RE if hardbreak else ESCAPE_RE, config['nbsp'], md),
"escape",
180
)
if config['hardbreak']:
md.inlinePatterns.register(SubstituteTagInlineProcessor(HARDBREAK_RE, 'br'), "hardbreak", 5.1)
def makeExtension(*args, **kwargs):
"""Return extension."""
return EscapeAllExtension(*args, **kwargs)
@@ -0,0 +1,65 @@
"""
Extra.
pymdown.extra
A wrapper that emulate PHP Markdown Extra.
Re-packages Python Markdowns 'extra' extensions,
but substitutes a few extensions with PyMdown extensions:
- fenced_code --> superfences
- smartstrong --> betterem
MIT license.
Copyright (c) 2015 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
extra_extensions = [
'pymdownx.betterem',
'pymdownx.superfences',
'markdown.extensions.footnotes',
'markdown.extensions.attr_list',
'markdown.extensions.def_list',
'markdown.extensions.tables',
'markdown.extensions.abbr',
'markdown.extensions.md_in_html'
]
extra_extension_configs = {}
class ExtraExtension(Extension):
"""Add various extensions to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = kwargs.pop('configs', {})
self.config.update(extra_extension_configs)
self.config.update(kwargs)
def extendMarkdown(self, md):
"""Register extension instances."""
md.registerExtensions(extra_extensions, self.config)
def makeExtension(*args, **kwargs):
"""Return extension."""
return ExtraExtension(*args, **kwargs)
@@ -0,0 +1,535 @@
"""
Fancy lists in the style of Pandoc.
---
# A Python implementation of John Gruber's Markdown.
# Started by Manfred Stienstra (http://www.dwerg.net/).
# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org).
# Currently maintained by Waylan Limberg (https://github.com/waylan),
# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser).
# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later)
# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b)
# Copyright 2004 Manfred Stienstra (the original version)
# License: BSD (see LICENSE.md for details).
---
Adapted to support "fancy" behavior by Copyright 2024 Isaac Muse.
Work in progress, not fully tested.
"""
from markdown.blockprocessors import BlockProcessor
from markdown.treeprocessors import Treeprocessor
from .blocks.block import Block
from .blocks import BlocksExtension
import xml.etree.ElementTree as etree
import re
ROMAN_MAP = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
OL_STYLE = {
'1': 'decimal',
'a': 'lower-alpha',
'A': 'upper-alpha',
'i': 'lower-roman',
'I': 'upper-roman'
}
def roman2int(s):
"""
Convert Roman numeral to integer.
Values should be validated before as no validation during conversion.
"""
s = s.upper()
# Initialize result
total = 0
i = 0
while i < len(s):
# Current index is less than the next, subtract current from next and sum value
if i + 1 < len(s) and ROMAN_MAP[s[i]] < ROMAN_MAP[s[i + 1]]:
total += ROMAN_MAP[s[i + 1]] - ROMAN_MAP[s[i]]
i += 2
# Sum the value
else:
total += ROMAN_MAP[s[i]]
i += 1
return total
class FancyOListProcessor(BlockProcessor):
"""Process fancy ordered list blocks."""
TAG = 'ol'
SIBLING_TAGS = ['ol']
OL_TYPES = {
'dot-decimal': '1',
'paren-decimal': '1',
'dot-roman': 'i',
'paren-roman': 'i',
'dot-ROMAN': 'I',
'paren-ROMAN': 'I',
'dot-alpha': 'a',
'paren-alpha': 'a',
'dot-ALPHA': 'A',
'paren-ALPHA': 'A'
}
def __init__(self, parser, config):
"""Initialize."""
super().__init__(parser)
list_types = config['additional_ordered_styles']
self.alpha_enabled = 'alpha' in list_types
self.roman_enabled = 'roman' in list_types
self.inject_style = config['inject_style']
self.inject_class = config['inject_class']
formats = ''
if 'generic' in list_types:
formats += r'| \#'
if 'roman' in list_types:
# Rules are similar to https://projecteuler.net/about=roman_numerals
# We do not follow the "rule of 3": repeated values should not occur more than 3 times.
# The above link suggests that repeats should be restricted such that lower denominations
# do not equal or exceed X, C or M. We alter this to allow equaling to help mitigate
# conflicts with alphabetical lists.
formats += r'''
| (?=[IVXLCDM]{2})
M*
(?:C[MD]|D(?:C{0,4}|C{5}\b)|(?:C{0,9}|C{10}\b))
(?:X[CL]|L(?:X{0,4}|X{5}\b)|(?:X{0,9}|X{10}\b))
(?:I[XV]|V(?:I{0,4}|I{5}\b)|(?:I{0,9}|I{10}\b))
| (?=[ivxlcdm])
m*
(?:c[md]|d(?:c{0,4}|c{5}\b)|(?:c{0,9}|c{10}\b))
(?:x[cl]|l(?:x{0,4}|x{5}\b)|(?:x{0,9}|x{10}\b))
(?:i[xv]|v(?:i{0,4}|i{5}\b)|(?:i{0,9}|i{10}\b))
'''
if 'alpha' not in list_types:
formats += r'''
| [IVXLCDM](?=\)|\.[ ]{2})
'''
if 'alpha' in list_types:
formats += r'''
| [a-z]
| [A-Z](?=\)|\.[ ]{2})
'''
# Detect an item list item.
self.list_re = re.compile(
r'^[ ]{0,%d}(?:(?:\d+%s)[).])[ ]+(.*)' % (self.tab_length - 1, formats),
re.VERBOSE
)
# Detect items on secondary lines which can be of any list type.
self.child_re = re.compile(
r'^[ ]{0,%d}((?:(?:\d+%s)[).]|[-*+]))[ ]+(.*)' % (self.tab_length - 1, formats),
re.VERBOSE
)
# Detect indented (nested) list items of any type.
self.indent_re = re.compile(
r'^[ ]{%d,%d}(?:(?:\d+%s)[).]|[-*+])[ ]+.*' % (self.tab_length, self.tab_length * 2 - 1, formats),
re.VERBOSE
)
self.startswith = "1"
def test(self, parent, block):
"""Test to see if block starts with a list."""
return bool(self.list_re.match(block))
def run(self, parent, blocks):
"""Process list items."""
sibling = self.lastChild(parent)
# Check for multiple items in one block and get the ordered list fancy type.
items, fancy_type = self.get_items(sibling, blocks.pop(0), blocks)
# Append list items that are under the sibling list if the list type matches
if (
sibling is not None and sibling.tag in self.SIBLING_TAGS and
sibling.attrib.get('__fancylist', '') == fancy_type
):
# Previous block was a list item, so set that as parent
lst = sibling
# Make sure previous item is in a `p` - if the item has text,
# then it isn't in a `p`.
if lst[-1].text:
# Since it's possible there are other children for this
# sibling, we can't just `SubElement` the `p`, we need to
# insert it as the first item.
p = etree.Element('p')
p.text = lst[-1].text
lst[-1].text = ''
lst[-1].insert(0, p)
# If the last item has a tail, then the tail needs to be put in a `p`
# likely only when a header is not followed by a blank line.
lch = self.lastChild(lst[-1])
if lch is not None and lch.tail:
p = etree.SubElement(lst[-1], 'p')
p.text = lch.tail.lstrip()
lch.tail = ''
# Parse first block differently as it gets wrapped in a `p`.
li = etree.SubElement(lst, 'li')
self.parser.state.set('looselist')
firstitem = items.pop(0)
self.parser.parseBlocks(li, [firstitem])
self.parser.state.reset()
# This catches the edge case of a multi-item indented list whose
# first item is in a blank parent-list item:
# ```
# * * subitem1
# * subitem2
# ```
# see also `ListIndentProcessor`
elif parent.tag in ['ol', 'ul']:
lst = parent
# This is a new, unique list so create parent with appropriate tag.
else:
if self.TAG == 'ol':
# Correct the metadata of a forced list to now represent the actual content
if sibling is not None and sibling.attrib.get('__fancylist', '').startswith('force'):
sibling.attrib['__fancylist'] = fancy_type
lst = sibling
else:
attrib = {'type': self.OL_TYPES[fancy_type], '__fancylist': fancy_type}
if self.inject_style:
attrib['style'] = f"list-style-type: {OL_STYLE[attrib['type']]};"
if self.inject_class:
attrib['class'] = f"fancylists-{OL_STYLE[attrib['type']]}"
lst = etree.SubElement(
parent,
self.TAG,
attrib
)
else:
lst = etree.SubElement(parent, self.TAG)
# Check if a custom start integer is set
if self.startswith != '1' and not lst.attrib.get('start', ''):
lst.attrib['start'] = self.startswith
# Set the parse set to list
self.parser.state.set('list')
# Loop through items in block, recursively parsing each with the appropriate parent.
for item in items:
# Item is indented. Parse with last item as parent
if item.startswith(' '*self.tab_length):
self.parser.parseBlocks(lst[-1], [item])
# New item. Create `li` and parse with it as parent
else:
li = etree.SubElement(lst, 'li')
self.parser.parseBlocks(li, [item])
# Reset the parse state
self.parser.state.reset()
def get_start(self, fancy_type, m):
"""Translate list convention into a logical start."""
# Generic marker
if m.group(1).startswith('#'):
return '1'
t = fancy_type.split('-')[1].lower()
if t == 'decimal':
return m.group(1)[:-1].lstrip('(')
elif t == 'roman':
return str(roman2int(m.group(1)[:-1]))
elif t == 'alpha':
return str(ord(m.group(1)[:-1].upper()) - 64)
def get_fancy_type(self, m, first, fancy_type):
"""Get the fancy type for a given list item."""
value = m.group(1)[:-1]
sep = m.group(1)[-1]
list_type = ''
# Determine list type convention: _., _), (_)
if sep == '.':
list_type += 'dot-'
elif sep == ')':
list_type += 'paren-'
else:
return list_type, fancy_type
# The first item will be forced to assume the sibling list's type
if fancy_type.startswith('force'):
ltype = fancy_type.split('-', 1)[1]
# Make sure we aren't forcing an impossible scenario.
# If everything looks sound, return the types
if value == '#' or (
(ltype.lower() == 'decimal' and value.isdigit()) or
(
ltype.lower() == 'roman' and
self.roman_enabled and
value.isalpha() and
(len(value) > 2 or value.lower() in 'ivxlcdm')
) or
(ltype.lower() == 'alpha' and self.alpha_enabled and len(value) == 1 and value.isalpha())
):
fancy_type = list_type + fancy_type.split('-', 1)[1] if list_type else list_type
return fancy_type, fancy_type
# Ignore the force as it cannot be done
fancy_type = ''
# Determine numbering: numerical, roman numerical, alphabetic, or `#` numerical placeholder.
if value == '#':
list_type += fancy_type.split('-', 1)[1] if fancy_type else 'decimal'
elif value.isdigit():
list_type += 'decimal'
elif len(value) == 1 and value.isalpha():
if value.islower():
in_roman = value in 'ivxlcdm'
if (
self.alpha_enabled and (
not self.roman_enabled or (
first and (not in_roman or ((list_type + 'roman') != fancy_type and value != 'i'))
)
)
):
list_type += 'alpha'
elif self.alpha_enabled and not first and ((list_type + 'alpha') == fancy_type or not in_roman):
list_type += 'alpha'
else:
list_type += 'roman'
elif value.isupper():
in_roman = value in 'IVXLCDM'
if (
self.alpha_enabled and (
not self.roman_enabled or (
first and (not in_roman or ((list_type + 'ROMAN') != fancy_type and value != 'I'))
)
)
):
list_type += 'ALPHA'
elif self.alpha_enabled and not first and ((list_type + 'ALPHA') == fancy_type or not in_roman):
list_type += 'ALPHA'
else:
list_type += 'ROMAN'
elif value.isupper():
list_type += 'ROMAN'
elif value.islower():
list_type += 'roman'
return list_type, fancy_type
def get_items(self, sibling, block, blocks):
"""Break a block into list items."""
# Get ordered list fancy type
fancy_type = ''
if self.TAG == 'ol':
if sibling is not None and sibling.tag in self.SIBLING_TAGS:
fancy_type = sibling.attrib.get('__fancylist', '')
fancy = fancy_type
items = []
rest = []
for line in block.split('\n'):
# We've found a list type that differs form the our current,
# so gather the rest to be processed separately.
if rest:
rest.append(line)
continue
# Child list items
m = self.child_re.match(line)
if m:
# This is a new list item check first item for the start index.
# Also check for list items that differ from the first.
fancy, fancy_type = self.get_fancy_type(m, not items, fancy)
# We found a different fancy type, so handle these separately
if items and fancy != fancy_type:
rest.append(line)
continue
# Detect the integer value of first list item.
# If we are already in a list, just grab that.
if not items and self.TAG == 'ol':
self.startswith = self.get_start(fancy, m)
fancy_type = fancy
# Append to the list
items.append(m.group(2))
# Indented, possibly nested content
elif self.indent_re.match(line):
# Previous item was indented. Append to that item.
if items[-1].startswith(' ' * self.tab_length):
items[-1] = '{}\n{}'.format(items[-1], line)
# Other indented content
else:
items.append(line)
# Append non list items to previous list item.
else:
items[-1] = '{}\n{}'.format(items[-1], line)
# Insert non-list items back into the blocks to be parsed later
if rest:
blocks.insert(0, '\n'.join(rest))
return items, fancy_type
class FancyListBlock(Block):
"""Collapse code."""
NAME = 'fancylists'
ARGUMENT = True
OL_TYPE = {
'1': 'decimal',
'a': 'alpha',
'A': 'ALPHA',
'i': 'roman',
'I': 'ROMAN'
}
def on_init(self):
"""Handle initialization."""
ordered_styles = self.config['additional_ordered_styles']
self.inject_style = self.config['inject_style']
self.inject_class = self.config['inject_class']
self.roman_enabled = 'roman' in ordered_styles
self.alpha_enabled = 'alpha' in ordered_styles
def on_validate(self, parent):
"""Handle on validate event."""
self.type = '1'
self.start = None
self.count = 0
try:
for a in self.argument.split():
name, value = [x.strip() for x in a.split('=')]
if name == 'type' and value in ['a', 'A', 'i', 'I', '1']:
if value.lower() == 'a' and not self.alpha_enabled:
raise ValueError('Alphabetical lists not enabled')
if value.lower() == 'i' and not self.roman_enabled:
raise ValueError('Alphabetical lists not enabled')
self.type = value
elif name == 'start':
self.start = max(0, int(value))
else:
raise ValueError('Not a valid option')
except Exception:
return False
return True
def on_create(self, parent):
"""Create the element."""
# Create an ordered list that will guide the first list item's type
attrib = {'type': self.type, '__fancylist': 'force-' + self.OL_TYPE[self.type]}
if self.start is not None:
attrib['start'] = str(self.start)
if self.inject_style:
attrib['style'] = f"list-style-type: {OL_STYLE[self.type]};"
if self.inject_class:
attrib['class'] = f"fancylists-{OL_STYLE[self.type]}"
self.parent = parent
self.ol = etree.SubElement(parent, 'ol', attrib)
return parent
def on_end(self, block):
"""On end."""
# Remove the ordered list if empty.
if not list(self.ol):
self.parent.remove(self.ol)
class FancyUListProcessor(FancyOListProcessor):
"""Process unordered list blocks."""
SIBLING_TAGS = ['ul']
TAG = 'ul'
def __init__(self, parser, config):
"""Initialize."""
super().__init__(parser, config)
self.list_re = re.compile(r'^[ ]{0,%d}[-+*][ ]+(.*)' % (self.tab_length - 1))
class FancyListTreeprocessor(Treeprocessor):
"""Clean up fancy list metadata."""
def run(self, root):
"""Remove intermediate fancy list type metadata."""
for ol in root.iter('ol'):
if '__fancylist' in ol.attrib:
del ol.attrib['__fancylist']
return root
class FancyListExtension(BlocksExtension):
"""HTML Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'additional_ordered_styles': [
['roman', 'alpha', 'generic'],
"Specify the ordered list formats to add in addition to decimal.",
],
'inject_style': [
False,
"Inject style attribute with the appropriate 'list-style-type'"
],
'inject_class': [
False,
"Inject a class indicating the 'list-style-type'"
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, blocks):
"""Add Details to Markdown instance."""
config = self.getConfigs()
blocks.register(FancyListBlock, config)
ol = FancyOListProcessor(md.parser, config)
ul = FancyUListProcessor(md.parser, config)
md.parser.blockprocessors.register(ol, 'olist', 40)
md.parser.blockprocessors.register(ul, 'ulist', 30)
md.treeprocessors.register(FancyListTreeprocessor(md), "olist-cleanup", 10)
def makeExtension(*args, **kwargs):
"""Return extension."""
return FancyListExtension(*args, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,613 @@
"""
Highlight.
A library for managing code highlighting.
All Changes Copyright 2014-2017 Isaac Muse.
---
CodeHilite Extension for Python-Markdown
========================================
Adds code/syntax highlighting to standard Python-Markdown code blocks.
See <https://pythonhosted.org/Markdown/extensions/code_hilite.html>
for documentation.
Original code Copyright 2006-2008 [Waylan Limberg](https://github.com/waylan).
All changes Copyright 2008-2014 The Python Markdown Project
License: [BSD](http://www.opensource.org/licenses/bsd-license.php)
"""
import re
from markdown import Extension
from markdown.treeprocessors import Treeprocessor
import xml.etree.ElementTree as etree
import copy
from collections import OrderedDict
try:
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.formatters import find_formatter_class
from pygments import __version__ as pygments_ver
p_ver = tuple([int(n) for n in pygments_ver.split('.')[:2]])
HtmlFormatter = find_formatter_class('html')
pygments = True
except ImportError: # pragma: no cover
pygments = False
p_ver = (0, 0)
RE_PYG_CODE = re.compile(r'^<(div)(\s*class="(.*?)")?\s*>')
CODE_WRAP = '<pre{}><code{}{}{}>{}</code></pre>'
CODE_WRAP_ON_PRE = '<pre{}{}{}><code>{}</code></pre>'
CLASS_ATTR = ' class="{}"'
ID_ATTR = ' id="{}"'
DEFAULT_CONFIG = {
'use_pygments': [
True,
'Use Pygments to highlight code blocks. '
'Disable if using a JavaScript library. '
'Default: True'
],
'guess_lang': [
0,
"Automatic language detection - Default: False"
],
'css_class': [
'highlight',
"CSS class to apply to wrapper element."
],
'pygments_style': [
'default',
'Pygments HTML Formatter Style '
'(color scheme) - Default: default'
],
'noclasses': [
False,
'Use inline styles instead of CSS classes - '
'Default false'
],
'linenums': [
None,
'Display line numbers in block code output (not inline) - Default: False'
],
'linenums_style': [
'table',
'Line number style -Default: "table"'
],
'linenums_special': [
-1,
'Globally make nth line special - Default: -1'
],
'linenums_class': [
"linenums",
"Control the linenums class name when not using Pygments - Default: 'linenums'"
],
'extend_pygments_lang': [
[],
'Extend pygments language with special language entry - Default: []'
],
'language_prefix': [
'language-',
'Controls the language prefix for non-Pygments code blocks. - Defaults: "language-"'
],
'code_attr_on_pre': [
False,
"Attach attribute list values on pre element instead of code element - Default: False"
],
'auto_title': [
False,
'Inject the lexer name as the title for block code - Defaults: False'
],
'auto_title_map': [
{},
'User defined mapping of overrides for "auto_title" - Defaults: {}'
],
'line_spans': [
'',
'If set to a nonempty string, e.g. foo, the formatter will wrap each output line '
'in a span tag with an id of foo-<code_block_number>-<line_number>. . - Defaults: ""'
],
'anchor_linenums': [
False,
'If set to True, will wrap line numbers in <a> tags. Used in combination with linenums and line_anchors.'
' - Defaults: False'
],
'line_anchors': [
'',
'If set to a nonempty string, e.g. foo, the formatter will wrap each output line in an anchor tag with'
' an id (and name) of foo-<code_block_number>-<line_number>. - Defaults: ""'
],
'pygments_lang_class': [
False,
'If set to True, the language name used will be included as a class attached to the element. - Defaults: False'
],
'stripnl': [
True,
'Strips leading and trailing newlines from code blocks. This is Pygments default behavior. Setting this to '
'False disables this and will retain leading and trailing newlines. This has no affect on inline code. '
'- Defaults: True'
],
'default_lang': [
'',
'The assumed highlight language of a code block when no language is set. - Default text'
],
'_enabled': [
True,
'Used internally to communicate if extension has been explicitly enabled - Default: False'
]
}
if pygments:
class InlineHtmlFormatter(HtmlFormatter):
"""Format the code blocks."""
def _wrap_div(self, inner):
"""Do not wrap with `div`."""
yield from inner
def wrap(self, source):
"""Overload wrap."""
return self._wrap_code(source)
def _wrap_code(self, source):
"""Return source, but do not wrap in inline <code> block."""
yield 0, ''
for i, t in source:
yield i, t.strip()
yield 0, ''
class BlockHtmlFormatter(HtmlFormatter):
"""Adds ability to output line numbers in a new way."""
# Capture `<span class="lineno"> 1 </span>`
RE_SPAN_NUMS = re.compile(r'(<span[^>]*?)(class="[^"]*\blinenos?\b[^"]*)"([^>]*)>([^<]+)(</span>)')
# Capture `<pre>` that is not followed by `<span></span>`
RE_TABLE_NUMS = re.compile(r'(<pre[^>]*>)(?!<span></span>)')
def __init__(self, **options):
"""Initialize."""
self.pymdownx_inline = options.get('linenos', False) == 'pymdownx-inline'
if self.pymdownx_inline:
options['linenos'] = 'inline'
HtmlFormatter.__init__(self, **options)
def _format_custom_line(self, m):
"""Format the custom line number."""
# We've broken up the match in such a way that we not only
# move the line number value to `data-linenos`, but we could
# wrap the gutter number in the future with a highlight class.
# The decision to do this has still not be made.
return (
m.group(1) +
m.group(2) +
'"' +
m.group(3) +
' data-linenos="' + m.group(4) + ' ">' +
m.group(5)
)
def _wrap_customlinenums(self, inner):
"""
Wrapper to handle block inline line numbers.
For our special inline version, don't display line numbers via `<span> 1</span>`,
but include as `<span data-linenos=" 1"></span>` and use CSS to display them:
`[data-linenos]:before {content: attr(data-linenos);}`. This allows us to use
inline and copy and paste without issue.
"""
for t, line in inner:
if t:
line = self.RE_SPAN_NUMS.sub(self._format_custom_line, line)
yield t, line
def wrap(self, source):
"""Wrap the source code."""
if self.linenos == 2 and self.pymdownx_inline:
source = self._wrap_customlinenums(source)
return HtmlFormatter.wrap(self, source)
def _wrap_tablelinenos(self, inner):
"""
Wrapper to handle line numbers better in table.
Pygments currently has a bug with line step where leading blank lines collapse.
Use the same fix Pygments uses for code content for code line numbers.
This fix should be pull requested on the Pygments repository.
"""
for t, line in HtmlFormatter._wrap_tablelinenos(self, inner):
yield t, self.RE_TABLE_NUMS.sub(r'\1<span></span>', line)
class Highlight:
"""Highlight class."""
def __init__(
self, guess_lang=False, pygments_style='default', use_pygments=True,
noclasses=False, extend_pygments_lang=None, linenums=None, linenums_special=-1,
linenums_style='table', linenums_class='linenums', language_prefix='language-',
code_attr_on_pre=False, auto_title=False, auto_title_map=None, line_spans='',
anchor_linenums=False, line_anchors='', pygments_lang_class=False, stripnl=True,
default_lang=''
):
"""Initialize."""
self.guess_lang = guess_lang
self.pygments_style = pygments_style
self.use_pygments = use_pygments
self.noclasses = noclasses
self.linenums = linenums
self.linenums_style = linenums_style
self.linenums_special = linenums_special
self.linenums_class = linenums_class
self.language_prefix = language_prefix
self.code_attr_on_pre = code_attr_on_pre
self.auto_title = auto_title
self.line_spans = line_spans
self.line_anchors = line_anchors
self.anchor_linenums = anchor_linenums
self.pygments_lang_class = pygments_lang_class
self.stripnl = stripnl
self.default_lang = default_lang
if self.anchor_linenums and not self.line_anchors:
self.line_anchors = '__codelineno'
if auto_title_map is None:
auto_title_map = {}
self.auto_title_map = auto_title_map
if extend_pygments_lang is None: # pragma: no cover
extend_pygments_lang = []
self.extend_pygments_lang = {}
for language in extend_pygments_lang:
if isinstance(language, (dict, OrderedDict)):
name = language.get('name')
if name is not None and name not in self.extend_pygments_lang:
self.extend_pygments_lang[name.lower()] = [
language.get('lang'),
language.get('options', {})
]
def get_extended_language(self, language):
"""Get extended language."""
return self.extend_pygments_lang.get(language.lower(), (language, {}))
def get_lexer(self, src, language, inline, stripnl):
"""Get the Pygments lexer."""
name = language
lexer_options = {'stripnl': stripnl}
if language:
language, options = self.get_extended_language(language)
lexer_options.update(options)
# Try and get lexer by the name given.
try:
lexer = get_lexer_by_name(language, **lexer_options)
except Exception:
lexer = None
if lexer is None:
if (self.guess_lang is True) or (self.guess_lang == 'inline' if inline else self.guess_lang == 'block'):
try:
lexer = guess_lexer(src, **lexer_options)
name = lexer.aliases[0]
except Exception: # pragma: no cover
pass
if lexer is None:
lexer = get_lexer_by_name(self.default_lang or 'text', **lexer_options)
name = lexer.aliases[0]
return lexer, name
def escape(self, txt):
"""Basic HTML escaping."""
txt = txt.replace('&', '&amp;')
txt = txt.replace('<', '&lt;')
txt = txt.replace('>', '&gt;')
return txt
def highlight(
self, src, language, css_class='highlight', hl_lines=None,
linestart=-1, linestep=-1, linespecial=-1, inline=False, classes=None, id_value='', attrs=None,
title=None, code_block_count=0
):
"""Highlight code."""
if attrs is None:
attrs = {}
class_names = classes[:] if classes else []
linenums_enabled = (
(self.linenums and linestart != 0) or
(self.linenums is not False and linestart > 0)
) and not inline > 0
class_str = ''
if not language and self.default_lang:
language = self.default_lang
# Convert with Pygments.
if pygments and self.use_pygments:
if p_ver < (2, 12): # pragma: no cover
raise RuntimeError('Pymdownx Highlight requires at least Pygments 2.12+ if enabling Pygments')
if inline:
stripnl = True
else:
stripnl = self.stripnl
# Setup language lexer.
lexer, lang_name = self.get_lexer(src, language, inline, stripnl)
if self.pygments_lang_class:
class_names.insert(0, self.language_prefix + lang_name)
linenums = self.linenums_style if linenums_enabled else False
if class_names:
if inline:
css_class = ' {}'.format('' if not css_class else css_class)
css_class = ' '.join(class_names) + css_class
stripped = css_class.strip()
css_class = stripped
id_str = ID_ATTR.format(id_value) if id_value else ''
lineno_id = id_value if id_value else str(code_block_count)
if not attrs:
attr_str = ''
else:
temp = []
for k, v in attrs.items():
if k.startswith('data-'):
temp.append(f'{k}="{v}"')
attr_str = ' ' + ' '.join(temp) if temp else ''
# Setup line specific settings.
if not linenums or linestep < 1:
linestep = 1
if not linenums or linestart < 1:
linestart = 1
if self.linenums_special >= 0 and linespecial < 0:
linespecial = self.linenums_special
if not linenums or linespecial < 0:
linespecial = 0
if hl_lines is None or inline:
hl_lines = []
if title is None and self.auto_title:
name = " ".join([w.title() if w.islower() else w for w in lexer.name.split()])
title = self.auto_title_map.get(name, name)
if title:
title = title.strip()
# Setup formatter
html_formatter = InlineHtmlFormatter if inline else BlockHtmlFormatter
formatter = html_formatter(
cssclass=css_class,
linenos=linenums,
linenostart=linestart,
linenostep=linestep,
linenospecial=linespecial,
style=self.pygments_style,
noclasses=self.noclasses,
hl_lines=hl_lines,
wrapcode=True,
filename=title if not inline else "",
linespans=f"{self.line_spans}-{lineno_id}" if self.line_spans and not inline else '',
lineanchors=(
f"{self.line_anchors}-{lineno_id}" if self.line_anchors and not inline else ""
),
anchorlinenos=self.anchor_linenums if not inline else False
)
# Convert
code = highlight(src, lexer, formatter)
if inline:
class_str = css_class
attr_str = ''
else:
m = RE_PYG_CODE.match(code)
if m is not None:
end = m.end(0)
start = m.start(0)
if class_names:
if m.group(2):
classes = ' class="{} {}"'.format(' '.join(class_names), m.group(3).strip())
else:
classes = ' class="{}"'.format(' '.join(class_names))
else:
classes = ' ' + m.group(2).lstrip() if m.group(2) else ''
code = f'{code[:start]}<{m.group(1)}{id_str}{classes}{attr_str}>{code[end:]}'
elif inline:
# Format inline code for a JavaScript Syntax Highlighter by specifying language.
code = self.escape(src)
if css_class:
class_names.insert(0, css_class)
if language:
class_names.insert(0, self.language_prefix + language)
class_str = ' '.join(class_names) if class_names else ''
id_str = id_value
else:
# Format block code for a JavaScript Syntax Highlighter by specifying language.
if self.code_attr_on_pre and css_class:
class_names.insert(0, css_class)
if language:
class_names.insert(0, self.language_prefix + language)
class_str = CLASS_ATTR.format(' '.join(class_names)) if class_names else ''
id_str = ID_ATTR.format(id_value) if id_value else ''
attr_str = ' ' + ' '.join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ''
if not self.code_attr_on_pre:
highlight_class = (CLASS_ATTR.format(css_class)) if css_class else ''
code = CODE_WRAP.format(highlight_class, id_str, class_str, attr_str, self.escape(src))
else:
code = CODE_WRAP_ON_PRE.format(id_str, class_str, attr_str, self.escape(src))
if inline:
attributes = {}
if class_str:
attributes['class'] = class_str
# This code exists for consistency, but we currently don't
# ever feed extra ids or attributes for inline code.
# We let `attr_list` handle this directly, but if we did
# need this, we would then want to exercise this logic.
if id_str: # pragma: no cover
attributes['id'] = id_str
for k, v in attrs: # pragma: no cover
attributes[k] = v # noqa: PERF403
el = etree.Element('code', attributes)
el.text = code
return el
else:
return code.strip()
class HighlightTreeprocessor(Treeprocessor):
"""Highlight source code in code blocks."""
def __init__(self, md, ext):
"""Initialize."""
self.ext = ext
super().__init__(md)
def code_unescape(self, text):
"""Unescape code."""
text = text.replace("&lt;", "<")
text = text.replace("&gt;", ">")
text = text.replace("&amp;", "&")
return text
def run(self, root):
"""Find code blocks and store in `htmlStash`."""
blocks = root.iter('pre')
for block in blocks:
if len(block) == 1 and block[0].tag == 'code':
self.ext.pygments_code_block += 1
code = Highlight(
guess_lang=self.config['guess_lang'],
pygments_style=self.config['pygments_style'],
use_pygments=self.config['use_pygments'],
noclasses=self.config['noclasses'],
linenums=self.config['linenums'],
linenums_style=self.config['linenums_style'],
linenums_special=self.config['linenums_special'],
linenums_class=self.config['linenums_class'],
extend_pygments_lang=self.config['extend_pygments_lang'],
language_prefix=self.config['language_prefix'],
code_attr_on_pre=self.config['code_attr_on_pre'],
auto_title=self.config['auto_title'],
auto_title_map=self.config['auto_title_map'],
pygments_lang_class=self.config['pygments_lang_class'],
stripnl=self.config['stripnl'],
default_lang=self.config['default_lang']
)
placeholder = self.md.htmlStash.store(
code.highlight(
self.code_unescape(block[0].text).rstrip('\n'),
'',
self.config['css_class'],
code_block_count=self.ext.pygments_code_block
)
)
# Clear code block in `etree` instance
block.clear()
# Change to `p` element which will later
# be removed when inserting raw HTML
block.tag = 'p'
block.text = placeholder
class HighlightExtension(Extension):
"""Configure highlight settings globally."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = copy.deepcopy(DEFAULT_CONFIG)
super().__init__(*args, **kwargs)
def get_pymdownx_highlight_settings(self):
"""Get the specified extension."""
target = None
if self.enabled:
target = self.getConfigs()
if target is None:
target = {}
config_clone = copy.deepcopy(DEFAULT_CONFIG)
for k in config_clone.keys():
target[k] = config_clone[k][0]
return target
def get_pymdownx_highlighter(self):
"""Get the highlighter."""
return Highlight
def extendMarkdown(self, md):
"""Add support for code highlighting."""
config = self.getConfigs()
self.pygments_code_block = -1
self.md = md
self.enabled = config.get("_enabled", False)
if self.enabled:
ht = HighlightTreeprocessor(self.md, self)
ht.config = self.getConfigs()
self.md.treeprocessors.register(ht, "indent-highlight", 30)
index = 0
register = None
for ext in self.md.registeredExtensions:
if isinstance(ext, HighlightExtension):
register = not ext.enabled and self.enabled
break
index += 1
if register is None:
register = True
index = -1
if register:
if index == -1:
self.md.registerExtension(self)
else:
self.md.registeredExtensions[index] = self
def reset(self):
"""Reset."""
self.pygments_code_block = -1
def makeExtension(*args, **kwargs):
"""Return extension."""
return HighlightExtension(*args, **kwargs)
@@ -0,0 +1,228 @@
"""
Inline Highlighting.
pymdownx.inlinehilite
An alternative inline code extension that highlights code. Can
use CodeHilite to source its settings or pymdownx.highlight.
`:::javascript var test = 0;`
- or -
`#!javascript var test = 0;`
Copyright 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
"""
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown import util as md_util
import xml.etree.ElementTree as etree
import functools
ESCAPED_BSLASH = '{}{}{}'.format(md_util.STX, ord('\\'), md_util.ETX)
DOUBLE_BSLASH = '\\\\'
BACKTICK_CODE_RE = r'''(?x)
(?:
(?<!\\)(?P<escapes>(?:\\{2})+)(?=`+) | # Process code escapes before code
(?<!\\)(?P<tic>`+)
((?:\:{3,}|\#!)(?P<lang>[\w#.+-]*)\s+)? # Optional language
(?P<code>.+?) # Code
(?<!`)(?P=tic)(?!`) # Closing
)
'''
class InlineHiliteException(Exception):
"""InlineHilite exception."""
def _escape(txt):
"""Basic html escaping."""
txt = txt.replace('&', '&amp;')
txt = txt.replace('<', '&lt;')
txt = txt.replace('>', '&gt;')
return txt
def _test(language, test_language=None):
"""Test language."""
return test_language is None or test_language == '*' or language == test_language
def _formatter(src="", language="", md=None, class_name="", fmt=None):
"""Formatter wrapper."""
return fmt(src, language, class_name, md)
class InlineHilitePattern(InlineProcessor):
"""Handle the inline code patterns."""
def __init__(self, pattern, config, md):
"""Initialize."""
self.config = config
InlineProcessor.__init__(self, pattern, md)
self.md = md
self.formatters = [
{
"name": "inlinehilite",
"test": _test,
"formatter": self.highlight_code
}
]
# Custom Fences
custom_inline = self.config.get('custom_inline', [])
for custom in custom_inline:
name = custom.get('name')
class_name = custom.get('class')
inline_format = custom.get('format', self.highlight_code)
if name is not None and class_name is not None:
self.extend_custom_inline(
name,
functools.partial(_formatter, class_name=class_name, fmt=inline_format)
)
self.get_hl_settings = False
def extend_custom_inline(self, name, formatter):
"""Extend SuperFences with the given name, language, and formatter."""
obj = {
"name": name,
"test": functools.partial(_test, test_language=name),
"formatter": formatter
}
if name == '*':
self.formatters[0] = obj
else:
self.formatters.append(obj)
def get_settings(self):
"""Check for Highlight extension settings."""
if not self.get_hl_settings:
self.get_hl_settings = True
self.style_plain_text = self.config['style_plain_text']
config = None
self.highlighter = None
for ext in self.md.registeredExtensions:
try:
config = ext.get_pymdownx_highlight_settings()
self.highlighter = ext.get_pymdownx_highlighter()
break
except AttributeError:
pass
css_class = self.config['css_class']
self.css_class = css_class if css_class else config['css_class']
self.extend_pygments_lang = config.get('extend_pygments_lang', None)
self.guess_lang = config['guess_lang']
self.pygments_style = config['pygments_style']
self.use_pygments = config['use_pygments']
self.noclasses = config['noclasses']
self.language_prefix = config['language_prefix']
self.pygments_lang_class = config['pygments_lang_class']
def highlight_code(self, src='', language='', classname=None, md=None):
"""Syntax highlight the inline code block."""
process_text = self.style_plain_text or language or self.guess_lang
default_lang = self.style_plain_text if isinstance(self.style_plain_text, str) else ''
if process_text:
el = self.highlighter(
guess_lang=self.guess_lang,
pygments_style=self.pygments_style,
use_pygments=self.use_pygments,
noclasses=self.noclasses,
extend_pygments_lang=self.extend_pygments_lang,
language_prefix=self.language_prefix,
pygments_lang_class=self.pygments_lang_class,
default_lang=default_lang
).highlight(src, language, self.css_class, inline=True)
el.text = self.md.htmlStash.store(el.text)
else:
el = etree.Element('code')
el.text = self.md.htmlStash.store(_escape(src))
return el
def handle_code(self, lang, src):
"""Handle code block."""
for entry in reversed(self.formatters):
if entry["test"](lang):
value = entry["formatter"](
src=src,
language=lang,
md=self.md
)
if isinstance(value, str):
value = self.md.htmlStash.store(value)
return value
def handleMatch(self, m, data):
"""Handle the pattern match."""
if m.group('escapes'):
return m.group('escapes').replace(DOUBLE_BSLASH, ESCAPED_BSLASH), m.start(0), m.end(0)
else:
lang = m.group('lang') if m.group('lang') else ''
src = m.group('code').strip()
self.get_settings()
try:
return self.handle_code(lang, src), m.start(0), m.end(0)
except InlineHiliteException:
raise
except Exception:
return m.group(0), None, None
class InlineHiliteExtension(Extension):
"""Add inline highlighting extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.inlinehilite = []
self.config = {
'style_plain_text': [
0,
"Process inline code even when a language is not specified. "
"When 'False', no classes will be added to code blocks without shebangs "
"and no scoping will performed. The content will just be escaped."
"If a language string is provided, then that language will be assumed "
"for any inline code block without a shebang. "
"- Default: False"
],
'css_class': [
'',
"Set class name for wrapper element. The default of Highlight will be used"
"if nothing is set. - "
"Default: ''"
],
'custom_inline': [[], "Custom inline - default []"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add support for `:::language code` and `#!language code` highlighting."""
config = self.getConfigs()
md.inlinePatterns.register(InlineHilitePattern(BACKTICK_CODE_RE, config, md), "backtick", 190)
md.registerExtensions(["pymdownx.highlight"], {"pymdownx.highlight": {"_enabled": False}})
def makeExtension(*args, **kwargs):
"""Return extension."""
return InlineHiliteExtension(*args, **kwargs)
@@ -0,0 +1,324 @@
"""English US keymap."""
keymap = {
# Digits
"0": "0",
"1": "1",
"2": "2",
"3": "3",
"4": "4",
"5": "5",
"6": "6",
"7": "7",
"8": "8",
"9": "9",
# Letters
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"e": "E",
"f": "F",
"g": "G",
"h": "H",
"i": "I",
"j": "J",
"k": "K",
"l": "L",
"m": "M",
"n": "N",
"o": "O",
"p": "P",
"q": "Q",
"r": "R",
"s": "S",
"t": "T",
"u": "U",
"v": "V",
"w": "W",
"x": "X",
"y": "Y",
"z": "Z",
# Space
"space": "Space",
# Punctuation
"backslash": "\\",
"bar": "|",
"brace-left": "{",
"brace-right": "}",
"bracket-left": "[",
"bracket-right": "]",
"colon": ":",
"comma": ",",
"double-quote": "\"",
"equal": "=",
"exclam": "!",
"grave": "`",
"greater": ">",
"less": "<",
"minus": "-",
"period": ".",
"plus": "+",
"question": "?",
"semicolon": ";",
"single-quote": "'",
"slash": "/",
"tilde": "~",
"underscore": "_",
# Navigation keys
"arrow-up": "Up",
"arrow-down": "Down",
"arrow-left": "Left",
"arrow-right": "Right",
"page-up": "Page Up",
"page-down": "Page Down",
"home": "Home",
"end": "End",
# Edit keys
"backspace": "Backspace",
"delete": "Del",
"insert": "Ins",
"tab": "Tab",
# Action keys
"break": "Break",
"caps-lock": "Caps Lock",
"clear": "Clear",
"eject": "Eject",
"enter": "Enter",
"escape": "Esc",
"help": "Help",
"print-screen": "Print Screen",
"scroll-lock": "Scroll Lock",
# Numeric keypad
"num0": "Num 0",
"num1": "Num 1",
"num2": "Num 2",
"num3": "Num 3",
"num4": "Num 4",
"num5": "Num 5",
"num6": "Num 6",
"num7": "Num 7",
"num8": "Num 8",
"num9": "Num 9",
"num-asterisk": "Num *",
"num-clear": "Num Clear",
"num-delete": "Num Del",
"num-equal": "Num =",
"num-lock": "Num Lock",
"num-minus": "Num -",
"num-plus": "Num +",
"num-separator": "Num .",
"num-slash": "Num /",
"num-enter": "Num Enter",
# Modifier keys
"alt": "Alt",
"alt-graph": "AltGr",
"command": "Cmd",
"control": "Ctrl",
"function": "Fn",
"left-alt": "Left Alt",
"left-command": "Left Command",
"left-control": "Left Ctrl",
"left-meta": "Left Meta",
"left-option": "Left Option",
"left-shift": "Left Shift",
"left-super": "Left Super",
"left-windows": "Left Win",
"meta": "Meta",
"option": "Option",
"right-alt": "Right Alt",
"right-command": "Right Command",
"right-control": "Right Ctrl",
"right-meta": "Right Meta",
"right-option": "Right Option",
"right-shift": "Right Shift",
"right-super": "Right Super",
"right-windows": "Right Win",
"shift": "Shift",
"super": "Super",
"windows": "Win",
# Function keys
"f1": "F1",
"f2": "F2",
"f3": "F3",
"f4": "F4",
"f5": "F5",
"f6": "F6",
"f7": "F7",
"f8": "F8",
"f9": "F9",
"f10": "F10",
"f11": "F11",
"f12": "F12",
"f13": "F13",
"f14": "F14",
"f15": "F15",
"f16": "F16",
"f17": "F17",
"f18": "F18",
"f19": "F19",
"f20": "F20",
"f21": "F21",
"f22": "F22",
"f23": "F23",
"f24": "F24",
# Extra keys
"backtab": "Back Tab",
"browser-back": "Browser Back",
"browser-favorites": "Browser Favorites",
"browser-forward": "Browser Forward",
"browser-home": "Browser Home",
"browser-refresh": "Browser Refresh",
"browser-search": "Browser Search",
"browser-stop": "Browser Stop",
"context-menu": "Menu",
"copy": "Copy",
"mail": "Mail",
"media": "Media",
"media-next-track": "Next Track",
"media-pause": "Pause",
"media-play": "Play",
"media-play-pause": "Play/Pause",
"media-prev-track": "Previous Track",
"media-stop": "Stop",
"print": "Print",
"reset": "Reset",
"select": "Select",
"sleep": "Sleep",
"volume-down": "Volume Down",
"volume-mute": "Mute",
"volume-up": "Volume Up",
"zoom": "Zoom",
"power": "Power",
"fingerprint": "Fingerprint",
# Mouse
"left-button": "Left Button",
"middle-button": "Middle Button",
"right-button": "Right Button",
"x-button1": "X Button 1",
"x-button2": "X Button 2"
}
aliases = {
"add": "num-plus",
"altgr": "alt-graph",
"apps": "context-menu",
"back": "backspace",
"bksp": "backspace",
"bktab": "backtab",
"cancel": "break",
"capital": "caps-lock",
"close-brace": "brace-right",
"close-bracket": "bracket-right",
"clr": "clear",
"cmd": "command",
"cplk": "caps-lock",
"ctrl": "control",
"dblquote": "double-quote",
"decimal": "num-separator",
"del": "delete",
"divide": "num-slash",
"down": "arrow-down",
"esc": "escape",
"return": "enter",
"exclamation": "exclam",
"favorites": "browser-favorites",
"fn": "function",
"forward": "browser-forward",
"grave-accent": "grave",
"greater-than": "greater",
"gt": "greater",
"hyphen": "minus",
"ins": "insert",
"lalt": "left-alt",
"launch-mail": "mail",
"launch-media": "media",
"lbutton": "left-button",
"lcmd": "left-command",
"lcommand": "left-command",
"lcontrol": "left-control",
"lctrl": "left-control",
"left": "arrow-left",
"left-cmd": "left-command",
"left-ctrl": "left-control",
"lopt": "left-option",
"loption": "left-option",
"left-opt": "left-option",
"left-win": "left-windows",
"less-than": "less",
"lmeta": "left-meta",
"lshift": "left-shift",
"lsuper": "left-super",
"lt": "less",
"lwin": "left-windows",
"lwindows": "left-windows",
"mbutton": "middle-button",
"menu": "context-menu",
"multiply": "num-asterisk",
"mute": "volume-mute",
"next": "page-down",
"next-track": "media-next-track",
"num-del": "num-delete",
"numlk": "num-lock",
"open-brace": "brace-left",
"open-bracket": "bracket-left",
"opt": "option",
"page-dn": "page-down",
"page-up": "page-up",
"pause": "media-pause",
"pg-dn": "page-down",
"pg-up": "page-up",
"pipe": "bar",
"play": "media-play",
"play-pause": "media-play-pause",
"prev-track": "media-prev-track",
"prior": "page-up",
"prtsc": "print-screen",
"question-mark": "question",
"ralt": "right-alt",
"rbutton": "right-button",
"rcontrol": "right-control",
"rcmd": "right-command",
"rcommand": "right-command",
"rctrl": "right-control",
"refresh": "browser-refresh",
"right": "arrow-right",
"right-cmd": "right-command",
"right-ctrl": "right-control",
"right-meta": "right-meta",
"right-opt": "right-option",
"right-win": "right-windows",
"rmeta": "right-meta",
"ropt": "right-option",
"roption": "right-option",
"rshift": "right-shift",
"rsuper": "right-super",
"rwin": "right-windows",
"rwindows": "right-windows",
"scroll": "scroll-lock",
"search": "browser-search",
"separator": "num-separator",
"spc": "space",
"stop": "media-stop",
"subtract": "num-minus",
"tabulator": "tab",
"up": "arrow-up",
"vol-down": "volume-down",
"vol-mute": "volume-mute",
"vol-up": "volume-up",
"win": "windows",
"xbutton1": "x-button1",
"xbutton2": "x-button2"
}
@@ -0,0 +1,240 @@
"""
Keys.
pymdownx.keys
Markdown extension for keystroke (user keyboard input) formatting.
It wraps the syntax `++key+key+key++` (for individual keystrokes with modifiers)
or `++"string"++` (for continuous keyboard input) into HTML `<kbd>` elements.
If a key is found in the extension's database, its `<kbd>` element gets a matching class.
Common synonyms are included, e.g. `++pg-up++` will match as `++page-up++`.
## Config
If `strict` is `True`, the entire series of keystrokes is wrapped into an outer`<kbd>` element, and then,
each keystroke is wrapped into a separate inner `<kbd>` element, which matches the HTML5 spec.
If `strict` is `False`, an outer `<span>` is used, which matches the practice on Github or StackOverflow.
The resulting `<kbd>` elements are separated by `separator` (`+` by default, can be `''` or something else).
If `camel_case` is `True`, `++PageUp++` will match the same as `++page-up++`.
The database can be extended or modified with the `key_map` dict.
## Examples
### Input
```
Press ++Shift+Alt+PgUp++, type in ++"Hello"++ and press ++Enter++.
```
### Config 1
```
pymdownx.keys:
camel_case: true
strict: false
separator: '+'
```
### Output 1
```
<p>Press <span class="keys"><kbd class="key-shift">Shift</kbd><span>+</span><kbd
class="key-alt">Alt</kbd><span>+</span><kbd class="key-page-up">Page Up</kbd></span>, type in <span
class="keys"><kbd>Hello</kbd></span> and press <span class="keys"><kbd class="key-enter">Enter</kbd></span>.</p>
```
### Config 2
```
pymdownx.keys:
camel_case: true
strict: true
separator: ''
```
### Output 2
```
<p>Press <kbd class="keys"><kbd class="key-shift">Shift</kbd><kbd class="key-alt">Alt</kbd><kbd
class="key-page-up">Page Up</kbd></kbd>, type in <kbd class="keys"><kbd>Hello</kbd></kbd> and press <kbd
class="keys"><kbd class="key-enter">Enter</kbd></kbd>.</p>
```
Idea by Adam Twardoch and coded by Isaac Muse.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import html
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown import util as md_util
import xml.etree.ElementTree as etree
from . import util
from . import keymap_db as keymap
import re
RE_EARLY_KBD = r'''(?x)
(?:
# Escape
(?<!\\)(?P<escapes>(?:\\{2})+)(?=\+)|
# Key
(?<!\\)\+{2}
(
(?:(?:[\w\-]+|"(?:\\.|[^"])+"|\'(?:\\.|[^\'])+\')\+)*?
(?:[\w\-]+|"(?:\\.|[^"])+"|\'(?:\\.|[^\'])+\')
)
\+{2}
)
'''
RE_KBD = r'\+{2}([\w\-]+(?:\+[\w\-]+)*?)\+{2}'
ESCAPE_RE = re.compile(r'''(?<!\\)(?:\\\\)*\\(.)''')
UNESCAPED_PLUS = re.compile(r'''(?<!\\)(?:\\\\)*(\+)''')
ESCAPED_BSLASH = '{}{}{}'.format(md_util.STX, ord('\\'), md_util.ETX)
DOUBLE_BSLASH = '\\\\'
class KeysPattern(InlineProcessor):
"""Return kbd tag."""
def __init__(self, pattern, config, md, early=False):
"""Initialize."""
self.ksep = config['separator']
self.strict = config['strict']
self.classes = config['class'].split(' ')
self.map = self.merge(keymap.keymap, config['key_map'])
self.aliases = keymap.aliases
self.camel = config['camel_case']
self.early = early
super().__init__(pattern, md)
def merge(self, x, y):
"""Given two dicts, merge them into a new dict."""
z = x.copy()
z.update(y)
return z
def normalize(self, key):
"""Normalize the value."""
if not self.camel:
return key
norm_key = []
last = ''
for c in key:
if c.isupper():
if not last or last == '-':
norm_key.append(c.lower())
else:
norm_key.extend(['-', c.lower()])
else:
norm_key.append(c)
last = c
return ''.join(norm_key)
def process_key(self, key):
"""Process key."""
if key.startswith(('"', "'")):
value = (None, html.unescape(ESCAPE_RE.sub(r'\1', key[1:-1])).strip())
else:
norm_key = self.normalize(key)
canonical_key = self.aliases.get(norm_key, norm_key)
name = self.map.get(canonical_key, None)
value = (canonical_key, name) if name else None
return value
def handleMatch(self, m, data):
"""Handle kbd pattern matches."""
if self.early:
if m.group(1):
return m.group('escapes').replace(DOUBLE_BSLASH, ESCAPED_BSLASH), m.start(0), m.end(0)
quoted = 0
content = []
for key in UNESCAPED_PLUS.split(m.group(2)):
if key != '+':
if key.startswith(('"', "'")):
quoted += 1
content.append(self.process_key(key))
# Defer unquoted cases until later to avoid parsing URLs
if not quoted:
return None, None, None
else:
content = [self.process_key(key) for key in m.group(1).split('+')]
if None in content:
return None, None, None
el = etree.Element(
('kbd' if self.strict else 'span'),
({'class': ' '.join(self.classes)} if self.classes else {})
)
last = None
for item_class, item_name in content:
classes = []
if item_class:
classes.append('key-' + item_class)
if last is not None and self.ksep:
span = etree.SubElement(el, 'span')
span.text = md_util.AtomicString(self.ksep)
attr = {}
if classes:
attr['class'] = ' '.join(classes)
kbd = etree.SubElement(el, 'kbd', attr)
kbd.text = md_util.AtomicString(item_name)
last = kbd
return el, m.start(0), m.end(0)
class KeysExtension(Extension):
"""Add `keys`` extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'separator': ['+', "Provide a keyboard separator - Default: \"+\""],
'strict': [False, "Format keys and menus according to HTML5 spec - Default: False"],
'class': ['keys', "Provide class(es) for the kbd elements - Default: \"keys\""],
'camel_case': [False, 'Allow camelCase conversion for key names PgDn -> pg-dn - Default: False'],
'key_map': [{}, 'Additional keys to include or keys to override - Default: {}']
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add support for keys."""
util.escape_chars(md, ['+'])
md.inlinePatterns.register(KeysPattern(RE_EARLY_KBD, self.getConfigs(), md, early=True), "keys-custom", 185)
md.inlinePatterns.register(KeysPattern(RE_KBD, self.getConfigs(), md), "keys", 70)
def makeExtension(*args, **kwargs):
"""Return extension."""
return KeysExtension(*args, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
"""
Mark.
pymdownx.mark
Really simple plugin to add support for
<mark>test</mark> tags as ==test==
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import re
from markdown import Extension
from markdown.inlinepatterns import SimpleTextInlineProcessor
from . import util
SMART_CONTENT = r'((?:(?<=\s)=+?(?=\s)|.)+?=*?)'
CONTENT = r'((?:[^=]|(?<!={2})=)+?)'
# Avoid starting a pattern with caret tokens that are surrounded by white space.
NOT_MARK = r'((^|(?<=\s))(=+)(?=\s|$))'
# ==mark==
MARK = r'(={{2}})(?!\s){}(?<!\s)\1'.format(CONTENT)
# ==mark==
SMART_MARK = r'(?:(?<=_)|(?<![\w=]))(={{2}})(?![\s=]){}(?<!\s)\1(?:(?=_)|(?![\w=]))'.format(SMART_CONTENT)
class MarkProcessor(util.PatternSequenceProcessor):
"""Handle mark patterns."""
PATTERNS = [
util.PatSeqItem(re.compile(MARK, re.DOTALL | re.UNICODE), 'single', 'mark')
]
class MarkSmartProcessor(util.PatternSequenceProcessor):
"""Handle smart mark patterns."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_MARK, re.DOTALL | re.UNICODE), 'single', 'mark')
]
class MarkExtension(Extension):
"""Add the mark extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'smart_mark': [True, "Treat ==connected==words== intelligently - Default: True"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Insert `<mark>test</mark>` tags as `==test==`."""
config = self.getConfigs()
smart = bool(config.get('smart_mark', True))
md.registerExtension(self)
escape_chars = []
escape_chars.append('=')
util.escape_chars(md, escape_chars)
md.inlinePatterns.register(SimpleTextInlineProcessor(NOT_MARK), 'not_tilde', 70)
mark = MarkSmartProcessor(r'=') if smart else MarkProcessor(r'=')
md.inlinePatterns.register(mark, "mark", 65)
def makeExtension(*args, **kwargs):
"""Return extension."""
return MarkExtension(*args, **kwargs)
@@ -0,0 +1,190 @@
"""
Path Converter.
pymdownx.pathconverter
An extension for Python Markdown.
An extension to covert tag paths to relative or absolute:
Given an absolute base and a target relative path, this extension searches for file
references that are relative and converts them to a path relative
to the base path.
-or-
Given an absolute base path, this extension searches for file
references that are relative and converts them to absolute paths.
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.postprocessors import Postprocessor
from . import util
import os
import re
from urllib.parse import urlunparse
RE_TAG_HTML = r'''(?xus)
(?:
(?P<avoid>
<\s*(?P<script_name>script|style)[^>]*>.*?</\s*(?P=script_name)\s*> |
(?:(\r?\n?\s*)<!--[\s\S]*?-->(\s*)(?=\r?\n)|<!--[\s\S]*?-->)
)|
(?P<open><\s*(?P<tag>(?:%s)))
(?P<attr>(?:\s+[\w\-:]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)
(?P<close>\s*(?:\/?)>)
)
'''
RE_TAG_LINK_ATTR = re.compile(
r'''(?xus)
(?P<attr>
(?:
(?P<name>\s+(?:href|src)\s*=\s*)
(?P<path>"[^"]*"|'[^']*')
)
)
'''
)
def repl_relative(m, base_path, relative_path):
"""Replace path with relative path."""
link = m.group(0)
try:
scheme, netloc, path, params, query, fragment, is_url, is_absolute = util.parse_url(m.group('path')[1:-1])
if not is_url:
# Get the absolute path of the file or return
# if we can't resolve the path
path = util.url2path(path)
if (not is_absolute):
# Convert current relative path to absolute
path = os.path.relpath(
os.path.normpath(os.path.join(base_path, path)),
os.path.normpath(relative_path)
)
# Convert the path, URL encode it, and format it as a link
path = util.path2url(path)
link = '{}"{}"'.format(
m.group('name'),
urlunparse((scheme, netloc, path, params, query, fragment))
)
except Exception: # pragma: no cover
# Parsing crashed and burned; no need to continue.
pass
return link
def repl_absolute(m, base_path, file_scheme):
"""Replace path with absolute path."""
link = m.group(0)
try:
scheme, netloc, path, params, query, fragment, is_url, is_absolute = util.parse_url(m.group('path')[1:-1])
if (not is_absolute and not is_url):
path = util.url2path(path)
path = os.path.normpath(os.path.join(base_path, path))
path = util.path2url(path)
if file_scheme:
if not path.startswith('/'):
path = '/' + path
link = '{}"{}"'.format(
m.group('name'),
urlunparse(("file", netloc, path, params, query, fragment))
)
else:
start = '/' if not path.startswith('/') else ''
link = '{}"{}{}"'.format(
m.group('name'),
start,
urlunparse((scheme, netloc, path, params, query, fragment))
)
except Exception: # pragma: no cover
# Parsing crashed and burned; no need to continue.
pass
return link
def repl(m, base_path, rel_path=None, file_scheme=None):
"""Replace."""
if m.group('avoid'):
tag = m.group('avoid')
else:
tag = m.group('open')
if rel_path is None:
tag += RE_TAG_LINK_ATTR.sub(lambda m2: repl_absolute(m2, base_path, file_scheme), m.group('attr'))
else:
tag += RE_TAG_LINK_ATTR.sub(lambda m2: repl_relative(m2, base_path, rel_path), m.group('attr'))
tag += m.group('close')
return tag
class PathConverterPostprocessor(Postprocessor):
"""Post process to find tag lings to convert."""
def run(self, text):
"""Find and convert paths."""
basepath = self.config['base_path']
relativepath = self.config['relative_path']
absolute = bool(self.config['absolute'])
filescheme = bool(self.config['file_scheme'])
tags = re.compile(RE_TAG_HTML % '|'.join(self.config['tags'].split()))
if not absolute and basepath and relativepath:
text = tags.sub(lambda m: repl(m, basepath, rel_path=relativepath), text)
elif absolute and basepath:
text = tags.sub(lambda m: repl(m, basepath, file_scheme=filescheme), text)
return text
class PathConverterExtension(Extension):
"""PathConverter extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'base_path': ["", "Base path used to find files - Default: \"\""],
'relative_path': ["", "Path that files will be relative to (not needed if using absolute) - Default: \"\""],
'absolute': [False, "Paths are absolute by default; disable for relative - Default: False"],
'tags': ["img script a link", "tags to convert src and/or href in - Default: 'img scripts a link'"],
'file_scheme': [False, "Use file:// scheme for absolute paths - Default: False"],
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add post processor to Markdown instance."""
rel_path = PathConverterPostprocessor(md)
rel_path.config = self.getConfigs()
md.postprocessors.register(rel_path, "path-converter", 2)
md.registerExtension(self)
def makeExtension(*args, **kwargs):
"""Return extension."""
return PathConverterExtension(*args, **kwargs)
@@ -0,0 +1,252 @@
"""
Progress Bar.
pymdownx.progressbar
Simple plugin to add support for progress bars
```
/* No label */
[==30%]
/* Label */
[==30% MyLabel]
/* works with attr_list inline style */
[==50/200 MyLabel]{: .additional-class }
```
New line is not required before the progress bar but suggested unless in a table.
Can take percentages and divisions.
Floats are okay. Numbers must be positive. This is an experimental extension.
Functionality is subject to change.
Minimum Recommended Styling
(but you could add gloss, candy striping, animation, or anything else):
```
.progress {
display: block;
width: 300px;
margin: 10px 0;
height: 24px;
border: 1px solid #ccc;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
background-color: #F8F8F8;
position: relative;
box-shadow: inset -1px 1px 3px rgba(0, 0, 0, .1);
}
.progress-label {
position: absolute;
text-align: center;
font-weight: bold;
width: 100%; margin: 0;
line-height: 24px;
color: #333;
-webkit-font-smoothing: antialiased !important;
white-space: nowrap;
overflow: hidden;
}
.progress-bar {
height: 24px;
float: left;
border-right: 1px solid #ccc;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
background-color: #34c2e3;
box-shadow: inset 0 1px 0px rgba(255, 255, 255, .5);
}
For Level Colors
.progress-100plus .progress-bar {
background-color: #1ee038;
}
.progress-80plus .progress-bar {
background-color: #86e01e;
}
.progress-60plus .progress-bar {
background-color: #f2d31b;
}
.progress-40plus .progress-bar {
background-color: #f2b01e;
}
.progress-20plus .progress-bar {
background-color: #f27011;
}
.progress-0plus .progress-bar {
background-color: #f63a0f;
}
```
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.inlinepatterns import InlineProcessor, dequote
import xml.etree.ElementTree as etree
from markdown.extensions.attr_list import AttrListTreeprocessor
from . import util
RE_PROGRESS = r'''(?x)
\[={1,}\s* # Opening
(?:
(?P<percent>100(?:.0+)?|[1-9]?[0-9](?:\.\d+)?)% | # Percent
(?:(?P<frac_num>\d+(?:\.\d+)?)\s*/\s*(?P<frac_den>\d+(?:\.\d+)?)) # Fraction
)
(?P<title>\s+(?P<quote>['"]).*?(?P=quote))?\s* # Title
\] # Closing
(?P<attr_list>\{\:?([^\}]*)\})? # Optional attr list
'''
CLASS_LEVEL = "progress-%dplus"
class ProgressBarTreeProcessor(AttrListTreeprocessor):
"""Used for AttrList compatibility."""
def run(self, elem):
"""Inline check for attributes at start of tail."""
if elem.tail:
m = self.INLINE_RE.match(elem.tail)
if m:
self.assign_attrs(elem, m.group(1))
elem.tail = elem.tail[m.end():]
class ProgressBarPattern(InlineProcessor):
"""Pattern handler for the progress bars."""
def __init__(self, pattern, md):
"""Initialize."""
InlineProcessor.__init__(self, pattern, md)
def create_tag(self, width, label, add_classes, alist):
"""Create the tag."""
# Create list of all classes and remove duplicates
classes = list(
set(
["progress"] +
self.config.get('add_classes', '').split() +
add_classes
)
)
classes.sort()
el = etree.Element("div")
el.set('class', ' '.join(classes))
bar = etree.SubElement(el, 'div')
bar.set('class', "progress-bar")
bar.set('style', 'width:%s%%' % width)
p = etree.SubElement(bar, 'p')
p.set('class', 'progress-label')
p.text = label
if alist is not None:
el.tail = alist
if 'attr_list' in self.md.treeprocessors:
ProgressBarTreeProcessor(self.md).run(el)
return el
def handleMatch(self, m, data):
"""Handle the match."""
label = ""
level_class = self.config.get('level_class', False)
increment = self.config.get('progress_increment', 20)
add_classes = []
alist = None
if m.group(5):
label = dequote(self.unescape(m.group('title').strip()))
if m.group('attr_list'):
alist = m.group('attr_list')
if m.group('percent'):
value = float(m.group('percent'))
else:
try:
num = float(m.group('frac_num'))
except Exception: # pragma: no cover
num = 0.0
try:
den = float(m.group('frac_den'))
except Exception: # pragma: no cover
den = 0.0
if den == 0.0:
value = 0.0
else:
value = (num / den) * 100.0
# We can never get a value < 0,
# but we must check for > 100.
if value > 100.0:
value = 100.0
# Round down to nearest increment step and include class if desired
if level_class:
add_classes.append(CLASS_LEVEL % int(value - (value % increment)))
return self.create_tag('%.2f' % value, label, add_classes, alist), m.start(0), m.end(0)
class ProgressBarExtension(Extension):
"""Add progress bar extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'level_class': [
True,
"Include class that defines progress level - Default: True"
],
'progress_increment': [
20,
"Progress increment step - Default: 20"
],
'add_classes': [
'',
"Add additional classes to the progress tag for styling. "
"Classes are separated by spaces. - Default: None"
]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add the progress bar pattern handler."""
util.escape_chars(md, ['='])
progress = ProgressBarPattern(RE_PROGRESS, md)
progress.config = self.getConfigs()
md.inlinePatterns.register(progress, "progress-bar", 179)
def makeExtension(*args, **kwargs):
"""Return extension."""
return ProgressBarExtension(*args, **kwargs)
@@ -0,0 +1,149 @@
"""
Extension for "enhanced" blockquotes.
This extension deviates from Python Markdown's original blockquote extension by:
- not grouping consecutive block quotes together.
- Allowing optional callout behavior that mimics GitHub or Obsidian.
"""
import re
import xml.etree.ElementTree as etree
from markdown.blockprocessors import BlockProcessor
from markdown.treeprocessors import Treeprocessor
from markdown import util
from markdown import Extension, Markdown
from markdown.blockparser import BlockParser
from typing import Any
class QuotesProcessor(BlockProcessor):
"""Process blockquotes."""
RE = re.compile(r'(^|\n)[ ]{0,3}>[ ]?(.*)')
RE_CALLOUT = re.compile(r'> *\[!([\w-]+(?: *\| *[\w-]+)*)]([-+])?(.*?)(?:\n|$)')
def __init__(self, parser: BlockParser, config: dict[str, Any]) -> None:
"""Initialize."""
super().__init__(parser)
self.callouts = config['callouts']
def test(self, parent: etree.Element, block: str) -> bool:
"""Test for block quote."""
return bool(self.RE.search(block)) and not util.nearing_recursion_limit()
def run(self, parent: etree.Element, blocks: list[str]) -> None:
"""Create blockquote."""
block = blocks.pop(0)
alert = []
details = ''
m = self.RE.search(block)
if m:
before = block[:m.start()] # Lines before blockquote
# Pass lines before blockquote in recursively for parsing first.
self.parser.parseBlocks(parent, [before])
# Remove `> ` from beginning of each line.
lines = block[m.start():].split('\n')
if lines and self.callouts:
m2 = None
index = 0
for line in lines:
if line and line.strip() != '>':
m2 = self.RE_CALLOUT.match(line)
break
index += 1
if m2:
alert = [x.strip() for x in m2.group(1).split('|')]
if m2.group(2):
details = 'open' if m2.group(2) == '+' else 'closed'
title = m2.group(3).strip() if m2.group(3) else ''
if not title:
title = alert[0].title()
lines[index] = ''
lines.insert(index, title)
if alert:
alert[0] = alert[0].lower()
block = '\n'.join([self.clean(l) for l in lines])
# This is a new blockquote. Create a new parent element.
attrs = {'data-alert': ' '.join(alert), 'data-alert-collapse': details} if alert else {}
quote = etree.SubElement(parent, 'blockquote', attrs)
# Recursively parse block with blockquote as parent.
# change parser state so blockquotes embedded in lists use `p` tags
self.parser.state.set('blockquote')
self.parser.parseChunk(quote, block)
self.parser.state.reset()
def clean(self, line: str) -> str:
"""Remove `>` from beginning of a line."""
m = self.RE.match(line)
if line.strip() == ">":
return ""
elif m:
return m.group(2)
else:
return line
class QuotesTreeprocessor(Treeprocessor):
"""Convert "special" quotes to the common output format for Admonitions and Details."""
def run(self, root: etree.Element) -> etree.Element:
"""Find and convert "special" blockquotes."""
for b in root.iter('blockquote'):
if b.attrib.get('data-alert'):
collapse = b.attrib.get('data-alert-collapse', '')
if collapse:
b.tag = 'details'
child = b.find('*')
if collapse == 'open':
b.attrib['open'] = 'open'
c = b.attrib.get('class', '').split(' ')
if child is not None and child.tag.lower() == 'p':
child.tag = 'summary'
else:
b.tag = 'div'
child = b.find('*')
c = b.attrib.get('class', '').split(' ')
c.append('admonition')
if child is not None and child.tag.lower() == 'p':
c2 = child.attrib.get('class', '').split(' ')
c2.append('admonition-title')
child.attrib['class'] = ' '.join(_c for _c in c2 if _c)
c.append(b.attrib.get('data-alert', ''))
b.attrib['class'] = ' '.join(_c for _c in c if _c)
del b.attrib['data-alert']
del b.attrib['data-alert-collapse']
return root
class QuotesExtension(Extension):
"""Add blockquotes extension to Markdown class."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize."""
self.config = {
'callouts': [False, "Enable GitHub/Obsidian style callouts - Default: False"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: Markdown) -> None:
"""Add support for blockquotes."""
md.registerExtension(self)
config = self.getConfigs()
md.parser.blockprocessors.register(QuotesProcessor(md.parser, config), "quote", 20)
if config['callouts']:
md.treeprocessors.register(QuotesTreeprocessor(md), 'quotes', 19.99)
def makeExtension(*args: Any, **kwargs: Any) -> Extension:
"""Return extension."""
return QuotesExtension(*args, **kwargs)
@@ -0,0 +1,33 @@
"""
Sane headers.
Allow for a header implementation that requires `#` headers to have a space
after the `#` portion. This allows for things like Magiclink issues to work
at the beginning of lines, and potentially other things like tag extensions
etc.
"""
import re
from markdown import Extension
from markdown.blockprocessors import HashHeaderProcessor
class SaneHeadersProcessor(HashHeaderProcessor):
"""Process hash headers syntax."""
RE = re.compile(r'(?:^|\n)(?P<level>#{1,6})(?=[ ])(?P<header>(?:\\.|[^\\])*?)#*(?:\n|$)')
class SaneHeadersExtension(Extension):
"""Adds the sane headers extension."""
def extendMarkdown(self, md):
"""Extend the inline and block processor objects."""
md.parser.blockprocessors.register(SaneHeadersProcessor(md.parser), 'hashheader', 70)
md.registerExtension(self)
def makeExtension(*args, **kwargs):
"""Return extension."""
return SaneHeadersExtension(*args, **kwargs)
@@ -0,0 +1,125 @@
"""
Slugs.
Additional slug outputs.
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import re
import unicodedata
import functools
from urllib.parse import quote
from . import util
RE_TAGS = re.compile(r'</?[^>]*>', re.UNICODE)
RE_INVALID_SLUG_CHAR = re.compile(r'[^\w\- ]', re.UNICODE)
RE_SEP = re.compile(r' ', re.UNICODE)
RE_ASCII_LETTERS = re.compile(r'[A-Z]', re.UNICODE)
def _uslugify(text, sep, case="none", percent_encode=False, normalize='NFC'):
"""Unicode slugify (`utf-8`)."""
# Normalize, Strip html tags, strip leading and trailing whitespace, and lower
slug = RE_TAGS.sub('', unicodedata.normalize(normalize, text)).strip()
if case == 'lower':
slug = slug.lower()
elif case == 'lower-ascii':
def lower(m):
"""Lowercase character."""
return m.group(0).lower()
slug = RE_ASCII_LETTERS.sub(lower, slug)
elif case == 'fold':
slug = slug.casefold()
# Remove non word characters, non spaces, and non dashes, and convert spaces to dashes.
slug = RE_SEP.sub(sep, RE_INVALID_SLUG_CHAR.sub('', slug))
return quote(slug.encode('utf-8')) if percent_encode else slug
def slugify(**kwargs):
"""Configurable slugify."""
case = kwargs.get('case', 'none')
percent = kwargs.get('percent_encode', False)
normalize = kwargs.get('normalize', 'NFC')
return functools.partial(_uslugify, case=case, percent_encode=percent, normalize=normalize)
@util.deprecated(
"'uslugify' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def uslugify(text, sep):
"""Unicode slugify."""
return slugify(case='lower')(text, sep)
@util.deprecated(
"'uslugify_encoded' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def uslugify_encoded(text, sep):
"""Unicode slugify (percent encoded)."""
return slugify(case='lower', percent_encode=True)(text, sep)
@util.deprecated(
"'uslugify_cased' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def uslugify_cased(text, sep):
"""Unicode slugify cased (keep case) (`utf-8`)."""
return slugify()(text, sep)
@util.deprecated(
"'uslugify_cased_encode' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def uslugify_cased_encoded(text, sep):
"""Unicode slugify cased (keep case) (percent encoded)."""
return slugify(percent_encode=True)(text, sep)
@util.deprecated(
"'gfm' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def gfm(text, sep):
"""Unicode slugify cased (cased Unicode only) (`utf-8`)."""
return slugify(case="lower-ascii")(text, sep)
@util.deprecated(
"'gfm_encoded' is deprecated in favor of the configurable 'slugify' function. "
"See documentation for more info."
)
def gfm_encoded(text, sep):
"""Unicode slugify cased (cased Unicode only) (percent encoded)."""
return slugify(case='lower-ascii', percent_encode=True)(text, sep)
@@ -0,0 +1,173 @@
"""
Smart Symbols.
pymdownx.smartsymbols
Really simple plugin to add support for:
copyright, trademark, and registered symbols
plus/minus, not equal, arrows via:
copyright = `(c)`
trademark = `(tm)`
registered = `(r)`
plus/minus = `+/-`
care/of = `c/o`
fractions = `1/2` etc.
(only certain available unicode fractions)
arrows:
left = `<--`
right = `-->`
both = `<-->`
not equal = `=/=`
(maybe this could be =/= in the future as this might be more
intuitive to non-programmers)
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown import treeprocessors
from markdown.util import Registry
from markdown.inlinepatterns import HtmlInlineProcessor
RE_TRADE = ("smart-trademark", r'\(tm\)', r'&trade;')
RE_COPY = ("smart-copyright", r'\(c\)', r'&copy;')
RE_REG = ("smart-registered", r'\(r\)', r'&reg;')
RE_PLUSMINUS = ("smart-plus-minus", r'\+/-', r'&plusmn;')
RE_NOT_EQUAL = ("smart-not-equal", r'=/=', r'&ne;')
RE_CARE_OF = ("smart-care-of", r'\bc/o\b', r'&#8453;')
RE_ORDINAL_NUMBERS = (
"smart-ordinal-numbers",
r'''(?x)
\b
(?P<leading>(?:[1-9][0-9]*)?)
(?P<tail>(?<=1)(?:1|2|3)th|1st|2nd|3rd|[04-9]th)
\b
''',
lambda m: '{}{}<sup>{}</sup>'.format(
m.group('leading') if m.group('leading') else '',
m.group('tail')[:-2], m.group('tail')[1:]
)
)
RE_ARROWS = (
"smart-arrows",
r'(?P<arrows>\<-{2}\>|(?<!-)-{2}\>|\<-{2}(?!-))',
lambda m: ARR[m.group('arrows')]
)
RE_FRACTIONS = (
"smart-fractions",
r'(?<!\d)(?P<fractions>1/4|1/2|3/4|1/3|2/3|1/5|2/5|3/5|4/5|1/6|5/6|1/8|3/8|5/8|7/8)(?!\d)',
lambda m: FRAC[m.group('fractions')]
)
REPL = {
'trademark': RE_TRADE,
'copyright': RE_COPY,
'registered': RE_REG,
'plusminus': RE_PLUSMINUS,
'arrows': RE_ARROWS,
'notequal': RE_NOT_EQUAL,
'fractions': RE_FRACTIONS,
'ordinal_numbers': RE_ORDINAL_NUMBERS,
'care_of': RE_CARE_OF
}
FRAC = {
"1/4": "&frac14;",
"1/2": "&frac12;",
"3/4": "&frac34;",
"1/3": "&#8531;",
"2/3": "&#8532;",
"1/5": "&#8533;",
"2/5": "&#8534;",
"3/5": "&#8535;",
"4/5": "&#8536;",
"1/6": "&#8537;",
"5/6": "&#8538;",
"1/8": "&#8539;",
"3/8": "&#8540;",
"5/8": "&#8541;",
"7/8": "&#8542;"
}
ARR = {
'-->': "&rarr;",
'<--': "&larr;",
'<-->': "&harr;"
}
class SmartSymbolsPattern(HtmlInlineProcessor):
"""Smart symbols patterns handler."""
def __init__(self, pattern, replace, md):
"""Setup replace pattern."""
super().__init__(pattern, md)
self.replace = replace
def handleMatch(self, m, data):
"""Replace symbol."""
return self.md.htmlStash.store(
m.expand(self.replace(m) if callable(self.replace) else self.replace),
), m.start(0), m.end(0)
class SmartSymbolsExtension(Extension):
"""Smart Symbols extension."""
def __init__(self, *args, **kwargs):
"""Setup config of which symbols are enabled."""
self.config = {
'trademark': [True, 'Trademark'],
'copyright': [True, 'Copyright'],
'registered': [True, 'Registered'],
'plusminus': [True, 'Plus/Minus'],
'arrows': [True, 'Arrows'],
'notequal': [True, 'Not Equal'],
'fractions': [True, 'Fractions'],
'ordinal_numbers': [True, 'Ordinal Numbers'],
'care_of': [True, 'Care/of']
}
super().__init__(*args, **kwargs)
def add_pattern(self, patterns, md):
"""Construct the inline symbol pattern."""
self.patterns.register(SmartSymbolsPattern(patterns[1], patterns[2], md), patterns[0], 30)
def extendMarkdown(self, md):
"""Create a dict of inline replace patterns and add to the tree processor."""
configs = self.getConfigs()
self.patterns = Registry()
for k, v in REPL.items():
if configs[k]:
self.add_pattern(v, md)
inline_processor = treeprocessors.InlineProcessor(md)
inline_processor.inlinePatterns = self.patterns
md.treeprocessors.register(inline_processor, "smart-symbols", 6.1)
def makeExtension(*args, **kwargs):
"""Return extension."""
return SmartSymbolsExtension(*args, **kwargs)
@@ -0,0 +1,473 @@
"""
Snippet ---8<---.
pymdownx.snippet
Inject snippets
MIT license.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.preprocessors import Preprocessor
import functools
import urllib
import re
import os
from . import util
import textwrap
import time
MI = 1024 * 1024 # mebibyte (MiB)
DEFAULT_URL_SIZE = MI * 32
DEFAULT_URL_TIMEOUT = 10.0 # in seconds
DEFAULT_URL_REQUEST_HEADERS = {}
DEFAULT_MAX_RETRIES = 3
DEFAULT_BACKOFF_FACTOR = 2
class SnippetMissingError(Exception):
"""Snippet missing exception."""
class SnippetPreprocessor(Preprocessor):
"""Handle snippets in Markdown content."""
RE_ALL_SNIPPETS = re.compile(
r'''(?x)
^(?P<space>[ \t]*)
(?P<escape>;*)
(?P<all>
(?P<inline_marker>-{1,}8<-{1,}[ \t]+)
(?P<snippet>(?:"(?:\\"|[^"\n\r])+?"|'(?:\\'|[^'\n\r])+?'))(?![ \t]) |
(?P<block_marker>-{1,}8<-{1,})(?![ \t])
)\r?$
'''
)
RE_SNIPPET = re.compile(
r'''(?x)
^(?P<space>[ \t]*)
(?P<snippet>.*?)\r?$
'''
)
RE_SNIPPET_SECTION = re.compile(
r'''(?xi)
^(?P<pre>.*?)
(?P<escape>;*)
(?P<inline_marker>-{1,}8<-{1,}[ \t]+)
(?P<section>\[[ \t]*(?P<type>start|end)[ \t]*:[ \t]*(?P<name>[a-z][-_0-9a-z]*)[ \t]*\])
(?P<post>.*?)$
'''
)
RE_SNIPPET_FILE = re.compile(
r'(?i)(.*?)(?:((?::-?[0-9]*){1,2}(?:(?:,(?=[-0-9:])-?[0-9]*)(?::-?[0-9]*)?)*)|(:[a-z][-_0-9a-z]*))?$'
)
def __init__(self, config, md):
"""Initialize."""
base = config.get('base_path')
if isinstance(base, (str, os.PathLike)):
base = [base]
self.base_path = [os.path.abspath(b) for b in base]
self.restrict_base_path = config['restrict_base_path']
self.encoding = config.get('encoding')
self.check_paths = config.get('check_paths')
self.auto_append = config.get('auto_append')
self.url_download = config['url_download']
self.url_max_size = config['url_max_size']
self.url_timeout = config['url_timeout']
self.url_request_headers = config['url_request_headers']
self.dedent_subsections = config['dedent_subsections']
self.max_retries = config['max_retries']
self.backoff_factor = config['backoff_factor']
self.tab_length = md.tab_length
super().__init__()
self.download.cache_clear()
def extract_section(self, section, lines):
"""Extract the specified section from the lines."""
new_lines = []
start = False
found = False
for l in lines:
# Found a snippet section marker with our specified name
m = self.RE_SNIPPET_SECTION.match(l)
# Handle escaped line
if m and start and m.group('escape'):
l = (
m.group('pre') + m.group('escape').replace(';', '', 1) + m.group('inline_marker') +
m.group('section') + m.group('post')
)
# Found a section we are looking for.
elif m is not None and m.group('name') == section:
# We found the start
if not start and m.group('type') == 'start':
start = True
found = True
continue
# Ignore duplicate start
elif start and m.group('type') == 'start':
continue
# We found the end
elif start and m.group('type') == 'end':
start = False
break
# We found an end, but no start
else:
break
# Found a section we don't care about, so ignore it.
elif m and start:
continue
# We are currently in a section, so append the line
if start:
new_lines.append(l)
if not found and self.check_paths:
raise SnippetMissingError(f"Snippet section '{section}' could not be located")
return self.dedent(new_lines) if self.dedent_subsections else new_lines
def dedent(self, lines):
"""De-indent lines."""
return textwrap.dedent('\n'.join(lines)).split('\n')
def get_snippet_path(self, path):
"""Get snippet path."""
snippet = None
for base in self.base_path:
if os.path.exists(base):
if os.path.isdir(base):
if self.restrict_base_path:
filename = os.path.abspath(os.path.join(base, path))
# If the absolute path is no longer under the specified base path, reject the file
if not filename.startswith(base):
continue
else:
filename = os.path.join(base, path)
if os.path.exists(filename):
snippet = filename
break
else:
dirname = os.path.dirname(base)
filename = os.path.join(dirname, path)
if os.path.exists(filename) and os.path.samefile(filename, base):
snippet = filename
break
return snippet
@functools.lru_cache # noqa: B019
def download(self, url):
"""
Actually download the snippet pointed to by the passed URL.
The most recently used files are kept in a cache until the next reset.
"""
retries = self.max_retries
while True:
try:
http_request = urllib.request.Request(url, headers=self.url_request_headers)
timeout = None if self.url_timeout == 0 else self.url_timeout
with urllib.request.urlopen(http_request, timeout=timeout) as response:
# Fail if status is not OK
status = response.status if util.PY39 else response.code
if status != 200:
raise SnippetMissingError(f"Cannot download snippet '{url}' (HTTP Error {status})")
# We provide some basic protection against absurdly large files.
# 32MB is chosen as an arbitrary upper limit. This can be raised if desired.
content = None
if "content-length" not in response.headers:
# we have to read to know if we went over the max, but never more than `url_max_size`
# where `url_max_size` == 0 means unlimited
content = response.read(self.url_max_size) if self.url_max_size != 0 else response.read()
content_length = len(content)
else:
content_length = int(response.headers["content-length"])
if self.url_max_size != 0 and content_length >= self.url_max_size:
raise ValueError(f"refusing to read payloads larger than or equal to {self.url_max_size}")
# Nothing to return
if content_length == 0:
return ['']
if content is None:
# content-length was in the header, so we did not read yet
content = response.read()
# Process lines
last = content.endswith((b'\r', b'\n'))
s_lines = [l.decode(self.encoding) for l in content.splitlines()]
if last:
s_lines.append('')
return s_lines
except urllib.error.HTTPError as e: # noqa: PERF203
# Handle rate limited error codes
if e.code == 429 and retries:
retries -= 1
wait = self.backoff_factor * (self.max_retries - retries)
time.sleep(wait)
continue
raise SnippetMissingError(f"Cannot download snippet '{url}' (HTTP Error {e.code})") from e
def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False):
"""Parse snippets snippet."""
if file_name:
# Track this file.
self.seen.add(file_name)
new_lines = []
inline = False
block = False
for line in lines:
# Check for snippets on line
inline = False
m = self.RE_ALL_SNIPPETS.match(line)
if m:
if m.group('escape'):
# The snippet has been escaped, replace first `;` and continue.
new_lines.append(line.replace(';', '', 1))
continue
if block and m.group('inline_marker'):
# Don't use inline notation directly under a block.
# It's okay if inline is used again in sub file though.
continue
elif m.group('inline_marker'):
# Inline
inline = True
else:
# Block
block = not block
continue
elif not block:
if not is_section:
# Check for section line, if present remove, if escaped, reformat it
m2 = self.RE_SNIPPET_SECTION.match(line)
if m2 and m2.group('escape'):
line = (
m2.group('pre') + m2.group('escape').replace(';', '', 1) + m2.group('inline_marker') +
m2.group('section') + m2.group('post')
)
m2 = None
# Found a section that must be removed
if m2 is not None:
continue
# Not in snippet, and we didn't find an inline,
# so just a normal line
new_lines.append(line)
continue
if block and not inline:
# We are in a block and we didn't just find a nested inline
# So check if a block path
m = self.RE_SNIPPET.match(line)
if m:
# Get spaces and snippet path. Remove quotes if inline.
space = m.group('space').expandtabs(self.tab_length)
path = m.group('snippet')[1:-1].strip() if inline else m.group('snippet').strip()
if not inline:
# Block path handling
if not path:
# Empty path line, insert a blank line
new_lines.append('')
continue
# Ignore commented out lines
if path.startswith(';'):
continue
# Get line numbers (if specified)
end = []
start = []
section = None
m = self.RE_SNIPPET_FILE.match(path)
path = '' if m is None else m.group(1).strip()
# Looks like we have an empty file and only lines specified
if not path:
if self.check_paths:
raise SnippetMissingError(f"Snippet at path '{path}' could not be found")
else:
continue
if m.group(2):
for nums in m.group(2)[1:].split(','):
span = nums.split(':')
st = int(span[0]) if span[0] else None
start.append(st if st is None or st < 0 else max(0, st - 1))
en = int(span[1]) if len(span) > 1 and span[1] else None
end.append(en)
elif m.group(3):
section = m.group(3)[1:]
# Ignore path links if we are in external, downloaded content
is_link = path.lower().startswith(('https://', 'http://'))
if is_url and not is_link:
continue
# If this is a link, and we are allowing URLs, set `url` to true.
# Make sure we don't process `path` as a local file reference.
url = self.url_download and is_link
snippet = self.get_snippet_path(path) if not url else path
if snippet:
# This is in the stack and we don't want an infinite loop!
if snippet in self.seen:
continue
if not url:
# Read file content
with open(snippet, 'r', encoding=self.encoding) as f:
last = False
s_lines = []
for l in f:
last = l.endswith(('\r', '\n'))
s_lines.append(l.strip('\r\n'))
if last:
s_lines.append('')
else:
# Read URL content
try:
s_lines = self.download(snippet)
except SnippetMissingError:
if self.check_paths:
raise
s_lines = []
if s_lines:
total = len(s_lines)
if start and end:
final_lines = []
for sel in zip(start, end):
s_start = util.clamp(total + sel[0], 0, total) if sel[0] and sel[0] < 0 else sel[0]
s_end = util.clamp(total + 1 + sel[1], 0, total) if sel[1] and sel[1] < 0 else sel[1]
final_lines.extend(s_lines[slice(s_start, s_end, None)])
s_lines = self.dedent(final_lines) if self.dedent_subsections else final_lines
elif section:
s_lines = self.extract_section(section, s_lines)
# Process lines looking for more snippets
new_lines.extend(
[
space + l2 for l2 in self.parse_snippets(
s_lines,
snippet,
is_url=url,
is_section=section is not None
)
]
)
elif self.check_paths:
raise SnippetMissingError(f"Snippet at path '{path}' could not be found")
# Pop the current file name out of the cache
if file_name:
self.seen.remove(file_name)
return new_lines
def run(self, lines):
"""Process snippets."""
self.seen = set()
if self.auto_append:
lines.extend("\n\n-8<-\n{}\n-8<-\n".format('\n\n'.join(self.auto_append)).split('\n'))
return self.parse_snippets(lines)
class SnippetExtension(Extension):
"""Snippet extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'base_path': [["."], "Base path for snippet paths - Default: [\".\"]"],
'restrict_base_path': [
True,
"Restrict snippet paths such that they are under the base paths - Default: True"
],
'encoding': ["utf-8", "Encoding of snippets - Default: \"utf-8\""],
'check_paths': [False, "Make the build fail if a snippet can't be found - Default: \"False\""],
"auto_append": [
[],
"A list of snippets (relative to the 'base_path') to auto append to the Markdown content - Default: []"
],
'url_download': [False, "Download external URLs as snippets - Default: \"False\""],
'url_max_size': [DEFAULT_URL_SIZE, "External URL max size (0 means no limit)- Default: 32 MiB"],
'url_timeout': [DEFAULT_URL_TIMEOUT, 'Defualt URL timeout (0 means no timeout) - Default: 10 sec'],
'url_request_headers': [DEFAULT_URL_REQUEST_HEADERS, "Extra request Headers - Default: {}"],
'dedent_subsections': [False, "Dedent subsection extractions e.g. 'sections' and/or 'lines'."],
'max_retries': [
DEFAULT_MAX_RETRIES, "Maximum number of retry attempts for rate-limited requests - Default: 3"
],
'backoff_factor': [DEFAULT_BACKOFF_FACTOR, "Backoff factor for retry attempts - Default: 2"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Register the extension."""
self.md = md
md.registerExtension(self)
config = self.getConfigs()
snippet = SnippetPreprocessor(config, md)
md.preprocessors.register(snippet, "snippet", 32)
def reset(self):
"""Reset."""
self.md.preprocessors['snippet'].download.cache_clear()
def makeExtension(*args, **kwargs):
"""Return extension."""
return SnippetExtension(*args, **kwargs)
@@ -0,0 +1,151 @@
"""
Strip HTML (previously named Plain HTML).
pymdownx.striphtml
An extension for Python Markdown.
Strip classes, styles, and ids from html
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.postprocessors import Postprocessor
import re
RE_TAG_HTML = re.compile(
r'''(?x)
(?:
(?P<comments>(?:\r?\n?\s*)<!--(?:-(?!->)|[^-])*?-->(?:\s*)(?=\r?\n)|<!--[\s\S]*?-->)|
(?P<scripts>
(?P<script_open><(?P<script_name>style|script))
(?P<script_attr>(?:\s+[\w\-:]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))?)*)
(?P<script_rest>\s*>.*?</(?P=script_name)\s*>)
)|
(?P<open><(?P<name>[\w\:\.\-]+))
(?P<attr>(?:\s+[\w\-:]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))?)*)
(?P<close>\s*(?P<self_close>/)?>)|
(?P<close_tag></(?P<close_name>[\w\:\.\-]+)\s*>)
)
''',
re.DOTALL | re.UNICODE
)
TAG_BAD_ATTR = r'''(?x)
(?P<attr>
(?:
\s+(?:%s)
(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))
)*
)
'''
class StripHtmlPostprocessor(Postprocessor):
"""Post processor to strip out unwanted content."""
def __init__(self, strip_comments, strip_js_on_attributes, strip_attributes, md):
"""Initialize."""
self.strip_comments = strip_comments
self.re_attributes = None
attributes = [re.escape(a.strip()) for a in strip_attributes]
if strip_js_on_attributes:
attributes.append(r'on[\w]+')
if attributes:
self.re_attributes = re.compile(
TAG_BAD_ATTR % '|'.join(attributes),
re.DOTALL | re.UNICODE
)
super().__init__(md)
def repl(self, m):
"""Replace comments and unwanted attributes."""
if m.group('comments'):
tag = '' if self.strip_comments else m.group('comments')
else:
if m.group('scripts'):
tag = m.group('script_open')
if self.re_attributes is not None:
tag += self.re_attributes.sub('', m.group('script_attr'))
else:
tag += m.group('script_attr')
tag += m.group('script_rest')
elif m.group('close_tag'):
tag = m.group(0)
else:
tag = m.group('open')
if self.re_attributes is not None:
tag += self.re_attributes.sub('', m.group('attr'))
else:
tag += m.group('attr')
tag += m.group('close')
return tag
def run(self, text):
"""Strip out ids and classes for a simplified HTML output."""
strip = self.strip_comments or self.strip_js_on_attributes or self.re_attributes
return RE_TAG_HTML.sub(self.repl, text) if strip else text
class StripHtmlExtension(Extension):
"""StripHTML extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'strip_comments': [
True,
"Strip HTML comments at the end of processing. "
"- Default: True"
],
'strip_attributes': [
[],
"A string of attributes separated by spaces."
"- Default: 'id class style']"
],
'strip_js_on_attributes': [
True,
"Strip JavaScript script attribues with the pattern on*. "
" - Default: True"
]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Strip unwanted HTML attributes and/or comments."""
md.registerExtension(self)
config = self.getConfigs()
striphtml = StripHtmlPostprocessor(
config.get('strip_comments'),
config.get('strip_js_on_attributes'),
config.get('strip_attributes'),
md
)
md.postprocessors.register(striphtml, "strip-html", 1)
def makeExtension(*args, **kwargs):
"""Return extension."""
return StripHtmlExtension(*args, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,427 @@
"""
Tabbed.
pymdownx.tabbed
MIT license.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.blockprocessors import BlockProcessor
from markdown.treeprocessors import Treeprocessor
from markdown.extensions import toc
import xml.etree.ElementTree as etree
import re
import html
HEADERS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
class TabbedProcessor(BlockProcessor):
"""Tabbed block processor."""
START = re.compile(
r'(?:^|\n)={3}(\+|\+!|!\+|!)? +"(.*?)" *(?:\n|$)'
)
COMPRESS_SPACES = re.compile(r' {2,}')
def __init__(self, parser, config):
"""Initialize."""
super().__init__(parser)
self.tab_group_count = 0
self.current_sibling = None
self.content_indention = 0
self.alternate_style = config['alternate_style']
self.slugify = callable(config['slugify'])
def detab_by_length(self, text, length):
"""Remove a tab from the front of each line of the given text."""
newtext = []
lines = text.split('\n')
for line in lines:
if line.startswith(' ' * length):
newtext.append(line[length:])
elif not line.strip():
newtext.append('') # pragma: no cover
else:
break
return '\n'.join(newtext), '\n'.join(lines[len(newtext):])
def parse_content(self, parent, block):
"""
Get sibling tab.
Retrieve the appropriate sibling element. This can get tricky when
dealing with lists.
"""
old_block = block
non_tabs = ''
tabbed_set = 'tabbed-set' if not self.alternate_style else 'tabbed-set tabbed-alternate'
# We already acquired the block via test
if self.current_sibling is not None:
sibling = self.current_sibling
block, non_tabs = self.detab_by_length(block, self.content_indent)
self.current_sibling = None
self.content_indent = 0
return sibling, block, non_tabs
sibling = self.lastChild(parent)
if sibling is None or sibling.tag.lower() != 'div' or sibling.attrib.get('class', '') != tabbed_set:
sibling = None
else:
# If the last child is a list and the content is indented sufficient
# to be under it, then the content's is sibling is in the list.
if self.alternate_style:
last_child = self.lastChild(self.lastChild(sibling))
tabbed_content = 'tabbed-block'
else:
last_child = self.lastChild(sibling)
tabbed_content = 'tabbed-content'
child_class = last_child.attrib.get('class', '') if last_child is not None else ''
indent = 0
while last_child is not None:
if (
sibling is not None and block.startswith(' ' * self.tab_length * 2) and
last_child is not None and (
last_child.tag in ('ul', 'ol', 'dl') or
(
last_child.tag == 'div' and
child_class == tabbed_content
)
)
):
# Handle nested tabbed content
if last_child.tag == 'div' and child_class == tabbed_content:
temp_child = self.lastChild(last_child)
if temp_child is None or temp_child.tag not in ('ul', 'ol', 'dl'):
break
last_child = temp_child
child_class = last_child.attrib.get('class', '') if last_child is not None else ''
# The expectation is that we'll find an `<li>`.
# We should get it's last child as well.
sibling = self.lastChild(last_child)
last_child = self.lastChild(sibling) if sibling is not None else None
child_class = last_child.attrib.get('class', '') if last_child is not None else ''
# Context has been lost at this point, so we must adjust the
# text's indentation level so it will be evaluated correctly
# under the list.
block = block[self.tab_length:]
indent += self.tab_length
else:
last_child = None
if not block.startswith(' ' * self.tab_length):
sibling = None
if sibling is not None:
indent += self.tab_length
block, non_tabs = self.detab_by_length(old_block, indent)
self.current_sibling = sibling
self.content_indent = indent
return sibling, block, non_tabs
def test(self, parent, block):
"""Test block."""
if self.START.search(block):
return True
else:
return self.parse_content(parent, block)[0] is not None
def run(self, parent, blocks):
"""Convert to tabbed block."""
block = blocks.pop(0)
m = self.START.search(block)
tabbed_set = 'tabbed-set' if not self.alternate_style else 'tabbed-set tabbed-alternate'
if m:
# removes the first line
if m.start() > 0:
self.parser.parseBlocks(parent, [block[:m.start()]])
block = block[m.end():]
sibling = self.lastChild(parent)
block, non_tabs = self.detab(block)
else:
sibling, block, non_tabs = self.parse_content(parent, block)
if m:
special = m.group(1) if m.group(1) else ''
title = m.group(2) if m.group(2) else m.group(3)
index = 0
labels = None
content = None
if (
sibling is not None and sibling.tag.lower() == 'div' and
sibling.attrib.get('class', '') == tabbed_set and
'!' not in special
):
first = False
tab_group = sibling
if self.alternate_style:
index = [index for index, _ in enumerate(tab_group.findall('input'), 1)][-1]
for d in tab_group.findall('div'):
if d.attrib['class'] == 'tabbed-labels':
labels = d
elif d.attrib['class'] == 'tabbed-content':
content = d
if labels is not None and content is not None:
break
else:
first = True
self.tab_group_count += 1
tab_group = etree.SubElement(
parent,
'div',
{'class': tabbed_set, 'data-tabs': '%d:0' % self.tab_group_count}
)
if self.alternate_style:
labels = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-labels'}
)
content = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-content'}
)
data = tab_group.attrib['data-tabs'].split(':')
tab_set = int(data[0])
tab_count = int(data[1]) + 1
attributes = {
"name": "__tabbed_%d" % tab_set,
"type": "radio"
}
if not self.slugify:
attributes['id'] = "__tabbed_%d_%d" % (tab_set, tab_count)
if first or '+' in special:
attributes['checked'] = 'checked'
# Remove any previously assigned "checked states" to siblings
for i in tab_group.findall('input'):
if i.attrib.get('name', '') == f'__tabbed_{tab_set}':
if 'checked' in i.attrib:
del i.attrib['checked']
attributes2 = {"for": "__tabbed_%d_%d" % (tab_set, tab_count)} if not self.slugify else {}
if self.alternate_style:
input_el = etree.Element(
'input',
attributes
)
tab_group.insert(index, input_el)
lab = etree.SubElement(
labels,
"label",
attributes2
)
lab.text = title
div = etree.SubElement(
content,
"div",
{'class': 'tabbed-block'}
)
else:
etree.SubElement(
tab_group,
'input',
attributes
)
lab = etree.SubElement(
tab_group,
"label",
attributes2
)
lab.text = title
div = etree.SubElement(
tab_group,
"div",
{
"class": "tabbed-content"
}
)
tab_group.attrib['data-tabs'] = '%d:%d' % (tab_set, tab_count)
else:
if sibling.tag in ('li', 'dd') and sibling.text:
# Sibling is a list item, but we need to wrap it's content should be wrapped in <p>
text = sibling.text
sibling.text = ''
p = etree.SubElement(sibling, 'p')
p.text = text
div = sibling
elif sibling.tag == 'div' and sibling.attrib.get('class', '') == tabbed_set:
# Get `tabbed-content` under `tabbed-set`
if self.alternate_style:
div = self.lastChild(self.lastChild(sibling))
else:
div = self.lastChild(sibling)
else:
# Pass anything else as the parent
div = sibling
self.parser.parseChunk(div, block)
if non_tabs:
# Insert the tabbed content back into blocks
blocks.insert(0, non_tabs)
class TabbedTreeprocessor(Treeprocessor):
"""Tab tree processor."""
def __init__(self, md, config):
"""Initialize."""
super().__init__(md)
self.slugify = config["slugify"]
self.alternate = config["alternate_style"]
self.sep = config["separator"]
self.combine_header_slug = config["combine_header_slug"]
def get_parent_header_slug(self, root, header_map, parent_map, el):
"""Attempt retrieval of parent header slug."""
parent = el
last_parent = parent
while parent is not root:
last_parent = parent
parent = parent_map[parent]
if parent in header_map:
headers = header_map[parent]
header = None
for i in list(parent):
if i is el and header is None:
break
if i is last_parent and header is not None:
return header.attrib.get("id", '')
if i in headers:
header = i
return ''
def run(self, doc):
"""Update tab IDs."""
# Get a list of id attributes
used_ids = set()
parent_map = {}
header_map = {}
if self.combine_header_slug:
parent_map = {c: p for p in doc.iter() for c in p}
for el in doc.iter():
if "id" in el.attrib:
if self.combine_header_slug and el.tag in HEADERS:
parent = parent_map[el]
if parent in header_map:
header_map[parent].append(el)
else:
header_map[parent] = [el]
used_ids.add(el.attrib["id"])
for el in doc.iter():
if isinstance(el.tag, str) and el.tag.lower() == 'div':
classes = el.attrib.get('class', '').split()
if 'tabbed-set' in classes and (not self.alternate or 'tabbed-alternate' in classes):
inputs = []
labels = []
if self.alternate:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'div' and i.attrib.get('class', '') == 'tabbed-labels':
labels = [j for j in list(i) if j.tag == 'label']
else:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'label':
labels.append(i)
# Generate slugged IDs
for inpt, label in zip(inputs, labels):
innerhtml = toc.render_inner_html(toc.remove_fnrefs(label), self.md)
innertext = html.unescape(toc.strip_tags(innerhtml))
if self.combine_header_slug:
parent_slug = self.get_parent_header_slug(doc, header_map, parent_map, el)
else:
parent_slug = ''
slug = self.slugify(innertext, self.sep)
if parent_slug:
slug = parent_slug + self.sep + slug
slug = toc.unique(slug, used_ids)
inpt.attrib["id"] = slug
label.attrib["for"] = slug
class TabbedExtension(Extension):
"""Add Tabbed extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'alternate_style': [False, "Use alternate style - Default: False"],
'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
'combine_header_slug': [False, "Combine the tab slug with the slug of the parent header - Default: False"],
'separator': ['-', "Slug separator - Default: '-'"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add Tabbed to Markdown instance."""
md.registerExtension(self)
config = self.getConfigs()
self.tab_processor = TabbedProcessor(md.parser, config)
md.parser.blockprocessors.register(self.tab_processor, "tabbed", 105)
if config['slugify']:
slugs = TabbedTreeprocessor(md, config)
md.treeprocessors.register(slugs, 'tab_slugs', 4)
def reset(self):
"""Reset."""
self.tab_processor.tab_group_count = 0
def makeExtension(*args, **kwargs):
"""Return extension."""
return TabbedExtension(*args, **kwargs)
@@ -0,0 +1,149 @@
"""
Tasklist.
pymdownx.tasklist
An extension for Python Markdown.
Github style tasklists
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
from markdown import Extension
from markdown.treeprocessors import Treeprocessor
import re
RE_CHECKBOX = re.compile(r"^(?P<checkbox> *\[(?P<state>(?:x|X| ){1})\] +)(?P<line>.*)", re.DOTALL)
def get_checkbox(state, custom_checkbox=False, clickable_checkbox=False):
"""Get checkbox tag."""
if custom_checkbox:
return (
'<label class="task-list-control">' +
'<input type="checkbox"{}{}/>'.format(
' disabled' if not clickable_checkbox else '',
' checked' if state.lower() == 'x' else '') +
'<span class="task-list-indicator"></span></label> '
)
return '<input type="checkbox"{}{}/> '.format(
' disabled' if not clickable_checkbox else '',
' checked' if state.lower() == 'x' else '')
class TasklistTreeprocessor(Treeprocessor):
"""Tasklist tree processor that finds lists with checkboxes."""
def __init__(self, md):
"""Initialize."""
super().__init__(md)
def inline(self, li):
"""Search for checkbox directly in `li` tag."""
found = False
m = RE_CHECKBOX.match(li.text)
if m is not None:
li.text = self.md.htmlStash.store(
get_checkbox(m.group('state'), self.custom_checkbox, self.clickable_checkbox)
) + m.group('line')
found = True
return found
def sub_paragraph(self, li):
"""Search for checkbox in sub-paragraph."""
found = False
if len(li):
first = next(iter(li))
if first.tag == "p" and first.text is not None:
m = RE_CHECKBOX.match(first.text)
if m is not None:
first.text = self.md.htmlStash.store(
get_checkbox(m.group('state'), self.custom_checkbox, self.clickable_checkbox)
) + m.group('line')
found = True
return found
def run(self, root):
"""Find list items that start with [ ] or [x] or [X]."""
self.custom_checkbox = bool(self.config["custom_checkbox"])
self.clickable_checkbox = bool(self.config["clickable_checkbox"])
parent_map = {c: p for p in root.iter() for c in p}
task_items = []
lilinks = root.iter('li')
for li in lilinks:
if li.text is None or li.text == "":
if not self.sub_paragraph(li):
continue
elif not self.inline(li):
continue
# Checkbox found
c = li.attrib.get("class", "")
classes = [] if c == "" else c.split()
classes.append("task-list-item")
li.attrib["class"] = ' '.join(classes)
task_items.append(li)
for li in task_items:
parent = parent_map[li]
c = parent.attrib.get("class", "")
classes = [] if c == "" else c.split()
if "task-list" not in classes:
classes.append("task-list")
parent.attrib["class"] = ' '.join(classes)
return root
class TasklistExtension(Extension):
"""Tasklist extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'custom_checkbox': [
False,
"Add an empty label tag after the input tag to allow for custom styling - Default: False"
],
'clickable_checkbox': [
False,
"Allow user to check/uncheck the checkbox - Default: False"
],
'delete': [True, "Enable delete - Default: True"],
'subscript': [True, "Enable subscript - Default: True"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Add checklist tree processor to Markdown instance."""
tasklist = TasklistTreeprocessor(md)
tasklist.config = self.getConfigs()
md.treeprocessors.register(tasklist, "task-list", 25)
md.registerExtension(self)
def makeExtension(*args, **kwargs):
"""Return extension."""
return TasklistExtension(*args, **kwargs)
@@ -0,0 +1,185 @@
"""
Tilde.
pymdownx.tilde
Really simple plugin to add support for
`<del>test</del>` tags as `~~test~~` and
`<sub>test</sub>` tags as `~test~`
MIT license.
Copyright (c) 2014 - 2017 Isaac Muse <isaacmuse@gmail.com>
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.
"""
import re
from markdown import Extension
from markdown.inlinepatterns import SimpleTextInlineProcessor
from . import util
SMART_CONTENT = r'(.+?~*?)'
SMART_LIMITED_CONTENT = r'((?:[^~]|(?<=\w)~+?(?=\w)|(?<=\s)~+?(?=\s))+?)'
CONTENT = r'(~|[^\s]+?)'
CONTENT2 = r'((?:[^~]|(?<!~{2})~)+?)'
# Avoid starting a pattern with tilde tokens that are surrounded by white space.
NOT_TILDE = r'((^|(?<=\s))(~+)(?=\s|$))'
# `~~~del,sub~~~`
DEL_SUB = r'(~{3})(?!\s)(~{1,2}|[^~\s]+?)(?<!\s)\1'
# `~~~del,sub~del~~`
DEL_SUB2 = r'(~{{3}})(?![\s~]){}(?<!\s)~{}(?<!\s)~{{2}}'.format(CONTENT, CONTENT2)
# `~~~sub,del~~sub~`
SUB_DEL = r'(~{{3}})(?![\s~]){}(?<!\s)~{{2}}{}(?<!\s)~'.format(CONTENT, CONTENT)
# `~~del~sub,del~~~`
DEL_SUB3 = r'(~{{2}})(?![\s~]){}~(?![\s~]){}(?<!\s)~{{3}}'.format(CONTENT2, CONTENT)
# `~~del~~`
DEL = r'(~{{2}})(?!\s){}(?<!\s)\1'.format(CONTENT2)
# `~sub~`
SUB = r'(~)(?!\s){}(?<!\s)\1'.format(CONTENT)
# `~sub ~~sub,del~~~`
SUB_DEL2 = r'(?<!~)(~)(?![~\s]){}~{{2}}{}~{{3}}'.format(CONTENT, CONTENT)
# Prioritize ~value~ when ~~value~~ is nested within
SUB2 = r'(?<!~)(~)(?![~\s])((?:[^\s~]|~{2,})+?)(?<![~\s])(~)(?!~)'
# Smart rules for when "smart tilde" is enabled
# SMART: `~~~del,sub~~~`
SMART_DEL_SUB = r'(~{{3}})(?![\s~]){}(?<!\s)\1'.format(CONTENT)
# SMART: `~~~del,sub~ del~~`
SMART_DEL_SUB2 = \
r'(~{{3}})(?![\s~]){}(?<!\s)~(?:(?=_)|(?![\w~])){}(?<!\s)~{{2}}'.format(
CONTENT, SMART_LIMITED_CONTENT
)
# SMART: `~~~sub,del~~ sub~`
SMART_SUB_DEL = \
r'(~{{3}})(?![\s~]){}(?<!\s)~{{2}}(?:(?=_)|(?![\w~])){}(?<!\s)~'.format(
CONTENT, CONTENT
)
# SMART: `~~del~~`
SMART_DEL = r'(?:(?<=_)|(?<![\w~]))(~{{2}})(?![\s~]){}(?<!\s)\1(?:(?=_)|(?![\w~]))'.format(SMART_CONTENT)
# SMART: `~sub ~~sub,del~~~`
SMART_SUB_DEL2 = \
r'(?<!~)(~)(?![\s~]){}(?:(?<=_)|(?<![\w~]))~{{2}}(?![\s~]){}(?<!\s)~{{3}}'.format(
CONTENT, CONTENT
)
# SMART: `~sub ~~sub,del~~~`
SMART_DEL_SUB3 = \
r'(?<!~)(~{{2}})(?![\s~]){}(?:(?<=_)|(?<![\w~]))~(?![\s~]){}(?<!\s)~{{3}}'.format(
SMART_LIMITED_CONTENT, CONTENT
)
class TildeProcessor(util.PatternSequenceProcessor):
"""Emphasis processor for handling delete and subscript matches."""
PATTERNS = [
util.PatSeqItem(re.compile(DEL_SUB, re.DOTALL | re.UNICODE), 'double', 'del,sub'),
util.PatSeqItem(re.compile(SUB_DEL, re.DOTALL | re.UNICODE), 'double', 'sub,del'),
util.PatSeqItem(re.compile(DEL_SUB2, re.DOTALL | re.UNICODE), 'double', 'del,sub'),
util.PatSeqItem(re.compile(DEL_SUB3, re.DOTALL | re.UNICODE), 'double2', 'del,sub'),
util.PatSeqItem(re.compile(DEL, re.DOTALL | re.UNICODE), 'single', 'del'),
util.PatSeqItem(re.compile(SUB_DEL2, re.DOTALL | re.UNICODE), 'double2', 'sub,del'),
util.PatSeqItem(re.compile(SUB2, re.DOTALL | re.UNICODE), 'single', 'sub', True),
util.PatSeqItem(re.compile(SUB, re.DOTALL | re.UNICODE), 'single', 'sub')
]
class TildeSmartProcessor(util.PatternSequenceProcessor):
"""Smart delete and subscript processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_DEL_SUB, re.DOTALL | re.UNICODE), 'double', 'del,sub'),
util.PatSeqItem(re.compile(SMART_SUB_DEL, re.DOTALL | re.UNICODE), 'double', 'sub,del'),
util.PatSeqItem(re.compile(SMART_DEL_SUB2, re.DOTALL | re.UNICODE), 'double', 'del,sub'),
util.PatSeqItem(re.compile(SMART_DEL_SUB3, re.DOTALL | re.UNICODE), 'double2', 'del,sub'),
util.PatSeqItem(re.compile(SMART_DEL, re.DOTALL | re.UNICODE), 'single', 'del'),
util.PatSeqItem(re.compile(SMART_SUB_DEL2, re.DOTALL | re.UNICODE), 'double2', 'sub,del'),
util.PatSeqItem(re.compile(SUB2, re.DOTALL | re.UNICODE), 'single', 'sub', True),
util.PatSeqItem(re.compile(SUB, re.DOTALL | re.UNICODE), 'single', 'sub')
]
class TildeSubProcessor(util.PatternSequenceProcessor):
"""Just subscript processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SUB, re.DOTALL | re.UNICODE), 'single', 'sub')
]
class TildeDeleteProcessor(util.PatternSequenceProcessor):
"""Just delete processor."""
PATTERNS = [
util.PatSeqItem(re.compile(DEL, re.DOTALL | re.UNICODE), 'single', 'del')
]
class TildeSmartDeleteProcessor(util.PatternSequenceProcessor):
"""Just smart delete processor."""
PATTERNS = [
util.PatSeqItem(re.compile(SMART_DEL, re.DOTALL | re.UNICODE), 'single', 'del')
]
class DeleteSubExtension(Extension):
"""Add delete and/or subscript extension to Markdown class."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'smart_delete': [True, "Treat ~~connected~~words~~ intelligently - Default: True"],
'delete': [True, "Enable delete - Default: True"],
'subscript': [True, "Enable subscript - Default: True"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Insert `<del>test</del>` tags as `~~test~~` and `<sub>test</sub>` tags as `~test~`."""
config = self.getConfigs()
delete = bool(config.get('delete', True))
subscript = bool(config.get('subscript', True))
smart = bool(config.get('smart_delete', True))
md.registerExtension(self)
escape_chars = []
if delete or subscript:
escape_chars.append('~')
if subscript:
escape_chars.append(' ')
util.escape_chars(md, escape_chars)
tilde = None
md.inlinePatterns.register(SimpleTextInlineProcessor(NOT_TILDE), 'not_tilde', 70)
if delete and subscript:
tilde = TildeSmartProcessor(r'~') if smart else TildeProcessor(r'~')
elif delete:
tilde = TildeSmartDeleteProcessor(r'~') if smart else TildeDeleteProcessor(r'~')
elif subscript:
tilde = TildeSubProcessor(r'~')
if tilde is not None:
md.inlinePatterns.register(tilde, "sub_del", 65)
def makeExtension(*args, **kwargs):
"""Return extension."""
return DeleteSubExtension(*args, **kwargs)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,369 @@
"""
General utilities.
MIT license.
Copyright (c) 2017 Isaac Muse <isaacmuse@gmail.com>
"""
from __future__ import annotations
from markdown import Markdown
from markdown.inlinepatterns import InlineProcessor
import xml.etree.ElementTree as etree
from collections import namedtuple
import sys
import copy
import re
import html
from urllib.request import pathname2url, url2pathname
from urllib.parse import urlparse
from functools import wraps
import warnings
from typing import Sequence, Callable, Any
RE_WIN_DRIVE_LETTER = re.compile(r"^[A-Za-z]$")
RE_WIN_DRIVE_PATH = re.compile(r"^[A-Za-z]:(?:\\.*)?$")
RE_URL = re.compile('(http|ftp)s?|data|mailto|tel|news')
RE_WIN_DEFAULT_PROTOCOL = re.compile(r"^///[A-Za-z]:(?:/.*)?$")
if sys.platform.startswith('win'):
_PLATFORM = "windows"
elif sys.platform == "darwin": # pragma: no cover
_PLATFORM = "osx"
else:
_PLATFORM = "linux"
PY39 = (3, 9) <= sys.version_info
PY314 = (3, 14) <= sys.version_info
def clamp(value: float, mn: float, mx: float) -> float:
"""Clamp the value to the given minimum and maximum."""
if mn is not None and mx is not None:
return max(min(value, mx), mn)
elif mn is not None:
return max(value, mn)
elif mx is not None:
return min(value, mx)
else:
return value
def is_win() -> bool: # pragma: no cover
"""Is Windows."""
return _PLATFORM == "windows"
def is_linux() -> bool: # pragma: no cover
"""Is Linux."""
return _PLATFORM == "linux"
def is_mac() -> bool: # pragma: no cover
"""Is macOS."""
return _PLATFORM == "osx"
def url2path(path: str) -> str:
"""Path to URL."""
return url2pathname(path)
def path2url(url: str) -> str:
"""URL to path."""
path = pathname2url(url)
# If on windows, replace the notation to use a default protocol `///` with nothing.
if is_win() and RE_WIN_DEFAULT_PROTOCOL.match(path):
path = path.replace('///', '', 1)
if PY314:
path = path.replace('///', '/')
return path
def get_code_points(s: str) -> list[str]:
"""Get the Unicode code points."""
return list(s)
def get_ord(c: str) -> int:
"""Get Unicode ord."""
return ord(c)
def get_char(value: int) -> str:
"""Get the Unicode char."""
return chr(value)
def escape_chars(md: Markdown, echrs: Sequence[str]) -> None:
"""
Add chars to the escape list.
Don't just append as it modifies the global list permanently.
Make a copy and extend **that** copy so that only this Markdown
instance gets modified.
"""
escaped = copy.copy(md.ESCAPED_CHARS)
for ec in echrs:
if ec not in escaped:
escaped.append(ec)
md.ESCAPED_CHARS = escaped
def parse_url(url: str) -> tuple[str, str, str, str, str, str, bool, bool]:
"""
Parse the URL.
Try to determine if the following is a file path or
(as we will call anything else) a URL.
We return it slightly modified and combine the path parts.
We also assume if we see something like c:/ it is a Windows path.
We don't bother checking if this **is** a Windows system, but
'nix users really shouldn't be creating weird names like c: for their folder.
"""
is_url = False
is_absolute = False
scheme, netloc, path, params, query, fragment = urlparse(html.unescape(url))
if RE_URL.match(scheme):
# Clearly a URL
is_url = True
elif scheme == '' and netloc == '' and path == '':
# Maybe just a URL fragment
is_url = True
elif scheme == 'file' and (RE_WIN_DRIVE_PATH.match(netloc)):
# file://c:/path or file://c:\path
path = '/' + (netloc + path).replace('\\', '/')
netloc = ''
is_absolute = True
elif scheme == 'file' and netloc.startswith('\\'):
# file://\c:\path or file://\\path
path = (netloc + path).replace('\\', '/')
netloc = ''
is_absolute = True
elif scheme == 'file':
# file:///path
is_absolute = True
elif RE_WIN_DRIVE_LETTER.match(scheme):
# c:/path
path = '/{}:{}'.format(scheme, path.replace('\\', '/'))
scheme = 'file'
netloc = ''
is_absolute = True
elif scheme == '' and netloc != '' and url.startswith('//'):
# //file/path
path = '//' + netloc + path
scheme = 'file'
netloc = ''
is_absolute = True
elif scheme != '' and netloc != '':
# A non-file path or strange URL
is_url = True
elif path.startswith(('/', '\\')):
# /root path
is_absolute = True
return (scheme, netloc, path, params, query, fragment, is_url, is_absolute)
class PatSeqItem(namedtuple('PatSeqItem', ['pattern', 'builder', 'tags', 'full_recursion'])):
"""Pattern sequence item item."""
def __new__(cls, pattern: re.Pattern[str], builder: str, tags: str, full_recursion: bool = False) -> PatSeqItem:
"""Create object."""
return super().__new__(cls, pattern, builder, tags, full_recursion)
class PatternSequenceProcessor(InlineProcessor):
"""Processor for handling complex nested patterns such as strong and em matches."""
PATTERNS = [] # type: list[PatSeqItem]
def build_single(self, m: re.Match[str], tag: str, full_recursion: bool, idx: int) -> etree.Element:
"""Return single tag."""
el1 = etree.Element(tag)
text = m.group(2)
self.parse_sub_patterns(text, el1, None, full_recursion, idx)
return el1
def build_double(self, m: re.Match[str], tags: str, full_recursion: bool, idx: int) -> etree.Element:
"""Return double tag."""
tag1, tag2 = tags.split(",")
el1 = etree.Element(tag1)
el2 = etree.Element(tag2)
text = m.group(2)
self.parse_sub_patterns(text, el2, None, full_recursion, idx)
el1.append(el2)
if len(m.groups()) == 3:
text = m.group(3)
self.parse_sub_patterns(text, el1, el2, full_recursion, idx)
return el1
def build_double2(self, m: re.Match[str], tags: str, full_recursion: bool, idx: int) -> etree.Element:
"""Return double tags (variant 2): `<strong>text <em>text</em></strong>`."""
tag1, tag2 = tags.split(",")
el1 = etree.Element(tag1)
el2 = etree.Element(tag2)
text = m.group(2)
self.parse_sub_patterns(text, el1, None, full_recursion, idx)
text = m.group(3)
el1.append(el2)
self.parse_sub_patterns(text, el2, None, full_recursion, idx)
return el1
def parse_sub_patterns(
self,
data: str,
parent: etree.Element,
last: None | etree.Element,
full_recursion: bool,
idx: int
) -> None:
"""
Parses sub patterns.
`data` (`str`):
text to evaluate.
`parent` (`etree.Element`):
Parent to attach text and sub elements to.
`last` (`etree.Element`):
Last appended child to parent. Can also be None if parent has no children.
`idx` (`int`):
Current pattern index that was used to evaluate the parent.
"""
offset = 0
pos = 0
length = len(data)
while pos < length:
# Find the start of potential emphasis or strong tokens
if self.compiled_re.match(data, pos):
matched = False
# See if the we can match an emphasis/strong pattern
for index, item in enumerate(self.PATTERNS):
# Only evaluate patterns that are after what was used on the parent
if not full_recursion and index <= idx:
continue
m = item.pattern.match(data, pos)
if m:
# Append child nodes to parent
# Text nodes should be appended to the last
# child if present, and if not, it should
# be added as the parent's text node.
text = data[offset:m.start(0)]
if text:
if last is not None:
last.tail = text
else:
parent.text = text
el = self.build_element(m, item.builder, item.tags, item.full_recursion, index)
parent.append(el)
last = el
# Move our position past the matched hunk
offset = pos = m.end(0)
matched = True
if not matched:
# We matched nothing, move on to the next character
pos += 1
else:
# Increment position as no potential emphasis start was found.
pos += 1
# Append any leftover text as a text node.
text = data[offset:]
if text:
if last is not None:
last.tail = text
else:
parent.text = text
def build_element(
self,
m: re.Match[str],
builder: str,
tags: str,
full_recursion: bool,
index: int
) -> etree.Element:
"""Element builder."""
if builder == 'double2':
return self.build_double2(m, tags, full_recursion, index)
elif builder == 'double':
return self.build_double(m, tags, full_recursion, index)
else:
return self.build_single(m, tags, full_recursion, index)
def handleMatch( # type: ignore[override]
self,
m: re.Match[str],
data: str
) -> tuple[etree.Element | None, int | None, int | None]:
"""Parse patterns."""
el = None
start = None
end = None
for index, item in enumerate(self.PATTERNS):
m1 = item.pattern.match(data, m.start(0))
if m1:
start = m1.start(0)
end = m1.end(0)
el = self.build_element(m1, item.builder, item.tags, item.full_recursion, index)
break
return el, start, end
def deprecated(message: str, stacklevel: int = 2) -> Callable[..., Any]: # pragma: no cover
"""
Raise a `DeprecationWarning` when wrapped function/method is called.
Usage:
@deprecated("This method will be removed in version X; use Y instead.")
def some_method()"
pass
"""
def _wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def _deprecated_func(*args: Any, **kwargs: Any) -> Any:
warnings.warn(
f"'{func.__name__}' is deprecated. {message}",
category=DeprecationWarning,
stacklevel=stacklevel
)
return func(*args, **kwargs)
return _deprecated_func
return _wrapper
def warn_deprecated(message: str, stacklevel: int = 2) -> None: # pragma: no cover
"""Warn deprecated."""
warnings.warn(
message,
category=DeprecationWarning,
stacklevel=stacklevel
)