summaryrefslogtreecommitdiff
path: root/packages/markdown-figure/lektor_markdown_figure.py
blob: e8523f83be5dd9a40a2c69da36d634575ba5b606 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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)