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/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)
@@ -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 ``&lt;^&gt;`` inside code.
21
+ _ESCAPED_HIGHLIGHT_RE = re.compile(rf"{_ESCAPE_GUARD}&lt;\^&gt;(.*?){_ESCAPE_GUARD}&lt;\^&gt;")
22
+ _BACKSLASH_MARKER_RE = re.compile(r"\\&lt;\^&gt;")
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 ``&lt;^&gt;`` 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 ``&lt;^&gt;``.
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("&lt;^&gt;", 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 ``&lt;`` and ``&gt;``, 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)