1# Fenced Code Extension for Python Markdown
2# =========================================
3
4# This extension adds Fenced Code Blocks to Python-Markdown.
5
6# See https://Python-Markdown.github.io/extensions/fenced_code_blocks
7# for documentation.
8
9# Original code Copyright 2007-2008 [Waylan Limberg](https://github.com/waylan).
10
11# All changes Copyright 2008-2014 The Python Markdown Project
12
13# License: [BSD](https://opensource.org/licenses/bsd-license.php)
14
15"""
16This extension adds Fenced Code Blocks to Python-Markdown.
17
18See the [documentation](https://Python-Markdown.github.io/extensions/fenced_code_blocks)
19for details.
20"""
21
22from __future__ import annotations
23
24from textwrap import dedent
25from . import Extension
26from ..preprocessors import Preprocessor
27from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines
28from .attr_list import get_attrs_and_remainder, AttrListExtension
29from ..util import parseBoolValue
30from ..serializers import _escape_attrib_html
31import re
32from typing import TYPE_CHECKING, Any, Iterable
33
34if TYPE_CHECKING: # pragma: no cover
35 from markdown import Markdown
36
37
38class FencedCodeExtension(Extension):
39 def __init__(self, **kwargs):
40 self.config = {
41 'lang_prefix': ['language-', 'Prefix prepended to the language. Default: "language-"']
42 }
43 """ Default configuration options. """
44 super().__init__(**kwargs)
45
46 def extendMarkdown(self, md):
47 """
48 Register the processor.
49
50 | Class Instance | Registry | Name | Priority |
51 | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------ | :------: |
52 | [`FencedBlockPreprocessor`][markdown.extensions.fenced_code.FencedBlockPreprocessor] | [`blockprocessors`][markdown.blockprocessors.build_block_parser] | `fenced_code_block` | `25` |
53
54 """
55 # flake8: noqa: E501 50-52
56 md.registerExtension(self)
57
58 md.preprocessors.register(FencedBlockPreprocessor(md, self.getConfigs()), 'fenced_code_block', 25)
59
60
61class FencedBlockPreprocessor(Preprocessor):
62 """ Find and extract fenced code blocks. """
63
64 FENCED_BLOCK_RE = re.compile(
65 dedent(r'''
66 (?P<fence>^(?:~{3,}|`{3,}))[ ]* # opening fence
67 ((\{(?P<attrs>[^\n]*)\})| # (optional {attrs} or
68 (\.?(?P<lang>[\w#.+-]*)[ ]*)? # optional (.)lang
69 (hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot)[ ]*)?) # optional hl_lines)
70 \n # newline (end of opening fence)
71 (?P<code>.*?)(?<=\n) # the code block
72 (?P=fence)[ ]*$ # closing fence
73 '''),
74 re.MULTILINE | re.DOTALL | re.VERBOSE
75 )
76
77 def __init__(self, md: Markdown, config: dict[str, Any]):
78 super().__init__(md)
79 self.config = config
80 self.checked_for_deps = False
81 self.codehilite_conf: dict[str, Any] = {}
82 self.use_attr_list = False
83 # List of options to convert to boolean values
84 self.bool_options = [
85 'linenums',
86 'guess_lang',
87 'noclasses',
88 'use_pygments'
89 ]
90
91 def run(self, lines: list[str]) -> list[str]:
92 """ Match and store Fenced Code Blocks in the `HtmlStash`. """
93
94 # Check for dependent extensions
95 if not self.checked_for_deps:
96 for ext in self.md.registeredExtensions:
97 if isinstance(ext, CodeHiliteExtension):
98 self.codehilite_conf = ext.getConfigs()
99 if isinstance(ext, AttrListExtension):
100 self.use_attr_list = True
101
102 self.checked_for_deps = True
103
104 text = "\n".join(lines)
105 index = 0
106 while 1:
107 m = self.FENCED_BLOCK_RE.search(text, index)
108 if m:
109 lang, id, classes, config = None, '', [], {}
110 if m.group('attrs'):
111 attrs, remainder = get_attrs_and_remainder(m.group('attrs'))
112 if remainder: # Does not have correctly matching curly braces, so the syntax is invalid.
113 index = m.end('attrs') # Explicitly skip over this, to prevent an infinite loop.
114 continue
115 id, classes, config = self.handle_attrs(attrs)
116 if len(classes):
117 lang = classes.pop(0)
118 else:
119 if m.group('lang'):
120 lang = m.group('lang')
121 if m.group('hl_lines'):
122 # Support `hl_lines` outside of `attrs` for backward-compatibility
123 config['hl_lines'] = parse_hl_lines(m.group('hl_lines'))
124
125 # If `config` is not empty, then the `codehighlite` extension
126 # is enabled, so we call it to highlight the code
127 if self.codehilite_conf and self.codehilite_conf['use_pygments'] and config.get('use_pygments', True):
128 local_config = self.codehilite_conf.copy()
129 local_config.update(config)
130 # Combine classes with `cssclass`. Ensure `cssclass` is at end
131 # as Pygments appends a suffix under certain circumstances.
132 # Ignore ID as Pygments does not offer an option to set it.
133 if classes:
134 local_config['css_class'] = '{} {}'.format(
135 ' '.join(classes),
136 local_config['css_class']
137 )
138 highliter = CodeHilite(
139 m.group('code'),
140 lang=lang,
141 style=local_config.pop('pygments_style', 'default'),
142 **local_config
143 )
144
145 code = highliter.hilite(shebang=False)
146 else:
147 id_attr = lang_attr = class_attr = kv_pairs = ''
148 if lang:
149 prefix = self.config.get('lang_prefix', 'language-')
150 lang_attr = f' class="{prefix}{_escape_attrib_html(lang)}"'
151 if classes:
152 class_attr = f' class="{_escape_attrib_html(" ".join(classes))}"'
153 if id:
154 id_attr = f' id="{_escape_attrib_html(id)}"'
155 if self.use_attr_list and config and not config.get('use_pygments', False):
156 # Only assign key/value pairs to code element if `attr_list` extension is enabled, key/value
157 # pairs were defined on the code block, and the `use_pygments` key was not set to `True`. The
158 # `use_pygments` key could be either set to `False` or not defined. It is omitted from output.
159 kv_pairs = ''.join(
160 f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments'
161 )
162 code = self._escape(m.group('code'))
163 code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>'
164
165 placeholder = self.md.htmlStash.store(code)
166 text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
167 # Continue from after the replaced text in the next iteration.
168 index = m.start() + 1 + len(placeholder)
169 else:
170 break
171 return text.split("\n")
172
173 def handle_attrs(self, attrs: Iterable[tuple[str, str]]) -> tuple[str, list[str], dict[str, Any]]:
174 """ Return tuple: `(id, [list, of, classes], {configs})` """
175 id = ''
176 classes = []
177 configs = {}
178 for k, v in attrs:
179 if k == 'id':
180 id = v
181 elif k == '.':
182 classes.append(v)
183 elif k == 'hl_lines':
184 configs[k] = parse_hl_lines(v)
185 elif k in self.bool_options:
186 configs[k] = parseBoolValue(v, fail_on_errors=False, preserve_none=True)
187 else:
188 configs[k] = v
189 return id, classes, configs
190
191 def _escape(self, txt: str) -> str:
192 """ basic html escaping """
193 txt = txt.replace('&', '&')
194 txt = txt.replace('<', '<')
195 txt = txt.replace('>', '>')
196 txt = txt.replace('"', '"')
197 return txt
198
199
200def makeExtension(**kwargs): # pragma: no cover
201 return FencedCodeExtension(**kwargs)