From 699d3be0f66966ee193006535c812e159c72190e Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 1 May 2026 13:09:24 +0200 Subject: [PATCH 1/5] fix: add Python backtrace on Markdown rendering error Signed-off-by: squidfunk --- crates/zensical/src/structure/markdown.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index b35a0a8..a8a4d98 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -26,7 +26,7 @@ //! Markdown rendering. use anyhow::Result; -use pyo3::types::PyAnyMethods; +use pyo3::types::{PyAnyMethods, PyTracebackMethods}; use pyo3::{FromPyObject, Python}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -85,12 +85,21 @@ impl Markdown { module .call_method1("render", (content, id.location(), url))? .extract::() + }) + .map_err(|err| { + Python::attach(|py| { + let traceback = err + .traceback(py) + .and_then(|tb| tb.format().ok()) + .unwrap_or_default(); + anyhow::anyhow!("Python error: {err}\n{traceback}") + }) }); // Explicitly drop the lock guard here, so we're sure to hold it just // until after Python finished executing the rendering logic drop(guard); - res.map_err(Into::into).map(|markdown| Markdown { + res.map(|markdown| Markdown { title: extract_title(&id, &markdown), meta: markdown.meta, content: markdown.content, From c050add8a02dce94b02ab660dc77e0d668fc6477 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 1 May 2026 13:12:13 +0200 Subject: [PATCH 2/5] fix: error when setting `caption_position` on `glightbox` extension (#604) Signed-off-by: squidfunk --- python/zensical/extensions/glightbox.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/python/zensical/extensions/glightbox.py b/python/zensical/extensions/glightbox.py index 93e5e8a..942e911 100644 --- a/python/zensical/extensions/glightbox.py +++ b/python/zensical/extensions/glightbox.py @@ -124,10 +124,12 @@ class GlightboxTreeprocessor(TreeprocessorExt): el.set("data-description", description) # Set image description position - if caption_position := ( - img.get("data-caption-position") - or self.config.get("caption_position") - ): + if ( + caption_position := ( + img.get("data-caption-position") + or self.config.get("caption_position") + ) + ) and caption_position != "bottom": el.set("data-desc-position", str(caption_position)) # Set gallery grouping @@ -258,7 +260,7 @@ class GlightboxExtension(ExtensionExt): "Use img alt attribute as the caption when no title is set.", ], "caption_position": [ - None, + "bottom", "Default caption position: bottom, top, left, or right.", ], } From 8ed39039cea78fca31461dab443e144640b92609 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 1 May 2026 13:27:06 +0200 Subject: [PATCH 3/5] chore: disable ruff's `N802` globally Signed-off-by: squidfunk --- .ruff.toml | 2 +- python/zensical/extensions/glightbox.py | 2 +- python/zensical/extensions/links.py | 2 +- python/zensical/extensions/preview.py | 4 ++-- python/zensical/extensions/search.py | 4 ++-- python/zensical/markdown/extensions.py | 3 +-- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 48751af..fceaf8c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -56,6 +56,7 @@ ignore = [ "ERA001", # Commented out code "FBT001", # Boolean positional parameter in function signature "FBT003", # Boolean positional value in function call + "N802", # Function name should be lowercase "G004", # Logging statement uses f-string "PLR0911", # Too many return statements "PLR0912", # Too many branches @@ -100,4 +101,3 @@ convention = "google" [format] docstring-code-format = true docstring-code-line-length = 80 - diff --git a/python/zensical/extensions/glightbox.py b/python/zensical/extensions/glightbox.py index 942e911..57b6962 100644 --- a/python/zensical/extensions/glightbox.py +++ b/python/zensical/extensions/glightbox.py @@ -284,6 +284,6 @@ class GlightboxExtension(ExtensionExt): # ----------------------------------------------------------------------------- -def makeExtension(**kwargs: Any) -> GlightboxExtension: # noqa: N802 +def makeExtension(**kwargs: Any) -> GlightboxExtension: """Register Markdown extension.""" return GlightboxExtension(**kwargs) diff --git a/python/zensical/extensions/links.py b/python/zensical/extensions/links.py index 0e36408..35cfb6f 100644 --- a/python/zensical/extensions/links.py +++ b/python/zensical/extensions/links.py @@ -137,7 +137,7 @@ class LinksExtension(ExtensionExt): self.path = path self.use_directory_urls = use_directory_urls - def extendMarkdown(self, md: MarkdownExt) -> None: # noqa: N802 + def extendMarkdown(self, md: MarkdownExt) -> None: """Register Markdown extension.""" md.registerExtension(self) diff --git a/python/zensical/extensions/preview.py b/python/zensical/extensions/preview.py index e4ad757..48942db 100644 --- a/python/zensical/extensions/preview.py +++ b/python/zensical/extensions/preview.py @@ -143,7 +143,7 @@ class PreviewExtension(ExtensionExt): } super().__init__(*args, **kwargs) - def extendMarkdown(self, md: MarkdownExt) -> None: # noqa: N802 + def extendMarkdown(self, md: MarkdownExt) -> None: """Register Markdown extension.""" md.registerExtension(self) @@ -188,6 +188,6 @@ def resolve(processor_path: str, url_path: str) -> str: return posixpath.join(*base_segments) -def makeExtension(**kwargs: Any) -> PreviewExtension: # noqa: N802 +def makeExtension(**kwargs: Any) -> PreviewExtension: """Register Markdown extension.""" return PreviewExtension(**kwargs) diff --git a/python/zensical/extensions/search.py b/python/zensical/extensions/search.py index 9bb8753..cf4a375 100644 --- a/python/zensical/extensions/search.py +++ b/python/zensical/extensions/search.py @@ -78,13 +78,13 @@ class SearchExtension(ExtensionExt): self.config = {"keep": [set(), "Set of HTML tags to keep in output"]} super().__init__(**kwargs) - def extendMarkdown(self, md: MarkdownExt) -> None: # noqa: N802 + def extendMarkdown(self, md: MarkdownExt) -> None: """Register the PostProcessor with Markdown.""" processor = SearchProcessor(md) md.postprocessors.register(processor, "search", 0) -def makeExtension(**kwargs: Any) -> SearchExtension: # noqa: N802 +def makeExtension(**kwargs: Any) -> SearchExtension: """Factory function for creating the extension.""" return SearchExtension(**kwargs) diff --git a/python/zensical/markdown/extensions.py b/python/zensical/markdown/extensions.py index 74a73b4..d16e9ae 100644 --- a/python/zensical/markdown/extensions.py +++ b/python/zensical/markdown/extensions.py @@ -47,7 +47,6 @@ class ExtensionExt(Extension): `MarkdownExt` instance, which includes the page and configuration. """ - def extendMarkdown(self, md: MarkdownExt) -> None: # noqa: N802 # ty:ignore[invalid-method-override] + def extendMarkdown(self, md: MarkdownExt) -> None: # ty:ignore[invalid-method-override] """Register Markdown extension.""" super().extendMarkdown(md) - From 3e27d6954b8df070f03be23f8bd1b11a026c7fbb Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 1 May 2026 13:36:14 +0200 Subject: [PATCH 4/5] refactor: move `glightbox` config options to dataclass Signed-off-by: squidfunk --- python/zensical/extensions/glightbox.py | 72 ++++++++++++++----------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/python/zensical/extensions/glightbox.py b/python/zensical/extensions/glightbox.py index 57b6962..2c43261 100644 --- a/python/zensical/extensions/glightbox.py +++ b/python/zensical/extensions/glightbox.py @@ -24,7 +24,8 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, cast +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any from xml.etree.ElementTree import Element, ParseError, fromstring, tostring from zensical.markdown.extensions import ExtensionExt, MarkdownExt @@ -45,6 +46,22 @@ _RE = re.compile(r"]*?>", re.IGNORECASE) # ----------------------------------------------------------------------------- +@dataclass +class GlightboxConfig: + """Configuration for the Glightbox Markdown extension.""" + + width: str = "auto" + height: str = "auto" + skip_classes: list[str] = field(default_factory=list) + auto: bool = True + auto_themed: bool = False + auto_caption: bool = False + caption_position: str = "bottom" + + +# ----------------------------------------------------------------------------- + + class GlightboxTreeprocessor(TreeprocessorExt): """Wraps image elements in anchor tags to integrate with GLightbox.""" @@ -52,15 +69,13 @@ class GlightboxTreeprocessor(TreeprocessorExt): {"emojione", "twemoji", "gemoji", "off-glb"} ) - def __init__(self, md: MarkdownExt, config: dict[str, object]): + def __init__(self, md: MarkdownExt, config: GlightboxConfig): super().__init__(md) self.config = config def run(self, root: Element) -> None: """Walk the element tree and wrap images with anchors.""" - skip_classes = self.SKIP_CLASSES | frozenset( - cast("list[str]", self.config.get("skip_classes") or []) - ) + skip_classes = self.SKIP_CLASSES | frozenset(self.config.skip_classes) # Iterate over all images in the tree and wrap them with anchors for img in list(root.iter("img")): @@ -74,7 +89,7 @@ class GlightboxTreeprocessor(TreeprocessorExt): return True # If manual mode is enabled, only wrap images explicitly marked - return not self.config.get("auto") and "on-glb" not in classes + return not self.config.auto and "on-glb" not in classes def _wrap_with_anchor(self, img: Element, root: Element) -> None: """Wrap an image with an anchor.""" @@ -106,16 +121,14 @@ class GlightboxTreeprocessor(TreeprocessorExt): el.set("data-type", "image") # Only set width/height if explicitly configured - if width := self.config.get("width"): - el.set("data-width", str(width)) - if height := self.config.get("height"): - el.set("data-height", str(height)) + if self.config.width != "auto": + el.set("data-width", self.config.width) + if self.config.height != "auto": + el.set("data-height", self.config.height) - # Set image title - auto_caption = bool(self.config.get("auto_caption", False)) - title = img.get("data-title") or ( - img.get("alt") if auto_caption else None - ) + # Set image title, or auto-caption from alt if enabled + alt = img.get("alt") if self.config.auto_caption else None + title = img.get("data-title", alt) if title: el.set("data-title", title) @@ -124,13 +137,11 @@ class GlightboxTreeprocessor(TreeprocessorExt): el.set("data-description", description) # Set image description position - if ( - caption_position := ( - img.get("data-caption-position") - or self.config.get("caption_position") - ) - ) and caption_position != "bottom": - el.set("data-desc-position", str(caption_position)) + caption_position = img.get( + "data-caption-position", self.config.caption_position + ) + if caption_position and caption_position != "bottom": + el.set("data-desc-position", caption_position) # Set gallery grouping if gallery := self._resolve_gallery(img): @@ -158,7 +169,7 @@ class GlightboxTreeprocessor(TreeprocessorExt): # If auto-themed grouping is enabled, group images by light/dark mode # hints in the URL (e.g. from GitHub's light/dark mode image syntax) - if self.config.get("auto_themed"): + if self.config.auto_themed: if "#only-light" in src or "#gh-light-mode-only" in src: return "light" if "#only-dark" in src or "#gh-dark-mode-only" in src: @@ -184,21 +195,21 @@ class GlightboxPostprocessor(PostprocessorExt): parse and modify the HTML with an actual parser. """ - def __init__(self, md: MarkdownExt, config: dict[str, object]): + def __init__(self, md: MarkdownExt, processor: GlightboxTreeprocessor): super().__init__(md) - self._processor = GlightboxTreeprocessor(md, config) + self._processor = processor self._processed: set[int] = set() # Source classes to skip from postprocessor self._skip_classes = GlightboxTreeprocessor.SKIP_CLASSES | frozenset( - cast("list[str]", config.get("skip_classes") or []) + processor.config.skip_classes ) def run(self, text: str) -> str: """Wrap images in stashed HTML blocks.""" for i, raw in enumerate(self.md.htmlStash.rawHtmlBlocks): if i not in self._processed: - self.md.htmlStash.rawHtmlBlocks[i] = _RE.sub( # ty:ignore[no-matching-overload] + self.md.htmlStash.rawHtmlBlocks[i] = _RE.sub( self._maybe_process, raw ) self._processed.add(i) @@ -266,16 +277,17 @@ class GlightboxExtension(ExtensionExt): } super().__init__(**kwargs) - def extendMarkdown(self, md: MarkdownExt) -> None: # noqa: N802 + def extendMarkdown(self, md: MarkdownExt) -> None: """Register Markdown extension.""" md.registerExtension(self) + config = GlightboxConfig(**self.getConfigs()) # Register treeprocessor - run after `attr_list` (priority 8) - treeprocessor = GlightboxTreeprocessor(md, self.getConfigs()) + treeprocessor = GlightboxTreeprocessor(md, config) md.treeprocessors.register(treeprocessor, "glightbox", 7) # Register postprocessor - run before `raw_html` (priority 30) - postprocessor = GlightboxPostprocessor(md, self.getConfigs()) + postprocessor = GlightboxPostprocessor(md, treeprocessor) md.postprocessors.register(postprocessor, "glightbox", 31) From dfc74bf9217756d7dc768406915008c5df95cbf9 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 1 May 2026 13:39:22 +0200 Subject: [PATCH 5/5] chore: fix `ty` warning Signed-off-by: squidfunk --- python/zensical/extensions/glightbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/zensical/extensions/glightbox.py b/python/zensical/extensions/glightbox.py index 2c43261..6eda31a 100644 --- a/python/zensical/extensions/glightbox.py +++ b/python/zensical/extensions/glightbox.py @@ -209,7 +209,7 @@ class GlightboxPostprocessor(PostprocessorExt): """Wrap images in stashed HTML blocks.""" for i, raw in enumerate(self.md.htmlStash.rawHtmlBlocks): if i not in self._processed: - self.md.htmlStash.rawHtmlBlocks[i] = _RE.sub( + self.md.htmlStash.rawHtmlBlocks[i] = _RE.sub( # ty:ignore[no-matching-overload] self._maybe_process, raw ) self._processed.add(i)