The README advertised a Codeberg URL, but the repository lives at code.randogoth.com. Verified that the new URL resolves over HTTPS, which is what `uv tool install --from git+...` needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| examples | ||
| src/wapdown | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
wapdown
Compile Markdown into a WML deck for WAP browsers.
wapdown trail.md -o trail.wml
A WAP screen is a few lines tall and driven by a numeric keypad, so a deck is a set of short cards joined by softkeys — not one long page. wapdown restructures a document into that shape: it decides where cards begin, builds a menu, wires up Back and More, and paginates whatever is still too big.
Install
uv tool install --from git+https://code.randogoth.com/randogoth/wapdown/ wapdown
Zero runtime dependencies, Python 3.13+.
Markdown → WML
Every mapping, with the markup wapdown actually emits:
| Markdown | WML | Notes |
|---|---|---|
# Heading (first on a card) |
<p><b><big>…</big></b></p> |
|
## Heading (and any later heading) |
<p><b>…</b></p> |
WML has no heading element |
Text over === / --- |
same as # / ## |
setext headings |
| Paragraph | <p>…</p> |
the only block container WML has |
- item |
<p>- item</p> |
no list element; the bullet is literal text |
1. item |
<p>1. item</p> |
numbering is counted and written out |
> quote |
<p><i>…</i></p> |
|
--- / *** / ___ |
(nothing) | starts a new card; WML has no rule element |
{.card Title} |
(nothing) | starts a new card, with a title |
```fenced``` / 4-space indent |
<p>a<br/>b</p> |
<br/> between lines |
**bold**, __bold__ |
<b>…</b> |
|
*italic*, _italic_ |
<i>…</i> |
|
`code` |
(markers stripped) | no equivalent |
~~strike~~ |
(markers stripped) | no equivalent |
[label](url) |
<a href="url">label</a> |
emphasis in the label is stripped |
 |
<img src="src" alt="alt"/> |
subject to --images |
| Pipe table | <p><table columns="N">…</table></p> |
header row bolded |
{.include file.md} |
(inlined before conversion) |
Two quirks of WML's own grammar:
- A literal
$becomes$$. WML uses$for variable substitution.Permit fee: $5is emitted as$$5and displays as$5. - Link labels cannot carry emphasis. WML declares
<a>as(#PCDATA | br | img)*, so[**go** now](url)becomesgo now.
WML 1.3 is the only supported DTD, by design.
Cards
A document with no dividers is one card. Three ways to split it:
--- starts a new card, taking its title from the section's first heading:
Intro shown on the menu card.
---
## Weather
Cold and clear.
*** and ___ work the same. A trailing ---, or two in a row, adds no empty card. --- under text is a setext heading, not a divider.
{.card Title} is the same thing with an explicit title, for sections with no heading to borrow from. The two compose freely.
--split-level 2 starts a card at every ##, for documents structured by headings alone. Explicit dividers win when both are present.
Navigation
Two or more cards get the classic WAP portal: a menu card listing each section, each spoke returning with Back.
| Flag | Effect |
|---|---|
--menu-style select |
menu choices as a keypad-pickable <select> instead of links |
--no-menu |
linear book — cards chained More/Back, no hub |
--home-label Home |
options softkey jumping straight to the menu from anywhere |
--no-template-nav |
repeat nav on every card instead of hoisting it into <template> |
Size limits
--max-card-bytes (default 1400, 0 disables) paginates any card still over budget with Prev/More. Some headroom is reserved for nav markup, so the effective budget is slightly under the number you pass.
When the whole deck is too large, paginating inside one file doesn't help — it still gets fetched whole. --deck-per-card writes one file per section, cross-linked by filename:
wapdown trail.md --deck-per-card -o ./decks/
# decks/index.wml decks/weather.wml decks/trail-status.wml
The hub lands in index.wml; in linear mode there is no hub, so the entry point is the first section's file.
Images
No conversion is ever performed. Most WAP 1.x browsers render only WBMP, so a PNG generally shows as a broken-image placeholder. .wbmp sources always pass through untouched.
--images |
Non-WBMP images |
|---|---|
keep (default) |
emitted as written |
alt |
replaced with their alt text, with a warning |
drop |
removed, with a warning |
Options
Every option works as a flag or as frontmatter — same name, underscores instead of dashes. Flags win over frontmatter.
---
title: Trail Conditions
menu_style: select
cache_control: 0
---
| Flag | Frontmatter | Default | Meaning |
|---|---|---|---|
--title |
title |
(none) | Deck title, shown on the menu card. |
--split-level |
split_level |
0 |
Heading level starting a new card; 0 disables. |
--split-on-rule / --no-… |
split_on_rule |
true |
Treat --- as a card divider. |
--max-card-bytes |
max_card_bytes |
1400 |
Per-card byte budget; 0 disables. |
--menu / --no-menu |
menu |
true |
Hub-and-spoke menu vs. linear chaining. |
--menu-style |
menu_style |
links |
links or select. |
--deck-per-card |
deck_per_card |
false |
One file per section. |
--nav-next-label |
nav_next_label |
More |
Forward label. |
--nav-prev-label |
nav_prev_label |
Prev |
Label between pagination parts. |
--nav-back-label |
nav_back_label |
Back |
Label back to the menu. |
--home-label |
home_label |
(none) | Adds an options softkey to the menu. |
--template-nav / --no-… |
template_nav |
true |
Hoist shared nav into <template>. |
--images |
images |
keep |
keep, alt, or drop. |
--cache-control |
cache_control |
(none) | Emit Cache-Control: max-age=N. |
--access-domain |
access_domain |
(none) | Emit <access domain=…>. |
--access-path |
access_path |
(none) | Emit <access path=…>. |
Serving
Serve .wml as text/vnd.wap.wml — a browser given text/html will usually refuse to render it. Set --cache-control 0 for content that changes; WAP gateways cache aggressively.
Development
uv sync
uv run pytest # unit, golden, well-formedness
uv run --extra dtd pytest # ...plus validation against the real WML 1.3 DTD
The DTD is vendored at tests/dtd/wml13.dtd; validation needs lxml and is skipped without it. Golden files pin whole-document output — after an intentional change, regenerate and read the diff:
uv run python tests/regenerate_golden.py && jj diff tests/golden
Parsers and renderers are registered as plugins (src/wapdown/plugins/), so another WAP-era target such as HDML or i-mode cHTML can be added without restructuring. The renderer is split into inline.py (markup and escaping), deck.py (sectioning, packing, nav topology) and renderer.py (element emission).
License
MIT