summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Fink2026-07-23 00:19:28 +0200
committerDennis Fink2026-07-23 00:19:28 +0200
commit2fc36e151300550b155fe37713429e365e8ec776 (patch)
tree9eebd7f759227f301fb780a390cfd41f87b5ff15
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--assets/static/css/style.css124
-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
4 files changed, 418 insertions, 4 deletions
diff --git a/assets/static/css/style.css b/assets/static/css/style.css
index 268c323..7237509 100644
--- a/assets/static/css/style.css
+++ b/assets/static/css/style.css
@@ -673,14 +673,117 @@ IMAGES
img {
height: auto;
max-width: 100%;
+}
+
+figure:not(.trail) {
+ width: fit-content;
+ max-width: 100%;
+ border: 1px solid var(--foreground);
+ border-radius: var(--default-border-radius);
+ margin: 0.75rem 0 0 0;
+ padding: 0.75rem;
+
+ &:has(+ h3) {
+ margin-top: 2rem;
+ }
+
+ &:has(+ p + *) {
+ margin-top: 0;
+ }
+ & > img {
+ display: block;
+ border-radius: var(--default-border-radius);
+ }
+
+ &.round > img {
+ border-radius: 100%;
+ }
+
+ &:is(
+ .w25,
+ .w33,
+ .w50,
+ .w66,
+ .w75,
+ .breakout-xxs,
+ .breakout-xs,
+ .breakout-sm,
+ .breakout-md,
+ .breakout-lg,
+ .breakout-xl,
+ .breakout-xxl,
+ .breakout-full
+ )
+ > img {
+ width: 100%;
+ }
+
+ figcaption {
+ color: var(--dim-foreground);
+ font-size: 0.85em;
+ line-height: 1.4;
+ margin-top: 0.5rem;
+ max-inline-size: none;
+ overflow-wrap: anywhere;
+
+ & > :first-child {
+ margin-top: 0;
+ }
+
+ & > :last-child {
+ margin-bottom: 0;
+ }
+
+ p {
+ font-size: inherit;
+ line-height: inherit;
+ max-inline-size: none;
+ font-style: italic;
+
+ & + p {
+ margin-top: 0.75rem;
+ }
+ }
+
+ ul,
+ ol,
+ dl {
+ font-size: inherit;
+ line-height: inherit;
+ margin-bottom: 0;
+ margin-top: 0.5rem;
+ }
+
+ li,
+ dd {
+ max-inline-size: none;
+ }
+ }
+
+ & + p {
+ margin-top: 0.75rem;
+ }
+}
+
+img.round {
+ border-radius: 100%;
+}
+
+:is(img, figure) {
&.center {
display: block;
margin-inline: auto;
}
- &.round {
- border-radius: 100%;
+ &.left {
+ float: left;
+ margin-right: 0.75rem;
+ }
+
+ &.right {
+ float: right;
+ margin-left: 0.75rem;
}
&.w25 {
@@ -755,18 +858,31 @@ img {
}
@media (max-width: 500px) {
+ &.left,
+ &.right {
+ float: none;
+ margin: 2rem auto;
+ }
+
&.breakout-xs,
&.breakout-sm,
&.breakout-md,
&.breakout-lg,
&.breakout-xl {
- width: 100vw;
- transform: none;
margin-left: calc(50% - 50vw);
+ transform: none;
+ width: 100vw;
}
}
}
+article::after,
+.clear {
+ clear: both;
+ content: "";
+ display: block;
+}
+
.missing-publication-date-warning {
background: var(--red);
color: var(--foreground);
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=[],
+)