Move the WML renderer out to wapdown

Every other renderer here -- text, micron, ama, gemini, nex -- is a
straight transform of a document into a flat, minimal text format. The
WML renderer was neither: it emitted an XML tree, and it restructured the
document rather than transforming it, splitting one file into many
<card>s and inventing navigation between them. That is why it was the
only renderer needing split_level / max_card_bytes / menu levers, the
only one buffering the whole event stream instead of streaming it, and
the only one that required a block kind in the shared parser --
CARD_BREAK, for its {.card} divider -- that every other renderer ignored
as a no-op.

It now lives in its own repository, where restructuring a document into a
navigable deck is the point rather than an intrusion:

    https://codeberg.org/randogoth/wapdown

Removed here: renderers/wml.py, its CLI registration, the wml examples,
the README section, and the CARD_BREAK event and {.card} directive that
existed solely to serve it. A leftover {.card} line in an old document
will now render as literal text rather than being silently dropped.

Verified: all five remaining renderers produce byte-identical output to
before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-23 09:01:40 +03:00
parent d5046543b3
commit e708dce8f7
8 changed files with 12 additions and 544 deletions

View file

@ -19,7 +19,7 @@ uv tool install --from git+https://codeberg.org/randogoth/md2txt/ md2txt
## CLI
- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, `gemini`, and `wml` renderers for Micron/Ancient Machine Book, Gemtext, or WML (WAP) output, and exposes the core pipeline so you can add your own parser or renderer modules:
- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, `gemini`, and `nex` renderers for Micron/Ancient Machine Book, Gemtext, or Nex output, and exposes the core pipeline so you can add your own parser or renderer modules:
- FIGlet-rendered headings.
- Emphasis styles converted to spaced or delimited characters.
- Optional alignment and margin controls via HTML `<p>` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
@ -36,7 +36,7 @@ md2txt input.md --width 72 # override column width
md2txt input.md --renderer micron # emit Micron-formatted output for NomadNet
md2txt input.md --renderer ama # emit AMA markup for Ancient Machine Book
md2txt input.md --renderer gemini # emit Gemtext for Gemini capsules
md2txt input.md --renderer wml # emit a single-card WML deck for WAP browsers
md2txt input.md --renderer nex # emit plain text for Nex capsules
md2txt input.md --renderer-option width=68 # pass column width to a renderer
```
@ -123,39 +123,13 @@ Inline or block-level ASCII art can be embedded directly in Markdown:
Generated text uses CRLF line endings to maintain DOS compatibility.
## WML Decks
## Nex
The `wml` renderer converts Markdown into a [WML](https://en.wikipedia.org/wiki/Wireless_Markup_Language) deck for old WAP browsers. WML screens are idiomatically short and button/link-navigated rather than one long scrolling page, so unlike the other renderers, `wml` can split a document into multiple `<card>`s — but only when you ask it to; a plain document with no options stays a single card.
The `nex` renderer converts Markdown into plain text for the [Nex](https://nightfall.city/nex/) protocol. Nex has no hypertext markup for hand-authored documents at all — its one special syntax, `=> URL`, is spec'd for server-auto-generated directory listings, not document bodies — so `nex` output is deliberately unmarked plain text, reusing `TextRenderer`'s wrapping/margin/hyphenation machinery (same approach as the `ama`/`micron` renderers):
Two independent, both-optional ways to define card boundaries:
- **Manual dividers** — insert a `{.card}` or `{.card Title}` line wherever you want a new card to start, regardless of heading structure:
```markdown
Intro shown on the menu card...
{.card Weather}
Cold and clear...
{.card Trail Status}
- North Loop: open
```
- **Heading-level splitting** — opt in with `--renderer-option split_level=N` to start a new card at every heading of level `N` (e.g. `split_level=2` for the common one-H1-title-plus-H2-sections pattern). Ignored if the document contains any `{.card}` markers.
When a document resolves to two or more cards, `wml` builds a "menu" card by default — the classic WAP-portal pattern: a list of button/link choices leading to each section, each with a Back control (`--renderer-option menu=false` switches to a linear book instead, chaining cards with Next/Back). A section whose content would still be too large for one card even after splitting is automatically paginated further (`--renderer-option max_card_bytes=N`, default 1400 — a nod to classic WAP 1.x per-deck cache limits; `0` disables it).
| Renderer option | Default | Meaning |
| --- | --- | --- |
| `title` | *(none)* | Overall deck title, used on the menu card (or the single card, if never split). |
| `split_level` | `0` (disabled) | Heading level that starts a new card. Ignored if `{.card}` markers are present. |
| `max_card_bytes` | `1400` | Byte safety-net per card; `0` disables. |
| `menu` | `true` | Hub-and-spoke menu card vs. linear Next/Back chaining (`menu=false`). |
| `nav_next_label` | `More` | Label for forward navigation (linear mode / overflow pagination). |
| `nav_prev_label` | `Prev` | Label for backward navigation between overflow pagination parts. |
| `nav_back_label` | `Back` | Label for backward navigation from a section back to the menu/previous section. |
WML 1.3 is the only supported DTD (fixed by design, not a flag). Since WML uses `$` for variable substitution, a literal `$` in your Markdown is escaped to `$$` in the output automatically. Images pass through as `<img src="..." alt="..."/>` referencing the original path/URL — no WBMP conversion is performed.
- Headings get a plain setext-style underline (`=` for H1, `-` for H2+) instead of a FIGlet banner. This is a readability convention borrowed from Markdown itself, not a requirement of the Nex spec.
- `**bold**`/`*italic*`/`~~strikethrough~~` markers are stripped rather than translated — there's nothing in Nex to translate them into.
- Links and images are inlined as `label (url)` plain prose (or bare `url` when there's no distinct label/alt, or when the label equals the url) rather than footnoted or turned into `=> ` lines, since that syntax isn't confirmed safe inside a regular document body.
## Plugin Architecture
@ -186,6 +160,10 @@ Once registered (for example in a small module that imports `md2txt.cli`), the n
The shared preprocessing helpers—YAML frontmatter parsing, recursive include expansion, ASCII art sentinels, and pipe-table sentinels—also live in `src/md2txt/conversion/core.py`, allowing alternate front-ends to reuse exactly the same behaviour without duplicating code.
## WML / WAP Output
The `wml` renderer used to live here. It has moved to [wapdown](https://codeberg.org/randogoth/wapdown), because a WML deck is not a minimal text format: it is an XML tree, and producing one means restructuring a document into navigable cards rather than transforming it. That needed controls the exporters here have no use for, so it now has a repository of its own. The `{.card}` divider moved with it.
---
* Directed and vibe coded by Tobias Raayoni Last

View file

@ -1,14 +0,0 @@
Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.
{.card Weather}
Cold and clear. Wind: **15 mph** gusting from the *northwest*. Permit fee: $5.
{.card Trail Status}
- North Loop: open
- South Loop: closed
- Summit Spur: open, ice above 2000m
{.card Contact}
Ranger station: [call dispatch](tel:+15555550123)
![Trail map](https://example.com/map.png)

View file

@ -1,19 +0,0 @@
# Trail Conditions
Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.
## Weather
Cold and clear. Wind: **15 mph** gusting from the *northwest*. Permit fee: $5.
## Trail Status
- North Loop: open
- South Loop: closed
- Summit Spur: open, ice above 2000m
## Contact
Ranger station: [call dispatch](tel:+15555550123)
![Trail map](https://example.com/map.png)

View file

@ -1,17 +0,0 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<card id="card1">
<p><b><big>Trail Conditions</big></b></p>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><b>Weather</b></p>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
<p><b>Trail Status</b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
<p><b>Contact</b></p>
<p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p>
<p><img src="https://example.com/map.png" alt="Trail map"/></p>
</card>
</wml>

View file

@ -25,7 +25,7 @@ from .renderers.text import TextRenderer
from .renderers import ama # noqa: F401 # register AMA renderer plugin
from .renderers import gemini # noqa: F401 # register Gemini renderer plugin
from .renderers import micron # noqa: F401 # register Micron renderer plugin
from .renderers import wml # noqa: F401 # register WML renderer plugin
from .renderers import nex # noqa: F401 # register Nex renderer plugin
def _split_option(token: str) -> Tuple[str, str]:

View file

@ -15,7 +15,6 @@ class BlockKind(Enum):
TABLE = "table"
BLANK_LINE = "blank_line"
CUSTOM_BLOCK = "custom_block"
CARD_BREAK = "card_break"
@dataclass
@ -103,18 +102,6 @@ class TablePayload:
rows: List[List[str]] # rows[0] is the header row
@dataclass
class CardBreakPayload:
"""Marks an explicit, renderer-agnostic 'start a new page/card here' hint.
Emitted for the `{.card}` / `{.card Title}` directive. Formats without a
notion of pages (plain text, Gemtext, Micron, AMA) simply ignore this
event kind via their existing unhandled-BlockKind no-op path.
"""
title: Optional[str] = None
@dataclass
class BlockEvent:
kind: BlockKind

View file

@ -13,7 +13,6 @@ from ..models import (
BlockKind,
BlockQuotePayload,
BlockStyle,
CardBreakPayload,
CodeBlockPayload,
HeadingPayload,
ListItemPayload,
@ -29,7 +28,6 @@ ORDERED_LIST_PATTERN = re.compile(r"^(\s*)(\d+\.)(\s+)(.*)$")
UNORDERED_LIST_PATTERN = re.compile(r"^(\s*)([*+-])(\s+)(.*)$")
BLOCKQUOTE_PATTERN = re.compile(r"^\s{0,3}>(.*)$")
HORIZONTAL_RULE_PATTERN = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$")
CARD_BREAK_PATTERN = re.compile(r"^\s*\{\s*\.card(?:\s+(?P<title>.+?))?\s*\}\s*$")
INLINE_PARA_RE = re.compile(r"^\s*<p\b([^>]*)>(.*?)</p>\s*$", re.IGNORECASE)
PARA_OPEN_RE = re.compile(r"^\s*<p\b([^>]*)>\s*$", re.IGNORECASE)
PARA_CLOSE_RE = re.compile(r"^\s*</p>\s*$", re.IGNORECASE)
@ -117,22 +115,6 @@ class MarkdownParser:
yield event
indented_code_lines = []
card_break_match = CARD_BREAK_PATTERN.match(line)
if card_break_match:
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
title = card_break_match.group("title")
self._last_stylable_block = False
yield BlockEvent(
kind=BlockKind.CARD_BREAK,
payload=CardBreakPayload(title=title.strip() if title else None),
style=self._clone_style(),
stylable=False,
)
continue
inline_para = INLINE_PARA_RE.match(line.strip())
if inline_para:
event = self._flush_paragraph(current_paragraph)

View file

@ -1,429 +0,0 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
from ..models import (
BlockEvent,
BlockKind,
FrontMatter,
StyleUpdateEvent,
)
from ..plugins import register_renderer
from .text import (
BOLD_RE,
CODE_STASH_RE,
IMAGE_RE,
ITALIC_RE,
LINK_RE,
STRIKETHROUGH_RE,
UNDERLINE_EM_RE,
UNDERLINE_STRONG_RE,
)
WML_PROLOG = (
'<?xml version="1.0"?>\n'
'<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" '
'"http://www.wapforum.org/DTD/wml13.dtd">\n'
)
# A conservative nod to classic WAP 1.x per-deck cache limits; purely a
# safety net for a section that's still too big after §-based splitting,
# not the primary splitting mechanism. Reserves headroom for the nav <do>
# markup each continuation card also carries.
DEFAULT_MAX_CARD_BYTES = 1400
_NAV_HEADROOM_BYTES = 160
def escape_wml(text: str) -> str:
"""XML-escape text for WML element content or a double-quoted attribute
value, plus WML's own quirk: a literal '$' must become '$$' since WML
uses '$' for variable substitution. Applied once, up front, to the whole
raw string before any inline markup is turned into tags (see
`WMLRenderer._process_inline`), so link/image URLs captured out of the
already-escaped text are correctly escaped for attribute use too.
"""
text = text.replace("&", "&amp;")
text = text.replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
return text.replace("$", "$$")
def _coerce_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() not in {"false", "no", "0", "off"}
@dataclass
class _Section:
title: Optional[str]
events: List[BlockEvent]
@dataclass
class _Card:
id: str
title: Optional[str]
fragments: List[str]
back_label: Optional[str] = None
next_id: Optional[str] = None
next_label: Optional[str] = None
class WMLRenderer:
"""Render Markdown events as a WML deck for WAP browsers.
Unlike TextRenderer/MicronRenderer/AmaRenderer, which emit output
incrementally per `handle_event`, this renderer buffers the whole event
stream and does the real work in `finalize()` — card boundaries, the
menu card's link list, and forward/backward nav targets all depend on
the full document's structure being known up front. It still satisfies
the same Renderer protocol (`handle_event`/`finalize`), so it plugs into
`run_pipeline` unchanged; `finalize()` just returns a single WML string
instead of a list of lines.
"""
def __init__(self, frontmatter: FrontMatter, **options: Any) -> None:
self.frontmatter = frontmatter
self._events: List[BlockEvent] = []
# 0 = disabled. Heading-based splitting is opt-in, not a default —
# a document with headings but no explicit split_level stays one card.
self.split_level = int(options.get("split_level", 0) or 0)
self.max_card_bytes = int(options.get("max_card_bytes", DEFAULT_MAX_CARD_BYTES) or 0)
self.menu = _coerce_bool(options.get("menu"), True)
self.nav_next_label = str(options.get("nav_next_label", "More"))
self.nav_prev_label = str(options.get("nav_prev_label", "Prev"))
self.nav_back_label = str(options.get("nav_back_label", "Back"))
title = options.get("title")
self.title: Optional[str] = str(title) if title else None
# -- Renderer protocol ---------------------------------------------------
def handle_event(self, event: "BlockEvent | StyleUpdateEvent") -> None:
if isinstance(event, BlockEvent):
self._events.append(event)
# StyleUpdateEvent: WML has no per-block alignment/style stack to
# apply this to, by design; ignored like other structural mismatches.
def finalize(self) -> str:
preamble_events, sections = self._split_sections(self._events)
if len(sections) < 2:
all_events = list(preamble_events)
if sections:
all_events.extend(sections[0].events)
fragments = self._render_events(all_events)
cards = self._pack_section(
"card1",
self.title,
fragments,
entry_back_label=None,
exit_next_id=None,
exit_next_label=None,
)
elif self.menu:
cards = self._build_hub_and_spoke(preamble_events, sections)
else:
cards = self._build_linear(preamble_events, sections)
body = "\n".join(self._render_card(card) for card in cards)
return f"{WML_PROLOG}<wml>\n{body}\n</wml>\n"
# -- Splitting into sections ----------------------------------------------
def _split_sections(self, events: List[BlockEvent]) -> Tuple[List[BlockEvent], List[_Section]]:
card_break_indices = [i for i, e in enumerate(events) if e.kind == BlockKind.CARD_BREAK]
if card_break_indices:
return self._split_by_card_breaks(events, card_break_indices)
if self.split_level > 0:
heading_indices = [
i
for i, e in enumerate(events)
if e.kind == BlockKind.HEADING and e.payload.level == self.split_level
]
if heading_indices:
return self._split_by_heading(events, heading_indices)
return list(events), []
def _split_by_card_breaks(
self, events: List[BlockEvent], indices: List[int]
) -> Tuple[List[BlockEvent], List[_Section]]:
preamble = events[: indices[0]]
boundaries = indices + [len(events)]
sections: List[_Section] = []
for idx in range(len(indices)):
start = indices[idx] + 1 # skip the {.card} marker event itself
end = boundaries[idx + 1]
section_events = events[start:end]
marker_title = events[indices[idx]].payload.title
title = marker_title or self._first_heading_text(section_events)
sections.append(_Section(title=title, events=section_events))
return preamble, sections
def _split_by_heading(
self, events: List[BlockEvent], indices: List[int]
) -> Tuple[List[BlockEvent], List[_Section]]:
preamble = events[: indices[0]]
boundaries = indices + [len(events)]
sections: List[_Section] = []
for idx in range(len(indices)):
start = indices[idx] # keep the heading event, it still renders in-body
end = boundaries[idx + 1]
section_events = events[start:end]
title = events[indices[idx]].payload.text
sections.append(_Section(title=title, events=section_events))
return preamble, sections
@staticmethod
def _first_heading_text(events: List[BlockEvent]) -> Optional[str]:
for event in events:
if event.kind == BlockKind.HEADING:
return event.payload.text
return None
# -- Card/menu/navigation topology ---------------------------------------
def _build_hub_and_spoke(
self, preamble_events: List[BlockEvent], sections: List[_Section]
) -> List[_Card]:
menu_fragments = self._render_events(preamble_events, big_first_heading=True)
for i, section in enumerate(sections):
label = escape_wml(section.title or "Untitled")
menu_fragments.append(f'<p><a href="#card{i + 1}">{label}</a></p>')
menu_title = self.title or self._first_heading_text(preamble_events)
cards: List[_Card] = [
_Card(id="menu", title=menu_title, fragments=menu_fragments)
]
for i, section in enumerate(sections):
fragments = self._render_events(section.events, big_first_heading=True)
cards.extend(
self._pack_section(
f"card{i + 1}",
section.title or "Untitled",
fragments,
entry_back_label=self.nav_back_label,
exit_next_id=None,
exit_next_label=None,
)
)
return cards
def _build_linear(
self, preamble_events: List[BlockEvent], sections: List[_Section]
) -> List[_Card]:
cards: List[_Card] = []
total = len(sections)
for i, section in enumerate(sections):
events = section.events
is_first = i == 0
if is_first:
events = list(preamble_events) + list(events)
fragments = self._render_events(events, big_first_heading=True)
# Card 1 also carries the preamble, so prefer the deck-level
# title / preamble's own heading over this section's heading.
card_title = (
(self.title or self._first_heading_text(preamble_events) or section.title or "Untitled")
if is_first
else (section.title or "Untitled")
)
is_last = i == total - 1
next_id = f"card{i + 2}" if not is_last else None
cards.extend(
self._pack_section(
f"card{i + 1}",
card_title,
fragments,
entry_back_label=None if is_first else self.nav_back_label,
exit_next_id=next_id,
exit_next_label=self.nav_next_label if next_id else None,
)
)
return cards
def _pack_section(
self,
base_id: str,
title: Optional[str],
fragments: List[str],
*,
entry_back_label: Optional[str],
exit_next_id: Optional[str],
exit_next_label: Optional[str],
) -> List[_Card]:
"""Split one section's rendered fragments into one or more physical
<card>s if they'd exceed max_card_bytes (0 disables). This is the
secondary byte-overflow safety net, not the primary splitting
mechanism (that's §-based or manual {.card} splitting upstream).
"""
chunks = self._greedy_pack(fragments, base_id)
cards: List[_Card] = []
last = len(chunks) - 1
for i, chunk in enumerate(chunks):
part_id = base_id if i == 0 else f"{base_id}-p{i + 1}"
is_first = i == 0
is_last = i == last
back_label = entry_back_label if is_first else self.nav_prev_label
next_id = f"{base_id}-p{i + 2}" if not is_last else exit_next_id
next_label = self.nav_next_label if not is_last else exit_next_label
cards.append(
_Card(
id=part_id,
title=title if is_first else None,
fragments=chunk,
back_label=back_label,
next_id=next_id,
next_label=next_label,
)
)
return cards
def _greedy_pack(self, fragments: List[str], base_id: str) -> List[List[str]]:
if self.max_card_bytes <= 0:
return [fragments]
budget = max(1, self.max_card_bytes - _NAV_HEADROOM_BYTES)
chunks: List[List[str]] = []
current: List[str] = []
current_size = 0
for frag in fragments:
frag_size = len(frag.encode("utf-8"))
if frag_size > budget:
if current:
chunks.append(current)
current, current_size = [], 0
chunks.append([frag])
print(
f"wml: warning: a single block in '{base_id}' is {frag_size} bytes, "
f"exceeding max_card_bytes ({self.max_card_bytes}); emitting it as its own card",
file=sys.stderr,
)
continue
if current and current_size + frag_size > budget:
chunks.append(current)
current, current_size = [], 0
current.append(frag)
current_size += frag_size
if current:
chunks.append(current)
return chunks or [[]]
def _render_card(self, card: _Card) -> str:
attrs = f' title="{escape_wml(card.title)}"' if card.title else ""
parts = [f'<card id="{card.id}"{attrs}>']
if card.back_label:
parts.append(f'<do type="prev" label="{escape_wml(card.back_label)}"><prev/></do>')
if card.next_id:
label = escape_wml(card.next_label or self.nav_next_label)
parts.append(f'<do type="accept" label="{label}"><go href="#{card.next_id}"/></do>')
parts.extend(card.fragments)
parts.append("</card>")
return "\n".join(parts)
# -- Block markup mapping -------------------------------------------------
def _render_events(self, events: List[BlockEvent], *, big_first_heading: bool = True) -> List[str]:
fragments: List[str] = []
seen_heading = not big_first_heading
list_counter = 0
for event in events:
kind = event.kind
if kind == BlockKind.PARAGRAPH:
fragments.append(f"<p>{self._process_inline(event.payload.text)}</p>")
list_counter = 0
elif kind == BlockKind.HEADING:
text = self._process_inline(event.payload.text)
if not seen_heading:
fragments.append(f"<p><b><big>{text}</big></b></p>")
seen_heading = True
else:
fragments.append(f"<p><b>{text}</b></p>")
list_counter = 0
elif kind == BlockKind.LIST_ITEM:
text = self._process_inline(event.payload.text)
if event.payload.ordered:
list_counter += 1
fragments.append(f"<p>{list_counter}. {text}</p>")
else:
list_counter = 0
fragments.append(f"<p>- {text}</p>")
elif kind == BlockKind.CODE_BLOCK:
lines = [escape_wml(line) for line in event.payload.lines]
fragments.append(f"<p>{'<br/>'.join(lines)}</p>")
list_counter = 0
elif kind == BlockKind.BLOCKQUOTE:
fragments.append(f"<p><i>{self._process_inline(event.payload.text)}</i></p>")
list_counter = 0
elif kind == BlockKind.HORIZONTAL_RULE:
fragments.append("<p>------------</p>")
list_counter = 0
elif kind == BlockKind.TABLE:
fragments.append(self._render_table(event.payload.rows))
list_counter = 0
# BLANK_LINE, CARD_BREAK, CUSTOM_BLOCK: no WML output of their own.
return fragments
def _render_table(self, rows: List[List[str]]) -> str:
if not rows:
return ""
columns = max(len(row) for row in rows)
tr_parts: List[str] = []
for row_index, row in enumerate(rows):
cells = list(row) + [""] * (columns - len(row))
td_parts = []
for cell in cells:
content = self._process_inline(cell)
if row_index == 0:
content = f"<b>{content}</b>"
td_parts.append(f"<td>{content}</td>")
tr_parts.append(f"<tr>{''.join(td_parts)}</tr>")
return f'<table columns="{columns}">{"".join(tr_parts)}</table>'
# -- Inline markup mapping -------------------------------------------------
def _process_inline(self, text: str) -> str:
# Escape once, up front; the markdown syntax markers below aren't
# XML-significant characters, so inline tag insertion on top of the
# already-escaped text is always safe (see `escape_wml`'s docstring).
escaped = escape_wml(text)
code_segments: List[str] = []
def stash_code(match) -> str:
code_segments.append(match.group(0)[1:-1])
return f"\u0000CODE{len(code_segments) - 1}\u0000"
escaped = CODE_STASH_RE.sub(stash_code, escaped)
escaped = STRIKETHROUGH_RE.sub(lambda m: m.group(1), escaped) # no WML equivalent
escaped = BOLD_RE.sub(lambda m: f"<b>{m.group(1)}</b>", escaped)
escaped = UNDERLINE_STRONG_RE.sub(lambda m: f"<b>{m.group(1)}</b>", escaped)
escaped = ITALIC_RE.sub(lambda m: f"<i>{m.group(1)}</i>", escaped)
escaped = UNDERLINE_EM_RE.sub(lambda m: f"<i>{m.group(1)}</i>", escaped)
escaped = LINK_RE.sub(self._replace_link, escaped)
escaped = IMAGE_RE.sub(self._replace_image, escaped)
for idx, code in enumerate(code_segments):
escaped = escaped.replace(f"\u0000CODE{idx}\u0000", code)
return escaped
@staticmethod
def _replace_link(match) -> str:
label = match.group(1).strip()
url = match.group(2).strip()
return f'<a href="{url}">{label}</a>'
@staticmethod
def _replace_image(match) -> str:
alt = match.group(1).strip()
src = match.group(2).strip()
return f'<img src="{src}" alt="{alt}"/>'
def _wml_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> WMLRenderer:
return WMLRenderer(frontmatter, **options)
try:
register_renderer("wml", _wml_renderer_factory)
except ValueError:
pass