194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
|
# Fenced Code Extension for Python Markdown
|
||
|
# =========================================
|
||
|
|
||
|
# This extension adds Fenced Code Blocks to Python-Markdown.
|
||
|
|
||
|
# See https://Python-Markdown.github.io/extensions/fenced_code_blocks
|
||
|
# for documentation.
|
||
|
|
||
|
# Original code Copyright 2007-2008 [Waylan Limberg](http://achinghead.com/).
|
||
|
|
||
|
# All changes Copyright 2008-2014 The Python Markdown Project
|
||
|
|
||
|
# License: [BSD](https://opensource.org/licenses/bsd-license.php)
|
||
|
|
||
|
"""
|
||
|
This extension adds Fenced Code Blocks to Python-Markdown.
|
||
|
|
||
|
See the [documentation](https://Python-Markdown.github.io/extensions/fenced_code_blocks)
|
||
|
for details.
|
||
|
"""
|
||
|
|
||
|
from __future__ import annotations
|
||
|
|
||
|
from textwrap import dedent
|
||
|
from . import Extension
|
||
|
from ..preprocessors import Preprocessor
|
||
|
from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines
|
||
|
from .attr_list import get_attrs_and_remainder, AttrListExtension
|
||
|
from ..util import parseBoolValue
|
||
|
from ..serializers import _escape_attrib_html
|
||
|
import re
|
||
|
from typing import TYPE_CHECKING, Any, Iterable
|
||
|
|
||
|
if TYPE_CHECKING: # pragma: no cover
|
||
|
from markdown import Markdown
|
||
|
|
||
|
|
||
|
class FencedCodeExtension(Extension):
|
||
|
def __init__(self, **kwargs):
|
||
|
self.config = {
|
||
|
'lang_prefix': ['language-', 'Prefix prepended to the language. Default: "language-"']
|
||
|
}
|
||
|
""" Default configuration options. """
|
||
|
super().__init__(**kwargs)
|
||
|
|
||
|
def extendMarkdown(self, md):
|
||
|
""" Add `FencedBlockPreprocessor` to the Markdown instance. """
|
||
|
md.registerExtension(self)
|
||
|
|
||
|
md.preprocessors.register(FencedBlockPreprocessor(md, self.getConfigs()), 'fenced_code_block', 25)
|
||
|
|
||
|
|
||
|
class FencedBlockPreprocessor(Preprocessor):
|
||
|
""" Find and extract fenced code blocks. """
|
||
|
|
||
|
FENCED_BLOCK_RE = re.compile(
|
||
|
dedent(r'''
|
||
|
(?P<fence>^(?:~{3,}|`{3,}))[ ]* # opening fence
|
||
|
((\{(?P<attrs>[^\n]*)\})| # (optional {attrs} or
|
||
|
(\.?(?P<lang>[\w#.+-]*)[ ]*)? # optional (.)lang
|
||
|
(hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot)[ ]*)?) # optional hl_lines)
|
||
|
\n # newline (end of opening fence)
|
||
|
(?P<code>.*?)(?<=\n) # the code block
|
||
|
(?P=fence)[ ]*$ # closing fence
|
||
|
'''),
|
||
|
re.MULTILINE | re.DOTALL | re.VERBOSE
|
||
|
)
|
||
|
|
||
|
def __init__(self, md: Markdown, config: dict[str, Any]):
|
||
|
super().__init__(md)
|
||
|
self.config = config
|
||
|
self.checked_for_deps = False
|
||
|
self.codehilite_conf: dict[str, Any] = {}
|
||
|
self.use_attr_list = False
|
||
|
# List of options to convert to boolean values
|
||
|
self.bool_options = [
|
||
|
'linenums',
|
||
|
'guess_lang',
|
||
|
'noclasses',
|
||
|
'use_pygments'
|
||
|
]
|
||
|
|
||
|
def run(self, lines: list[str]) -> list[str]:
|
||
|
""" Match and store Fenced Code Blocks in the `HtmlStash`. """
|
||
|
|
||
|
# Check for dependent extensions
|
||
|
if not self.checked_for_deps:
|
||
|
for ext in self.md.registeredExtensions:
|
||
|
if isinstance(ext, CodeHiliteExtension):
|
||
|
self.codehilite_conf = ext.getConfigs()
|
||
|
if isinstance(ext, AttrListExtension):
|
||
|
self.use_attr_list = True
|
||
|
|
||
|
self.checked_for_deps = True
|
||
|
|
||
|
text = "\n".join(lines)
|
||
|
index = 0
|
||
|
while 1:
|
||
|
m = self.FENCED_BLOCK_RE.search(text, index)
|
||
|
if m:
|
||
|
lang, id, classes, config = None, '', [], {}
|
||
|
if m.group('attrs'):
|
||
|
attrs, remainder = get_attrs_and_remainder(m.group('attrs'))
|
||
|
if remainder: # Does not have correctly matching curly braces, so the syntax is invalid.
|
||
|
index = m.end('attrs') # Explicitly skip over this, to prevent an infinite loop.
|
||
|
continue
|
||
|
id, classes, config = self.handle_attrs(attrs)
|
||
|
if len(classes):
|
||
|
lang = classes.pop(0)
|
||
|
else:
|
||
|
if m.group('lang'):
|
||
|
lang = m.group('lang')
|
||
|
if m.group('hl_lines'):
|
||
|
# Support `hl_lines` outside of `attrs` for backward-compatibility
|
||
|
config['hl_lines'] = parse_hl_lines(m.group('hl_lines'))
|
||
|
|
||
|
# If `config` is not empty, then the `codehighlite` extension
|
||
|
# is enabled, so we call it to highlight the code
|
||
|
if self.codehilite_conf and self.codehilite_conf['use_pygments'] and config.get('use_pygments', True):
|
||
|
local_config = self.codehilite_conf.copy()
|
||
|
local_config.update(config)
|
||
|
# Combine classes with `cssclass`. Ensure `cssclass` is at end
|
||
|
# as Pygments appends a suffix under certain circumstances.
|
||
|
# Ignore ID as Pygments does not offer an option to set it.
|
||
|
if classes:
|
||
|
local_config['css_class'] = '{} {}'.format(
|
||
|
' '.join(classes),
|
||
|
local_config['css_class']
|
||
|
)
|
||
|
highliter = CodeHilite(
|
||
|
m.group('code'),
|
||
|
lang=lang,
|
||
|
style=local_config.pop('pygments_style', 'default'),
|
||
|
**local_config
|
||
|
)
|
||
|
|
||
|
code = highliter.hilite(shebang=False)
|
||
|
else:
|
||
|
id_attr = lang_attr = class_attr = kv_pairs = ''
|
||
|
if lang:
|
||
|
prefix = self.config.get('lang_prefix', 'language-')
|
||
|
lang_attr = f' class="{prefix}{_escape_attrib_html(lang)}"'
|
||
|
if classes:
|
||
|
class_attr = f' class="{_escape_attrib_html(" ".join(classes))}"'
|
||
|
if id:
|
||
|
id_attr = f' id="{_escape_attrib_html(id)}"'
|
||
|
if self.use_attr_list and config and not config.get('use_pygments', False):
|
||
|
# Only assign key/value pairs to code element if `attr_list` extension is enabled, key/value
|
||
|
# pairs were defined on the code block, and the `use_pygments` key was not set to `True`. The
|
||
|
# `use_pygments` key could be either set to `False` or not defined. It is omitted from output.
|
||
|
kv_pairs = ''.join(
|
||
|
f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments'
|
||
|
)
|
||
|
code = self._escape(m.group('code'))
|
||
|
code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>'
|
||
|
|
||
|
placeholder = self.md.htmlStash.store(code)
|
||
|
text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
|
||
|
# Continue from after the replaced text in the next iteration.
|
||
|
index = m.start() + 1 + len(placeholder)
|
||
|
else:
|
||
|
break
|
||
|
return text.split("\n")
|
||
|
|
||
|
def handle_attrs(self, attrs: Iterable[tuple[str, str]]) -> tuple[str, list[str], dict[str, Any]]:
|
||
|
""" Return tuple: `(id, [list, of, classes], {configs})` """
|
||
|
id = ''
|
||
|
classes = []
|
||
|
configs = {}
|
||
|
for k, v in attrs:
|
||
|
if k == 'id':
|
||
|
id = v
|
||
|
elif k == '.':
|
||
|
classes.append(v)
|
||
|
elif k == 'hl_lines':
|
||
|
configs[k] = parse_hl_lines(v)
|
||
|
elif k in self.bool_options:
|
||
|
configs[k] = parseBoolValue(v, fail_on_errors=False, preserve_none=True)
|
||
|
else:
|
||
|
configs[k] = v
|
||
|
return id, classes, configs
|
||
|
|
||
|
def _escape(self, txt: str) -> str:
|
||
|
""" basic html escaping """
|
||
|
txt = txt.replace('&', '&')
|
||
|
txt = txt.replace('<', '<')
|
||
|
txt = txt.replace('>', '>')
|
||
|
txt = txt.replace('"', '"')
|
||
|
return txt
|
||
|
|
||
|
|
||
|
def makeExtension(**kwargs): # pragma: no cover
|
||
|
return FencedCodeExtension(**kwargs)
|