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.
- markwright/__init__.py +0 -0
- markwright/_util.py +31 -0
- markwright/cli.py +147 -0
- markwright/codepen.py +206 -0
- markwright/fence.py +483 -0
- markwright/highlight.py +189 -0
- markwright/image_compare.py +128 -0
- markwright/instagram.py +193 -0
- markwright/py.typed +0 -0
- markwright/registry.py +133 -0
- markwright/slideshow.py +150 -0
- markwright/twitter.py +187 -0
- markwright/youtube.py +106 -0
- markwright-0.1.0.dist-info/METADATA +194 -0
- markwright-0.1.0.dist-info/RECORD +20 -0
- markwright-0.1.0.dist-info/WHEEL +4 -0
- markwright-0.1.0.dist-info/entry_points.txt +3 -0
- markwright-0.1.0.dist-info/licenses/LICENSE +21 -0
- markwright-0.1.0.dist-info/licenses/LICENSE-Apache-2.0 +191 -0
- markwright-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -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)
|
markwright/instagram.py
ADDED
|
@@ -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
|
markwright/slideshow.py
ADDED
|
@@ -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})()">‹</div>\n'
|
|
116
|
+
right_arrow = f' <div class="action right" onclick="{scroll_left} += {width})()">›</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)
|