Skip to content

Commit dfaef75

Browse files
authored
Merge pull request #44 from pydsigner/GH-31_rich_markdown_step
Add an extended Markdown rendering Step
2 parents fe9c725 + 3af88f9 commit dfaef75

File tree

5 files changed

+227
-22
lines changed

5 files changed

+227
-22
lines changed

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ Source = "https://github.com/pydsigner/anchovy"
3434
[project.optional-dependencies]
3535
toml = ['tomli >= 2.0.1; python_version < "3.11"']
3636
jinja = ["Jinja2>=3.1.2"]
37-
markdown = ["anchovy[jinja]", "markdown_it_py>=3.0.0"]
37+
markdown = [
38+
"anchovy[jinja]",
39+
"anchovy[toml]",
40+
"markdown_it_py>=3.0.0",
41+
"mdit_py_plugins>=0.4.0",
42+
# Pygments is a fairly large dependency and perhaps not critical; but we
43+
# include as part of [base] via rich anyways.
44+
"Pygments>=2.12.0",
45+
]
3846
css = ["tinycss2>=1.1.1"]
3947
pretty = ["rich>=12.5.1"]
4048
pillow = ["Pillow>=9.2.0"]

src/anchovy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
)
1010
from .images import CWebPStep, ImageMagickStep, IMThumbnailStep, PillowStep, OptipngStep
1111
from .include import RequestsFetchStep, UnpackArchiveStep, URLLibFetchStep
12-
from .jinja import JinjaMarkdownStep, JinjaRenderStep
12+
from .jinja import JinjaExtendedMarkdownStep, JinjaMarkdownStep, JinjaRenderStep
1313
from .minify import CSSMinifierStep, HTMLMinifierStep, ResourcePackerStep
1414
from .paths import DirPathCalc, OutputDirPathCalc, REMatcher, WorkingDirPathCalc
1515
from .simple import DirectCopyStep

src/anchovy/components/__init__.py

Whitespace-only changes.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import typing as t
5+
if sys.version_info < (3, 11):
6+
import tomli as tomllib
7+
else:
8+
import tomllib
9+
10+
from markdown_it.common.utils import escapeHtml, unescapeAll
11+
from markdown_it.renderer import RendererHTML
12+
if t.TYPE_CHECKING:
13+
from collections.abc import Sequence
14+
from markdown_it.token import Token
15+
from markdown_it.utils import EnvType, OptionsDict
16+
17+
18+
def get_container_renderer(container_name, html_tag):
19+
def render(
20+
self: RendererHTML,
21+
tokens: Sequence[Token],
22+
idx: int,
23+
_options: OptionsDict,
24+
env: EnvType,
25+
) -> str:
26+
tokens[idx].tag = html_tag
27+
# add a class to the opening tag
28+
if tokens[idx].nesting == 1:
29+
tokens[idx].attrJoin("class", container_name)
30+
nt = tokens[idx+1]
31+
if nt.type == 'paragraph_open':
32+
nt.hidden = True
33+
counter = idx + 2
34+
while tokens[counter].type != 'paragraph_close' or tokens[counter].level != nt.level:
35+
counter += 1
36+
tokens[counter].hidden = True
37+
38+
return self.renderToken(tokens, idx, _options, env)
39+
40+
render.__name__ = f'render_{container_name}_to_{html_tag}'
41+
return render
42+
43+
44+
class AnchovyRendererHTML(RendererHTML):
45+
# https://github.com/executablebooks/markdown-it-py/issues/256
46+
def fence(self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType):
47+
token = tokens[idx]
48+
info = unescapeAll(token.info).strip() if token.info else ''
49+
langName = info.split(maxsplit=1)[0] if info else ''
50+
51+
return (
52+
options.highlight
53+
and options.highlight(token.content, langName, '')
54+
or escapeHtml(token.content)
55+
)
56+
57+
def front_matter(self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType):
58+
parsed = tomllib.loads(tokens[idx].content)
59+
env['anchovy_meta'].update(parsed)
60+
return ''

src/anchovy/jinja.py

Lines changed: 157 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,33 @@
11
from __future__ import annotations
22

33
import shutil
4+
import sys
45
import typing as t
56
from functools import reduce
67
from pathlib import Path
78

8-
from .core import Context, Step
9+
from .core import Step
910
from .dependencies import pip_dependency, Dependency
1011

1112
if t.TYPE_CHECKING:
13+
from collections.abc import Sequence
1214
from jinja2 import Environment
13-
15+
from markdown_it.renderer import RendererHTML
16+
from markdown_it.token import Token
17+
from markdown_it.utils import EnvType, OptionsDict
1418

1519
MDProcessor = t.Callable[[str], str]
20+
MDContainerRenderer = t.Callable[
21+
['RendererHTML', 'Sequence[Token]', int, 'OptionsDict', 'EnvType'],
22+
str
23+
]
1624

1725

1826
class JinjaRenderStep(Step):
1927
"""
2028
Abstract base class for Steps using Jinja rendering.
2129
"""
2230
encoding = 'utf-8'
23-
env: Environment
2431

2532
@classmethod
2633
def get_dependencies(cls):
@@ -31,26 +38,24 @@ def get_dependencies(cls):
3138
def __init__(self,
3239
env: Environment | None = None,
3340
extra_globals: dict[str, t.Any] | None = None):
34-
self._temporary_env = env
41+
if env and extra_globals:
42+
env.globals.update(extra_globals)
43+
self._env = env
3544
self._extra_globals = extra_globals
3645

37-
def bind(self, context: Context):
38-
"""
39-
Bind this Step to a specific context. Also initializes a Jinja
40-
environment if none is set up already.
41-
"""
42-
super().bind(context)
43-
44-
if self._temporary_env:
45-
self.env = self._temporary_env
46-
else:
47-
from jinja2 import Environment, FileSystemLoader, select_autoescape
48-
self.env = Environment(
49-
loader=FileSystemLoader(context['input_dir']),
50-
autoescape=select_autoescape()
51-
)
46+
@property
47+
def env(self):
48+
if self._env:
49+
return self._env
50+
51+
from jinja2 import Environment, FileSystemLoader, select_autoescape
52+
self._env = Environment(
53+
loader=FileSystemLoader(self.context['input_dir']),
54+
autoescape=select_autoescape()
55+
)
5256
if self._extra_globals:
53-
self.env.globals.update(self._extra_globals)
57+
self._env.globals.update(self._extra_globals)
58+
return self._env
5459

5560
def render_template(self, template_name: str, meta: dict[str, t.Any], output_paths: list[Path]):
5661
"""
@@ -184,3 +189,135 @@ def extract_metadata(self, text: str):
184189
i += 1
185190

186191
return meta, '\n'.join(lines[i:])
192+
193+
194+
class JinjaExtendedMarkdownStep(JinjaRenderStep):
195+
encoding = 'utf-8'
196+
197+
@classmethod
198+
def get_dependencies(cls):
199+
deps = super().get_dependencies() | {
200+
pip_dependency('markdown-it-py', check_name='markdown_it'),
201+
pip_dependency('mdit_py_plugins'),
202+
pip_dependency('Pygments', check_name='pygments'),
203+
}
204+
if sys.version_info < (3, 11):
205+
deps.add(pip_dependency('tomli'))
206+
return deps
207+
208+
def __init__(self,
209+
default_template: str | None = None,
210+
jinja_env: Environment | None = None,
211+
jinja_globals: dict[str, t.Any] | None = None,
212+
*,
213+
container_types: list[tuple[str | None, list[str]]] | None = None,
214+
container_renderers: dict[str, MDContainerRenderer] | None = None,
215+
substitutions: dict[str, str] | None = None,
216+
auto_anchors: bool = False,
217+
auto_typography: bool = True,
218+
code_highlighting: bool = True,
219+
pygments_params: dict[str, t.Any] | None = None,
220+
wordcount: bool = False):
221+
super().__init__(jinja_env, jinja_globals)
222+
self.default_template = default_template
223+
self.container_types = container_types or []
224+
self.container_renderers = container_renderers or {}
225+
self.substitutions = substitutions or {}
226+
self.auto_anchors = auto_anchors
227+
self.auto_typography = auto_typography
228+
self.code_highlighting = code_highlighting
229+
self.pygments_params = pygments_params or {}
230+
self.wordcount = wordcount
231+
self._md_processor: t.Callable[[str], tuple[str, dict[str, t.Any]]] | None = None
232+
233+
def __call__(self, path: Path, output_paths: list[Path]):
234+
md, meta = self.md_processor(
235+
self.apply_substitutions(
236+
path.read_text(self.encoding).strip()
237+
)
238+
)
239+
240+
meta['rendered_markdown'] = md
241+
242+
template_path = self.render_template(
243+
meta.get('template', self.default_template),
244+
meta,
245+
output_paths
246+
)
247+
if template_path:
248+
return [path, Path(template_path)], output_paths
249+
250+
@property
251+
def md_processor(self):
252+
if not self._md_processor:
253+
self._md_processor = self._build_processor()
254+
return self._md_processor
255+
256+
def apply_substitutions(self, text: str):
257+
for sub, value in self.substitutions.items():
258+
text = text.replace('${{ ' + sub + ' }}', value)
259+
return text
260+
261+
def highlight_code(self, code: str, lang: str, lang_attrs: str):
262+
from pygments import highlight
263+
from pygments.formatters import HtmlFormatter
264+
from pygments.lexers import get_lexer_by_name, guess_lexer
265+
from pygments.util import ClassNotFound
266+
try:
267+
lexer = get_lexer_by_name(lang)
268+
except ClassNotFound:
269+
try:
270+
lexer = guess_lexer(code)
271+
except ClassNotFound:
272+
return ''
273+
274+
return highlight(code, lexer, HtmlFormatter(**self.pygments_params))
275+
276+
def _build_processor(self):
277+
import markdown_it
278+
# TODO Need for pyright suppression will be eliminated in the next
279+
# release of mdit_py_plugins:
280+
# https://github.com/executablebooks/mdit-py-plugins/pull/91
281+
from mdit_py_plugins.anchors import anchors_plugin # type: ignore[reportPrivateImportUsage]
282+
from mdit_py_plugins.attrs import attrs_block_plugin, attrs_plugin # type: ignore[reportPrivateImportUsage]
283+
from mdit_py_plugins.container import container_plugin # type: ignore[reportPrivateImportUsage]
284+
from mdit_py_plugins.front_matter import front_matter_plugin # type: ignore[reportPrivateImportUsage]
285+
from mdit_py_plugins.wordcount import wordcount_plugin # type: ignore[reportPrivateImportUsage]
286+
from .components import md_rendering
287+
288+
processor = markdown_it.MarkdownIt(
289+
'commonmark',
290+
{
291+
'typographer': self.auto_typography,
292+
'highlight': self.highlight_code if self.code_highlighting else None,
293+
},
294+
renderer_cls=md_rendering.AnchovyRendererHTML
295+
)
296+
processor.enable(['strikethrough', 'table'])
297+
if self.auto_typography:
298+
processor.enable(['smartquotes', 'replacements'])
299+
if self.auto_anchors:
300+
anchors_plugin(processor)
301+
attrs_plugin(processor)
302+
attrs_block_plugin(processor)
303+
front_matter_plugin(processor)
304+
if self.wordcount:
305+
wordcount_plugin(processor)
306+
307+
for tag, names in self.container_types:
308+
for name in names:
309+
renderer = (
310+
self.container_renderers.get(name)
311+
or (md_rendering.get_container_renderer(name, tag) if tag else None)
312+
)
313+
container_plugin(processor, name, render=renderer)
314+
315+
def convert(md_string: str):
316+
env = {'anchovy_meta': dict[str, t.Any]()}
317+
md: str = processor.render(md_string, env=env)
318+
meta = env['anchovy_meta']
319+
if self.wordcount:
320+
meta['wordcount'] = env['wordcount']
321+
return md, meta
322+
323+
return convert

0 commit comments

Comments
 (0)