markwright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ # ABOUTME: Image compare embed extension for Python-Markdown.
2
+ # Converts [compare URL1 URL2 height width] syntax to side-by-side image comparison with slider.
3
+
4
+ from __future__ import annotations
5
+
6
+ import html
7
+ import re
8
+
9
+ from markdown import Markdown
10
+ from markdown.extensions import Extension
11
+ from markdown.preprocessors import Preprocessor
12
+
13
+ COMPARE_RE = re.compile(r"^\[compare\s+(\S+)\s+(\S+)(?:\s+(\d+))?(?:\s+(\d+))?\]$")
14
+
15
+ DEFAULT_HEIGHT = 270
16
+ DEFAULT_WIDTH = 480
17
+
18
+ SVG_ARROW = (
19
+ '<svg class="control-arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
20
+ '<path fill="currentColor" d="M504.3 273.6c4.9-4.5 7.7-10.9 7.7-17.6s-2.8-13-7.7-17.6l-112-104c-7-6.5-17.2-8.2'
21
+ "-25.9-4.4s-14.4 12.5-14.4 22l0 56-192 0 0-56c0-9.5-5.7-18.2-14.4-22s-18.9-2.1-25.9 4.4l-112 104C2.8 243 0 "
22
+ "249.3 0 256s2.8 13 7.7 17.6l112 104c7 6.5 17.2 8.2 25.9 4.4s14.4-12.5 14.4-22l0-56 192 0 0 56c0 9.5 5.7 18.2 "
23
+ '14.4 22s18.9 2.1 25.9-4.4l112-104z"/>'
24
+ "</svg>"
25
+ )
26
+
27
+
28
+ def _render_match(line: str) -> str | None:
29
+ """Build the image compare HTML for a standalone compare embed line.
30
+
31
+ :param line: A single source line.
32
+ :returns: The compare HTML if the line is a compare embed, else ``None``.
33
+ """
34
+ compare_match = COMPARE_RE.match(line.strip())
35
+ if not compare_match:
36
+ return None
37
+
38
+ left_url = compare_match.group(1)
39
+ right_url = compare_match.group(2)
40
+ height = int(compare_match.group(3)) if compare_match.group(3) else DEFAULT_HEIGHT
41
+ width = int(compare_match.group(4)) if compare_match.group(4) else DEFAULT_WIDTH
42
+
43
+ return _build_compare_html(left_url, right_url, height, width)
44
+
45
+
46
+ def expand_source(text: str) -> str:
47
+ """Expand standalone compare embed lines to HTML in raw source.
48
+
49
+ Used by the ``mw pre`` CLI stage. Emits the HTML inline without any
50
+ Python-Markdown stash placeholder.
51
+
52
+ :param text: The source text.
53
+ :returns: The text with standalone compare embeds replaced by HTML.
54
+ """
55
+ return "\n".join(_render_match(line) or line for line in text.split("\n"))
56
+
57
+
58
+ class ImageComparePreprocessor(Preprocessor):
59
+ """Replace [compare ...] lines with image comparison HTML.
60
+
61
+ :param md: The Markdown instance.
62
+ """
63
+
64
+ def run(self, lines: list[str]) -> list[str]:
65
+ """Process lines, replacing image compare syntax with HTML.
66
+
67
+ :param lines: Source lines to process.
68
+ :returns: Modified lines with image compare embeds replaced by HTML.
69
+ """
70
+ output: list[str] = []
71
+ for line in lines:
72
+ # Stash the raw HTML so Markdown does not parse its contents. The
73
+ # oninput handler contains a JS backtick template literal that
74
+ # would otherwise be turned into an inline <code> span, and the
75
+ # block-level <div> would be wrapped in an invalid <p>.
76
+ compare_html = _render_match(line)
77
+ if compare_html is not None:
78
+ output.append(self.md.htmlStash.store(compare_html))
79
+ else:
80
+ output.append(line)
81
+ return output
82
+
83
+
84
+ def _build_compare_html(left_url: str, right_url: str, height: int, width: int) -> str:
85
+ """Build the image compare HTML from parsed arguments.
86
+
87
+ :param left_url: URL for the left image.
88
+ :param right_url: URL for the right image.
89
+ :param height: Compare widget height in pixels.
90
+ :param width: Compare widget width in pixels.
91
+ :returns: Complete image compare HTML string.
92
+ """
93
+ escaped_left = html.escape(left_url)
94
+ escaped_right = html.escape(right_url)
95
+
96
+ return (
97
+ f'<div class="image-compare" style="--value:50%; height: {height}px; width: {width}px;">\n'
98
+ f' <img class="image-left" src="{escaped_left}" alt="Image left"/>\n'
99
+ f' <img class="image-right" src="{escaped_right}" alt="Image right"/>\n'
100
+ f' <input type="range" class="control" min="0" max="100" value="50"\n'
101
+ f" oninput=\"this.parentNode.style.setProperty('--value', `${{this.value}}%`)\" />\n"
102
+ f" {SVG_ARROW}\n"
103
+ f"</div>"
104
+ )
105
+
106
+
107
+ class ImageCompareExtension(Extension):
108
+ """Python-Markdown extension for side-by-side image comparison.
109
+
110
+ :param \\*\\*kwargs: Configuration options passed to the extension.
111
+ """
112
+
113
+ def extendMarkdown(self, md: Markdown) -> None:
114
+ """Register the image compare preprocessor.
115
+
116
+ :param md: The Markdown instance to extend.
117
+ """
118
+ preprocessor = ImageComparePreprocessor(md)
119
+ md.preprocessors.register(preprocessor, "do-image-compare", 20)
120
+
121
+
122
+ def makeExtension(**kwargs: object) -> ImageCompareExtension:
123
+ """Create and return the ImageCompareExtension instance.
124
+
125
+ :param \\*\\*kwargs: Configuration options.
126
+ :returns: A configured ImageCompareExtension.
127
+ """
128
+ return ImageCompareExtension(**kwargs)
@@ -0,0 +1,193 @@
1
+ # ABOUTME: Instagram embed extension for Python-Markdown.
2
+ # Converts [instagram URL flags...] syntax to Instagram blockquote embeds with script injection.
3
+
4
+ from __future__ import annotations
5
+
6
+ import html
7
+ import re
8
+
9
+ from markdown import Markdown
10
+ from markdown.extensions import Extension
11
+ from markdown.postprocessors import Postprocessor
12
+ from markdown.preprocessors import Preprocessor
13
+
14
+ from markwright._util import URL_SCHEME_WWW_PREFIX
15
+
16
+ # Scheme, "www.", the instagram.com host, and a "p/"-style path segment are
17
+ # all optional, so inputs from a bare shortcode up to a full
18
+ # "https://www.instagram.com/p/<shortcode>" all match. Ported verbatim from
19
+ # upstream instagram.js's URL-matching grammar.
20
+ INSTAGRAM_RE = re.compile(
21
+ r"^\[instagram\s+(?:(?:" + URL_SCHEME_WWW_PREFIX + r"instagram\.com)?/)?(?:\w+/)?(\w+)"
22
+ r"((?:\s+(?:caption|left|center|right|\d+))*)\]$"
23
+ )
24
+
25
+ INSTAGRAM_PERMALINK_TEMPLATE = "https://www.instagram.com/p/{post_id}"
26
+ INSTAGRAM_HREF_TEMPLATE = "https://instagram.com/p/{post_id}"
27
+
28
+ INSTAGRAM_MIN_WIDTH = 326
29
+ INSTAGRAM_MAX_WIDTH = 550
30
+
31
+ INSTAGRAM_SIGNATURE = 'class="instagram-media"'
32
+
33
+ INSTAGRAM_SCRIPT = (
34
+ '<script async defer src="https://www.instagram.com/embed.js"'
35
+ ' onload="window.instgrm && window.instgrm.Embeds.process()"></script>'
36
+ )
37
+
38
+
39
+ def _parse_instagram_flags(raw_flags: str) -> dict[str, str | int | bool]:
40
+ """Parse space-separated Instagram flags into a settings dictionary.
41
+
42
+ :param raw_flags: Raw space-separated flags string.
43
+ :returns: Dictionary with caption, alignment, and width settings.
44
+ """
45
+ flags = raw_flags.split()
46
+
47
+ caption = "caption" in flags
48
+
49
+ alignment = "center"
50
+ for align_option in ("left", "right", "center"):
51
+ if align_option in flags:
52
+ alignment = align_option
53
+ break
54
+
55
+ width = 0
56
+ for flag in flags:
57
+ if flag.isdigit():
58
+ parsed_width = int(flag)
59
+ width = max(INSTAGRAM_MIN_WIDTH, min(INSTAGRAM_MAX_WIDTH, parsed_width))
60
+ break
61
+
62
+ return {
63
+ "caption": caption,
64
+ "alignment": alignment,
65
+ "width": width,
66
+ }
67
+
68
+
69
+ def _render_match(line: str) -> str | None:
70
+ """Build the Instagram embed HTML for a standalone embed line.
71
+
72
+ :param line: A single source line.
73
+ :returns: The embed HTML if the line is an Instagram embed, else ``None``.
74
+ """
75
+ instagram_match = INSTAGRAM_RE.match(line.strip())
76
+ if not instagram_match:
77
+ return None
78
+
79
+ post_id = instagram_match.group(1)
80
+ raw_flags = instagram_match.group(2).strip()
81
+ settings = _parse_instagram_flags(raw_flags)
82
+
83
+ escaped_href = html.escape(INSTAGRAM_HREF_TEMPLATE.format(post_id=post_id))
84
+ escaped_permalink = html.escape(INSTAGRAM_PERMALINK_TEMPLATE.format(post_id=post_id))
85
+
86
+ alignment = settings["alignment"]
87
+ width = int(settings["width"])
88
+ caption = settings["caption"]
89
+
90
+ align_attr = f' align="{alignment}"' if alignment != "center" else ""
91
+ caption_attr = " data-instgrm-captioned" if caption else ""
92
+ width_style = f' style="width: {width}px;"' if width > 0 else ""
93
+
94
+ return (
95
+ f'<div class="instagram"{align_attr}>\n'
96
+ f' <blockquote class="instagram-media"'
97
+ f' data-instgrm-permalink="{escaped_permalink}"'
98
+ f' data-instgrm-version="14"{caption_attr}{width_style}>\n'
99
+ f' <a href="{escaped_href}">View post</a>\n'
100
+ f" </blockquote>\n"
101
+ f"</div>"
102
+ )
103
+
104
+
105
+ def expand_source(text: str) -> str:
106
+ """Expand standalone Instagram embed lines to embed HTML in raw source.
107
+
108
+ Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
109
+ HTML inline without any Python-Markdown stash placeholder.
110
+
111
+ :param text: The source text.
112
+ :returns: The text with standalone Instagram embeds replaced by HTML.
113
+ """
114
+ return "\n".join(_render_match(line) or line for line in text.split("\n"))
115
+
116
+
117
+ def apply_html(rendered_html: str, warnings: list[str] | None = None) -> str:
118
+ """Inject the Instagram embed script once if an Instagram embed is present.
119
+
120
+ Used by the ``mw post`` CLI stage and the in-process postprocessor. The
121
+ script is appended only when the Instagram class signature is present and
122
+ the script is not already injected, so the transform is idempotent.
123
+
124
+ :param rendered_html: Rendered HTML content.
125
+ :param warnings: Optional warnings list; unused (signature injection never warns).
126
+ :returns: HTML with the Instagram script appended if needed.
127
+ """
128
+ if INSTAGRAM_SIGNATURE in rendered_html and INSTAGRAM_SCRIPT not in rendered_html:
129
+ return rendered_html + "\n" + INSTAGRAM_SCRIPT
130
+ return rendered_html
131
+
132
+
133
+ class InstagramPreprocessor(Preprocessor):
134
+ """Replace [instagram ...] lines with Instagram embed HTML.
135
+
136
+ :param md: The Markdown instance.
137
+ """
138
+
139
+ def run(self, lines: list[str]) -> list[str]:
140
+ """Process lines, replacing Instagram embed syntax with HTML.
141
+
142
+ :param lines: Source lines to process.
143
+ :returns: Modified lines with Instagram embeds replaced by HTML.
144
+ """
145
+ output: list[str] = []
146
+ for line in lines:
147
+ embed_html = _render_match(line)
148
+ if embed_html is not None:
149
+ output.append(self.md.htmlStash.store(embed_html))
150
+ else:
151
+ output.append(line)
152
+ return output
153
+
154
+
155
+ class InstagramPostprocessor(Postprocessor):
156
+ """Append the Instagram embed script tag once if any embeds are present.
157
+
158
+ :param md: The Markdown instance.
159
+ """
160
+
161
+ def run(self, text: str) -> str:
162
+ """Append the script tag if an Instagram embed signature is present.
163
+
164
+ :param text: Rendered HTML content.
165
+ :returns: HTML with Instagram script appended if needed.
166
+ """
167
+ return apply_html(text)
168
+
169
+
170
+ class InstagramExtension(Extension):
171
+ """Python-Markdown extension for Instagram embeds.
172
+
173
+ :param \\*\\*kwargs: Configuration options passed to the extension.
174
+ """
175
+
176
+ def extendMarkdown(self, md: Markdown) -> None:
177
+ """Register the Instagram preprocessor and postprocessor.
178
+
179
+ :param md: The Markdown instance to extend.
180
+ """
181
+ preprocessor = InstagramPreprocessor(md)
182
+ postprocessor = InstagramPostprocessor(md)
183
+ md.preprocessors.register(preprocessor, "do-instagram", 20)
184
+ md.postprocessors.register(postprocessor, "do-instagram-script", 15)
185
+
186
+
187
+ def makeExtension(**kwargs: object) -> InstagramExtension:
188
+ """Create and return the InstagramExtension instance.
189
+
190
+ :param \\*\\*kwargs: Configuration options.
191
+ :returns: A configured InstagramExtension.
192
+ """
193
+ return InstagramExtension(**kwargs)
markwright/py.typed ADDED
File without changes
markwright/registry.py ADDED
@@ -0,0 +1,133 @@
1
+ # ABOUTME: Declarative registry mapping each extension to its pre/post stage functions.
2
+ # Drives select_extensions, run_pre, run_post, and describe for the mw CLI pipeline.
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+ from typing import TypedDict
8
+
9
+ from markwright.codepen import apply_html as codepen_post
10
+ from markwright.codepen import expand_source as codepen_pre
11
+ from markwright.fence import apply_html as fence_post
12
+ from markwright.fence import expand_source as fence_pre
13
+ from markwright.highlight import apply_html as highlight_post
14
+ from markwright.highlight import expand_source as highlight_pre
15
+ from markwright.image_compare import expand_source as image_compare_pre
16
+ from markwright.instagram import apply_html as instagram_post
17
+ from markwright.instagram import expand_source as instagram_pre
18
+ from markwright.slideshow import expand_source as slideshow_pre
19
+ from markwright.twitter import apply_html as twitter_post
20
+ from markwright.twitter import expand_source as twitter_pre
21
+ from markwright.youtube import expand_source as youtube_pre
22
+
23
+ PreFn = Callable[[str], str]
24
+ PostFn = Callable[[str, list[str] | None], str]
25
+
26
+
27
+ class StageSpec(TypedDict):
28
+ """Stage functions and priorities for a single extension.
29
+
30
+ :ivar pre: Source-stage transform, or ``None`` if the extension has no pre stage.
31
+ :ivar post: HTML-stage transform, or ``None`` if the extension has no post stage.
32
+ :ivar pre_priority: Descending order key for the pre stage (higher runs first).
33
+ :ivar post_priority: Descending order key for the post stage (higher runs first).
34
+ """
35
+
36
+ pre: PreFn | None
37
+ post: PostFn | None
38
+ pre_priority: int
39
+ post_priority: int
40
+
41
+
42
+ REGISTRY: dict[str, StageSpec] = {
43
+ "youtube": {"pre": youtube_pre, "post": None, "pre_priority": 20, "post_priority": 0},
44
+ "slideshow": {"pre": slideshow_pre, "post": None, "pre_priority": 20, "post_priority": 0},
45
+ "image_compare": {"pre": image_compare_pre, "post": None, "pre_priority": 20, "post_priority": 0},
46
+ "codepen": {"pre": codepen_pre, "post": codepen_post, "pre_priority": 20, "post_priority": 15},
47
+ "twitter": {"pre": twitter_pre, "post": twitter_post, "pre_priority": 20, "post_priority": 15},
48
+ "instagram": {"pre": instagram_pre, "post": instagram_post, "pre_priority": 20, "post_priority": 15},
49
+ "fence": {"pre": fence_pre, "post": fence_post, "pre_priority": 40, "post_priority": 25},
50
+ "highlight": {"pre": highlight_pre, "post": highlight_post, "pre_priority": 10, "post_priority": 25},
51
+ }
52
+
53
+ EXTENSION_NAMES: tuple[str, ...] = tuple(REGISTRY)
54
+
55
+
56
+ def select_extensions(use: list[str], exclude: list[str]) -> list[str]:
57
+ """Resolve the active extension set from ``use`` and ``exclude`` filters.
58
+
59
+ An empty ``use`` selects every registered extension; a non-empty ``use`` restricts
60
+ to exactly those names. ``exclude`` then removes names from the result.
61
+
62
+ :param use: Names to include, or empty to include all.
63
+ :param exclude: Names to remove from the included set.
64
+ :returns: Selected extension names in registry order.
65
+ :raises ValueError: If any name in ``use`` or ``exclude`` is not registered.
66
+ """
67
+ for name in [*use, *exclude]:
68
+ if name not in REGISTRY:
69
+ raise ValueError(f"unknown extension: {name!r}")
70
+ included = use if use else list(EXTENSION_NAMES)
71
+ return [name for name in EXTENSION_NAMES if name in included and name not in exclude]
72
+
73
+
74
+ def _ordered(
75
+ names: list[str],
76
+ get_stage: Callable[[StageSpec], PreFn | PostFn | None],
77
+ get_priority: Callable[[StageSpec], int],
78
+ ) -> list[str]:
79
+ """Return selected names that have the given stage, ordered by descending priority.
80
+
81
+ :param names: Selected extension names.
82
+ :param get_stage: Accessor returning the stage function (or ``None``) for a spec.
83
+ :param get_priority: Accessor returning the stage priority for a spec.
84
+ :returns: Names with a non-``None`` stage function, highest priority first.
85
+ """
86
+ selected = [name for name in EXTENSION_NAMES if name in names and get_stage(REGISTRY[name]) is not None]
87
+ return sorted(selected, key=lambda name: get_priority(REGISTRY[name]), reverse=True)
88
+
89
+
90
+ def run_pre(text: str, names: list[str]) -> str:
91
+ """Apply each selected pre-stage transform to ``text`` in descending priority order.
92
+
93
+ :param text: Markdown source text.
94
+ :param names: Selected extension names.
95
+ :returns: Source text after every selected pre stage has run.
96
+ """
97
+ for name in _ordered(names, lambda spec: spec["pre"], lambda spec: spec["pre_priority"]):
98
+ pre_fn = REGISTRY[name]["pre"]
99
+ assert pre_fn is not None
100
+ text = pre_fn(text)
101
+ return text
102
+
103
+
104
+ def run_post(html: str, names: list[str], warnings: list[str] | None = None) -> str:
105
+ """Apply each selected post-stage transform to ``html`` in descending priority order.
106
+
107
+ :param html: Rendered HTML.
108
+ :param names: Selected extension names.
109
+ :param warnings: Optional list collecting skip reasons from stages that validate markers.
110
+ :returns: HTML after every selected post stage has run.
111
+ """
112
+ for name in _ordered(names, lambda spec: spec["post"], lambda spec: spec["post_priority"]):
113
+ post_fn = REGISTRY[name]["post"]
114
+ assert post_fn is not None
115
+ html = post_fn(html, warnings)
116
+ return html
117
+
118
+
119
+ def describe() -> list[tuple[str, list[str]]]:
120
+ """Report each registered extension and the stages it provides.
121
+
122
+ :returns: ``(name, stages)`` pairs in registry order, where ``stages`` lists
123
+ ``"pre"`` and/or ``"post"`` depending on which stage functions exist.
124
+ """
125
+ described: list[tuple[str, list[str]]] = []
126
+ for name in EXTENSION_NAMES:
127
+ stages: list[str] = []
128
+ if REGISTRY[name]["pre"] is not None:
129
+ stages.append("pre")
130
+ if REGISTRY[name]["post"] is not None:
131
+ stages.append("post")
132
+ described.append((name, stages))
133
+ return described
@@ -0,0 +1,150 @@
1
+ # ABOUTME: Slideshow embed extension for Python-Markdown.
2
+ # Converts [slideshow URL1 URL2 ... height width] syntax to image slideshow HTML.
3
+
4
+ from __future__ import annotations
5
+
6
+ import html
7
+ import re
8
+
9
+ from markdown import Markdown
10
+ from markdown.extensions import Extension
11
+ from markdown.preprocessors import Preprocessor
12
+
13
+ SLIDESHOW_RE = re.compile(r"^\[slideshow\s+(.+)\]$")
14
+
15
+ DEFAULT_HEIGHT = 270
16
+ DEFAULT_WIDTH = 480
17
+
18
+
19
+ def _parse_slideshow_args(raw_args: str) -> tuple[list[str], int, int]:
20
+ """Parse slideshow arguments into URLs and optional dimensions.
21
+
22
+ Separates URLs from trailing integer arguments (height, width).
23
+ Requires at least 1 URL, matching upstream slideshow.js:82 (which
24
+ rejects only zero images).
25
+
26
+ :param raw_args: The raw argument string after 'slideshow'.
27
+ :returns: Tuple of (urls, height, width). Returns empty urls list if there are no URLs.
28
+ """
29
+ parts = raw_args.split()
30
+
31
+ # Separate trailing integers from URLs
32
+ trailing_ints: list[int] = []
33
+ while parts and parts[-1].isdigit():
34
+ trailing_ints.insert(0, int(parts.pop()))
35
+
36
+ urls = parts
37
+
38
+ if len(urls) < 1:
39
+ return [], DEFAULT_HEIGHT, DEFAULT_WIDTH
40
+
41
+ height = trailing_ints[0] if len(trailing_ints) >= 1 else DEFAULT_HEIGHT
42
+ width = trailing_ints[1] if len(trailing_ints) >= 2 else DEFAULT_WIDTH
43
+
44
+ return urls, height, width
45
+
46
+
47
+ def _render_match(line: str) -> str | None:
48
+ """Build the slideshow HTML for a standalone slideshow embed line.
49
+
50
+ Returns ``None`` when the line is not a slideshow embed or has fewer than
51
+ two URLs.
52
+
53
+ :param line: A single source line.
54
+ :returns: The slideshow HTML if the line is a valid embed, else ``None``.
55
+ """
56
+ slideshow_match = SLIDESHOW_RE.match(line.strip())
57
+ if not slideshow_match:
58
+ return None
59
+
60
+ urls, height, width = _parse_slideshow_args(slideshow_match.group(1))
61
+ if not urls:
62
+ return None
63
+
64
+ return _build_slideshow_html(urls, height, width)
65
+
66
+
67
+ def expand_source(text: str) -> str:
68
+ """Expand standalone slideshow embed lines to HTML in raw source.
69
+
70
+ Used by the ``mw pre`` CLI stage. Emits the HTML inline without any
71
+ Python-Markdown stash placeholder.
72
+
73
+ :param text: The source text.
74
+ :returns: The text with standalone slideshow embeds replaced by HTML.
75
+ """
76
+ return "\n".join(_render_match(line) or line for line in text.split("\n"))
77
+
78
+
79
+ class SlideshowPreprocessor(Preprocessor):
80
+ """Replace [slideshow ...] lines with slideshow HTML.
81
+
82
+ :param md: The Markdown instance.
83
+ """
84
+
85
+ def run(self, lines: list[str]) -> list[str]:
86
+ """Process lines, replacing slideshow embed syntax with HTML.
87
+
88
+ :param lines: Source lines to process.
89
+ :returns: Modified lines with slideshow embeds replaced by HTML.
90
+ """
91
+ output: list[str] = []
92
+ for line in lines:
93
+ slideshow_html = _render_match(line)
94
+ if slideshow_html is not None:
95
+ output.append(self.md.htmlStash.store(slideshow_html))
96
+ else:
97
+ output.append(line)
98
+ return output
99
+
100
+
101
+ def _build_slideshow_html(urls: list[str], height: int, width: int) -> str:
102
+ """Build the slideshow HTML from parsed arguments.
103
+
104
+ :param urls: List of image URLs.
105
+ :param height: Slideshow height in pixels.
106
+ :param width: Slideshow width in pixels.
107
+ :returns: Complete slideshow HTML string.
108
+ """
109
+ slides = "\n".join(
110
+ f' <img src="{html.escape(url)}" alt="Slide #{slide_index}" />'
111
+ for slide_index, url in enumerate(urls, start=1)
112
+ )
113
+
114
+ scroll_left = "(() => this.parentNode.getElementsByClassName('slides')[0].scrollLeft"
115
+ left_arrow = f' <div class="action left" onclick="{scroll_left} -= {width})()">&#8249;</div>\n'
116
+ right_arrow = f' <div class="action right" onclick="{scroll_left} += {width})()">&#8250;</div>\n'
117
+
118
+ return (
119
+ f'<div class="slideshow" style="height: {height}px; width: {width}px;">\n'
120
+ f"{left_arrow}"
121
+ f"{right_arrow}"
122
+ f' <div class="slides">\n'
123
+ f"{slides}\n"
124
+ f" </div>\n"
125
+ f"</div>"
126
+ )
127
+
128
+
129
+ class SlideshowExtension(Extension):
130
+ """Python-Markdown extension for image slideshow embeds.
131
+
132
+ :param \\*\\*kwargs: Configuration options passed to the extension.
133
+ """
134
+
135
+ def extendMarkdown(self, md: Markdown) -> None:
136
+ """Register the slideshow preprocessor.
137
+
138
+ :param md: The Markdown instance to extend.
139
+ """
140
+ preprocessor = SlideshowPreprocessor(md)
141
+ md.preprocessors.register(preprocessor, "do-slideshow", 20)
142
+
143
+
144
+ def makeExtension(**kwargs: object) -> SlideshowExtension:
145
+ """Create and return the SlideshowExtension instance.
146
+
147
+ :param \\*\\*kwargs: Configuration options.
148
+ :returns: A configured SlideshowExtension.
149
+ """
150
+ return SlideshowExtension(**kwargs)