diff options
| author | Dennis Fink | 2026-07-23 00:19:28 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-07-23 00:19:28 +0200 |
| commit | 2fc36e151300550b155fe37713429e365e8ec776 (patch) | |
| tree | 9eebd7f759227f301fb780a390cfd41f87b5ff15 | |
| parent | 1fe1d13c600b7dfe421b3b99b833b7d5d1232724 (diff) | |
| download | dennisfink.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.css | 124 | ||||
| -rw-r--r-- | packages/markdown-figure/README.md | 63 | ||||
| -rw-r--r-- | packages/markdown-figure/lektor_markdown_figure.py | 222 | ||||
| -rw-r--r-- | packages/markdown-figure/setup.py | 13 |
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 + + +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 + +::: +``` + +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 +  + + 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=[], +) |
