summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorDennis Fink2026-07-23 00:19:28 +0200
committerDennis Fink2026-07-23 00:19:28 +0200
commit2fc36e151300550b155fe37713429e365e8ec776 (patch)
tree9eebd7f759227f301fb780a390cfd41f87b5ff15 /packages
parent1fe1d13c600b7dfe421b3b99b833b7d5d1232724 (diff)
downloaddennisfink.me-2fc36e151300550b155fe37713429e365e8ec776.tar.gz
dennisfink.me-2fc36e151300550b155fe37713429e365e8ec776.zip
Add Markdown figure support
This adds a project-local Markdown figure plugin so blog posts can use semantic `<figure>` blocks with arbitrary CSS classes, optional captions, and Markdown inside captions. The image CSS is also updated so figures share the existing width, breakout, float, centering, and rounded-image utilities while keeping captions styled and spaced consistently with the rest of the article layout.
Diffstat (limited to '')
-rw-r--r--packages/markdown-figure/README.md63
-rw-r--r--packages/markdown-figure/lektor_markdown_figure.py222
-rw-r--r--packages/markdown-figure/setup.py13
3 files changed, 298 insertions, 0 deletions
diff --git a/packages/markdown-figure/README.md b/packages/markdown-figure/README.md
new file mode 100644
index 0000000..a063f3c
--- /dev/null
+++ b/packages/markdown-figure/README.md
@@ -0,0 +1,63 @@
+# Lektor Markdown Figure
+
+A project-local Lektor plugin providing semantic Markdown figures with:
+
+- any whitespace-separated CSS classes;
+- an optional caption;
+- Markdown parsing inside the caption;
+- Lektor's normal attachment-relative image URL resolution.
+
+## Installation
+
+Place this directory at:
+
+```text
+packages/markdown-figure/
+```
+
+Restart `lektor server` after adding it.
+
+## Syntax
+
+```markdown
+:::figure right w50 breakout-sm
+![Alternative text](example.jpg "Optional image title")
+
+A caption with *emphasis*, **strong text**, [links](https://example.com/),
+and other Markdown.
+:::
+```
+
+The first non-empty line must be the Markdown image. Everything after that is
+the optional caption.
+
+Without a caption:
+
+```markdown
+:::figure center w75
+![Alternative text](example.jpg)
+:::
+```
+
+This produces no `<figcaption>` element.
+
+## Output
+
+```html
+<figure class="right w50 breakout-sm">
+ <img src="example.jpg" alt="Alternative text" title="Optional image title">
+ <figcaption>
+ <p>A caption with <em>emphasis</em>, …</p>
+ </figcaption>
+</figure>
+```
+
+The class names are not allow-listed or transformed. They are copied to the
+`class` attribute after HTML escaping and whitespace normalisation.
+
+## Notes
+
+- The opening marker is `:::figure`, followed by zero or more CSS classes.
+- The closing marker is `:::` on its own line.
+- Nested figure directives inside captions are intentionally not parsed.
+- A matched figure block whose first content line is not an image fails with a clear `ValueError`.
diff --git a/packages/markdown-figure/lektor_markdown_figure.py b/packages/markdown-figure/lektor_markdown_figure.py
new file mode 100644
index 0000000..e8523f8
--- /dev/null
+++ b/packages/markdown-figure/lektor_markdown_figure.py
@@ -0,0 +1,222 @@
+"""Lektor Markdown figures with arbitrary CSS classes and Markdown captions.
+
+Syntax::
+
+ :::figure right w50
+ ![Alternative text](image.jpg)
+
+ Caption with *Markdown* and [links](https://example.com/).
+ :::
+
+The first non-empty content line must be a Markdown image. Every remaining
+line is treated as the optional caption.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from typing import Any
+
+import mistune
+from lektor.pluginsystem import Plugin
+
+FIGURE_PATTERN = re.compile(
+ r" {0,3}:::figure(?:[ \t]+(?P<classes>[^\n]*?))?[ \t]*\n"
+ r"(?P<body>[\s\S]*?)"
+ r"\n {0,3}:::[ \t]*(?:\n+|$)"
+)
+
+# Mistune 0.8 renders this marker as inline HTML. The renderer mixin removes
+# it and, at the same time, avoids wrapping the figure image in a paragraph.
+_FIGURE_IMAGE_MARKER = "<!--lektor-markdown-figure:image-->"
+
+
+def _normalise_classes(value: str | None) -> str:
+ """Return whitespace-normalised, otherwise unrestricted CSS classes."""
+ return " ".join((value or "").split())
+
+
+def _class_attribute(classes: str) -> str:
+ if not classes:
+ return ""
+ return f' class="{html.escape(classes, quote=True)}"'
+
+
+def _split_figure(match: re.Match[str]) -> tuple[str, str, str]:
+ """Extract classes, the image line, and the optional caption source."""
+ classes = _normalise_classes(match.group("classes"))
+ lines = match.group("body").splitlines()
+
+ while lines and not lines[0].strip():
+ lines.pop(0)
+ while lines and not lines[-1].strip():
+ lines.pop()
+
+ if not lines:
+ raise ValueError("A figure directive must contain a Markdown image.")
+
+ image_source = lines.pop(0).strip()
+ if not image_source.startswith("!["):
+ raise ValueError(
+ "The first non-empty line in a figure directive must be a Markdown image."
+ )
+
+ caption_source = "\n".join(lines).strip()
+ return classes, image_source, caption_source
+
+
+# ---------------------------------------------------------------------------
+# Mistune 0.8 support (used by Lektor 3.3.x)
+# ---------------------------------------------------------------------------
+
+if hasattr(mistune, "BlockGrammar"):
+
+ class FigureBlockGrammar(mistune.BlockGrammar): # type: ignore[misc]
+ figure = FIGURE_PATTERN
+
+ class FigureBlockLexer(mistune.BlockLexer): # type: ignore[misc]
+ grammar_class = FigureBlockGrammar
+ default_rules = list(mistune.BlockLexer.default_rules)
+ default_rules.insert(default_rules.index("fences"), "figure")
+ caption_rules = [rule for rule in default_rules if rule != "figure"]
+
+ def parse_figure(self, match: re.Match[str]) -> None:
+ classes, image_source, caption_source = _split_figure(match)
+ class_attribute = _class_attribute(classes)
+
+ # ``close_html`` is Mistune 0.8's raw block-HTML output token.
+ self.tokens.append(
+ {
+ "type": "close_html",
+ "text": f"<figure{class_attribute}>\n",
+ }
+ )
+
+ # The marker lets FigureRendererMixin remove the otherwise
+ # automatic <p> wrapper while retaining Lektor's normal inline
+ # image renderer and attachment-relative URL resolution.
+ self.tokens.append(
+ {
+ "type": "paragraph",
+ "text": _FIGURE_IMAGE_MARKER + image_source,
+ }
+ )
+
+ if caption_source:
+ self.tokens.append({"type": "close_html", "text": "<figcaption>\n"})
+ self.parse(caption_source, self.caption_rules)
+ self.tokens.append({"type": "close_html", "text": "</figcaption>\n"})
+
+ self.tokens.append({"type": "close_html", "text": "</figure>\n"})
+
+ class FigureRendererMixin:
+ """Remove the paragraph wrapper from the directive's image only."""
+
+ def paragraph(self, text: str) -> str:
+ if text.startswith(_FIGURE_IMAGE_MARKER):
+ return text[len(_FIGURE_IMAGE_MARKER) :] + "\n"
+ return super().paragraph(text) # type: ignore[misc]
+
+else:
+ FigureBlockLexer = None # type: ignore[assignment,misc]
+ FigureRendererMixin = None # type: ignore[assignment,misc]
+
+
+# ---------------------------------------------------------------------------
+# Mistune 2 support (used by Lektor's newer Markdown controller)
+# ---------------------------------------------------------------------------
+
+
+def _render_figure(text: str, classes: str) -> str:
+ return f"<figure{_class_attribute(classes)}>\n{text}</figure>\n"
+
+
+def _render_figure_image(text: str) -> str:
+ return text + "\n"
+
+
+def _render_figure_caption(text: str) -> str:
+ return f"<figcaption>\n{text}</figcaption>\n"
+
+
+def _render_ast_figure(children: list[dict[str, Any]], classes: str) -> dict[str, Any]:
+ return {"type": "figure", "classes": classes.split(), "children": children}
+
+
+def _render_ast_figure_image(children: list[dict[str, Any]]) -> dict[str, Any]:
+ return {"type": "figure_image", "children": children}
+
+
+def _render_ast_figure_caption(children: list[dict[str, Any]]) -> dict[str, Any]:
+ return {"type": "figure_caption", "children": children}
+
+
+def mistune_figure_plugin(md: Any) -> None:
+ """Register the figure block with Mistune 2."""
+
+ def parse_figure(
+ block: Any, match: re.Match[str], state: dict[str, Any]
+ ) -> dict[str, Any]:
+ classes, image_source, caption_source = _split_figure(match)
+
+ children: list[dict[str, Any]] = [
+ {"type": "figure_image", "text": image_source}
+ ]
+
+ if caption_source:
+ caption_rules = [rule for rule in block.rules if rule != "figure"]
+ caption_children = block.parse(caption_source, state, caption_rules)
+ children.append({"type": "figure_caption", "children": caption_children})
+
+ return {
+ "type": "figure",
+ "children": children,
+ "params": (classes,),
+ }
+
+ md.block.register_rule("figure", FIGURE_PATTERN, parse_figure)
+ if "figure" not in md.block.rules:
+ md.block.rules.insert(0, "figure")
+
+ if md.renderer.NAME == "html":
+ md.renderer.register("figure", _render_figure)
+ md.renderer.register("figure_image", _render_figure_image)
+ md.renderer.register("figure_caption", _render_figure_caption)
+ elif md.renderer.NAME == "ast":
+ md.renderer.register("figure", _render_ast_figure)
+ md.renderer.register("figure_image", _render_ast_figure_image)
+ md.renderer.register("figure_caption", _render_ast_figure_caption)
+
+
+class MarkdownFigurePlugin(Plugin):
+ name = "Markdown Figure"
+ description = (
+ "Adds :::figure blocks with arbitrary CSS classes and optional "
+ "Markdown captions."
+ )
+
+ def on_markdown_config(self, config: Any, **extra: Any) -> None:
+ # Newer Lektor controller using Mistune 2.
+ if hasattr(config, "parser_options"):
+ print("Using mistune 2")
+ plugins = config.parser_options.setdefault("plugins", [])
+ if mistune_figure_plugin not in plugins:
+ plugins.append(mistune_figure_plugin)
+ return
+
+ print("Not using mistune 2")
+ # Stable Lektor 3.3 controller using Mistune 0.8.
+ if FigureBlockLexer is None or FigureRendererMixin is None:
+ raise RuntimeError("Unsupported Mistune version for Markdown Figure.")
+
+ existing_block = config.options.get("block")
+ if existing_block not in (None, FigureBlockLexer):
+ raise RuntimeError(
+ "Markdown Figure cannot be combined with another plugin "
+ "that replaces Mistune's block lexer."
+ )
+
+ config.options["block"] = FigureBlockLexer
+ if FigureRendererMixin not in config.renderer_mixins:
+ config.renderer_mixins.insert(0, FigureRendererMixin)
diff --git a/packages/markdown-figure/setup.py b/packages/markdown-figure/setup.py
new file mode 100644
index 0000000..a81f66e
--- /dev/null
+++ b/packages/markdown-figure/setup.py
@@ -0,0 +1,13 @@
+from setuptools import setup
+
+setup(
+ name="lektor-markdown-figure",
+ version="0.1.0",
+ py_modules=["lektor_markdown_figure"],
+ entry_points={
+ "lektor.plugins": [
+ "markdown-figure = lektor_markdown_figure:MarkdownFigurePlugin",
+ ]
+ },
+ install_requires=[],
+)