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
markwright/__init__.py
ADDED
|
File without changes
|
markwright/_util.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ABOUTME: Shared utility functions and constants for markwright extensions.
|
|
2
|
+
# Provides fraction reduction and the permissive URL-prefix grammar used by embed extensions.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
URL_SCHEME_WWW_PREFIX = r"(?:(?:https?:)?//)?(?:www\.)?"
|
|
9
|
+
"""Regex fragment matching an optional ``scheme://`` and an optional ``www.``.
|
|
10
|
+
|
|
11
|
+
Shared by the Twitter and Instagram embed grammars (upstream ``twitter.js``
|
|
12
|
+
and ``instagram.js``), which both accept a URL prefix ranging from nothing
|
|
13
|
+
(a bare path) up to a full ``https://www.<host>``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def reduce_fraction(numerator: int, denominator: int) -> tuple[int, int]:
|
|
18
|
+
"""Reduce a fraction to its lowest terms.
|
|
19
|
+
|
|
20
|
+
A zero operand has no defined greatest common divisor, so the fraction is
|
|
21
|
+
returned unchanged rather than raising ``ZeroDivisionError``; the caller
|
|
22
|
+
decides how to treat a degenerate zero numerator or denominator.
|
|
23
|
+
|
|
24
|
+
:param numerator: The numerator of the fraction.
|
|
25
|
+
:param denominator: The denominator of the fraction.
|
|
26
|
+
:returns: Tuple of (reduced numerator, reduced denominator).
|
|
27
|
+
"""
|
|
28
|
+
if numerator == 0 or denominator == 0:
|
|
29
|
+
return numerator, denominator
|
|
30
|
+
divisor = math.gcd(numerator, denominator)
|
|
31
|
+
return numerator // divisor, denominator // divisor
|
markwright/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# ABOUTME: Command-line entry point for the mw markwright pipeline tool.
|
|
2
|
+
# Builds the argparse parser and dispatches list/pre/post/render; --version reports the package version.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
import markdown
|
|
11
|
+
|
|
12
|
+
from markwright import registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _package_version() -> str:
|
|
16
|
+
"""Return the installed markwright distribution version.
|
|
17
|
+
|
|
18
|
+
:returns: The version string for the ``markwright`` distribution.
|
|
19
|
+
"""
|
|
20
|
+
return version("markwright")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
"""Construct the ``mw`` argument parser with its subcommands.
|
|
25
|
+
|
|
26
|
+
:returns: A parser exposing ``--version`` and the ``list`` subcommand.
|
|
27
|
+
"""
|
|
28
|
+
parser = argparse.ArgumentParser(prog="mw", description="markwright Markdown pipeline CLI.")
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"mw {_package_version()}")
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
31
|
+
subparsers.add_parser("list", help="List registered extensions and the stages each provides.")
|
|
32
|
+
pre_parser = subparsers.add_parser("pre", help="Expand markwright source directives read from stdin.")
|
|
33
|
+
_add_selection_flags(pre_parser)
|
|
34
|
+
post_parser = subparsers.add_parser("post", help="Post-process rendered HTML read from stdin.")
|
|
35
|
+
_add_selection_flags(post_parser)
|
|
36
|
+
post_parser.add_argument("--warn", action="store_true", help="Report skipped markers to stderr.")
|
|
37
|
+
render_parser = subparsers.add_parser("render", help="Render Markdown from stdin to final HTML.")
|
|
38
|
+
_add_selection_flags(render_parser)
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _add_selection_flags(subparser: argparse.ArgumentParser) -> None:
|
|
43
|
+
"""Add the shared ``--use`` and ``--exclude`` selection flags to a subparser.
|
|
44
|
+
|
|
45
|
+
:param subparser: The subcommand parser to extend.
|
|
46
|
+
"""
|
|
47
|
+
subparser.add_argument("--use", action="append", default=[], help="Restrict to the named extension (repeatable).")
|
|
48
|
+
subparser.add_argument("--exclude", action="append", default=[], help="Drop the named extension (repeatable).")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_list() -> int:
|
|
52
|
+
"""Print each registered extension and its available stages.
|
|
53
|
+
|
|
54
|
+
:returns: Always ``0``.
|
|
55
|
+
"""
|
|
56
|
+
for name, stages in registry.describe():
|
|
57
|
+
print(f"{name}: {', '.join(stages)}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_selection(args: argparse.Namespace) -> list[str] | None:
|
|
62
|
+
"""Resolve the active extension names, reporting unknown names to stderr.
|
|
63
|
+
|
|
64
|
+
:param args: Parsed arguments carrying ``use`` and ``exclude`` lists.
|
|
65
|
+
:returns: Selected extension names, or ``None`` if a name is unknown (the
|
|
66
|
+
caller should then return exit code ``2``).
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return registry.select_extensions(args.use, args.exclude)
|
|
70
|
+
except ValueError as selection_error:
|
|
71
|
+
print(selection_error, file=sys.stderr)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_pre(args: argparse.Namespace) -> int:
|
|
76
|
+
"""Expand source directives from stdin and write the result to stdout.
|
|
77
|
+
|
|
78
|
+
:param args: Parsed arguments carrying ``use`` and ``exclude``.
|
|
79
|
+
:returns: ``0`` on success, ``2`` if a selected extension name is unknown.
|
|
80
|
+
"""
|
|
81
|
+
names = _resolve_selection(args)
|
|
82
|
+
if names is None:
|
|
83
|
+
return 2
|
|
84
|
+
sys.stdout.write(registry.run_pre(sys.stdin.read(), names))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run_post(args: argparse.Namespace) -> int:
|
|
89
|
+
"""Post-process HTML from stdin and write the result to stdout.
|
|
90
|
+
|
|
91
|
+
:param args: Parsed arguments carrying ``use``, ``exclude``, and ``warn``.
|
|
92
|
+
:returns: ``0`` on success, ``2`` if a selected extension name is unknown.
|
|
93
|
+
"""
|
|
94
|
+
names = _resolve_selection(args)
|
|
95
|
+
if names is None:
|
|
96
|
+
return 2
|
|
97
|
+
warnings: list[str] | None = [] if args.warn else None
|
|
98
|
+
rendered_html = registry.run_post(sys.stdin.read(), names, warnings)
|
|
99
|
+
sys.stdout.write(rendered_html)
|
|
100
|
+
if warnings is not None:
|
|
101
|
+
for warning in warnings:
|
|
102
|
+
print(warning, file=sys.stderr)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run_render(args: argparse.Namespace) -> int:
|
|
107
|
+
"""Render Markdown from stdin to final HTML using the in-process stack.
|
|
108
|
+
|
|
109
|
+
Builds a ``markdown.Markdown`` instance configured with ``pymdownx.superfences``
|
|
110
|
+
and ``pymdownx.highlight`` plus the selected ``markwright.*`` extensions, mirroring
|
|
111
|
+
the site stack so fence and highlight render correctly.
|
|
112
|
+
|
|
113
|
+
:param args: Parsed arguments carrying ``use`` and ``exclude``.
|
|
114
|
+
:returns: ``0`` on success, ``2`` if a selected extension name is unknown.
|
|
115
|
+
"""
|
|
116
|
+
names = _resolve_selection(args)
|
|
117
|
+
if names is None:
|
|
118
|
+
return 2
|
|
119
|
+
instance = markdown.Markdown(
|
|
120
|
+
extensions=["pymdownx.superfences", "pymdownx.highlight", *(f"markwright.{name}" for name in names)],
|
|
121
|
+
extension_configs={"pymdownx.highlight": {"pygments_lang_class": True}},
|
|
122
|
+
)
|
|
123
|
+
sys.stdout.write(instance.convert(sys.stdin.read()))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
"""Parse ``argv`` and dispatch to the selected subcommand.
|
|
129
|
+
|
|
130
|
+
:param argv: Argument vector, or ``None`` to read from ``sys.argv``.
|
|
131
|
+
:returns: Process exit code (``0`` success, ``2`` usage error).
|
|
132
|
+
"""
|
|
133
|
+
parser = build_parser()
|
|
134
|
+
try:
|
|
135
|
+
args = parser.parse_args(argv)
|
|
136
|
+
except SystemExit as exit_error:
|
|
137
|
+
return exit_error.code if isinstance(exit_error.code, int) else 2
|
|
138
|
+
if args.command == "list":
|
|
139
|
+
return _run_list()
|
|
140
|
+
if args.command == "pre":
|
|
141
|
+
return _run_pre(args)
|
|
142
|
+
if args.command == "post":
|
|
143
|
+
return _run_post(args)
|
|
144
|
+
if args.command == "render":
|
|
145
|
+
return _run_render(args)
|
|
146
|
+
parser.print_usage()
|
|
147
|
+
return 2
|
markwright/codepen.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# ABOUTME: CodePen embed extension for Python-Markdown.
|
|
2
|
+
# Converts [codepen USER HASH flags...] syntax to CodePen embed HTML with script injection.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
|
|
10
|
+
from markdown import Markdown
|
|
11
|
+
from markdown.extensions import Extension
|
|
12
|
+
from markdown.postprocessors import Postprocessor
|
|
13
|
+
from markdown.preprocessors import Preprocessor
|
|
14
|
+
|
|
15
|
+
CODEPEN_RE = re.compile(r"^\[codepen\s+(\S+)\s+(\S+)((?:\s+(?:lazy|light|dark|editable|html|css|js|result|\d+))*)\]$")
|
|
16
|
+
|
|
17
|
+
TAB_PRIORITY = ["html", "css", "js"]
|
|
18
|
+
|
|
19
|
+
DEFAULT_HEIGHT = 256
|
|
20
|
+
|
|
21
|
+
CODEPEN_SIGNATURE = 'class="codepen"'
|
|
22
|
+
|
|
23
|
+
CODEPEN_SCRIPT = (
|
|
24
|
+
'<script async defer src="https://static.codepen.io/assets/embed/ei.js" type="text/javascript"></script>'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_flags(raw_flags: str) -> dict[str, str | int | bool]:
|
|
29
|
+
"""Parse space-separated CodePen flags into a settings dictionary.
|
|
30
|
+
|
|
31
|
+
:param raw_flags: Raw space-separated flags string.
|
|
32
|
+
:returns: Dictionary with theme, height, tab, lazy, and editable settings.
|
|
33
|
+
"""
|
|
34
|
+
flags = raw_flags.split()
|
|
35
|
+
|
|
36
|
+
# Theme: dark wins if both present
|
|
37
|
+
theme = "dark" if "dark" in flags else "light"
|
|
38
|
+
|
|
39
|
+
# Height: first integer found, or default
|
|
40
|
+
height = DEFAULT_HEIGHT
|
|
41
|
+
for flag in flags:
|
|
42
|
+
if flag.isdigit():
|
|
43
|
+
height = int(flag)
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
# Lazy and editable
|
|
47
|
+
lazy = "lazy" in flags
|
|
48
|
+
editable = "editable" in flags
|
|
49
|
+
|
|
50
|
+
# Tab: priority is html > css > js; result can combine with another tab
|
|
51
|
+
selected_tab = ""
|
|
52
|
+
for tab_name in TAB_PRIORITY:
|
|
53
|
+
if tab_name in flags:
|
|
54
|
+
selected_tab = tab_name
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
has_result = "result" in flags
|
|
58
|
+
|
|
59
|
+
if selected_tab and has_result:
|
|
60
|
+
tab = f"{selected_tab},result"
|
|
61
|
+
elif selected_tab:
|
|
62
|
+
tab = selected_tab
|
|
63
|
+
else:
|
|
64
|
+
tab = "result"
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"theme": theme,
|
|
68
|
+
"height": height,
|
|
69
|
+
"tab": tab,
|
|
70
|
+
"lazy": lazy,
|
|
71
|
+
"editable": editable,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _render_match(line: str) -> str | None:
|
|
76
|
+
"""Build the CodePen embed HTML for a standalone embed line.
|
|
77
|
+
|
|
78
|
+
:param line: A single source line.
|
|
79
|
+
:returns: The embed HTML if the line is a CodePen embed, else ``None``.
|
|
80
|
+
"""
|
|
81
|
+
codepen_match = CODEPEN_RE.match(line.strip())
|
|
82
|
+
if not codepen_match:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
user = codepen_match.group(1)
|
|
86
|
+
hash_id = codepen_match.group(2)
|
|
87
|
+
raw_flags = codepen_match.group(3).strip()
|
|
88
|
+
settings = _parse_flags(raw_flags)
|
|
89
|
+
|
|
90
|
+
escaped_user = html.escape(str(user))
|
|
91
|
+
escaped_hash = html.escape(str(hash_id))
|
|
92
|
+
encoded_user = urllib.parse.quote(str(user), safe="")
|
|
93
|
+
encoded_hash = urllib.parse.quote(str(hash_id), safe="")
|
|
94
|
+
height = settings["height"]
|
|
95
|
+
theme = settings["theme"]
|
|
96
|
+
tab = settings["tab"]
|
|
97
|
+
|
|
98
|
+
lazy_attr = ' data-preview="true"' if settings["lazy"] else ""
|
|
99
|
+
editable_attr = ' data-editable="true"' if settings["editable"] else ""
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
f'<p class="codepen" data-height="{height}"'
|
|
103
|
+
f' data-theme-id="{theme}" data-default-tab="{tab}"'
|
|
104
|
+
f' data-user="{escaped_user}" data-slug-hash="{escaped_hash}"'
|
|
105
|
+
f"{lazy_attr}{editable_attr}"
|
|
106
|
+
f' style="height: {height}px; box-sizing: border-box;'
|
|
107
|
+
f" display: flex; align-items: center; justify-content: center;"
|
|
108
|
+
f' border: 2px solid; margin: 1em 0; padding: 1em;">\n'
|
|
109
|
+
f" <span>See the Pen"
|
|
110
|
+
f' <a href="https://codepen.io/{encoded_user}/pen/{encoded_hash}">'
|
|
111
|
+
f"{escaped_hash} by {escaped_user}</a>"
|
|
112
|
+
f' (<a href="https://codepen.io/{encoded_user}">@{escaped_user}</a>)'
|
|
113
|
+
f" on <a href='https://codepen.io'>CodePen</a>.</span>\n"
|
|
114
|
+
f"</p>"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def expand_source(text: str) -> str:
|
|
119
|
+
"""Expand standalone CodePen embed lines to embed HTML in raw source.
|
|
120
|
+
|
|
121
|
+
Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
|
|
122
|
+
HTML inline without any Python-Markdown stash placeholder.
|
|
123
|
+
|
|
124
|
+
:param text: The source text.
|
|
125
|
+
:returns: The text with standalone CodePen embeds replaced by HTML.
|
|
126
|
+
"""
|
|
127
|
+
return "\n".join(_render_match(line) or line for line in text.split("\n"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_html(rendered_html: str, warnings: list[str] | None = None) -> str:
|
|
131
|
+
"""Inject the CodePen embed script once if a CodePen embed is present.
|
|
132
|
+
|
|
133
|
+
Used by the ``mw post`` CLI stage and the in-process postprocessor. The
|
|
134
|
+
script is appended only when the CodePen class signature is present and the
|
|
135
|
+
script is not already injected, so the transform is idempotent.
|
|
136
|
+
|
|
137
|
+
:param rendered_html: Rendered HTML content.
|
|
138
|
+
:param warnings: Optional warnings list; unused (signature injection never warns).
|
|
139
|
+
:returns: HTML with the CodePen script appended if needed.
|
|
140
|
+
"""
|
|
141
|
+
if CODEPEN_SIGNATURE in rendered_html and CODEPEN_SCRIPT not in rendered_html:
|
|
142
|
+
return rendered_html + "\n" + CODEPEN_SCRIPT
|
|
143
|
+
return rendered_html
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CodePenPreprocessor(Preprocessor):
|
|
147
|
+
"""Replace [codepen ...] lines with CodePen embed HTML.
|
|
148
|
+
|
|
149
|
+
:param md: The Markdown instance.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def run(self, lines: list[str]) -> list[str]:
|
|
153
|
+
"""Process lines, replacing CodePen embed syntax with HTML.
|
|
154
|
+
|
|
155
|
+
:param lines: Source lines to process.
|
|
156
|
+
:returns: Modified lines with CodePen embeds replaced by HTML.
|
|
157
|
+
"""
|
|
158
|
+
output: list[str] = []
|
|
159
|
+
for line in lines:
|
|
160
|
+
embed_html = _render_match(line)
|
|
161
|
+
if embed_html is not None:
|
|
162
|
+
output.append(self.md.htmlStash.store(embed_html))
|
|
163
|
+
else:
|
|
164
|
+
output.append(line)
|
|
165
|
+
return output
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CodePenPostprocessor(Postprocessor):
|
|
169
|
+
"""Append the CodePen embed script tag once if any embeds are present.
|
|
170
|
+
|
|
171
|
+
:param md: The Markdown instance.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def run(self, text: str) -> str:
|
|
175
|
+
"""Append the script tag if a CodePen embed signature is present.
|
|
176
|
+
|
|
177
|
+
:param text: Rendered HTML content.
|
|
178
|
+
:returns: HTML with CodePen script appended if needed.
|
|
179
|
+
"""
|
|
180
|
+
return apply_html(text)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CodePenExtension(Extension):
|
|
184
|
+
"""Python-Markdown extension for CodePen embeds.
|
|
185
|
+
|
|
186
|
+
:param \\*\\*kwargs: Configuration options passed to the extension.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def extendMarkdown(self, md: Markdown) -> None:
|
|
190
|
+
"""Register the CodePen preprocessor and postprocessor.
|
|
191
|
+
|
|
192
|
+
:param md: The Markdown instance to extend.
|
|
193
|
+
"""
|
|
194
|
+
preprocessor = CodePenPreprocessor(md)
|
|
195
|
+
postprocessor = CodePenPostprocessor(md)
|
|
196
|
+
md.preprocessors.register(preprocessor, "do-codepen", 20)
|
|
197
|
+
md.postprocessors.register(postprocessor, "do-codepen-script", 15)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def makeExtension(**kwargs: object) -> CodePenExtension:
|
|
201
|
+
"""Create and return the CodePenExtension instance.
|
|
202
|
+
|
|
203
|
+
:param \\*\\*kwargs: Configuration options.
|
|
204
|
+
:returns: A configured CodePenExtension.
|
|
205
|
+
"""
|
|
206
|
+
return CodePenExtension(**kwargs)
|