diff --git a/pyproject.toml b/pyproject.toml index 8375c0b..3bc2cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,12 @@ requires-python = ">=3.12" dependencies = [ "dacite>=1.9.2", "jinja2>=3.1.6", + "markdown>=3.8.2", "marko>=2.1.4", "pygments>=2.19.1", "pygments-ashen>=0.1.3", "pygments-kakoune>=0.1.0", + "pymdown-extensions>=10.16", "python-dateutil>=2.9.0.post0", "python-frontmatter>=1.1.0", "rich>=14.0.0", diff --git a/src/zona/markdown.py b/src/zona/markdown.py index d2cf4e5..c28a10b 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,3 +1,4 @@ +from markdown import Markdown from rich import print from typing import Any, override from pathlib import Path @@ -8,6 +9,15 @@ from marko.parser import Parser from zona.config import ZonaConfig from zona.layout import Layout +from markdown.treeprocessors import Treeprocessor +from markdown.extensions.codehilite import CodeHiliteExtension +from markdown.extensions.extra import ExtraExtension +from markdown.extensions.smarty import SmartyExtension +from markdown.extensions.sane_lists import SaneListExtension +from pymdownx.inlinehilite import InlineHiliteExtension +import xml.etree.ElementTree as etree + + from pygments import highlight from pygments.lexers import get_lexer_by_name, TextLexer from pygments.formatters import HtmlFormatter @@ -19,7 +29,7 @@ from zona.log import get_logger logger = get_logger() -class ZonaRenderer(HTMLRenderer): +class ZonaLinkProcessor(Treeprocessor): def __init__( self, config: ZonaConfig | None, @@ -40,73 +50,36 @@ class ZonaRenderer(HTMLRenderer): self.config: ZonaConfig | None = config @override - def render_link(self, element: Link): - href = element.dest - assert isinstance(href, str) - if self.resolve: - cur = Path(href) - _href = href - if href.startswith("/"): - # resolve relative to content root - resolved = ( - self.layout.content / cur.relative_to("/") - ).resolve() - else: - # treat as relative link and try to resolve - resolved = (self.source.parent / cur).resolve() - # only substitute if link points to an actual file - if resolved.exists(): - item = self.item_map.get(resolved) - if item: - href = util.normalize_url(item.url) - logger.debug( - f"Link in file {self.source}: {_href} resolved to {href}" - ) + def run(self, root: etree.Element): + for element in root.iter("a"): + href = element.get("href") + if not href: + continue + if self.resolve: + cur = Path(href) + _href = href + if href.startswith("/"): + # resolve relative to content root + resolved = ( + self.layout.content / cur.relative_to("/") + ).resolve() else: - logger.debug( - f"Warning: resolved path {resolved} not found in item map" - ) - body: Any = self.render_children(element) - return f'{body}' - - # TODO: image compression/dithering? - @override - def render_image(self, element: Image): - assert self.config - if not self.config.markdown.image_labels: - return super().render_image(element) - # get label text from children - text = self.render_children(element) - title = element.title or "" - caption = f"{text}" if text else "" - return ( - f'
\n' - # TODO: convert to plaintext and add as alt attribute - f'\n' - f"{caption}
" - ) - - @override - def render_fenced_code(self, element: FencedCode): - assert self.config - config = self.config.markdown.syntax_highlighting - if not config.enabled: - return super().render_fenced_code(element) - code = "".join(child.children for child in element.children) # type: ignore - lang = element.lang or "text" - - try: - lexer = get_lexer_by_name(lang, stripall=False) - except Exception: - lexer = TextLexer(stripall=False) # type: ignore - - formatter = get_formatter(self.config) - highlighted = highlight(code, lexer, formatter) # type: ignore - - return ( - f'
'
-            f"{highlighted}
" - ) + # treat as relative link and try to resolve + resolved = (self.source.parent / cur).resolve() + # only substitute if link points to an actual file + if resolved.exists(): + item = self.item_map.get(resolved) + if item: + href = util.normalize_url(item.url) + element.set("href", href) + logger.debug( + f"Link in file {self.source}: {_href} resolved to {href}" + ) + else: + logger.debug( + f"Warning: resolved path {resolved} not found in item map" + ) + element.set("target", "_blank") def get_formatter(config: ZonaConfig): @@ -125,26 +98,42 @@ def md_to_html( layout: Layout | None = None, item_map: dict[Path, Item] | None = None, ) -> str: - if resolve_links and ( - source is None or layout is None or item_map is None - ): - raise TypeError( - "md_to_html() missing source and ctx when resolve_links is true" + if config: + md = Markdown( + extensions=[ + CodeHiliteExtension( + linenums=False, + noclasses=False, + pygments_style=config.markdown.syntax_highlighting.theme, + ), + ExtraExtension(), + SmartyExtension(), + "pymdownx.tilde", + "pymdownx.caret", + "pymdownx.smartsymbols", + InlineHiliteExtension(css_class="codehilite"), + SaneListExtension(), + ] ) - parser = Parser() - ast = parser.parse(content) - renderer = ZonaRenderer( - config, - resolve_links, - source, - layout=layout, - item_map=item_map, - ) - return renderer.render(ast) + else: + md = Markdown() + if resolve_links: + if source is None or layout is None or item_map is None: + raise TypeError( + "md_to_html() missing source and ctx when resolve_links is true" + ) + md.treeprocessors.register( + item=ZonaLinkProcessor( + config, resolve_links, source, layout, item_map + ), + name="zona_links", + priority=15, + ) + return md.convert(content) def get_style_defs(config: ZonaConfig) -> str: formatter = get_formatter(config) - defs = formatter.get_style_defs("pre.code-block code") + defs = formatter.get_style_defs(".codehilite") assert isinstance(defs, str) return defs diff --git a/uv.lock b/uv.lock index 72e258c..3a0a146 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "markdown" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -200,6 +209,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/e8/ccd661508989a0469ee296909f2d6cae8ac24304f9db9afe706f0ef5b8d5/pygments_kakoune-0.1.0-py3-none-any.whl", hash = "sha256:573a933b61c7c7993f52fd02a04b7a936558da2b5a6690728732f2c0a67221a9", size = 3606, upload-time = "2025-06-30T20:27:30.457Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -398,10 +420,12 @@ source = { editable = "." } dependencies = [ { name = "dacite" }, { name = "jinja2" }, + { name = "markdown" }, { name = "marko" }, { name = "pygments" }, { name = "pygments-ashen" }, { name = "pygments-kakoune" }, + { name = "pymdown-extensions" }, { name = "python-dateutil" }, { name = "python-frontmatter" }, { name = "rich" }, @@ -421,10 +445,12 @@ dev = [ requires-dist = [ { name = "dacite", specifier = ">=1.9.2" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "markdown", specifier = ">=3.8.2" }, { name = "marko", specifier = ">=2.1.4" }, { name = "pygments", specifier = ">=2.19.1" }, { name = "pygments-ashen", specifier = ">=0.1.3" }, { name = "pygments-kakoune", specifier = ">=0.1.0" }, + { name = "pymdown-extensions", specifier = ">=10.16" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=14.0.0" },