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/fence.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# ABOUTME: Fence extension adding labels, environments, and line prefixes to code blocks.
|
|
2
|
+
# Coordinates a Preprocessor and Postprocessor and exposes the mw-fence stage functions.
|
|
3
|
+
|
|
4
|
+
# Fence directives travel from the pre stage to the post stage as an HTML comment
|
|
5
|
+
# placed immediately before the fence: ``<!-- mw-fence:{JSON} -->``. This comment is
|
|
6
|
+
# the only cross-tool contract, so its payload is versioned. The v1 schema is::
|
|
7
|
+
#
|
|
8
|
+
# {
|
|
9
|
+
# "version": 1, # integer, the only required field
|
|
10
|
+
# "label": "deploy.sh", # optional
|
|
11
|
+
# "secondary_label": "...", # optional
|
|
12
|
+
# "environment": "local", # optional
|
|
13
|
+
# "prefix_type": "command", # optional: line_numbers | command | super_user | custom_prefix
|
|
14
|
+
# "prefix_value": "$" # optional: rendered prefix, absent for line_numbers
|
|
15
|
+
# }
|
|
16
|
+
#
|
|
17
|
+
# Every field other than ``version`` is optional and present only when the author used
|
|
18
|
+
# that directive. The post stage applies whatever fields it recognizes and skips a
|
|
19
|
+
# marker (fail-soft, optionally warning) when the JSON is malformed, the version is
|
|
20
|
+
# unsupported, or no code block follows.
|
|
21
|
+
#
|
|
22
|
+
# The serialized payload is comment-safe: ``_encode_marker_payload`` escapes every
|
|
23
|
+
# literal ``<``/``>`` in the JSON to its ``\uXXXX`` form before it is written between
|
|
24
|
+
# ``<!--`` and ``-->``, so a directive value containing ``-->`` can never close the
|
|
25
|
+
# comment early. The transform is self-reversing: ``json.loads`` restores the
|
|
26
|
+
# characters on read, so the post stage needs no manual decode step.
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import html
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
from markdown import Markdown
|
|
35
|
+
from markdown.extensions import Extension
|
|
36
|
+
from markdown.postprocessors import Postprocessor
|
|
37
|
+
from markdown.preprocessors import Preprocessor
|
|
38
|
+
|
|
39
|
+
MARKER_NAME = "mw-fence"
|
|
40
|
+
MARKER_VERSION = 1
|
|
41
|
+
DEFAULT_LABEL_CLASS = "code-label"
|
|
42
|
+
DEFAULT_SECONDARY_LABEL_CLASS = "secondary-code-label"
|
|
43
|
+
|
|
44
|
+
FENCE_RE = re.compile(r"^(`{3,}|~{3,})")
|
|
45
|
+
LABEL_RE = re.compile(r"^\[label (.+)\]$")
|
|
46
|
+
SECONDARY_LABEL_RE = re.compile(r"^\[secondary_label (.+)\]$")
|
|
47
|
+
ENVIRONMENT_RE = re.compile(r"^\[environment (.+)\]$")
|
|
48
|
+
COMMENT_RE = re.compile(rf"<!-- {MARKER_NAME}:(.*?) -->")
|
|
49
|
+
CUSTOM_PREFIX_RE = re.compile(r"^custom_prefix\((.+)\)$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _encode_marker_payload(payload: dict[str, object]) -> str:
|
|
53
|
+
"""Serialize a marker payload for safe embedding inside an HTML comment.
|
|
54
|
+
|
|
55
|
+
``json.dumps`` leaves ``<`` and ``>`` as literal characters, so a directive
|
|
56
|
+
value containing ``-->`` would close the comment early and inject live
|
|
57
|
+
markup. Escaping both to their JSON ``\\uXXXX`` forms removes every literal
|
|
58
|
+
``<``/``>`` from the comment body, so ``-->`` and ``<!--`` can never form
|
|
59
|
+
inside it. ``json.loads`` restores the original characters on read, so this
|
|
60
|
+
is self-reversing and requires no read-side change.
|
|
61
|
+
|
|
62
|
+
:param payload: The marker payload to serialize.
|
|
63
|
+
:returns: JSON text with every literal ``<`` and ``>`` unicode-escaped.
|
|
64
|
+
"""
|
|
65
|
+
return json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_prefix_from_info(info_string: str) -> tuple[str, dict[str, str]]:
|
|
69
|
+
"""Parse prefix flags from a fence info string and return cleaned info + prefix metadata.
|
|
70
|
+
|
|
71
|
+
:param info_string: The raw info string after the opening fence markers.
|
|
72
|
+
:returns: Tuple of (cleaned info string, prefix metadata dict).
|
|
73
|
+
"""
|
|
74
|
+
parts = info_string.split(",")
|
|
75
|
+
prefix_metadata: dict[str, str] = {}
|
|
76
|
+
remaining_parts: list[str] = []
|
|
77
|
+
add_bash = False
|
|
78
|
+
|
|
79
|
+
for part in parts:
|
|
80
|
+
stripped_part = part.strip()
|
|
81
|
+
if stripped_part == "line_numbers":
|
|
82
|
+
prefix_metadata["prefix_type"] = "line_numbers"
|
|
83
|
+
elif stripped_part == "command":
|
|
84
|
+
prefix_metadata["prefix_type"] = "command"
|
|
85
|
+
prefix_metadata["prefix_value"] = "$"
|
|
86
|
+
add_bash = True
|
|
87
|
+
elif stripped_part == "super_user":
|
|
88
|
+
prefix_metadata["prefix_type"] = "super_user"
|
|
89
|
+
prefix_metadata["prefix_value"] = "#"
|
|
90
|
+
add_bash = True
|
|
91
|
+
else:
|
|
92
|
+
custom_match = CUSTOM_PREFIX_RE.match(stripped_part)
|
|
93
|
+
if custom_match:
|
|
94
|
+
raw_prefix = custom_match.group(1).replace("\\s", " ")
|
|
95
|
+
prefix_metadata["prefix_type"] = "custom_prefix"
|
|
96
|
+
prefix_metadata["prefix_value"] = raw_prefix
|
|
97
|
+
add_bash = True
|
|
98
|
+
else:
|
|
99
|
+
remaining_parts.append(stripped_part)
|
|
100
|
+
|
|
101
|
+
if add_bash and not remaining_parts:
|
|
102
|
+
remaining_parts.append("bash")
|
|
103
|
+
|
|
104
|
+
cleaned_info = ",".join(remaining_parts)
|
|
105
|
+
return cleaned_info, prefix_metadata
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_LINE_SPAN_OPEN = '<span class="line"'
|
|
109
|
+
_SPAN_TAG_RE = re.compile(r"<(/?)span\b[^>]*>")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _split_line_spans(code_content: str) -> list[str]:
|
|
113
|
+
"""Split Chroma-wrapped code into one string per ``<span class="line">`` block.
|
|
114
|
+
|
|
115
|
+
:param code_content: Rendered code containing ``<span class="line">`` line wrappers.
|
|
116
|
+
:returns: One balanced ``<span class="line">...</span>`` string per line.
|
|
117
|
+
"""
|
|
118
|
+
lines: list[str] = []
|
|
119
|
+
cursor = 0
|
|
120
|
+
while True:
|
|
121
|
+
start = code_content.find(_LINE_SPAN_OPEN, cursor)
|
|
122
|
+
if start == -1:
|
|
123
|
+
break
|
|
124
|
+
depth = 0
|
|
125
|
+
end = len(code_content)
|
|
126
|
+
for tag_match in _SPAN_TAG_RE.finditer(code_content, start):
|
|
127
|
+
depth += -1 if tag_match.group(1) else 1
|
|
128
|
+
if depth == 0:
|
|
129
|
+
end = tag_match.end()
|
|
130
|
+
break
|
|
131
|
+
lines.append(code_content[start:end])
|
|
132
|
+
cursor = end
|
|
133
|
+
return lines
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _split_rendered_lines(code_content: str) -> list[str]:
|
|
137
|
+
"""Split rendered code content into visual lines for prefix wrapping.
|
|
138
|
+
|
|
139
|
+
Pygments-style highlighters emit flat, newline-delimited lines, so a split on
|
|
140
|
+
newlines is correct. Chroma (Hugo) wraps each line in ``<span class="line">...
|
|
141
|
+
</span>`` with the newline inside the span; splitting that on newlines would cut
|
|
142
|
+
a line span in half and emit a spurious empty prefixed line, so its line spans
|
|
143
|
+
are split on their own boundaries instead.
|
|
144
|
+
|
|
145
|
+
:param code_content: The raw content between <code> and </code> tags.
|
|
146
|
+
:returns: One string per visual line.
|
|
147
|
+
"""
|
|
148
|
+
if _LINE_SPAN_OPEN in code_content:
|
|
149
|
+
return _split_line_spans(code_content)
|
|
150
|
+
flat = code_content[:-1] if code_content.endswith("\n") else code_content
|
|
151
|
+
return flat.split("\n")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _wrap_lines_with_prefix(code_content: str, prefix_metadata: dict[str, str]) -> str:
|
|
155
|
+
"""Wrap each code line in <li> elements with data-prefix attributes inside an <ol>.
|
|
156
|
+
|
|
157
|
+
:param code_content: The raw content between <code> and </code> tags.
|
|
158
|
+
:param prefix_metadata: Metadata dict containing prefix_type and optionally prefix_value.
|
|
159
|
+
:returns: The content wrapped in <ol><li data-prefix="..."> elements.
|
|
160
|
+
"""
|
|
161
|
+
prefix_type = prefix_metadata["prefix_type"]
|
|
162
|
+
prefix_value = prefix_metadata.get("prefix_value", "")
|
|
163
|
+
|
|
164
|
+
code_lines = _split_rendered_lines(code_content)
|
|
165
|
+
wrapped_lines: list[str] = []
|
|
166
|
+
|
|
167
|
+
for line_index, line_text in enumerate(code_lines):
|
|
168
|
+
current_prefix = str(line_index + 1) if prefix_type == "line_numbers" else prefix_value
|
|
169
|
+
escaped_prefix = html.escape(current_prefix)
|
|
170
|
+
wrapped_lines.append(f'<li data-prefix="{escaped_prefix}">{line_text}\n</li>')
|
|
171
|
+
|
|
172
|
+
return "<ol>" + "".join(wrapped_lines) + "</ol>\n"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _expand_lines(lines: list[str], allowed_environments: list[str] | None) -> list[str]:
|
|
176
|
+
"""Extract fence directives and prefix flags, inserting the mw-fence marker comment.
|
|
177
|
+
|
|
178
|
+
Shared by the in-process preprocessor and the ``mw pre`` stage function.
|
|
179
|
+
|
|
180
|
+
:param lines: Source lines to process.
|
|
181
|
+
:param allowed_environments: Allowed environment names; an empty list or ``None`` allows all.
|
|
182
|
+
:returns: Modified lines with directives replaced by mw-fence marker comments.
|
|
183
|
+
"""
|
|
184
|
+
output: list[str] = []
|
|
185
|
+
line_index = 0
|
|
186
|
+
while line_index < len(lines):
|
|
187
|
+
fence_match = FENCE_RE.match(lines[line_index])
|
|
188
|
+
if not fence_match:
|
|
189
|
+
output.append(lines[line_index])
|
|
190
|
+
line_index += 1
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
fence_marker = fence_match.group(1)
|
|
194
|
+
fence_char = fence_marker[0]
|
|
195
|
+
fence_len = len(fence_marker)
|
|
196
|
+
fence_line = lines[line_index]
|
|
197
|
+
|
|
198
|
+
# Parse prefix flags from the info string (text after the fence markers)
|
|
199
|
+
info_string = fence_line[fence_len:].strip()
|
|
200
|
+
cleaned_info, prefix_metadata = _parse_prefix_from_info(info_string)
|
|
201
|
+
|
|
202
|
+
# Reconstruct the fence line with cleaned info (prefix flags removed, language kept)
|
|
203
|
+
if prefix_metadata:
|
|
204
|
+
fence_line = fence_marker + cleaned_info if cleaned_info else fence_marker
|
|
205
|
+
|
|
206
|
+
# Scan content lines for directives
|
|
207
|
+
metadata: dict[str, str] = {}
|
|
208
|
+
metadata.update(prefix_metadata)
|
|
209
|
+
content_lines: list[str] = []
|
|
210
|
+
scan_index = line_index + 1
|
|
211
|
+
directive_zone = True
|
|
212
|
+
while scan_index < len(lines):
|
|
213
|
+
# Check for closing fence
|
|
214
|
+
close_match = FENCE_RE.match(lines[scan_index])
|
|
215
|
+
if (
|
|
216
|
+
close_match
|
|
217
|
+
and close_match.group(1)[0] == fence_char
|
|
218
|
+
and len(close_match.group(1)) >= fence_len
|
|
219
|
+
and lines[scan_index].strip() == close_match.group(1)
|
|
220
|
+
):
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
if directive_zone:
|
|
224
|
+
stripped_line = lines[scan_index].strip()
|
|
225
|
+
label_match = LABEL_RE.match(stripped_line)
|
|
226
|
+
secondary_match = SECONDARY_LABEL_RE.match(stripped_line)
|
|
227
|
+
environment_match = ENVIRONMENT_RE.match(stripped_line)
|
|
228
|
+
|
|
229
|
+
if label_match:
|
|
230
|
+
metadata["label"] = label_match.group(1)
|
|
231
|
+
scan_index += 1
|
|
232
|
+
continue
|
|
233
|
+
elif secondary_match:
|
|
234
|
+
metadata["secondary_label"] = secondary_match.group(1)
|
|
235
|
+
scan_index += 1
|
|
236
|
+
continue
|
|
237
|
+
elif environment_match:
|
|
238
|
+
env_name = environment_match.group(1)
|
|
239
|
+
if not allowed_environments or env_name in allowed_environments:
|
|
240
|
+
metadata["environment"] = env_name
|
|
241
|
+
scan_index += 1
|
|
242
|
+
continue
|
|
243
|
+
else:
|
|
244
|
+
directive_zone = False
|
|
245
|
+
else:
|
|
246
|
+
directive_zone = False
|
|
247
|
+
|
|
248
|
+
content_lines.append(lines[scan_index])
|
|
249
|
+
scan_index += 1
|
|
250
|
+
|
|
251
|
+
if metadata:
|
|
252
|
+
payload: dict[str, object] = {"version": MARKER_VERSION}
|
|
253
|
+
payload.update(metadata)
|
|
254
|
+
output.append(f"<!-- {MARKER_NAME}:{_encode_marker_payload(payload)} -->")
|
|
255
|
+
|
|
256
|
+
output.append(fence_line)
|
|
257
|
+
output.extend(content_lines)
|
|
258
|
+
|
|
259
|
+
# Append closing fence if found
|
|
260
|
+
if scan_index < len(lines):
|
|
261
|
+
output.append(lines[scan_index])
|
|
262
|
+
scan_index += 1
|
|
263
|
+
|
|
264
|
+
line_index = scan_index
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
return output
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def expand_source(text: str) -> str:
|
|
271
|
+
"""Extract fence directives and emit mw-fence marker comments in raw source.
|
|
272
|
+
|
|
273
|
+
Used by the ``mw pre`` CLI stage. Environments are not restricted (the
|
|
274
|
+
allow-list is an in-process configuration option only).
|
|
275
|
+
|
|
276
|
+
:param text: The source text.
|
|
277
|
+
:returns: The text with directives replaced by mw-fence marker comments.
|
|
278
|
+
"""
|
|
279
|
+
return "\n".join(_expand_lines(text.split("\n"), None))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class FencePreprocessor(Preprocessor):
|
|
283
|
+
"""Extract directives and prefix flags from fenced code blocks.
|
|
284
|
+
|
|
285
|
+
:param md: The Markdown instance.
|
|
286
|
+
:param extension: The parent FenceExtension instance.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, md: Markdown, extension: FenceExtension) -> None:
|
|
290
|
+
super().__init__(md)
|
|
291
|
+
self.extension = extension
|
|
292
|
+
|
|
293
|
+
def run(self, lines: list[str]) -> list[str]:
|
|
294
|
+
"""Process lines, extracting fence directives and injecting metadata comments.
|
|
295
|
+
|
|
296
|
+
:param lines: Source lines to process.
|
|
297
|
+
:returns: Modified lines with directives replaced by metadata comments.
|
|
298
|
+
"""
|
|
299
|
+
return _expand_lines(lines, self.extension.getConfig("allowed_environments"))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
CODE_TAG_RE = re.compile(r"<code[^>]*>")
|
|
303
|
+
CODE_CLOSE_RE = re.compile(r"</code>")
|
|
304
|
+
PRE_TAG_RE = re.compile(r"<pre[^>]*>")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _add_pre_classes(text: str, search_start: int, css_classes: str) -> str:
|
|
308
|
+
"""Add CSS classes to the <pre> tag nearest after search_start.
|
|
309
|
+
|
|
310
|
+
:param text: The full HTML string.
|
|
311
|
+
:param search_start: Position to start searching from.
|
|
312
|
+
:param css_classes: Space-separated CSS class string to add.
|
|
313
|
+
:returns: Modified HTML with classes added to <pre>.
|
|
314
|
+
"""
|
|
315
|
+
pre_match = PRE_TAG_RE.search(text, search_start)
|
|
316
|
+
if pre_match:
|
|
317
|
+
pre_tag = pre_match.group(0)
|
|
318
|
+
if 'class="' in pre_tag:
|
|
319
|
+
new_pre_tag = pre_tag.replace('class="', f'class="{css_classes} ')
|
|
320
|
+
else:
|
|
321
|
+
new_pre_tag = pre_tag.replace("<pre", f'<pre class="{css_classes}"')
|
|
322
|
+
text = text[: pre_match.start()] + new_pre_tag + text[pre_match.end() :]
|
|
323
|
+
return text
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _apply_marker(
|
|
327
|
+
rendered_html: str,
|
|
328
|
+
warnings: list[str] | None,
|
|
329
|
+
label_class: str,
|
|
330
|
+
secondary_label_class: str,
|
|
331
|
+
) -> str:
|
|
332
|
+
"""Style code blocks from their mw-fence marker comments, validating each marker.
|
|
333
|
+
|
|
334
|
+
A marker is skipped (and optionally warned about) when its JSON is malformed,
|
|
335
|
+
its version is unsupported, or no code block follows it. The recognized marker
|
|
336
|
+
comment is always removed from the output.
|
|
337
|
+
|
|
338
|
+
:param rendered_html: Rendered HTML containing mw-fence marker comments.
|
|
339
|
+
:param warnings: Optional list to collect skip reasons; ``None`` suppresses warnings.
|
|
340
|
+
:param label_class: CSS class for the label div.
|
|
341
|
+
:param secondary_label_class: CSS class for the secondary label div.
|
|
342
|
+
:returns: HTML with label divs, environment classes, and line prefixes injected.
|
|
343
|
+
"""
|
|
344
|
+
text = rendered_html
|
|
345
|
+
for match in reversed(list(COMMENT_RE.finditer(text))):
|
|
346
|
+
raw_payload = match.group(1)
|
|
347
|
+
comment_start = match.start()
|
|
348
|
+
comment_end = match.end()
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
metadata = json.loads(raw_payload)
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
if warnings is not None:
|
|
354
|
+
warnings.append(f"Skipping malformed {MARKER_NAME} marker: {raw_payload!r}")
|
|
355
|
+
text = text[:comment_start] + text[comment_end:]
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
version = metadata.get("version")
|
|
359
|
+
if version != MARKER_VERSION:
|
|
360
|
+
if warnings is not None:
|
|
361
|
+
warnings.append(f"Skipping {MARKER_NAME} marker with unsupported version {version!r}")
|
|
362
|
+
text = text[:comment_start] + text[comment_end:]
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
if PRE_TAG_RE.search(text, comment_end) is None and CODE_TAG_RE.search(text, comment_end) is None:
|
|
366
|
+
if warnings is not None:
|
|
367
|
+
warnings.append(f"Skipping {MARKER_NAME} marker with no following code block")
|
|
368
|
+
text = text[:comment_start] + text[comment_end:]
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
label_html = ""
|
|
372
|
+
if "label" in metadata:
|
|
373
|
+
label_text = html.escape(metadata["label"])
|
|
374
|
+
label_html = f'<div class="{label_class}" title="{label_text}">{label_text}</div>\n'
|
|
375
|
+
|
|
376
|
+
# Replace the comment with the label div (or empty string)
|
|
377
|
+
text = text[:comment_start] + label_html + text[comment_end:]
|
|
378
|
+
|
|
379
|
+
# Add environment class to <pre>
|
|
380
|
+
if "environment" in metadata:
|
|
381
|
+
env_name = re.sub(r"[^a-zA-Z0-9-]", "", metadata["environment"])
|
|
382
|
+
env_class = f"environment-{env_name}"
|
|
383
|
+
text = _add_pre_classes(text, comment_start, env_class)
|
|
384
|
+
|
|
385
|
+
# Add prefix classes to <pre> and wrap code lines
|
|
386
|
+
if "prefix_type" in metadata:
|
|
387
|
+
prefix_type = metadata["prefix_type"]
|
|
388
|
+
prefix_classes = f"prefixed {prefix_type}"
|
|
389
|
+
text = _add_pre_classes(text, comment_start, prefix_classes)
|
|
390
|
+
|
|
391
|
+
# Find <code>...</code> block and wrap lines
|
|
392
|
+
code_open_match = CODE_TAG_RE.search(text, comment_start)
|
|
393
|
+
if code_open_match:
|
|
394
|
+
code_close_match = CODE_CLOSE_RE.search(text, code_open_match.end())
|
|
395
|
+
if code_close_match:
|
|
396
|
+
code_content = text[code_open_match.end() : code_close_match.start()]
|
|
397
|
+
wrapped_content = _wrap_lines_with_prefix(code_content, metadata)
|
|
398
|
+
text = text[: code_open_match.end()] + wrapped_content + text[code_close_match.start() :]
|
|
399
|
+
|
|
400
|
+
if "secondary_label" in metadata:
|
|
401
|
+
secondary_text = html.escape(metadata["secondary_label"])
|
|
402
|
+
secondary_html = f'<div class="{secondary_label_class}" title="{secondary_text}">{secondary_text}</div>'
|
|
403
|
+
|
|
404
|
+
# Find the first <code...> tag after where the comment was
|
|
405
|
+
code_match = CODE_TAG_RE.search(text, comment_start)
|
|
406
|
+
if code_match:
|
|
407
|
+
insert_pos = code_match.end()
|
|
408
|
+
text = text[:insert_pos] + secondary_html + text[insert_pos:]
|
|
409
|
+
|
|
410
|
+
return text
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def apply_html(rendered_html: str, warnings: list[str] | None = None) -> str:
|
|
414
|
+
"""Style code blocks from their mw-fence marker comments.
|
|
415
|
+
|
|
416
|
+
Used by the ``mw post`` CLI stage and the in-process postprocessor. Markers are
|
|
417
|
+
validated and skipped fail-soft; pass a ``warnings`` list to collect skip reasons.
|
|
418
|
+
|
|
419
|
+
:param rendered_html: Rendered HTML containing mw-fence marker comments.
|
|
420
|
+
:param warnings: Optional list to collect skip reasons; ``None`` suppresses warnings.
|
|
421
|
+
:returns: HTML with label divs, environment classes, and line prefixes injected.
|
|
422
|
+
"""
|
|
423
|
+
return _apply_marker(rendered_html, warnings, DEFAULT_LABEL_CLASS, DEFAULT_SECONDARY_LABEL_CLASS)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class FencePostprocessor(Postprocessor):
|
|
427
|
+
"""Inject label HTML and line prefixes based on metadata comments.
|
|
428
|
+
|
|
429
|
+
:param md: The Markdown instance.
|
|
430
|
+
:param extension: The parent FenceExtension instance.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def __init__(self, md: Markdown, extension: FenceExtension) -> None:
|
|
434
|
+
super().__init__(md)
|
|
435
|
+
self.extension = extension
|
|
436
|
+
|
|
437
|
+
def run(self, text: str) -> str:
|
|
438
|
+
"""Process rendered HTML, replacing metadata comments with label elements and prefixes.
|
|
439
|
+
|
|
440
|
+
:param text: Rendered HTML string.
|
|
441
|
+
:returns: Modified HTML with label divs, environment classes, and line prefixes injected.
|
|
442
|
+
"""
|
|
443
|
+
return _apply_marker(
|
|
444
|
+
text,
|
|
445
|
+
None,
|
|
446
|
+
self.extension.getConfig("label_class"),
|
|
447
|
+
self.extension.getConfig("secondary_label_class"),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class FenceExtension(Extension):
|
|
452
|
+
"""Python-Markdown extension for fence labels, environments, and prefixes.
|
|
453
|
+
|
|
454
|
+
:param \\*\\*kwargs: Configuration options passed to the extension.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
def __init__(self, **kwargs: object) -> None:
|
|
458
|
+
self.config: dict[str, list[object]] = {
|
|
459
|
+
"label_class": ["code-label", "CSS class for the label div"],
|
|
460
|
+
"secondary_label_class": ["secondary-code-label", "CSS class for the secondary label div"],
|
|
461
|
+
"allowed_environments": [[], "List of allowed environment names (empty = allow all)"],
|
|
462
|
+
}
|
|
463
|
+
super().__init__(**kwargs)
|
|
464
|
+
|
|
465
|
+
def extendMarkdown(self, md: Markdown) -> None:
|
|
466
|
+
"""Register the fence preprocessor and postprocessor.
|
|
467
|
+
|
|
468
|
+
:param md: The Markdown instance to extend.
|
|
469
|
+
"""
|
|
470
|
+
preprocessor = FencePreprocessor(md, self)
|
|
471
|
+
md.preprocessors.register(preprocessor, "mw-fence-pre", 40)
|
|
472
|
+
|
|
473
|
+
postprocessor = FencePostprocessor(md, self)
|
|
474
|
+
md.postprocessors.register(postprocessor, "mw-fence-post", 25)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def makeExtension(**kwargs: object) -> FenceExtension:
|
|
478
|
+
"""Create and return the FenceExtension instance.
|
|
479
|
+
|
|
480
|
+
:param \\*\\*kwargs: Configuration options.
|
|
481
|
+
:returns: A configured FenceExtension.
|
|
482
|
+
"""
|
|
483
|
+
return FenceExtension(**kwargs)
|
markwright/highlight.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# ABOUTME: Highlight extension converting <^>text<^> to <mark> tags.
|
|
2
|
+
# Works in regular text, inline code, and fenced code blocks.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as etree
|
|
8
|
+
|
|
9
|
+
from markdown import Markdown
|
|
10
|
+
from markdown.extensions import Extension
|
|
11
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
12
|
+
from markdown.postprocessors import Postprocessor
|
|
13
|
+
|
|
14
|
+
# A backslash before a marker (``\<^>``) escapes it. Every consumer (the
|
|
15
|
+
# in-process InlineProcessor, the escaped post-stage regex, and the prose
|
|
16
|
+
# pre-stage regex) shares this guard so the escape survives as a literal
|
|
17
|
+
# marker no matter which stage resolves it.
|
|
18
|
+
_ESCAPE_GUARD = r"(?<!\\)"
|
|
19
|
+
_HIGHLIGHT_PATTERN = rf"{_ESCAPE_GUARD}<\^>(.*?){_ESCAPE_GUARD}<\^>"
|
|
20
|
+
# Markers are HTML-escaped to ``<^>`` inside code.
|
|
21
|
+
_ESCAPED_HIGHLIGHT_RE = re.compile(rf"{_ESCAPE_GUARD}<\^>(.*?){_ESCAPE_GUARD}<\^>")
|
|
22
|
+
_BACKSLASH_MARKER_RE = re.compile(r"\\<\^>")
|
|
23
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
24
|
+
|
|
25
|
+
# Source-stage (``mw pre``) markers operate on raw, un-escaped text. They mirror
|
|
26
|
+
# the escaped post-stage regex above.
|
|
27
|
+
_PROSE_HIGHLIGHT_RE = re.compile(rf"{_ESCAPE_GUARD}<\^>(.*?){_ESCAPE_GUARD}<\^>")
|
|
28
|
+
# Code regions the pre stage must skip: fenced code blocks (backtick or tilde)
|
|
29
|
+
# and inline code spans. Markers inside code are HTML-escaped during rendering
|
|
30
|
+
# and handled by :func:`apply_html` in the post stage instead.
|
|
31
|
+
_CODE_REGION_RE = re.compile(
|
|
32
|
+
r"(?P<fence>^```[^\n]*\n.*?^```|^~~~[^\n]*\n.*?^~~~)|(?P<inline>`[^`\n]*`)",
|
|
33
|
+
re.DOTALL | re.MULTILINE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _wrap_highlight_segments(match: re.Match[str]) -> str:
|
|
38
|
+
"""Wrap the text between escaped markers in ``<mark>`` without crossing tags.
|
|
39
|
+
|
|
40
|
+
Syntax highlighters such as Pygments wrap code in ``<span>`` tokens, so the
|
|
41
|
+
region between two markers may contain tag boundaries. Wrapping the whole
|
|
42
|
+
region in a single ``<mark>`` would produce overlapping HTML; instead each
|
|
43
|
+
run of plain text is wrapped individually, leaving the intervening tags
|
|
44
|
+
untouched and the markup well-formed.
|
|
45
|
+
|
|
46
|
+
:param match: A match of :data:`_ESCAPED_HIGHLIGHT_RE`.
|
|
47
|
+
:returns: The marked-up replacement for the matched region.
|
|
48
|
+
"""
|
|
49
|
+
region = match.group(1)
|
|
50
|
+
if not region:
|
|
51
|
+
return "<mark></mark>"
|
|
52
|
+
|
|
53
|
+
rebuilt_parts: list[str] = []
|
|
54
|
+
text_cursor = 0
|
|
55
|
+
for tag_match in _HTML_TAG_RE.finditer(region):
|
|
56
|
+
text_run = region[text_cursor : tag_match.start()]
|
|
57
|
+
if text_run:
|
|
58
|
+
rebuilt_parts.append(f"<mark>{text_run}</mark>")
|
|
59
|
+
rebuilt_parts.append(tag_match.group(0))
|
|
60
|
+
text_cursor = tag_match.end()
|
|
61
|
+
trailing_text = region[text_cursor:]
|
|
62
|
+
if trailing_text:
|
|
63
|
+
rebuilt_parts.append(f"<mark>{trailing_text}</mark>")
|
|
64
|
+
return "".join(rebuilt_parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_html(html: str, warnings: list[str] | None = None) -> str:
|
|
68
|
+
"""Convert HTML-escaped ``<^>`` markers in rendered HTML to ``<mark>``.
|
|
69
|
+
|
|
70
|
+
The HTML-stage transform for ``mw post`` and the in-process postprocessor.
|
|
71
|
+
Escaped markers inside rendered code are wrapped span-safely (see
|
|
72
|
+
:func:`_wrap_highlight_segments`); backslash-escaped markers are revealed as
|
|
73
|
+
a literal ``<^>``.
|
|
74
|
+
|
|
75
|
+
:param html: Rendered HTML content.
|
|
76
|
+
:param warnings: Optional warnings list; unused (highlighting never warns).
|
|
77
|
+
:returns: HTML with escaped highlight markers converted to ``<mark>``.
|
|
78
|
+
"""
|
|
79
|
+
highlighted = _ESCAPED_HIGHLIGHT_RE.sub(_wrap_highlight_segments, html)
|
|
80
|
+
return _BACKSLASH_MARKER_RE.sub("<^>", highlighted)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _highlight_prose(segment: str) -> str:
|
|
84
|
+
"""Wrap unescaped prose ``<^>...<^>`` markers in ``<mark>``.
|
|
85
|
+
|
|
86
|
+
Backslash-escaped markers are left untouched here, mirroring how code
|
|
87
|
+
regions are handled: the renderer HTML-escapes them and :func:`apply_html`
|
|
88
|
+
reveals the literal marker in the post stage. Resolving the escape in this
|
|
89
|
+
stage instead would strip the backslash before the renderer sees it, and
|
|
90
|
+
the renderer's own HTML-escaping of the exposed marker would then be
|
|
91
|
+
indistinguishable from a genuine unescaped marker once it reaches
|
|
92
|
+
:func:`apply_html`.
|
|
93
|
+
|
|
94
|
+
:param segment: A run of source text known to be outside any code region.
|
|
95
|
+
:returns: The segment with unescaped markers wrapped in ``<mark>``;
|
|
96
|
+
backslash-escaped markers are unchanged.
|
|
97
|
+
"""
|
|
98
|
+
return _PROSE_HIGHLIGHT_RE.sub(r"<mark>\1</mark>", segment)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def expand_source(text: str) -> str:
|
|
102
|
+
"""Wrap prose highlight markers in ``<mark>`` outside code regions.
|
|
103
|
+
|
|
104
|
+
The source-stage transform for ``mw pre``. Fenced code blocks, inline code
|
|
105
|
+
spans, and backslash-escaped prose markers are left untouched: they are
|
|
106
|
+
HTML-escaped during rendering and resolved by :func:`apply_html` in the
|
|
107
|
+
post stage.
|
|
108
|
+
|
|
109
|
+
:param text: Raw Markdown source.
|
|
110
|
+
:returns: Source with unescaped prose ``<^>...<^>`` markers wrapped in
|
|
111
|
+
``<mark>``.
|
|
112
|
+
"""
|
|
113
|
+
result_parts: list[str] = []
|
|
114
|
+
last_end = 0
|
|
115
|
+
for code_match in _CODE_REGION_RE.finditer(text):
|
|
116
|
+
result_parts.append(_highlight_prose(text[last_end : code_match.start()]))
|
|
117
|
+
result_parts.append(code_match.group(0))
|
|
118
|
+
last_end = code_match.end()
|
|
119
|
+
result_parts.append(_highlight_prose(text[last_end:]))
|
|
120
|
+
return "".join(result_parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class HighlightInlineProcessor(InlineProcessor):
|
|
124
|
+
"""Inline processor that converts ``<^>text<^>`` to ``<mark>text</mark>``.
|
|
125
|
+
|
|
126
|
+
:param pattern: Regex pattern for matching highlight markers.
|
|
127
|
+
:param md: The Markdown instance.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def handleMatch(self, match: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # type: ignore[override]
|
|
131
|
+
"""Create a ``<mark>`` element from the matched text.
|
|
132
|
+
|
|
133
|
+
:param match: The regex match object.
|
|
134
|
+
:param data: The full source string being processed.
|
|
135
|
+
:returns: A tuple of (element, start, end).
|
|
136
|
+
"""
|
|
137
|
+
mark_element = etree.Element("mark")
|
|
138
|
+
mark_element.text = match.group(1)
|
|
139
|
+
return mark_element, match.start(0), match.end(0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class HighlightPostprocessor(Postprocessor):
|
|
143
|
+
"""Postprocessor that replaces HTML-escaped ``<^>`` markers in rendered code blocks.
|
|
144
|
+
|
|
145
|
+
Code blocks render ``<`` and ``>`` as ``<`` and ``>``, so the inline
|
|
146
|
+
processor cannot reach them. This postprocessor catches those escaped markers
|
|
147
|
+
in the final HTML and converts them to ``<mark>`` tags.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def run(self, text: str) -> str:
|
|
151
|
+
"""Replace escaped highlight markers with ``<mark>`` tags.
|
|
152
|
+
|
|
153
|
+
:param text: The rendered HTML string.
|
|
154
|
+
:returns: HTML with highlight markers replaced.
|
|
155
|
+
"""
|
|
156
|
+
return apply_html(text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class HighlightExtension(Extension):
|
|
160
|
+
"""Python-Markdown extension for ``<^>text<^>`` highlight syntax.
|
|
161
|
+
|
|
162
|
+
Registers an :class:`HighlightInlineProcessor` for regular inline text and a
|
|
163
|
+
:class:`HighlightPostprocessor` for code blocks where markers are HTML-escaped.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def extendMarkdown(self, md: Markdown) -> None:
|
|
167
|
+
"""Register the highlight processors with the Markdown instance.
|
|
168
|
+
|
|
169
|
+
:param md: The Markdown instance to extend.
|
|
170
|
+
"""
|
|
171
|
+
md.inlinePatterns.register(
|
|
172
|
+
HighlightInlineProcessor(_HIGHLIGHT_PATTERN, md),
|
|
173
|
+
"do_highlight_inline",
|
|
174
|
+
175,
|
|
175
|
+
)
|
|
176
|
+
md.postprocessors.register(
|
|
177
|
+
HighlightPostprocessor(md),
|
|
178
|
+
"do_highlight_post",
|
|
179
|
+
25,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def makeExtension(**kwargs: str) -> HighlightExtension:
|
|
184
|
+
"""Entry point for Python-Markdown extension loading.
|
|
185
|
+
|
|
186
|
+
:param kwargs: Extension configuration options.
|
|
187
|
+
:returns: A configured HighlightExtension instance.
|
|
188
|
+
"""
|
|
189
|
+
return HighlightExtension(**kwargs)
|