Extract the WML exporter from md2txt into wapdown

md2txt is a suite of minimal text-based markup exporters, and every other
renderer in it (text, micron, ama, gemini, nex) is a straight transform of
a document into a flat text format. The WML renderer is neither: it emits
an XML tree, and it restructures the document rather than transforming it
-- splitting one file into many <card>s, inventing navigation between
them, building a menu card that exists in no source document, and
paginating on a byte budget. That is why it needed levers no sibling
renderer needed, and why it alone forced a CARD_BREAK block kind and a
{.card} directive into the shared parser that every other renderer
ignores.

wapdown is that renderer given a repository of its own, where
restructuring a document into a navigable deck is the point.

Ported: the Markdown parser, block event models, and pipeline core, each
trimmed to what a deck compiler actually uses (no FIGlet, no hyphenation,
no ASCII-art includes, no text-layout frontmatter), plus the eight inline
regexes the renderer had been borrowing from a 1039-line text renderer.
The plugin registry comes along so other WAP-era targets can register
alongside wml later. Zero runtime dependencies.

Config is now first-class: every lever is an argparse flag with
validation and a frontmatter equivalent, layered CLI > frontmatter >
default, replacing the generic --renderer-option KEY=VALUE passthrough.

Five defects found while reading the code, each with a regression test:

- Link and image URLs were shredded by the emphasis patterns, which ran
  before LINK_RE: a path like /v1_2_3/ became /v1<i>2</i>3/ inside the
  href. Links and images are now resolved and stashed first.
- <table> was emitted as a direct child of <card>. The WML 1.3 content
  model is (onevent*, timer?, (do | p | pre)*), so it must sit in a <p>.
- Link labels carried emphasis, but <a> is declared (#PCDATA | br | img)*
  -- there is nowhere valid for a <b> inside one. Markers are now stripped.
- The menu card was built directly and never byte-packed, so the one card
  most likely to be large was the one card exempt from the budget.
- Output was written with CRLF line endings. WML is XML served over HTTP.

New, WAP-idiomatic rather than Markdown-idiomatic:

- deck-level <template> hoisting nav that would otherwise repeat per card
- <select> menus, picked with a keypad digit instead of scrolled to
- --deck-per-card, one file per section cross-linked by filename, which
  is the real answer to per-deck cache limits
- <head> metadata: Cache-Control max-age and <access>
- a Home options softkey, which <prev/> alone cannot provide
- an image policy (keep/alt/drop) for the WBMP-only reality of WAP 1.x
  browsers -- no conversion is performed

186 tests, where neither repo had any. Golden files pin whole-document
output; every deck is validated against the real WML 1.3 DTD, vendored at
tests/dtd/wml13.dtd. Rendering examples/trail.md still matches md2txt's
committed output byte for byte, modulo the CRLF fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-23 09:27:49 +03:00
commit b83e24e2f7
51 changed files with 4016 additions and 0 deletions

94
tests/test_inline.py Normal file
View file

@ -0,0 +1,94 @@
"""Inline markup mapping and WML's escaping rules."""
from __future__ import annotations
import pytest
from wapdown.renderers.wml.inline import escape_wml, process_inline
class TestEscaping:
def test_xml_significant_characters(self):
assert escape_wml('a & b < c > d "e"') == "a &amp; b &lt; c &gt; d &quot;e&quot;"
def test_dollar_is_doubled(self):
# WML uses $ to introduce a variable substitution, so a literal $
# must be written $$ or the device eats the following word.
assert escape_wml("Permit fee: $5") == "Permit fee: $$5"
def test_ampersand_escaped_before_other_entities(self):
assert escape_wml("<") == "&lt;"
assert "&amp;lt;" not in escape_wml("a & b")
class TestEmphasis:
@pytest.mark.parametrize(
("source", "expected"),
[
("**bold**", "<b>bold</b>"),
("__bold__", "<b>bold</b>"),
("*italic*", "<i>italic</i>"),
("_italic_", "<i>italic</i>"),
("~~struck~~", "struck"), # no WML equivalent, markers dropped
("`code`", "code"),
],
)
def test_mapping(self, source, expected):
assert process_inline(source) == expected
def test_code_span_contents_are_not_reinterpreted(self):
assert process_inline("`a *b* c`") == "a *b* c"
class TestLinks:
def test_basic_link(self):
assert process_inline("[call](tel:+15555550123)") == (
'<a href="tel:+15555550123">call</a>'
)
def test_underscores_in_url_survive(self):
# Regression: emphasis patterns used to run before LINK_RE, so
# UNDERLINE_EM_RE shredded `v1_2_3` into `v1<i>2</i>3` inside the href.
result = process_inline("[notes](https://example.com/v1_2_3/x.html)")
assert result == '<a href="https://example.com/v1_2_3/x.html">notes</a>'
assert "<i>" not in result
def test_asterisks_in_url_survive(self):
result = process_inline("[log](https://ci.example.com/job/a*b*c/log)")
assert "a*b*c" in result
assert "<i>" not in result
def test_label_emphasis_is_stripped_not_translated(self):
# WML 1.3 declares <a> as (#PCDATA | br | img)*: there is nowhere
# valid to put a <b> inside a link label.
assert process_inline("[**go** now](http://x.test/)") == (
'<a href="http://x.test/">go now</a>'
)
def test_url_is_escaped_for_attribute_use(self):
assert process_inline("[q](http://x.test/?a=1&b=2)") == (
'<a href="http://x.test/?a=1&amp;b=2">q</a>'
)
class TestImages:
def test_keep_is_the_default(self):
assert process_inline("![Map](map.png)") == '<img src="map.png" alt="Map"/>'
def test_alt_policy_replaces_non_wbmp(self, capsys):
assert process_inline("![Map](map.png)", images="alt") == "Map"
assert "non-WBMP" in capsys.readouterr().err
def test_drop_policy_removes_non_wbmp(self, capsys):
assert process_inline("![Map](map.png)", images="drop") == ""
assert "dropped" in capsys.readouterr().err
@pytest.mark.parametrize("policy", ["alt", "drop"])
def test_wbmp_always_survives(self, policy):
result = process_inline("![Icon](icon.wbmp)", images=policy)
assert result == '<img src="icon.wbmp" alt="Icon"/>'
def test_wbmp_detected_past_a_query_string(self):
assert '<img' in process_inline("![I](i.wbmp?v=2)", images="drop")
def test_image_is_not_parsed_as_a_link(self):
assert process_inline("![Map](map.png)").startswith("<img")