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>
2026-09-23 09:27:49 +03:00
|
|
|
"""Deck-level WML emission: prolog, head, block mapping, and validity."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from wapdown.renderers.wml.renderer import WML_PROLOG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TWO_SECTIONS = "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse(markup: str) -> ElementTree.Element:
|
|
|
|
|
"""Parse the deck, proving it is well-formed XML.
|
|
|
|
|
|
|
|
|
|
ElementTree honours the DOCTYPE declaration without trying to fetch the
|
|
|
|
|
external DTD, which matters because wapforum.org has been gone for years.
|
|
|
|
|
"""
|
|
|
|
|
return ElementTree.fromstring(markup)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDocumentShape:
|
|
|
|
|
def test_prolog_and_doctype(self, render):
|
|
|
|
|
markup = render("Body.\n")
|
|
|
|
|
assert markup.startswith(WML_PROLOG)
|
|
|
|
|
assert 'PUBLIC "-//WAPFORUM//DTD WML 1.3//EN"' in markup
|
|
|
|
|
|
|
|
|
|
def test_root_element_is_wml(self, render):
|
|
|
|
|
assert parse(render("Body.\n")).tag == "wml"
|
|
|
|
|
|
|
|
|
|
def test_output_is_well_formed(self, render):
|
|
|
|
|
parse(render(TWO_SECTIONS))
|
|
|
|
|
|
|
|
|
|
def test_no_carriage_returns(self, render):
|
|
|
|
|
# WML is XML served over HTTP, not a DOS text file.
|
|
|
|
|
assert "\r" not in render(TWO_SECTIONS)
|
|
|
|
|
|
|
|
|
|
def test_ends_with_a_single_newline(self, render):
|
|
|
|
|
markup = render("Body.\n")
|
|
|
|
|
assert markup.endswith("</wml>\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCardContentModel:
|
|
|
|
|
"""WML 1.3 declares <card> as (onevent*, timer?, (do | p)*)."""
|
|
|
|
|
|
|
|
|
|
def test_card_children_are_only_do_and_p(self, render):
|
|
|
|
|
for card in parse(render(TWO_SECTIONS)).findall("card"):
|
|
|
|
|
assert {child.tag for child in card} <= {"do", "p"}
|
|
|
|
|
|
|
|
|
|
def test_table_is_wrapped_in_a_paragraph(self, render):
|
|
|
|
|
# Regression: <table> was emitted as a direct child of <card>, which
|
|
|
|
|
# the DTD does not permit -- it is flow content and belongs in a <p>.
|
|
|
|
|
markup = render("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
|
|
|
|
|
assert "<p><table" in markup
|
|
|
|
|
card = parse(markup).find("card")
|
|
|
|
|
assert [child.tag for child in card] == ["p"]
|
|
|
|
|
assert card.find("p/table") is not None
|
|
|
|
|
|
|
|
|
|
def test_do_elements_precede_content(self, render):
|
|
|
|
|
card = parse(render(TWO_SECTIONS, menu=False)).find("card")
|
|
|
|
|
tags = [child.tag for child in card]
|
|
|
|
|
assert tags.index("do") < tags.index("p")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBlockMapping:
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("source", "expected"),
|
|
|
|
|
[
|
|
|
|
|
("Just text.\n", "<p>Just text.</p>"),
|
|
|
|
|
("- item\n", "<p>- item</p>"),
|
|
|
|
|
("> quoted\n", "<p><i>quoted</i></p>"),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_blocks(self, render, source, expected):
|
|
|
|
|
assert expected in render(source)
|
|
|
|
|
|
Make `---` divide cards, and stop drawing ASCII rules
WML has no horizontal rule element -- its entire %layout entity is
`<br>` -- so a thematic break was being rendered as `<p>------------</p>`,
a hardcoded twelve-dash guess at screen width that wraps into nonsense on
a narrow device and is short on a wide one. It was the one construct
where the renderer invented a width out of nothing.
A `---` already looks like a divider and reads as one in every Markdown
document ever written, so it now is one: it starts a new card, by
default. Rules are never drawn, whether or not they divide.
Thematic breaks and `{.card Title}` markers are now a single mechanism --
an explicit divider, optionally carrying a title -- and compose freely in
one document. Both still win over --split-level. A section takes its
title from its own first heading when the divider does not supply one, so
the common shape needs no titles at all:
Intro on the menu.
---
## Weather
Cold and clear.
`--no-split-on-rule` / `split_on_rule: false` opts out of the splitting;
it does not bring the dashes back.
Three edges this opened, each fixed here rather than left to bite:
- Setext headings. `Heading` over `---` is an H2 in Markdown, but the
parser was ATX-only, so it produced a paragraph plus a rule. Once a
rule divides cards, a setext document would have split at every heading
and stranded each heading as body copy on the card before it. Setext
headings are now parsed, so such a document keeps its structure.
- Empty sections. A trailing `---`, or two in a row, produced an empty
"Untitled" card and a menu entry leading to it. Sections with no
renderable content are dropped.
- The multi-card threshold was two sections, so a document with a single
divider collapsed back into one card. Any explicit divider now makes a
multi-card deck: `A --- B` asks for two screens.
Also fixes a latent frontmatter bug this made far more likely: a document
opening with `---` and no `key: value` lines had its opening swallowed as
if the break were a frontmatter fence. A fenced block with no keys in it
is not frontmatter.
222 tests. Rendering examples/trail.md still matches md2txt's committed
output byte for byte; no golden file contains an ASCII rule any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:27:49 +03:00
|
|
|
def test_thematic_break_draws_nothing(self, render):
|
|
|
|
|
# WML has no rule element -- its whole %layout entity is <br> -- and
|
|
|
|
|
# a row of ASCII dashes is noise on a small screen.
|
|
|
|
|
assert "---" not in render("Before.\n\n---\n\nAfter.\n")
|
|
|
|
|
|
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>
2026-09-23 09:27:49 +03:00
|
|
|
def test_first_heading_is_emphasised_larger(self, render):
|
|
|
|
|
markup = render("# Title\n\n## Sub\n")
|
|
|
|
|
assert "<p><b><big>Title</big></b></p>" in markup
|
|
|
|
|
assert "<p><b>Sub</b></p>" in markup
|
|
|
|
|
|
|
|
|
|
def test_ordered_list_is_numbered_manually(self, render):
|
|
|
|
|
# WML has no list element, so the numbers become literal text.
|
|
|
|
|
markup = render("1. first\n2. second\n")
|
|
|
|
|
assert "<p>1. first</p>" in markup
|
|
|
|
|
assert "<p>2. second</p>" in markup
|
|
|
|
|
|
|
|
|
|
def test_ordered_list_numbering_restarts_after_a_paragraph(self, render):
|
|
|
|
|
markup = render("1. a\n\nBreak.\n\n1. b\n")
|
|
|
|
|
assert markup.count("<p>1. ") == 2
|
|
|
|
|
|
|
|
|
|
def test_code_block_lines_join_with_breaks(self, render):
|
|
|
|
|
markup = render("```\none\ntwo\n```\n")
|
|
|
|
|
assert "<p>one<br/>two</p>" in markup
|
|
|
|
|
|
|
|
|
|
def test_code_block_content_is_escaped(self, render):
|
|
|
|
|
assert "<wml>" in render("```\n<wml>\n```\n")
|
|
|
|
|
|
|
|
|
|
def test_table_header_row_is_bold(self, render):
|
|
|
|
|
markup = render("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
|
|
|
|
|
assert "<td><b>A</b></td>" in markup
|
|
|
|
|
assert "<td>1</td>" in markup
|
|
|
|
|
|
|
|
|
|
def test_ragged_table_rows_are_padded(self, render):
|
|
|
|
|
markup = render("| A | B | C |\n| --- | --- | --- |\n| 1 |\n")
|
|
|
|
|
assert '<table columns="3">' in markup
|
|
|
|
|
assert markup.count("<td>") % 3 == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHead:
|
|
|
|
|
def test_no_head_by_default(self, render):
|
|
|
|
|
assert "<head>" not in render("Body.\n")
|
|
|
|
|
|
|
|
|
|
def test_cache_control(self, render):
|
|
|
|
|
markup = render("Body.\n", cache_control=0)
|
|
|
|
|
assert '<meta http-equiv="Cache-Control" content="max-age=0"/>' in markup
|
|
|
|
|
parse(markup)
|
|
|
|
|
|
|
|
|
|
def test_access_control(self, render):
|
|
|
|
|
markup = render("Body.\n", access_domain="example.com", access_path="/wap")
|
|
|
|
|
assert '<access domain="example.com" path="/wap"/>' in markup
|
|
|
|
|
parse(markup)
|
|
|
|
|
|
|
|
|
|
def test_head_precedes_template_and_cards(self, render):
|
|
|
|
|
markup = render(TWO_SECTIONS, cache_control=60)
|
|
|
|
|
assert [child.tag for child in parse(markup)][:2] == ["head", "template"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTitles:
|
|
|
|
|
def test_explicit_title_reaches_the_menu_card(self, render):
|
|
|
|
|
assert '<card id="menu" title="Deck">' in render(TWO_SECTIONS, title="Deck")
|
|
|
|
|
|
|
|
|
|
def test_single_card_is_untitled_without_an_explicit_title(self, render):
|
|
|
|
|
# The document's own heading already renders into the card body.
|
|
|
|
|
assert '<card id="card1">' in render("# Heading\n\nBody.\n")
|
|
|
|
|
|
|
|
|
|
def test_title_is_escaped(self, render):
|
|
|
|
|
markup = render(TWO_SECTIONS, title='Fish & "Chips"')
|
|
|
|
|
assert 'title="Fish & "Chips""' in markup
|
|
|
|
|
parse(markup)
|
|
|
|
|
|
|
|
|
|
def test_dollar_in_a_title_is_doubled(self, render):
|
|
|
|
|
assert 'title="$$5"' in render(TWO_SECTIONS, title="$5")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMultiDeck:
|
|
|
|
|
def test_one_file_per_section(self, render_output):
|
|
|
|
|
output = render_output(TWO_SECTIONS, deck_per_card=True)
|
|
|
|
|
assert sorted(doc.name for doc in output.documents) == [
|
|
|
|
|
"index.wml",
|
|
|
|
|
"one.wml",
|
|
|
|
|
"two.wml",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_menu_links_across_files(self, render_output):
|
|
|
|
|
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
|
|
|
|
|
assert '<a href="one.wml#card1">One</a>' in docs["index.wml"]
|
|
|
|
|
|
|
|
|
|
def test_links_within_the_same_file_stay_local(self, render_output):
|
|
|
|
|
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
|
|
|
|
|
assert "index.wml#" not in docs["one.wml"].split("</template>")[-1]
|
|
|
|
|
|
|
|
|
|
def test_every_file_is_well_formed(self, render_output):
|
|
|
|
|
for doc in render_output(TWO_SECTIONS, deck_per_card=True).documents:
|
|
|
|
|
parse(doc.text)
|
|
|
|
|
|
|
|
|
|
def test_single_section_document_stays_one_file(self, render_output):
|
|
|
|
|
output = render_output("# Title\n\nBody.\n", deck_per_card=True)
|
|
|
|
|
assert len(output.documents) == 1
|
|
|
|
|
assert output.documents[0].name is None
|
|
|
|
|
|
|
|
|
|
def test_duplicate_section_titles_get_distinct_filenames(self, render_output):
|
|
|
|
|
source = "Intro.\n\n{.card Same}\nA.\n\n{.card Same}\nB.\n"
|
|
|
|
|
names = [d.name for d in render_output(source, deck_per_card=True).documents]
|
|
|
|
|
assert len(set(names)) == len(names)
|