test bkp
This commit is contained in:
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()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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('&', '&')
|
||||
txt = txt.replace('<', '<')
|
||||
txt = txt.replace('>', '>')
|
||||
txt = txt.replace('"', '"')
|
||||
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."""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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('&', '&')
|
||||
text = text.replace('<', '<')
|
||||
text = text.replace('>', '>')
|
||||
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"> </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"> </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('&', '&')
|
||||
txt = txt.replace('<', '<')
|
||||
txt = txt.replace('>', '>')
|
||||
txt = txt.replace('"', '"')
|
||||
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 = '<'
|
||||
elif char == '>':
|
||||
char = '>'
|
||||
elif char == '&':
|
||||
char = '&'
|
||||
escape = self.md.htmlStash.store(char)
|
||||
elif self.nbsp and char == ' ':
|
||||
escape = self.md.htmlStash.store(' ')
|
||||
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('&', '&')
|
||||
txt = txt.replace('<', '<')
|
||||
txt = txt.replace('>', '>')
|
||||
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("<", "<")
|
||||
text = text.replace(">", ">")
|
||||
text = text.replace("&", "&")
|
||||
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('&', '&')
|
||||
txt = txt.replace('<', '<')
|
||||
txt = txt.replace('>', '>')
|
||||
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'™')
|
||||
RE_COPY = ("smart-copyright", r'\(c\)', r'©')
|
||||
RE_REG = ("smart-registered", r'\(r\)', r'®')
|
||||
RE_PLUSMINUS = ("smart-plus-minus", r'\+/-', r'±')
|
||||
RE_NOT_EQUAL = ("smart-not-equal", r'=/=', r'≠')
|
||||
RE_CARE_OF = ("smart-care-of", r'\bc/o\b', r'℅')
|
||||
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": "¼",
|
||||
"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": "⅞"
|
||||
}
|
||||
|
||||
ARR = {
|
||||
'-->': "→",
|
||||
'<--': "←",
|
||||
'<-->': "↔"
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
Reference in new issue
Block a user