Tighten the README around a Markdown-to-WML mapping table

The README explained the design before it explained the output. Someone
arriving with a document in hand had to read four sections of rationale
before learning what a heading or a table actually becomes.

The mapping now comes first and is complete: every Markdown construct
against the markup wapdown emits for it, with the WML-side reasons in a
notes column rather than in prose. Each row was checked against real
output rather than carried over from the old text.

Cut the "why this is not just another text exporter" section down to two
sentences in the intro, folded the navigation and image prose into small
tables, and dropped the separate Architecture section into the end of
Development. 210 lines to 164, 1673 words to 1176, with more of the
reference material a user actually looks things up in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-23 09:31:03 +03:00
parent 3b728d9d74
commit d6361b927a

204
README.md
View file

@ -6,204 +6,154 @@ Compile Markdown into a [WML](https://en.wikipedia.org/wiki/Wireless_Markup_Lang
wapdown trail.md -o trail.wml wapdown trail.md -o trail.wml
``` ```
Write a document the ordinary way; get back valid WML 1.3 that a 2001 handset can actually navigate. 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.
## Why this is not just another text exporter
Most Markdown converters transform a document: same content, different markup, one file in and one file out. A WML deck is not that shape. A WAP screen is a few lines tall, scrolling is painful, and the input device is a numeric keypad — so a deck is a set of short **cards** joined by softkeys and menus, not one long page.
So wapdown **restructures**. It decides where cards begin, builds a menu that exists nowhere in your source, wires up Back and More controls, and paginates anything still too big for the device's cache. That is why it has more knobs than a plain text exporter needs: the knobs *are* the tool.
## Install ## Install
Install the CLI with [uv](https://github.com/astral-sh/uv):
```bash ```bash
uv tool install --from git+https://codeberg.org/randogoth/wapdown/ wapdown uv tool install --from git+https://codeberg.org/randogoth/wapdown/ wapdown
``` ```
Zero runtime dependencies, Python 3.13+. 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 |
| `![alt](src)` | `<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: $5` is emitted as `$$5` and displays as `$5`.
- **Link labels cannot carry emphasis.** WML declares `<a>` as `(#PCDATA | br | img)*`, so `[**go** now](url)` becomes `go now`.
WML 1.3 is the only supported DTD, by design.
## Cards ## Cards
A document with no dividers becomes a single card. There are three ways to ask for more. A document with no dividers is one card. Three ways to split it:
**Thematic breaks** — a plain `---` starts a new card. WML has no rule element to draw (its entire layout vocabulary is `<br>`), and a row of ASCII dashes is noise on a small screen, so the glyph is put to better use as the divider it already looks like: **`---`** starts a new card, taking its title from the section's first heading:
```markdown ```markdown
Intro shown on the menu card... Intro shown on the menu card.
--- ---
## Weather ## Weather
Cold and clear. Cold and clear.
---
## Trail Status
- North Loop: open
``` ```
Each card takes its title from its own first heading. `***` and `___` work identically — any Markdown thematic break divides. A trailing `---`, or two in a row, adds no empty card. `--no-split-on-rule` turns this off; breaks are then ignored rather than drawn, because there is still nothing to draw them with. `***` and `___` work the same. A trailing `---`, or two in a row, adds no empty card. `---` *under* text is a setext heading, not a divider.
`---` under a line of text is a setext heading, not a break, and is parsed as one — so a document written in that style keeps its headings instead of shattering into cards. **`{.card Title}`** is the same thing with an explicit title, for sections with no heading to borrow from. The two compose freely.
**Named dividers** — `{.card Title}` is the same mechanism with an explicit title, for when a section has no heading to borrow one from: **`--split-level 2`** starts a card at every `##`, for documents structured by headings alone. Explicit dividers win when both are present.
```markdown
{.card Trail Status}
- North Loop: open
```
The two compose freely in one document.
**Heading-level splitting** — `--split-level 2` starts a new card at every `##`, which fits a document that is already structured by headings and has no dividers in it:
```bash
wapdown trail.md --split-level 2
```
Explicit dividers win over `--split-level` when both are present: a boundary written into the document is a stronger statement of intent than a rule passed on the command line.
## Navigation ## Navigation
Once a document resolves to two or more cards, wapdown builds the classic WAP-portal shape — a **menu** card listing each section, each spoke reachable in one press and returning with Back: Two or more cards get the classic WAP portal: a **menu** card listing each section, each spoke returning with Back.
```bash | Flag | Effect |
wapdown trail.md --split-level 2 --menu-style select | --- | --- |
``` | `--menu-style select` | menu choices as a keypad-pickable `<select>` instead of links |
| `--no-menu` | linear book — cards chained More/Back, no hub |
`--menu-style select` renders those choices as a `<select>` rather than a list of links, so the destination is picked with a digit instead of scrolled to. On a keypad that is usually the better default; it is not the default here only because it changes how the deck looks. | `--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>` |
`--no-menu` switches to a linear book instead: cards chained with More and Back, no hub.
`--home-label Home` adds an options softkey jumping straight back to the menu from anywhere, which `<prev/>` alone cannot do since it only pops history.
Shared navigation is hoisted into a deck-level `<template>` so it is transmitted once rather than repeated on every card — a real saving when the whole deck has to fit in a cache. `--no-template-nav` turns that off.
## Size limits ## Size limits
WAP 1.x gateways cap how much deck a device will hold. `--max-card-bytes` (default 1400, `0` disables) is the safety net: any card still over budget after section splitting gets paginated with Prev/More. Some headroom is reserved for each card's navigation markup, which is why the effective budget is slightly below the number you pass. `--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 a deck as a whole is too large, paginating inside one file does not help — the file still has to be fetched whole. `--deck-per-card` writes **one `.wml` file per section** into an output directory, cross-linked by filename, so a device only ever downloads the card it asked for: 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:
```bash ```bash
wapdown trail.md --split-level 2 --deck-per-card -o ./decks/ wapdown trail.md --deck-per-card -o ./decks/
# decks/index.wml decks/weather.wml decks/trail-status.wml ... # decks/index.wml decks/weather.wml decks/trail-status.wml
``` ```
In menu mode the hub lands in `index.wml`. In linear mode there is no hub, so the entry point is the first section's file. The hub lands in `index.wml`; in linear mode there is no hub, so the entry point is the first section's file.
## Images ## Images
wapdown performs **no image conversion**. Most WAP 1.x browsers render only WBMP, so referencing a PNG generally yields a broken-image placeholder on the device. `--images` decides what to do about that: **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.
| Value | Behaviour | | `--images` | Non-WBMP images |
| --- | --- | | --- | --- |
| `keep` *(default)* | Emit `<img>` as written, whatever the format. | | `keep` *(default)* | emitted as written |
| `alt` | Replace non-WBMP images with their alt text, and warn. | | `alt` | replaced with their alt text, with a warning |
| `drop` | Remove non-WBMP images entirely, and warn. | | `drop` | removed, with a warning |
`.wbmp` sources always pass through untouched. ## Options
## Configuration Every option works as a flag or as frontmatter — same name, underscores instead of dashes. **Flags win over frontmatter.**
Every option can be set on the command line or in the document's frontmatter. The frontmatter key is the long flag name with underscores; **command-line flags win**.
```markdown ```markdown
--- ---
title: Trail Conditions title: Trail Conditions
split_level: 2
menu_style: select menu_style: select
cache_control: 0 cache_control: 0
--- ---
# Trail Conditions
...
``` ```
| Flag | Frontmatter | Default | Meaning | | Flag | Frontmatter | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--title` | `title` | *(none)* | Deck title, used on the menu card. | | `--title` | `title` | *(none)* | Deck title, shown on the menu card. |
| `--split-level` | `split_level` | `0` | Heading level that starts a new card; `0` disables. Ignored when `{.card}` markers are present. | | `--split-level` | `split_level` | `0` | Heading level starting a new card; `0` disables. |
| `--max-card-bytes` | `max_card_bytes` | `1400` | Per-card byte safety net; `0` disables. | | `--split-on-rule` / `--no-…` | `split_on_rule` | `true` | Treat `---` as a card divider. |
| `--menu` / `--no-menu` | `menu` | `true` | Hub-and-spoke menu vs. linear More/Back chaining. | | `--max-card-bytes` | `max_card_bytes` | `1400` | Per-card byte budget; `0` disables. |
| `--menu-style` | `menu_style` | `links` | `links` or `select` for the menu's choices. | | `--menu` / `--no-menu` | `menu` | `true` | Hub-and-spoke menu vs. linear chaining. |
| `--split-on-rule` / `--no-split-on-rule` | `split_on_rule` | `true` | Start a new card at every thematic break. | | `--menu-style` | `menu_style` | `links` | `links` or `select`. |
| `--deck-per-card` | `deck_per_card` | `false` | Write one file per section into the output directory. | | `--deck-per-card` | `deck_per_card` | `false` | One file per section. |
| `--nav-next-label` | `nav_next_label` | `More` | Forward label. | | `--nav-next-label` | `nav_next_label` | `More` | Forward label. |
| `--nav-prev-label` | `nav_prev_label` | `Prev` | Label between pagination parts. | | `--nav-prev-label` | `nav_prev_label` | `Prev` | Label between pagination parts. |
| `--nav-back-label` | `nav_back_label` | `Back` | Label back to the menu or previous section. | | `--nav-back-label` | `nav_back_label` | `Back` | Label back to the menu. |
| `--home-label` | `home_label` | *(none)* | Adds an options softkey to the menu card. | | `--home-label` | `home_label` | *(none)* | Adds an options softkey to the menu. |
| `--template-nav` / `--no-template-nav` | `template_nav` | `true` | Hoist shared nav into a `<template>`. | | `--template-nav` / `--no-…` | `template_nav` | `true` | Hoist shared nav into `<template>`. |
| `--images` | `images` | `keep` | `keep`, `alt`, or `drop`. | | `--images` | `images` | `keep` | `keep`, `alt`, or `drop`. |
| `--cache-control` | `cache_control` | *(none)* | Emit a `Cache-Control: max-age=N` meta element. | | `--cache-control` | `cache_control` | *(none)* | Emit `Cache-Control: max-age=N`. |
| `--access-domain` | `access_domain` | *(none)* | Emit `<access domain=...>`. | | `--access-domain` | `access_domain` | *(none)* | Emit `<access domain=…>`. |
| `--access-path` | `access_path` | *(none)* | Emit `<access path=...>`. | | `--access-path` | `access_path` | *(none)* | Emit `<access path=…>`. |
## Markdown support ## Serving
| Markdown | WML | 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.
| --- | --- |
| Headings | `<p><b><big>` for the first on a card, `<p><b>` after |
| Paragraphs, lists | `<p>` — WML has no list element, so bullets and numbers become literal text |
| `---` / `***` / `___` | a card divider; never drawn, as WML has no rule element |
| Setext headings (`===` / `---` underlines) | same as `#` / `##` |
| `**bold**`, `*italic*` | `<b>`, `<i>` |
| `~~strike~~`, `` `code` `` | markers stripped; WML has no equivalent |
| Links | `<a href>` |
| Images | `<img src alt>`, subject to `--images` |
| Fenced/indented code | `<p>` with `<br/>` between lines |
| Blockquotes | `<p><i>` |
| Pipe tables | `<p><table columns="N">` with a bold header row |
| `{.card Title}` | a card divider carrying a title |
| `{.include file.md}` | inlined before conversion |
Two consequences of WML's own grammar are worth knowing:
- **A literal `$` is written `$$`.** WML uses `$` to introduce a variable substitution, so wapdown doubles it for you. `Permit fee: $5` comes out as `Permit fee: $$5`, which the device renders as `$5`.
- **Link labels cannot carry emphasis.** WML declares `<a>` as `(#PCDATA | br | img)*`, so there is nowhere valid to put a `<b>` inside one. `[**go** now](url)` becomes `go now`, with the markers stripped rather than translated.
WML 1.3 is the only supported DTD, by design rather than by flag.
## Serving a deck
Serve `.wml` as `text/vnd.wap.wml` — a browser that receives `text/html` will usually refuse to render it. `--cache-control 0` is worth setting for content that changes, since WAP gateways cache aggressively.
## Development ## Development
```bash ```bash
uv sync uv sync
uv run pytest # unit, golden, and well-formedness tests uv run pytest # unit, golden, well-formedness
uv run --extra dtd pytest # ...plus validation against the real WML 1.3 DTD uv run --extra dtd pytest # ...plus validation against the real WML 1.3 DTD
``` ```
The DTD is vendored at `tests/dtd/wml13.dtd` so the suite never depends on fetching a twenty-year-old URL. DTD validation needs `lxml` and is skipped when it is absent. 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:
Golden files under `tests/golden/` pin whole-document output. After an intentional renderer change:
```bash ```bash
uv run python tests/regenerate_golden.py uv run python tests/regenerate_golden.py && jj diff tests/golden
jj diff tests/golden # the diff is the change, stated in output
``` ```
## Architecture 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).
Markdown is parsed into a stream of block events, which a renderer turns into output. The pieces are registered as plugins (`src/wapdown/plugins/`), so another WAP-era target — HDML, i-mode cHTML — can be added alongside `wml` without restructuring anything.
- `parsers/markdown.py` — Markdown to `BlockEvent`s
- `renderers/wml/inline.py` — inline markup and WML's escaping rules
- `renderers/wml/deck.py` — sectioning, byte packing, navigation topology
- `renderers/wml/renderer.py` — element emission
- `config.py` — defaults, frontmatter, and CLI layering
Unlike a streaming text renderer, the WML renderer buffers the whole event stream: card boundaries, menu choices, and nav targets all depend on knowing the document's full structure up front.
## Credit
The WML renderer began life inside [md2txt](https://codeberg.org/randogoth/md2txt), a suite of plain-text Markdown exporters, and was extracted here because a deck compiler is a different kind of thing from a text exporter.
## License ## License