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'
'
- 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" },