Spaces:
Running
Running
# 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) | |