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

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tobias Raayoni Last
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

187
README.md Normal file
View file

@ -0,0 +1,187 @@
# wapdown
Compile Markdown into a [WML](https://en.wikipedia.org/wiki/Wireless_Markup_Language) deck for WAP browsers.
```bash
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.
## 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 the CLI with [uv](https://github.com/astral-sh/uv):
```bash
uv tool install --from git+https://codeberg.org/randogoth/wapdown/ wapdown
```
Zero runtime dependencies, Python 3.13+.
## Cards
A document with no options becomes a single card. Splitting is always opt-in, and there are two ways to ask for it.
**Manual dividers** — put a `{.card}` or `{.card Title}` line wherever a new card should start:
```markdown
Intro shown on the menu card...
{.card Weather}
Cold and clear.
{.card Trail Status}
- North Loop: open
```
**Heading-level splitting** — `--split-level 2` starts a new card at every `##`, which fits the common one-H1-title-plus-H2-sections document:
```bash
wapdown trail.md --split-level 2
```
If a document contains any `{.card}` markers, they win and `--split-level` is ignored: an explicit boundary in the document is a stronger statement of intent than a rule passed on the command line.
## 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:
```bash
wapdown trail.md --split-level 2 --menu-style select
```
`--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.
`--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
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.
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:
```bash
wapdown trail.md --split-level 2 --deck-per-card -o ./decks/
# 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.
## 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:
| Value | Behaviour |
| --- | --- |
| `keep` *(default)* | Emit `<img>` as written, whatever the format. |
| `alt` | Replace non-WBMP images with their alt text, and warn. |
| `drop` | Remove non-WBMP images entirely, and warn. |
`.wbmp` sources always pass through untouched.
## Configuration
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
---
title: Trail Conditions
split_level: 2
menu_style: select
cache_control: 0
---
# Trail Conditions
...
```
| Flag | Frontmatter | Default | Meaning |
| --- | --- | --- | --- |
| `--title` | `title` | *(none)* | Deck title, used on the menu card. |
| `--split-level` | `split_level` | `0` | Heading level that starts a new card; `0` disables. Ignored when `{.card}` markers are present. |
| `--max-card-bytes` | `max_card_bytes` | `1400` | Per-card byte safety net; `0` disables. |
| `--menu` / `--no-menu` | `menu` | `true` | Hub-and-spoke menu vs. linear More/Back chaining. |
| `--menu-style` | `menu_style` | `links` | `links` or `select` for the menu's choices. |
| `--deck-per-card` | `deck_per_card` | `false` | Write one file per section into the output directory. |
| `--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 or previous section. |
| `--home-label` | `home_label` | *(none)* | Adds an options softkey to the menu card. |
| `--template-nav` / `--no-template-nav` | `template_nav` | `true` | Hoist shared nav into a `<template>`. |
| `--images` | `images` | `keep` | `keep`, `alt`, or `drop`. |
| `--cache-control` | `cache_control` | *(none)* | Emit a `Cache-Control: max-age=N` meta element. |
| `--access-domain` | `access_domain` | *(none)* | Emit `<access domain=...>`. |
| `--access-path` | `access_path` | *(none)* | Emit `<access path=...>`. |
## Markdown support
| Markdown | WML |
| --- | --- |
| Headings | `<p><b><big>` for the first on a card, `<p><b>` after |
| Paragraphs, lists, rules | `<p>` — WML has no list element, so bullets and numbers become literal text |
| `**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 |
| `{.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
```bash
uv sync
uv run pytest # unit, golden, and well-formedness tests
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.
Golden files under `tests/golden/` pin whole-document output. After an intentional renderer change:
```bash
uv run python tests/regenerate_golden.py
jj diff tests/golden # the diff is the change, stated in output
```
## Architecture
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
MIT

34
examples/kitchen-sink.md Normal file
View file

@ -0,0 +1,34 @@
---
title: Kitchen Sink
split_level: 2
menu_style: select
---
# Kitchen Sink
A deck exercising every block and inline construct wapdown understands.
## Links and emphasis
Tricky URL: [release notes](https://example.com/docs/v1_2_3/notes.html) and
a starred path [build log](https://ci.example.com/job/a*b*c/log).
**Bold**, *italic*, `inline code`, ~~struck~~, and a literal $5 fee.
## A table
| Trail | Status | Fee |
| --- | --- | --- |
| North Loop | open | $2 |
| Summit Spur | icy | $5 |
## Other blocks
> A quoted warning.
1. First
2. Second
---
indented code $HOME

14
examples/trail-manual.md Normal file
View file

@ -0,0 +1,14 @@
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)

19
examples/trail.md Normal file
View file

@ -0,0 +1,19 @@
# 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)

29
pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[project]
name = "wapdown"
version = "0.1.0"
description = "Markdown to WML deck compiler for WAP browsers"
readme = "README.md"
requires-python = ">=3.13"
license = { text = "MIT" }
dependencies = []
[project.scripts]
wapdown = "wapdown.cli:main"
[project.optional-dependencies]
dtd = [
"lxml>=5.0",
]
[dependency-groups]
dev = ["pytest>=8.0"]
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]

6
src/wapdown/__init__.py Normal file
View file

@ -0,0 +1,6 @@
"""Public package interface for wapdown."""
from .cli import convert, main
from .config import DeckConfig
__all__ = ["DeckConfig", "convert", "main"]

7
src/wapdown/__main__.py Normal file
View file

@ -0,0 +1,7 @@
"""Entry point for `python -m wapdown`."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

250
src/wapdown/cli.py Normal file
View file

@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Compile a Markdown document into a WML deck for WAP browsers."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from .config import IMAGE_POLICIES, MENU_STYLES, ConfigError, DeckConfig
from .conversion.core import read_lines, run_conversion, strip_frontmatter
from .models import BlockStyle
from .parsers.markdown import MarkdownParser
from .plugins import (
available_parsers,
available_renderers,
get_parser_factory,
get_renderer_factory,
register_parser,
)
from .renderers import wml as _wml # noqa: F401 # registers the WML renderer plugin
from .renderers.wml.renderer import DeckOutput
def _markdown_parser_factory(*, base_style: BlockStyle, **_: Any) -> MarkdownParser:
return MarkdownParser(base_style)
try:
register_parser("markdown", _markdown_parser_factory)
except ValueError: # pragma: no cover - already registered on re-import
pass
def convert(
lines: Iterable[str],
*,
config: DeckConfig,
base_path: Optional[Path] = None,
parser_name: str = "markdown",
renderer_name: str = "wml",
) -> DeckOutput:
return run_conversion(
lines,
parser_factory=get_parser_factory(parser_name),
renderer_factory=get_renderer_factory(renderer_name),
renderer_options={"config": config},
base_path=base_path,
)
def build_parser() -> argparse.ArgumentParser:
# Every option defaults to None so that "absent" stays distinguishable
# from "explicitly set to the default"; DeckConfig.resolve then layers
# CLI over frontmatter over defaults.
parser = argparse.ArgumentParser(
prog="wapdown",
description="Compile Markdown into a WML deck for WAP browsers.",
epilog=(
"Every option can also be set in the document's YAML-ish frontmatter "
"using the long flag name with underscores (--split-level -> split_level). "
"Command-line flags win over frontmatter."
),
)
parser.add_argument("input_path", type=Path, help="Path to the Markdown input file.")
parser.add_argument(
"-o",
"--output",
type=Path,
help="File to write the deck to (a directory with --deck-per-card). Defaults to stdout.",
)
structure = parser.add_argument_group("deck structure")
structure.add_argument("--title", help="Overall deck title, used on the menu card.")
structure.add_argument(
"--split-level",
type=int,
metavar="N",
help="Start a new card at every level-N heading (1-6; 0 disables). "
"Ignored when the document contains {.card} markers.",
)
structure.add_argument(
"--max-card-bytes",
type=int,
metavar="N",
help="Byte safety net per card; 0 disables (default: 1400).",
)
menu_group = structure.add_mutually_exclusive_group()
menu_group.add_argument(
"--menu",
dest="menu",
action="store_true",
default=None,
help="Build a hub-and-spoke menu card (the default for multi-card decks).",
)
menu_group.add_argument(
"--no-menu",
dest="menu",
action="store_false",
default=None,
help="Chain cards linearly with Next/Back instead of building a menu.",
)
structure.add_argument(
"--menu-style",
choices=MENU_STYLES,
help="Render menu choices as anchors or as a keypad-pickable <select> (default: links).",
)
structure.add_argument(
"--deck-per-card",
dest="deck_per_card",
action="store_true",
default=None,
help="Write one .wml file per section into the output directory, cross-linked by filename.",
)
nav = parser.add_argument_group("navigation")
nav.add_argument("--nav-next-label", metavar="TEXT", help="Forward label (default: More).")
nav.add_argument("--nav-prev-label", metavar="TEXT", help="Label between overflow parts (default: Prev).")
nav.add_argument("--nav-back-label", metavar="TEXT", help="Label back to the menu (default: Back).")
nav.add_argument(
"--home-label",
metavar="TEXT",
help="Add an options softkey jumping straight to the menu card.",
)
template_group = nav.add_mutually_exclusive_group()
template_group.add_argument(
"--template-nav",
dest="template_nav",
action="store_true",
default=None,
help="Hoist shared nav into a deck-level <template> (the default).",
)
template_group.add_argument(
"--no-template-nav",
dest="template_nav",
action="store_false",
default=None,
help="Repeat nav markup on every card instead.",
)
content = parser.add_argument_group("content")
content.add_argument(
"--images",
choices=IMAGE_POLICIES,
help="What to do with non-WBMP images: keep them, replace with alt text, or drop (default: keep). "
"No format conversion is ever performed.",
)
head = parser.add_argument_group("deck head")
head.add_argument(
"--cache-control",
type=int,
metavar="SECONDS",
help="Emit a Cache-Control max-age meta element.",
)
head.add_argument("--access-domain", metavar="DOMAIN", help="Emit <access domain=...>.")
head.add_argument("--access-path", metavar="PATH", help="Emit <access path=...>.")
plugins = parser.add_argument_group("plugins")
plugins.add_argument(
"--parser",
default="markdown",
choices=available_parsers() or ["markdown"],
help="Name of the parser plugin to use.",
)
plugins.add_argument(
"--renderer",
default="wml",
choices=available_renderers() or ["wml"],
help="Name of the renderer plugin to use.",
)
return parser
_CONFIG_DESTS = (
"title",
"split_level",
"max_card_bytes",
"menu",
"menu_style",
"deck_per_card",
"nav_next_label",
"nav_prev_label",
"nav_back_label",
"home_label",
"template_nav",
"images",
"cache_control",
"access_domain",
"access_path",
)
def _cli_overrides(args: argparse.Namespace) -> Dict[str, Any]:
return {dest: getattr(args, dest, None) for dest in _CONFIG_DESTS}
def write_output(output: DeckOutput, path: Optional[Path]) -> None:
"""Write the deck. WML is XML served over HTTP, so lines end with LF."""
multi = len(output.documents) > 1 or any(doc.name for doc in output.documents)
if multi:
if path is None:
raise SystemExit(
"wapdown: --deck-per-card writes several files; pass -o DIRECTORY."
)
path.mkdir(parents=True, exist_ok=True)
for doc in output.documents:
(path / (doc.name or "index.wml")).write_text(doc.text, encoding="utf-8")
return
text = output.text
if path is None:
sys.stdout.write(text)
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
try:
lines = read_lines(args.input_path)
except OSError as exc:
sys.stderr.write(f"wapdown: {exc}\n")
return 2
frontmatter, content = strip_frontmatter(lines)
try:
config = DeckConfig.resolve(frontmatter, _cli_overrides(args))
except ConfigError as exc:
sys.stderr.write(f"wapdown: {exc}\n")
return 2
try:
output = convert(
content,
config=config,
base_path=args.input_path.parent,
parser_name=args.parser,
renderer_name=args.renderer,
)
except (KeyError, TypeError, FileNotFoundError, RuntimeError) as exc:
sys.stderr.write(f"wapdown: {exc}\n")
return 2
write_output(output, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())

130
src/wapdown/config.py Normal file
View file

@ -0,0 +1,130 @@
"""Deck configuration: defaults, document frontmatter, and CLI overrides.
Precedence is CLI flag > frontmatter > default. The CLI achieves that by
giving every option `default=None`, so "not given on the command line"
stays distinguishable from "given a value that happens to equal the
default"; `DeckConfig.resolve` then layers the three sources.
Frontmatter keys are the long flag names with underscores
(`--split-level` <-> `split_level`), so one table documents both.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, fields
from typing import Any, Dict, Mapping, Optional
MENU_STYLES = ("links", "select")
IMAGE_POLICIES = ("keep", "alt", "drop")
# 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 section-based
# splitting, not the primary splitting mechanism.
DEFAULT_MAX_CARD_BYTES = 1400
class ConfigError(ValueError):
"""Raised for an out-of-range or unparseable configuration value."""
@dataclass
class DeckConfig:
# Structure
title: Optional[str] = None
split_level: int = 0 # 0 = heading splitting disabled
max_card_bytes: int = DEFAULT_MAX_CARD_BYTES # 0 = disabled
menu: bool = True
menu_style: str = "links"
deck_per_card: bool = False
# Navigation
nav_next_label: str = "More"
nav_prev_label: str = "Prev"
nav_back_label: str = "Back"
home_label: Optional[str] = None
template_nav: bool = True
# Content
images: str = "keep"
# Deck <head>
cache_control: Optional[int] = None
access_domain: Optional[str] = None
access_path: Optional[str] = None
@classmethod
def resolve(
cls,
frontmatter: Optional[Mapping[str, str]] = None,
overrides: Optional[Mapping[str, Any]] = None,
) -> "DeckConfig":
config = cls()
known = {f.name for f in fields(cls)}
for key, raw in (frontmatter or {}).items():
name = key.strip().replace("-", "_")
if name not in known:
print(
f"wapdown: warning: unknown frontmatter key '{key}' ignored",
file=sys.stderr,
)
continue
setattr(config, name, _coerce(name, raw))
for name, value in (overrides or {}).items():
if value is None or name not in known:
continue
setattr(config, name, _coerce(name, value))
config.validate()
return config
def validate(self) -> None:
if not 0 <= self.split_level <= 6:
raise ConfigError(f"split_level must be between 0 and 6, got {self.split_level}")
if self.max_card_bytes < 0:
raise ConfigError(f"max_card_bytes must be 0 or greater, got {self.max_card_bytes}")
if self.menu_style not in MENU_STYLES:
raise ConfigError(
f"menu_style must be one of {', '.join(MENU_STYLES)}, got '{self.menu_style}'"
)
if self.images not in IMAGE_POLICIES:
raise ConfigError(
f"images must be one of {', '.join(IMAGE_POLICIES)}, got '{self.images}'"
)
if self.cache_control is not None and self.cache_control < 0:
raise ConfigError(f"cache_control must be 0 or greater, got {self.cache_control}")
def as_renderer_options(self) -> Dict[str, Any]:
return {f.name: getattr(self, f.name) for f in fields(self)}
_BOOL_FIELDS = {"menu", "template_nav", "deck_per_card"}
_INT_FIELDS = {"split_level", "max_card_bytes", "cache_control"}
_TRUE = {"true", "yes", "1", "on"}
_FALSE = {"false", "no", "0", "off"}
def _coerce(name: str, value: Any) -> Any:
"""Normalize a frontmatter string (or an already-typed CLI value)."""
if name in _BOOL_FIELDS:
if isinstance(value, bool):
return value
lowered = str(value).strip().lower()
if lowered in _TRUE:
return True
if lowered in _FALSE:
return False
raise ConfigError(f"{name} must be a boolean, got '{value}'")
if name in _INT_FIELDS:
if isinstance(value, bool):
raise ConfigError(f"{name} must be a number, got '{value}'")
if isinstance(value, int):
return value
text = str(value).strip()
try:
return int(text)
except ValueError as exc:
raise ConfigError(f"{name} must be a number, got '{value}'") from exc
text = str(value).strip()
if name in {"menu_style", "images"}:
return text.lower()
return text or None if name in {"title", "home_label", "access_domain", "access_path"} else text

View file

@ -0,0 +1,19 @@
"""Conversion pipeline helpers."""
from .core import (
ParserFactory,
RendererFactory,
read_lines,
run_conversion,
run_pipeline,
strip_frontmatter,
)
__all__ = [
"ParserFactory",
"RendererFactory",
"read_lines",
"run_conversion",
"run_pipeline",
"strip_frontmatter",
]

View file

@ -0,0 +1,200 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, List, Optional, Protocol, Set, Tuple, TypeVar
from ..models import BlockEvent, BlockStyle, StyleUpdateEvent
INCLUDE_WIKILINK_PATTERN = re.compile(r"^\s*!\[\[(.+?)\]\]\s*$")
INCLUDE_DIRECTIVE_PATTERN = re.compile(r"^\s*\{\s*\.include\s+(.+?)\s*\}\s*$")
TABLE_SENTINEL_PREFIX = "\u0000TABLE:"
SEPARATOR_CELL_PATTERN = re.compile(r"^:?-{1,}:?$")
Event = BlockEvent | StyleUpdateEvent
RendererOutput = TypeVar("RendererOutput")
class Parser(Protocol):
def parse(self, lines: Iterable[str]) -> Iterator[Event]:
...
class Renderer(Protocol[RendererOutput]):
def handle_event(self, event: Event) -> None:
...
def finalize(self) -> RendererOutput:
...
class ParserFactory(Protocol):
def __call__(self, *, base_style: BlockStyle, **kwargs: Any) -> Parser:
...
class RendererFactory(Protocol[RendererOutput]):
def __call__(self, **kwargs: Any) -> Renderer[RendererOutput]:
...
def read_lines(path: Path) -> List[str]:
with path.open("r", encoding="utf-8") as handle:
return handle.readlines()
def expand_includes(
lines: List[str],
base_dir: Path,
include_stack: Set[Path],
) -> List[str]:
expanded: List[str] = []
for line in lines:
target = _extract_include_target(line)
if target is None:
expanded.append(line)
continue
target_path = (base_dir / target).resolve()
if target_path in include_stack:
raise RuntimeError(f"Circular include detected for '{target_path}'.")
if not target_path.exists():
raise FileNotFoundError(f"Included file '{target_path}' was not found.")
include_stack.add(target_path)
included_lines = read_lines(target_path)
_, include_body = strip_frontmatter(included_lines)
included_content = expand_includes(include_body, target_path.parent, include_stack)
expanded.extend(included_content)
include_stack.remove(target_path)
return expanded
def strip_frontmatter(lines: List[str]) -> Tuple[Dict[str, str], List[str]]:
"""Split a leading `---` fenced block off the document.
Returns the raw key/value pairs and the remaining body. Interpreting
those pairs is `wapdown.config`'s job, not the pipeline's; an included
file's frontmatter is parsed only so it can be discarded.
"""
if not lines or lines[0].strip() != "---":
return {}, lines
fields: Dict[str, str] = {}
idx = 1
while idx < len(lines):
if lines[idx].strip() == "---":
break
if ":" in lines[idx]:
key, value = lines[idx].split(":", 1)
fields[key.strip()] = value.strip()
idx += 1
if idx >= len(lines):
# Unterminated fence: treat the whole thing as body.
return {}, lines
remaining = lines[idx + 1 :] if idx + 1 < len(lines) else []
return fields, remaining
def _extract_include_target(line: str) -> Optional[str]:
stripped = line.rstrip("\n")
match = INCLUDE_WIKILINK_PATTERN.match(stripped)
if match:
return _normalize_include_target(match.group(1))
match = INCLUDE_DIRECTIVE_PATTERN.match(stripped)
if match:
return _normalize_include_target(match.group(1))
return None
def _normalize_include_target(value: str) -> str:
trimmed = value.strip()
if len(trimmed) >= 2 and ((trimmed[0] == trimmed[-1]) and trimmed[0] in {"'", '"'}):
trimmed = trimmed[1:-1].strip()
return trimmed
def _split_pipe_row(line: str) -> List[str]:
trimmed = line.strip()
if trimmed.startswith("|"):
trimmed = trimmed[1:]
if trimmed.endswith("|"):
trimmed = trimmed[:-1]
return [cell.strip() for cell in trimmed.split("|")]
def _is_table_separator_row(line: str, expected_cells: int) -> bool:
stripped = line.strip()
if not stripped:
return False
cells = _split_pipe_row(stripped)
if len(cells) != expected_cells:
return False
return all(SEPARATOR_CELL_PATTERN.match(cell) for cell in cells)
def expand_tables(lines: List[str]) -> List[str]:
"""Collapse GFM-style pipe tables into single JSON sentinel lines.
The parser only ever sees one physical line per table, so no
lookahead/pushback is needed in its line-by-line loop. Runs after
`expand_includes` so tables inside included files are also detected.
"""
expanded: List[str] = []
index = 0
total = len(lines)
while index < total:
line = lines[index]
stripped = line.rstrip("\n")
if stripped.startswith(TABLE_SENTINEL_PREFIX):
expanded.append(line)
index += 1
continue
if "|" in stripped and stripped.strip() and index + 1 < total:
header_cells = _split_pipe_row(stripped)
if header_cells and _is_table_separator_row(lines[index + 1].rstrip("\n"), len(header_cells)):
rows = [header_cells]
index += 2 # header + separator row
while index < total:
row_line = lines[index].rstrip("\n")
if not row_line.strip() or "|" not in row_line:
break
rows.append(_split_pipe_row(row_line))
index += 1
sentinel = f"{TABLE_SENTINEL_PREFIX}{json.dumps({'rows': rows})}\n"
expanded.append(sentinel)
continue
expanded.append(line)
index += 1
return expanded
def run_pipeline(
lines: Iterable[str],
*,
parser: Parser,
renderer: Renderer[RendererOutput],
base_path: Optional[Path] = None,
) -> RendererOutput:
base_dir = (base_path or Path.cwd()).resolve()
expanded_lines = expand_includes(list(lines), base_dir, set())
expanded_lines = expand_tables(expanded_lines)
for event in parser.parse(expanded_lines):
renderer.handle_event(event)
return renderer.finalize()
def run_conversion(
lines: Iterable[str],
*,
parser_factory: ParserFactory,
renderer_factory: RendererFactory[RendererOutput],
parser_options: Optional[Dict[str, Any]] = None,
renderer_options: Optional[Dict[str, Any]] = None,
base_path: Optional[Path] = None,
) -> RendererOutput:
parser = parser_factory(
base_style=BlockStyle(align="left", margin_left=0, margin_right=0),
**(parser_options or {}),
)
renderer = renderer_factory(**(renderer_options or {}))
return run_pipeline(lines, parser=parser, renderer=renderer, base_path=base_path)

91
src/wapdown/models.py Normal file
View file

@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
class BlockKind(Enum):
PARAGRAPH = "paragraph"
HEADING = "heading"
CODE_BLOCK = "code_block"
BLOCKQUOTE = "blockquote"
LIST_ITEM = "list_item"
HORIZONTAL_RULE = "horizontal_rule"
TABLE = "table"
BLANK_LINE = "blank_line"
CARD_BREAK = "card_break"
@dataclass
class BlockStyle:
align: str = "left"
margin_left: int = 0
margin_right: int = 0
@dataclass
class StyleSpec:
align: Optional[str] = None
margin_left: Optional[int] = None
margin_right: Optional[int] = None
@dataclass
class ParagraphPayload:
text: str
@dataclass
class HeadingPayload:
level: int
text: str
@dataclass
class CodeBlockPayload:
lines: List[str]
@dataclass
class BlockQuotePayload:
depth: int
text: str
@dataclass
class ListItemPayload:
indent: str
marker: str
spacing: str
text: str
ordered: bool
@dataclass
class TablePayload:
rows: List[List[str]] # rows[0] is the header row
@dataclass
class CardBreakPayload:
"""Marks an explicit 'start a new card here' hint.
Emitted for the `{.card}` / `{.card Title}` directive, which is the
manual alternative to heading-level splitting.
"""
title: Optional[str] = None
@dataclass
class BlockEvent:
kind: BlockKind
payload: object
style: BlockStyle
stylable: bool = False
@dataclass
class StyleUpdateEvent:
spec: StyleSpec

View file

@ -0,0 +1,5 @@
"""Bundled parser implementations."""
from .markdown import MarkdownParser
__all__ = ["MarkdownParser"]

View file

@ -0,0 +1,589 @@
from __future__ import annotations
import json
import re
from typing import Iterable, Iterator, List, Optional, Union
from ..conversion.core import TABLE_SENTINEL_PREFIX
from ..models import (
BlockEvent,
BlockKind,
BlockQuotePayload,
BlockStyle,
CardBreakPayload,
CodeBlockPayload,
HeadingPayload,
ListItemPayload,
ParagraphPayload,
StyleSpec,
StyleUpdateEvent,
TablePayload,
)
HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*)$")
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)
MMD_ATTR_LINE_RE = re.compile(r"^\{\s*:(.+)\}\s*$")
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
class MarkdownParser:
def __init__(self, base_style: BlockStyle) -> None:
self._base_style = base_style
self._style_stack: List[BlockStyle] = [self._make_base_style()]
self._paragraph_style_spec: Optional[StyleSpec] = None
self._pending_block_style_spec: Optional[StyleSpec] = None
self._last_stylable_block: bool = False
def parse(self, lines: Iterable[str]) -> Iterator[Union[BlockEvent, StyleUpdateEvent]]:
self._reset_state()
iterator = iter(lines)
in_code_block = False
code_lines: List[str] = []
indented_code_lines: List[str] = []
current_paragraph: List[str] = []
for raw_line in iterator:
line = raw_line.rstrip("\n")
if line.startswith(TABLE_SENTINEL_PREFIX):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
payload = self._build_table_payload(line)
style = self._combine_styles(self._current_style(), self._pending_block_style_spec)
self._pending_block_style_spec = None
self._last_stylable_block = True
yield BlockEvent(
kind=BlockKind.TABLE,
payload=payload,
style=style,
stylable=True,
)
continue
if in_code_block:
if line.strip().startswith("```"):
event = self._flush_code_block(code_lines)
if event is not None:
yield event
code_lines = []
in_code_block = False
else:
code_lines.append(line)
continue
if indented_code_lines:
if line.startswith(" "):
indented_code_lines.append(line[4:])
continue
if not line.strip():
event = self._flush_code_block(indented_code_lines)
if event is not None:
yield event
indented_code_lines = []
else:
event = self._flush_code_block(indented_code_lines)
if event is not None:
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)
if event is not None:
yield event
current_paragraph = []
spec = self._style_spec_from_html_attributes(inline_para.group(1) or "")
self._push_style(spec)
content = inline_para.group(2)
if content:
current_paragraph.append(content)
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
self._pop_style()
continue
open_para = PARA_OPEN_RE.match(line)
if open_para:
spec = self._style_spec_from_html_attributes(open_para.group(1) or "")
self._push_style(spec)
continue
close_para = PARA_CLOSE_RE.match(line)
if close_para:
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
self._paragraph_style_spec = None
self._pop_style()
continue
stripped = line.strip()
attr_match = MMD_ATTR_LINE_RE.match(stripped)
if attr_match:
spec = self._parse_style_spec_from_tokens(attr_match.group(1))
if spec:
if current_paragraph:
self._paragraph_style_spec = self._merge_specs(self._paragraph_style_spec, spec)
elif self._last_stylable_block:
yield StyleUpdateEvent(spec)
else:
self._pending_block_style_spec = self._merge_specs(self._pending_block_style_spec, spec)
continue
if line.strip().startswith("```"):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
in_code_block = True
code_lines = []
continue
if line.startswith(" "):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
indented_code_lines = [line[4:]]
continue
heading_match = HEADING_PATTERN.match(line)
if heading_match:
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
level = len(heading_match.group(1))
heading_text = heading_match.group(2).strip()
heading_text, inline_spec = self._extract_trailing_attr(heading_text)
combined_spec = self._merge_specs(self._pending_block_style_spec, inline_spec)
style = self._combine_styles(self._current_style(), combined_spec)
self._pending_block_style_spec = None
self._last_stylable_block = True
yield BlockEvent(
kind=BlockKind.HEADING,
payload=HeadingPayload(level=level, text=heading_text),
style=style,
stylable=True,
)
continue
if HORIZONTAL_RULE_PATTERN.match(line):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
self._last_stylable_block = False
yield BlockEvent(
kind=BlockKind.HORIZONTAL_RULE,
payload=None,
style=self._clone_style(),
stylable=False,
)
continue
if BLOCKQUOTE_PATTERN.match(line):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
quote_event = self._parse_blockquote(line)
if quote_event is not None:
yield quote_event
continue
if UNORDERED_LIST_PATTERN.match(line) or ORDERED_LIST_PATTERN.match(line):
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
current_paragraph = []
list_event = self._parse_list_line(line)
if list_event is not None:
yield list_event
continue
if not line.strip():
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
yield BlockEvent(
kind=BlockKind.BLANK_LINE,
payload=None,
style=self._clone_style(),
stylable=False,
)
current_paragraph = []
continue
current_paragraph.append(line)
event = self._flush_paragraph(current_paragraph)
if event is not None:
yield event
if in_code_block:
final_event = self._flush_code_block(code_lines)
if final_event is not None:
yield final_event
if indented_code_lines:
final_event = self._flush_code_block(indented_code_lines)
if final_event is not None:
yield final_event
def _reset_state(self) -> None:
self._style_stack = [self._make_base_style()]
self._paragraph_style_spec = None
self._pending_block_style_spec = None
self._last_stylable_block = False
def _flush_paragraph(self, paragraph_lines: List[str]) -> Optional[BlockEvent]:
if not paragraph_lines:
return None
text = " ".join(line.strip() for line in paragraph_lines)
combined_spec = self._merge_specs(self._pending_block_style_spec, self._paragraph_style_spec)
style = self._combine_styles(self._current_style(), combined_spec)
paragraph_lines.clear()
self._paragraph_style_spec = None
self._pending_block_style_spec = None
self._last_stylable_block = True
return BlockEvent(
kind=BlockKind.PARAGRAPH,
payload=ParagraphPayload(text=text),
style=style,
stylable=True,
)
def _flush_code_block(self, code_lines: List[str]) -> Optional[BlockEvent]:
if not code_lines:
return None
lines = code_lines.copy()
code_lines.clear()
self._last_stylable_block = False
return BlockEvent(
kind=BlockKind.CODE_BLOCK,
payload=CodeBlockPayload(lines=lines),
style=self._clone_style(),
stylable=False,
)
def _parse_blockquote(self, line: str) -> Optional[BlockEvent]:
content = line
depth = 0
while content.lstrip().startswith(">"):
depth += 1
content = content.lstrip()[1:]
text = content.lstrip()
self._last_stylable_block = False
return BlockEvent(
kind=BlockKind.BLOCKQUOTE,
payload=BlockQuotePayload(depth=max(1, depth), text=text),
style=self._clone_style(),
stylable=False,
)
def _parse_list_line(self, line: str) -> Optional[BlockEvent]:
ordered = ORDERED_LIST_PATTERN.match(line)
unordered = UNORDERED_LIST_PATTERN.match(line)
if ordered:
indent, marker, spacing, rest = ordered.groups()
ordered_flag = True
elif unordered:
indent, marker, spacing, rest = unordered.groups()
ordered_flag = False
else:
return None
self._last_stylable_block = False
return BlockEvent(
kind=BlockKind.LIST_ITEM,
payload=ListItemPayload(
indent=indent,
marker=marker,
spacing=spacing,
text=rest,
ordered=ordered_flag,
),
style=self._clone_style(),
stylable=False,
)
def _build_table_payload(self, sentinel_line: str) -> TablePayload:
payload = sentinel_line[len(TABLE_SENTINEL_PREFIX) :]
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid table sentinel payload: {payload}") from exc
rows = data.get("rows", [])
return TablePayload(rows=[[str(cell) for cell in row] for row in rows])
def _make_base_style(self) -> BlockStyle:
return BlockStyle(
align=self._base_style.align,
margin_left=self._base_style.margin_left,
margin_right=self._base_style.margin_right,
)
def _current_style(self) -> BlockStyle:
return self._style_stack[-1]
def _clone_style(self) -> BlockStyle:
style = self._current_style()
return BlockStyle(
align=style.align,
margin_left=style.margin_left,
margin_right=style.margin_right,
)
def _push_style(self, spec: Optional[StyleSpec]) -> None:
base = self._current_style()
self._style_stack.append(self._combine_styles(base, spec))
def _pop_style(self) -> None:
if len(self._style_stack) > 1:
self._style_stack.pop()
def _combine_styles(self, base: BlockStyle, spec: Optional[StyleSpec]) -> BlockStyle:
if spec is None:
return BlockStyle(
align=base.align,
margin_left=base.margin_left,
margin_right=base.margin_right,
)
return BlockStyle(
align=spec.align or base.align,
margin_left=spec.margin_left if spec.margin_left is not None else base.margin_left,
margin_right=spec.margin_right if spec.margin_right is not None else base.margin_right,
)
def _merge_specs(self, first: Optional[StyleSpec], second: Optional[StyleSpec]) -> Optional[StyleSpec]:
if first is None and second is None:
return None
if first is None:
return second
if second is None:
return first
return StyleSpec(
align=second.align or first.align,
margin_left=second.margin_left if second.margin_left is not None else first.margin_left,
margin_right=second.margin_right if second.margin_right is not None else first.margin_right,
)
def _style_spec_from_html_attributes(self, attributes: str) -> Optional[StyleSpec]:
if not attributes:
return None
attr_pattern = re.compile(r"([\w:-]+)\s*=\s*(\".*?\"|'.*?'|\S+)")
attr_map = {name.lower(): value.strip().strip("\"'") for name, value in attr_pattern.findall(attributes)}
spec: Optional[StyleSpec] = None
align_value = attr_map.get("align")
if align_value:
normalized = self._normalize_align(align_value)
if normalized:
spec = self._merge_specs(spec, StyleSpec(align=normalized))
style_value = attr_map.get("style")
if style_value:
css_spec = self._style_spec_from_css(style_value)
spec = self._merge_specs(spec, css_spec)
return spec
def _style_spec_from_css(self, css: str) -> Optional[StyleSpec]:
spec = StyleSpec()
changed = False
for declaration in css.split(";"):
if ":" not in declaration:
continue
name, value = declaration.split(":", 1)
name = name.strip().lower()
value = value.strip()
if not value:
continue
if name == "text-align":
normalized = self._normalize_align(value)
if normalized:
spec.align = normalized
changed = True
elif name == "margin":
left, right, auto_center = self._parse_css_margin_shorthand(value)
if left is not None:
spec.margin_left = left
changed = True
if right is not None:
spec.margin_right = right
changed = True
if auto_center:
spec.align = "center"
changed = True
elif name == "margin-left":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_left = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
elif name == "margin-right":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_right = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
return spec if changed else None
def _parse_style_spec_from_tokens(self, token_str: str) -> Optional[StyleSpec]:
tokens = re.split(r"\s+", token_str.strip())
spec = StyleSpec()
changed = False
for token in tokens:
token = token.strip()
if not token:
continue
if token.startswith("."):
align = self._class_to_align(token[1:])
if align:
spec.align = align
changed = True
continue
if "=" in token:
key, value = token.split("=", 1)
key = key.strip().lower().lstrip(".")
value = value.strip().strip("\"'")
if key in {"align", "text-align"}:
normalized = self._normalize_align(value)
if normalized:
spec.align = normalized
changed = True
elif key in {"margin", "margin-left", "margin-right"}:
if key == "margin":
left, right, auto_center = self._parse_css_margin_shorthand(value)
if left is not None:
spec.margin_left = left
changed = True
if right is not None:
spec.margin_right = right
changed = True
if auto_center:
spec.align = "center"
changed = True
elif key == "margin-left":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_left = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
elif key == "margin-right":
parsed = self._parse_space_value(value)
if parsed is not None:
spec.margin_right = parsed
changed = True
elif value.lower() == "auto":
spec.align = spec.align or "center"
changed = True
continue
align = self._normalize_align(token)
if align:
spec.align = align
changed = True
return spec if changed else None
def _parse_css_margin_shorthand(self, value: str):
parts = [part for part in re.split(r"\s+", value.strip()) if part]
if not parts:
return None, None, False
values: List[Optional[int]] = []
autos: List[bool] = []
for part in parts:
if part.lower() == "auto":
values.append(None)
autos.append(True)
else:
parsed = self._parse_space_value(part)
values.append(parsed)
autos.append(False)
left_auto = False
right_auto = False
if len(values) == 1:
left = right = values[0]
left_auto = right_auto = autos[0]
elif len(values) == 2:
left = right = values[1]
left_auto = right_auto = autos[1]
elif len(values) == 3:
left = right = values[1]
left_auto = right_auto = autos[1]
else:
right = values[1]
left = values[3]
right_auto = autos[1]
left_auto = autos[3]
auto_center = left_auto and right_auto
return left, right, auto_center
def _parse_space_value(self, value: str) -> Optional[int]:
match = re.match(r"(-?\d+(?:\.\d+)?)", value.strip())
if not match:
return None
number = float(match.group(1))
return max(0, int(round(number)))
def _normalize_align(self, value: str) -> Optional[str]:
normalized = value.strip().lower()
mapping = {
"centre": "center",
"center": "center",
"left": "left",
"right": "right",
}
return mapping.get(normalized)
def _class_to_align(self, class_name: str) -> Optional[str]:
name = class_name.strip().lower().lstrip(".")
if name in {"center", "text-center", "align-center"}:
return "center"
if name in {"left", "text-left", "align-left"}:
return "left"
if name in {"right", "text-right", "align-right"}:
return "right"
return None
def _extract_trailing_attr(self, text: str):
match = MMD_ATTR_TAIL_RE.match(text)
if not match:
return text, None
clean_text = match.group(1).rstrip()
spec = self._parse_style_spec_from_tokens(match.group(2))
return clean_text, spec

View file

@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Any
from ..conversion.core import ParserFactory, RendererFactory
from .registry import PluginRegistry
parser_plugins = PluginRegistry[ParserFactory]()
renderer_plugins = PluginRegistry[RendererFactory[Any]]()
def register_parser(name: str, factory: ParserFactory) -> None:
parser_plugins.register(name, factory)
def register_renderer(name: str, factory: RendererFactory[Any]) -> None:
renderer_plugins.register(name, factory)
def get_parser_factory(name: str) -> ParserFactory:
return parser_plugins.get(name)
def get_renderer_factory(name: str) -> RendererFactory[Any]:
return renderer_plugins.get(name)
def available_parsers() -> list[str]:
return parser_plugins.names()
def available_renderers() -> list[str]:
return renderer_plugins.names()
__all__ = [
"available_parsers",
"available_renderers",
"get_parser_factory",
"get_renderer_factory",
"register_parser",
"register_renderer",
]

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Dict, Generic, List, TypeVar
T = TypeVar("T")
class PluginRegistry(Generic[T]):
def __init__(self) -> None:
self._factories: Dict[str, T] = {}
def register(self, name: str, factory: T) -> None:
if name in self._factories:
raise ValueError(f"Plugin '{name}' is already registered.")
self._factories[name] = factory
def get(self, name: str) -> T:
try:
return self._factories[name]
except KeyError as exc:
raise KeyError(f"Plugin '{name}' is not registered.") from exc
def names(self) -> List[str]:
return sorted(self._factories.keys())

View file

@ -0,0 +1,5 @@
"""Bundled renderer implementations."""
from .wml import WMLRenderer
__all__ = ["WMLRenderer"]

View file

@ -0,0 +1,28 @@
"""WML deck renderer, registered under the name `wml`."""
from typing import Any
from ...plugins import register_renderer
from .deck import Card, Section
from .inline import escape_wml, process_inline
from .renderer import DeckOutput, WMLRenderer, WmlDocument
def _wml_renderer_factory(**options: Any) -> WMLRenderer:
return WMLRenderer(**options)
try:
register_renderer("wml", _wml_renderer_factory)
except ValueError: # pragma: no cover - already registered on re-import
pass
__all__ = [
"Card",
"DeckOutput",
"Section",
"WMLRenderer",
"WmlDocument",
"escape_wml",
"process_inline",
]

View file

@ -0,0 +1,194 @@
"""Document topology: how one Markdown file becomes a navigable deck.
This is the part that makes WML output unlike a plain text export. A text
renderer transforms a document; a deck *restructures* it into short,
button-navigated cards with invented navigation between them. Three
independent concerns live here:
1. Sectioning -- where card boundaries fall (`{.card}` or `split_level`).
2. Packing -- splitting an oversized section across physical cards.
3. Topology -- hub-and-spoke vs. linear, and the nav each card carries.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from ...models import BlockEvent, BlockKind
# Headroom reserved per card for the nav markup a continuation card carries.
# When nav lives in a deck-level <template>, each card's own nav markup is
# effectively free and only the rare per-card override needs room.
NAV_HEADROOM_BYTES = 160
TEMPLATED_NAV_HEADROOM_BYTES = 24
@dataclass
class Section:
"""A logical run of the document destined for one card (before packing)."""
title: Optional[str]
events: List[BlockEvent]
@dataclass
class Card:
"""One physical <card>, after byte packing."""
id: str
title: Optional[str]
fragments: List[str] = field(default_factory=list)
back_label: Optional[str] = None
next_id: Optional[str] = None
next_label: Optional[str] = None
home_id: Optional[str] = None
deck_file: Optional[str] = None # set only in deck-per-card mode
def split_sections(
events: List[BlockEvent], split_level: int
) -> Tuple[List[BlockEvent], List[Section]]:
"""Return (preamble, sections).
Manual `{.card}` markers win over heading-level splitting when both are
present: an explicit boundary in the document is a stronger statement of
intent than a structural rule passed on the command line.
"""
card_breaks = [i for i, e in enumerate(events) if e.kind == BlockKind.CARD_BREAK]
if card_breaks:
return _split_by_card_breaks(events, card_breaks)
if split_level > 0:
headings = [
i
for i, e in enumerate(events)
if e.kind == BlockKind.HEADING and e.payload.level == split_level
]
if headings:
return _split_by_heading(events, headings)
return list(events), []
def _split_by_card_breaks(
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
section_events = events[start : boundaries[idx + 1]]
marker_title = events[indices[idx]].payload.title
sections.append(
Section(
title=marker_title or first_heading_text(section_events),
events=section_events,
)
)
return preamble, sections
def _split_by_heading(
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
section_events = events[start : boundaries[idx + 1]]
sections.append(
Section(title=events[indices[idx]].payload.text, events=section_events)
)
return preamble, sections
def first_heading_text(events: List[BlockEvent]) -> Optional[str]:
for event in events:
if event.kind == BlockKind.HEADING:
return event.payload.text
return None
def pack_section(
base_id: str,
title: Optional[str],
fragments: List[str],
*,
max_card_bytes: int,
headroom: int,
nav_next_label: str,
nav_prev_label: str,
entry_back_label: Optional[str],
exit_next_id: Optional[str],
exit_next_label: Optional[str],
home_id: Optional[str] = None,
) -> List[Card]:
"""Split one section's fragments across physical cards if they exceed
the byte budget. This is the overflow safety net, not the primary
splitting mechanism -- that is section-based splitting upstream.
"""
chunks = greedy_pack(fragments, base_id, max_card_bytes, headroom)
cards: List[Card] = []
last = len(chunks) - 1
for i, chunk in enumerate(chunks):
is_first = i == 0
is_last = i == last
next_id = f"{base_id}-p{i + 2}" if not is_last else exit_next_id
cards.append(
Card(
id=base_id if is_first else f"{base_id}-p{i + 1}",
title=title if is_first else None,
fragments=chunk,
back_label=entry_back_label if is_first else nav_prev_label,
next_id=next_id,
next_label=(nav_next_label if not is_last else exit_next_label),
home_id=home_id,
)
)
return cards
def greedy_pack(
fragments: List[str], base_id: str, max_card_bytes: int, headroom: int
) -> List[List[str]]:
if max_card_bytes <= 0:
return [fragments]
budget = max(1, max_card_bytes - headroom)
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"wapdown: warning: a single block in '{base_id}' is {frag_size} bytes, "
f"over the {budget}-byte budget (max_card_bytes {max_card_bytes} "
f"less {headroom} reserved for navigation); "
f"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 [[]]
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
def slugify(text: Optional[str], fallback: str) -> str:
"""Filename-safe slug for deck-per-card output."""
slug = _SLUG_STRIP_RE.sub("-", (text or "").lower()).strip("-")
return slug[:40] or fallback

View file

@ -0,0 +1,125 @@
"""Inline Markdown -> WML markup mapping, plus WML's escaping rules."""
from __future__ import annotations
import re
import sys
from typing import List
CODE_STASH_RE = re.compile(r"`[^`]*`")
STRIKETHROUGH_RE = re.compile(r"~~(.*?)~~")
BOLD_RE = re.compile(r"\*\*(.*?)\*\*")
ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)")
UNDERLINE_STRONG_RE = re.compile(r"__(.*?)__")
UNDERLINE_EM_RE = re.compile(r"(?<!_)_(?!_)(.*?)(?<!_)_(?!_)")
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
LINK_RE = re.compile(r"(?<!\!)\[([^\]]+)\]\(([^)]+)\)")
_STASH = "\u0000{kind}{index}\u0000"
_CODE = "CODE"
_TAG = "TAG"
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, 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 process_inline(text: str, *, images: str = "keep") -> str:
"""Convert one run of inline Markdown into WML markup.
Links and images are resolved *before* the emphasis patterns run and
stashed behind sentinels, because a URL is not Markdown: a path like
`/a_b_c` or `/a*b*c` would otherwise be shredded into `<i>` tags by
UNDERLINE_EM_RE / ITALIC_RE before LINK_RE ever saw it.
"""
escaped = escape_wml(text)
code_segments: List[str] = []
tag_segments: List[str] = []
def stash_code(match: re.Match[str]) -> str:
code_segments.append(match.group(0)[1:-1])
return _STASH.format(kind=_CODE, index=len(code_segments) - 1)
def stash(markup: str) -> str:
tag_segments.append(markup)
return _STASH.format(kind=_TAG, index=len(tag_segments) - 1)
def stash_image(match: re.Match[str]) -> str:
alt = match.group(1).strip()
src = match.group(2).strip()
rendered = _render_image(alt, src, images)
return stash(rendered) if rendered else ""
def stash_link(match: re.Match[str]) -> str:
label = match.group(1).strip()
url = match.group(2).strip()
# WML 1.3 declares <a> as (#PCDATA | br | img)*, so a link label
# cannot carry emphasis at all -- there is nowhere valid to put a
# <b> inside it. The markers are stripped rather than translated.
return stash(f'<a href="{url}">{strip_emphasis(label)}</a>')
escaped = CODE_STASH_RE.sub(stash_code, escaped)
escaped = IMAGE_RE.sub(stash_image, escaped) # before LINK_RE: `![x](y)` is not a link
escaped = LINK_RE.sub(stash_link, 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)
for index, markup in enumerate(tag_segments):
escaped = escaped.replace(_STASH.format(kind=_TAG, index=index), markup)
for index, code in enumerate(code_segments):
escaped = escaped.replace(_STASH.format(kind=_CODE, index=index), code)
return escaped
_EMPHASIS_MARKERS_RE = re.compile(r"\*\*|__|~~|[*_`]")
def strip_emphasis(text: str) -> str:
"""Drop inline emphasis markers, leaving their content.
Used where the WML content model admits no emphasis elements, such as a
link label or a <select> option.
"""
return _EMPHASIS_MARKERS_RE.sub("", text)
def _render_image(alt: str, src: str, policy: str) -> str:
"""Apply the image policy. No WBMP conversion is ever performed.
Most WAP 1.x browsers render only WBMP, so `alt` and `drop` exist to
degrade a document that references PNG/JPEG art rather than emit an
element the device will show as a broken-image placeholder.
"""
if policy == "keep" or _is_wbmp(src):
return f'<img src="{src}" alt="{alt}"/>'
if policy == "drop":
_warn_image(src, "dropped")
return ""
_warn_image(src, "replaced with its alt text")
return alt
def _is_wbmp(src: str) -> bool:
return src.split("?", 1)[0].split("#", 1)[0].lower().endswith(".wbmp")
def _warn_image(src: str, action: str) -> None:
print(
f"wapdown: warning: non-WBMP image '{src}' {action}; "
f"WAP 1.x browsers generally render only WBMP",
file=sys.stderr,
)

View file

@ -0,0 +1,434 @@
"""The WML deck renderer."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ...config import DeckConfig
from ...models import BlockEvent, BlockKind, StyleUpdateEvent
from .deck import (
NAV_HEADROOM_BYTES,
TEMPLATED_NAV_HEADROOM_BYTES,
Card,
Section,
first_heading_text,
pack_section,
slugify,
split_sections,
)
from .inline import escape_wml, process_inline
WML_PROLOG = (
'<?xml version="1.0"?>\n'
'<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" '
'"http://www.wapforum.org/DTD/wml13.dtd">\n'
)
MENU_CARD_ID = "menu"
INDEX_DECK_FILE = "index.wml"
@dataclass
class WmlDocument:
"""One output file. `name` is None for single-deck output."""
text: str
name: Optional[str] = None
@dataclass
class DeckOutput:
documents: List[WmlDocument] = field(default_factory=list)
@property
def text(self) -> str:
"""The single deck's markup; only valid when one document was produced."""
if len(self.documents) != 1:
raise ValueError(
f"Deck produced {len(self.documents)} files; write them individually."
)
return self.documents[0].text
class WMLRenderer:
"""Render Markdown events as a WML deck for WAP browsers.
Unlike a text renderer, which can emit output incrementally per
`handle_event`, this buffers the whole event stream and does the real
work in `finalize()`: card boundaries, the menu card's choices, and
forward/backward nav targets all depend on the full document's
structure being known up front. It still satisfies the Renderer
protocol, so it plugs into `run_pipeline` unchanged.
"""
def __init__(self, config: Optional[DeckConfig] = None, **options: Any) -> None:
self.config = config or DeckConfig.resolve(overrides=options)
self._events: List[BlockEvent] = []
# base card id -> output filename; populated only in deck-per-card mode.
self._deck_files: Dict[str, str] = {}
# -- 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) -> DeckOutput:
cfg = self.config
preamble, sections = split_sections(self._events, cfg.split_level)
multi_deck = cfg.deck_per_card and len(sections) >= 2
# Filenames must be known before any card is rendered, since the menu's
# choices and every cross-deck <go> need to name the target file.
self._deck_files = self._plan_deck_files(sections) if multi_deck else {}
if len(sections) < 2:
cards = self._build_single(preamble, sections)
elif cfg.menu:
cards = self._build_hub_and_spoke(preamble, sections)
else:
cards = self._build_linear(preamble, sections)
if multi_deck:
return self._emit_multi_deck(cards)
return DeckOutput([WmlDocument(text=self._emit_deck(cards))])
def _plan_deck_files(self, sections: List[Section]) -> Dict[str, str]:
planned: Dict[str, str] = {}
used: set[str] = {INDEX_DECK_FILE}
for i, section in enumerate(sections):
base = f"card{i + 1}"
planned[base] = self._unique(f"{slugify(section.title, base)}.wml", used)
return planned
# -- Card topology --------------------------------------------------------
def _build_single(
self, preamble: List[BlockEvent], sections: List[Section]
) -> List[Card]:
events = list(preamble)
if sections:
events.extend(sections[0].events)
# Only an explicit title becomes a card title here: the document's
# own first heading is already rendered into the card body, and a
# <card title=> would duplicate it on the device's screen heading.
return self._pack(
"card1",
self.config.title,
self._render_events(events),
entry_back_label=None,
exit_next_id=None,
exit_next_label=None,
)
def _build_hub_and_spoke(
self, preamble: List[BlockEvent], sections: List[Section]
) -> List[Card]:
cfg = self.config
menu_title = cfg.title or first_heading_text(preamble)
menu_fragments = self._render_events(preamble)
menu_fragments.extend(self._render_menu_choices(sections, menu_title))
# The menu card is packed like any other: a long preamble plus many
# choices is exactly the shape that overflows the byte budget.
cards = self._pack(
MENU_CARD_ID,
menu_title,
menu_fragments,
entry_back_label=None,
exit_next_id=None,
exit_next_label=None,
home_id=None,
)
for i, section in enumerate(sections):
cards.extend(
self._pack(
f"card{i + 1}",
section.title or "Untitled",
self._render_events(section.events),
entry_back_label=cfg.nav_back_label,
exit_next_id=None,
exit_next_label=None,
home_id=MENU_CARD_ID,
)
)
return cards
def _build_linear(
self, preamble: List[BlockEvent], sections: List[Section]
) -> List[Card]:
cfg = self.config
cards: List[Card] = []
total = len(sections)
for i, section in enumerate(sections):
is_first = i == 0
events = (list(preamble) + list(section.events)) if is_first else section.events
# Card 1 also carries the preamble, so prefer the deck-level
# title / preamble's own heading over this section's heading.
title = (
(cfg.title or first_heading_text(preamble) 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(
f"card{i + 1}",
title,
self._render_events(events),
entry_back_label=None if is_first else cfg.nav_back_label,
exit_next_id=next_id,
exit_next_label=cfg.nav_next_label if next_id else None,
)
)
return cards
def _pack(
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],
home_id: Optional[str] = None,
) -> List[Card]:
cfg = self.config
return pack_section(
base_id,
title,
fragments,
max_card_bytes=cfg.max_card_bytes,
headroom=self._headroom(),
nav_next_label=cfg.nav_next_label,
nav_prev_label=cfg.nav_prev_label,
entry_back_label=entry_back_label,
exit_next_id=exit_next_id,
exit_next_label=exit_next_label,
home_id=home_id if cfg.home_label else None,
)
def _headroom(self) -> int:
return TEMPLATED_NAV_HEADROOM_BYTES if self.config.template_nav else NAV_HEADROOM_BYTES
def _render_menu_choices(
self, sections: List[Section], menu_title: Optional[str]
) -> List[str]:
"""The hub's list of destinations.
`select` is the WAP-native form: a numeric keypad picks an option by
digit, where a list of anchors has to be scrolled through.
"""
targets = [
(escape_wml(section.title or "Untitled"), self._href(f"card{i + 1}"))
for i, section in enumerate(sections)
]
if self.config.menu_style == "select":
options = "".join(
f'<option onpick="{href}">{label}</option>' for label, href in targets
)
title = escape_wml(menu_title or "Menu")
return [f'<p><select title="{title}">{options}</select></p>']
return [f'<p><a href="{href}">{label}</a></p>' for label, href in targets]
# -- Deck emission --------------------------------------------------------
def _emit_deck(self, cards: List[Card], *, cards_in_file: Optional[List[Card]] = None) -> str:
body_cards = cards if cards_in_file is None else cards_in_file
parts = [WML_PROLOG, "<wml>\n"]
head = self._render_head()
if head:
parts.append(head + "\n")
template = self._render_template(cards)
if template:
parts.append(template + "\n")
parts.append("\n".join(self._render_card(card, template=bool(template)) for card in body_cards))
parts.append("\n</wml>\n")
return "".join(parts)
def _emit_multi_deck(self, cards: List[Card]) -> DeckOutput:
"""One file per section, cross-linked by filename.
This is the real answer to per-deck cache limits: `max_card_bytes`
can only paginate within a single file, which still has to be
fetched whole.
"""
self._assign_deck_files(cards)
by_file: Dict[str, List[Card]] = {}
for card in cards:
by_file.setdefault(card.deck_file or INDEX_DECK_FILE, []).append(card)
return DeckOutput(
[
WmlDocument(text=self._emit_deck(cards, cards_in_file=group), name=name)
for name, group in by_file.items()
]
)
def _assign_deck_files(self, cards: List[Card]) -> None:
for card in cards:
base = card.id.split("-p", 1)[0]
card.deck_file = (
INDEX_DECK_FILE if base == MENU_CARD_ID else self._deck_files.get(base, INDEX_DECK_FILE)
)
@staticmethod
def _unique(name: str, used: set[str]) -> str:
candidate, counter = name, 2
while candidate in used:
candidate = f"{name[:-4]}-{counter}.wml"
counter += 1
used.add(candidate)
return candidate
def _render_head(self) -> str:
cfg = self.config
entries: List[str] = []
if cfg.access_domain or cfg.access_path:
attrs = ""
if cfg.access_domain:
attrs += f' domain="{escape_wml(cfg.access_domain)}"'
if cfg.access_path:
attrs += f' path="{escape_wml(cfg.access_path)}"'
entries.append(f"<access{attrs}/>")
if cfg.cache_control is not None:
entries.append(
'<meta http-equiv="Cache-Control" '
f'content="max-age={cfg.cache_control}"/>'
)
return f"<head>\n{chr(10).join(entries)}\n</head>" if entries else ""
def _render_template(self, cards: List[Card]) -> str:
"""Hoist nav shared by every card into a deck-level <template>.
Only `<prev/>` and the Home softkey qualify: a forward `<go>` points
somewhere different from each card, so it cannot be shared. A card
that needs different nav than the template overrides it by declaring
a `<do>` of the same type (see `_render_card`).
"""
cfg = self.config
if not cfg.template_nav or len(cards) < 2:
return ""
entries: List[str] = []
if any(card.back_label for card in cards):
label = escape_wml(cfg.nav_back_label)
entries.append(f'<do type="prev" label="{label}"><prev/></do>')
if cfg.home_label and any(card.home_id for card in cards):
label = escape_wml(cfg.home_label)
href = self._href(MENU_CARD_ID, from_card=None)
entries.append(f'<do type="options" label="{label}"><go href="{href}"/></do>')
return f"<template>\n{chr(10).join(entries)}\n</template>" if entries else ""
def _render_card(self, card: Card, *, template: bool) -> str:
cfg = self.config
attrs = f' title="{escape_wml(card.title)}"' if card.title else ""
parts = [f'<card id="{card.id}"{attrs}>']
if template:
# Shadow the template's shared nav where this card differs from it.
# A <do> with the same type (its implicit name) replaces the
# inherited one; <noop/> suppresses it outright.
if not card.back_label:
parts.append('<do type="prev"><noop/></do>')
elif card.back_label != cfg.nav_back_label:
label = escape_wml(card.back_label)
parts.append(f'<do type="prev" label="{label}"><prev/></do>')
if cfg.home_label and not card.home_id:
parts.append('<do type="options"><noop/></do>')
else:
if card.back_label:
label = escape_wml(card.back_label)
parts.append(f'<do type="prev" label="{label}"><prev/></do>')
if cfg.home_label and card.home_id:
label = escape_wml(cfg.home_label)
href = self._href(card.home_id, from_card=card)
parts.append(f'<do type="options" label="{label}"><go href="{href}"/></do>')
if card.next_id:
label = escape_wml(card.next_label or cfg.nav_next_label)
href = self._href(card.next_id, from_card=card)
parts.append(f'<do type="accept" label="{label}"><go href="{href}"/></do>')
parts.extend(card.fragments)
parts.append("</card>")
return "\n".join(parts)
def _href(self, card_id: str, *, from_card: Optional[Card] = None) -> str:
"""Link to a card, crossing deck files when they exist."""
if not self.config.deck_per_card:
return f"#{card_id}"
base = card_id.split("-p", 1)[0]
target_file = (
INDEX_DECK_FILE if base == MENU_CARD_ID else self._deck_files.get(base, INDEX_DECK_FILE)
)
if from_card is not None and target_file == from_card.deck_file:
return f"#{card_id}"
return f"{target_file}#{card_id}"
# -- Block markup mapping -------------------------------------------------
def _render_events(self, events: List[BlockEvent]) -> List[str]:
fragments: List[str] = []
seen_heading = False
list_counter = 0
for event in events:
kind = event.kind
if kind == BlockKind.PARAGRAPH:
fragments.append(f"<p>{self._inline(event.payload.text)}</p>")
list_counter = 0
elif kind == BlockKind.HEADING:
text = self._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._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._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: 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._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>")
# <table> is flow content: the WML 1.3 content model for <card> is
# (onevent*, timer?, (do | p)*), so a bare <table> child is invalid.
return f'<p><table columns="{columns}">{"".join(tr_parts)}</table></p>'
def _inline(self, text: str) -> str:
return process_inline(text, images=self.config.images)

59
tests/conftest.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
from wapdown.cli import convert
from wapdown.config import DeckConfig
from wapdown.conversion.core import strip_frontmatter, read_lines
from wapdown.renderers.wml.renderer import DeckOutput
PROJECT_ROOT = Path(__file__).resolve().parent.parent
EXAMPLES = PROJECT_ROOT / "examples"
GOLDEN = Path(__file__).resolve().parent / "golden"
def render_source(text: str, **options: Any) -> str:
"""Render Markdown given as a string, returning the single deck's markup."""
lines = text.splitlines(keepends=True)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=EXAMPLES).text
def render_file(path: Path, **options: Any) -> DeckOutput:
lines = read_lines(path)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=path.parent)
@pytest.fixture
def render():
return render_source
@pytest.fixture
def render_output():
"""Like `render`, but exposes every output file (deck-per-card mode)."""
def _render(text: str, **options: Any) -> DeckOutput:
lines = text.splitlines(keepends=True)
frontmatter, body = strip_frontmatter(lines)
config = DeckConfig.resolve(frontmatter, options)
return convert(body, config=config, base_path=EXAMPLES)
return _render
@pytest.fixture(scope="session")
def example_decks() -> dict[str, str]:
"""Every examples/*.md rendered with its own frontmatter settings."""
return {
path.name: render_file(path).documents[0].text
for path in sorted(EXAMPLES.glob("*.md"))
}

4
tests/dtd/wml13.dtd Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu" title="Kitchen Sink">
<do type="prev"><noop/></do>
<p><b><big>Kitchen Sink</big></b></p>
<p>A deck exercising every block and inline construct wapdown understands.</p>
<p><select title="Kitchen Sink"><option onpick="#card1">Links and emphasis</option><option onpick="#card2">A table</option><option onpick="#card3">Other blocks</option></select></p>
</card>
<card id="card1" title="Links and emphasis">
<p><b><big>Links and emphasis</big></b></p>
<p>Tricky URL: <a href="https://example.com/docs/v1_2_3/notes.html">release notes</a> and a starred path <a href="https://ci.example.com/job/a*b*c/log">build log</a>.</p>
<p><b>Bold</b>, <i>italic</i>, inline code, struck, and a literal $$5 fee.</p>
</card>
<card id="card2" title="A table">
<p><b><big>A table</big></b></p>
<p><table columns="3"><tr><td><b>Trail</b></td><td><b>Status</b></td><td><b>Fee</b></td></tr><tr><td>North Loop</td><td>open</td><td>$$2</td></tr><tr><td>Summit Spur</td><td>icy</td><td>$$5</td></tr></table></p>
</card>
<card id="card3" title="Other blocks">
<p><b><big>Other blocks</big></b></p>
<p><i>A quoted warning.</i></p>
<p>1. First</p>
<p>2. Second</p>
<p>------------</p>
<p>indented code $$HOME</p>
</card>
</wml>

View file

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card3" title="Contact">
<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

@ -0,0 +1,16 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<do type="options"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="weather.wml#card1">Weather</a></p>
<p><a href="trail-status.wml#card2">Trail Status</a></p>
<p><a href="contact.wml#card3">Contact</a></p>
</card>
</wml>

View file

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
</wml>

View file

@ -0,0 +1,11 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="index.wml#menu"/></do>
</template>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
</wml>

View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<head>
<access domain="example.com"/>
<meta http-equiv="Cache-Control" content="max-age=0"/>
</head>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<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

@ -0,0 +1,28 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
<do type="options" label="Home"><go href="#menu"/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<do type="options"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<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

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

View file

@ -0,0 +1,27 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="card1" title="Trail Conditions">
<do type="prev"><noop/></do>
<do type="accept" label="More"><go href="#card2"/></do>
<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>
</card>
<card id="card2" title="Trail Status">
<do type="accept" label="More"><go href="#card3"/></do>
<p><b><big>Trail Status</big></b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p><b><big>Contact</big></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

@ -0,0 +1,26 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<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

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<card id="menu">
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<do type="prev" label="Back"><prev/></do>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<do type="prev" label="Back"><prev/></do>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<do type="prev" label="Back"><prev/></do>
<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

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

View file

@ -0,0 +1,24 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu">
<do type="prev"><noop/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<p><select title="Menu"><option onpick="#card1">Weather</option><option onpick="#card2">Trail Status</option><option onpick="#card3">Contact</option></select></p>
</card>
<card id="card1" title="Weather">
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<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

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="menu" title="Trail Conditions">
<do type="prev"><noop/></do>
<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><a href="#card1">Weather</a></p>
<p><a href="#card2">Trail Status</a></p>
<p><a href="#card3">Contact</a></p>
</card>
<card id="card1" title="Weather">
<p><b><big>Weather</big></b></p>
<p>Cold and clear. Wind: <b>15 mph</b> gusting from the <i>northwest</i>. Permit fee: $$5.</p>
</card>
<card id="card2" title="Trail Status">
<p><b><big>Trail Status</big></b></p>
<p>- North Loop: open</p>
<p>- South Loop: closed</p>
<p>- Summit Spur: open, ice above 2000m</p>
</card>
<card id="card3" title="Contact">
<p><b><big>Contact</big></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>

17
tests/golden/trail.wml Normal file
View file

@ -0,0 +1,17 @@
<?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>

29
tests/golden_cases.py Normal file
View file

@ -0,0 +1,29 @@
"""The set of (source, options) -> golden file cases.
Shared by `test_golden.py` and `regenerate_golden.py` so the suite and the
regeneration script can never disagree about what should exist.
"""
from __future__ import annotations
from typing import Any, Dict, Tuple
# name -> (example filename, renderer options)
CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
"trail": ("trail.md", {}),
"trail-split": ("trail.md", {"split_level": 2}),
"trail-linear": ("trail.md", {"split_level": 2, "menu": False}),
"trail-manual": ("trail-manual.md", {}),
"trail-select": ("trail-manual.md", {"menu_style": "select"}),
"trail-home": ("trail-manual.md", {"home_label": "Home"}),
"trail-no-template": ("trail-manual.md", {"template_nav": False}),
"trail-paginated": ("trail-manual.md", {"max_card_bytes": 120}),
"trail-images-alt": ("trail-manual.md", {"images": "alt"}),
"trail-head": ("trail-manual.md", {"cache_control": 0, "access_domain": "example.com"}),
"kitchen-sink": ("kitchen-sink.md", {}),
}
# Cases that produce several files; each is written to its own directory.
MULTI_CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
"trail-decks": ("trail-manual.md", {"deck_per_card": True, "home_label": "Home"}),
}

View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Rewrite every golden file under tests/golden/.
Run after an intentional change to the renderer, then read the diff --
that diff is the change, stated in output rather than in code:
uv run python tests/regenerate_golden.py
jj diff tests/golden
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from conftest import EXAMPLES, GOLDEN, render_file # noqa: E402
from golden_cases import CASES, MULTI_CASES # noqa: E402
def main() -> int:
GOLDEN.mkdir(parents=True, exist_ok=True)
for name, (source, options) in CASES.items():
output = render_file(EXAMPLES / source, **options)
(GOLDEN / f"{name}.wml").write_text(output.text, encoding="utf-8")
print(f"wrote {name}.wml")
for name, (source, options) in MULTI_CASES.items():
directory = GOLDEN / name
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
for document in render_file(EXAMPLES / source, **options).documents:
(directory / (document.name or "index.wml")).write_text(
document.text, encoding="utf-8"
)
print(f"wrote {name}/ ({len(list(directory.iterdir()))} files)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

143
tests/test_cli.py Normal file
View file

@ -0,0 +1,143 @@
"""End-to-end behaviour of the `wapdown` command."""
from __future__ import annotations
import pytest
from conftest import EXAMPLES
from wapdown.cli import main
TWO_SECTIONS = "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n"
@pytest.fixture
def source(tmp_path):
def _write(text: str = TWO_SECTIONS, name: str = "doc.md"):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
return path
return _write
class TestOutput:
def test_writes_to_stdout_by_default(self, source, capsys):
assert main([str(source())]) == 0
assert capsys.readouterr().out.startswith('<?xml version="1.0"?>')
def test_writes_to_a_file(self, source, tmp_path):
out = tmp_path / "deck.wml"
assert main([str(source()), "-o", str(out)]) == 0
assert out.read_text(encoding="utf-8").endswith("</wml>\n")
def test_creates_missing_parent_directories(self, source, tmp_path):
out = tmp_path / "nested" / "deep" / "deck.wml"
assert main([str(source()), "-o", str(out)]) == 0
assert out.exists()
def test_uses_lf_line_endings(self, source, tmp_path):
out = tmp_path / "deck.wml"
main([str(source()), "-o", str(out)])
assert b"\r" not in out.read_bytes()
def test_deck_per_card_writes_a_directory(self, source, tmp_path):
out = tmp_path / "decks"
assert main([str(source()), "--deck-per-card", "-o", str(out)]) == 0
assert sorted(p.name for p in out.iterdir()) == [
"index.wml",
"one.wml",
"two.wml",
]
def test_deck_per_card_without_output_is_refused(self, source):
# Several files cannot be written to stdout, so say so rather than
# silently emitting only one of them.
with pytest.raises(SystemExit, match="pass -o DIRECTORY"):
main([str(source()), "--deck-per-card"])
class TestOptions:
def test_flags_reach_the_renderer(self, source, capsys):
main([str(source()), "--menu-style", "select", "--title", "Deck"])
out = capsys.readouterr().out
assert "<select" in out
assert 'title="Deck"' in out
def test_flag_overrides_frontmatter(self, source, capsys):
path = source("---\ntitle: FromFile\n---\n" + TWO_SECTIONS)
main([str(path), "--title", "FromFlag"])
assert 'title="FromFlag"' in capsys.readouterr().out
def test_frontmatter_applies_without_a_flag(self, source, capsys):
path = source("---\nmenu_style: select\n---\n" + TWO_SECTIONS)
main([str(path)])
assert "<select" in capsys.readouterr().out
def test_no_menu_switches_to_linear(self, source, capsys):
main([str(source()), "--no-menu"])
assert '<card id="menu"' not in capsys.readouterr().out
def test_frontmatter_is_not_rendered_as_content(self, source, capsys):
path = source("---\ntitle: Deck\n---\n" + TWO_SECTIONS)
main([str(path)])
assert "title: Deck" not in capsys.readouterr().out
class TestErrors:
def test_missing_input_file(self, tmp_path, capsys):
assert main([str(tmp_path / "nope.md")]) == 2
assert "wapdown:" in capsys.readouterr().err
def test_invalid_option_value(self, source, capsys):
assert main([str(source()), "--split-level", "9"]) == 2
assert "split_level" in capsys.readouterr().err
def test_invalid_frontmatter_value(self, source, capsys):
path = source("---\nsplit_level: deep\n---\n" + TWO_SECTIONS)
assert main([str(path)]) == 2
assert "must be a number" in capsys.readouterr().err
def test_unknown_menu_style_is_rejected_by_argparse(self, source):
with pytest.raises(SystemExit) as exc:
main([str(source()), "--menu-style", "carousel"])
assert exc.value.code == 2
def test_missing_include_is_reported(self, source, capsys):
path = source("Intro.\n\n{.include nowhere.md}\n")
assert main([str(path)]) == 2
assert "was not found" in capsys.readouterr().err
class TestIncludes:
def test_included_file_contributes_content(self, tmp_path, capsys):
(tmp_path / "part.md").write_text("Included body.\n", encoding="utf-8")
main([str(_write(tmp_path, "Intro.\n\n{.include part.md}\n"))])
assert "Included body." in capsys.readouterr().out
def test_included_frontmatter_is_discarded(self, tmp_path, capsys):
(tmp_path / "part.md").write_text(
"---\ntitle: Ignored\n---\nIncluded body.\n", encoding="utf-8"
)
main([str(_write(tmp_path, "Intro.\n\n{.include part.md}\n"))])
out = capsys.readouterr().out
assert "Included body." in out
assert "Ignored" not in out
def test_circular_include_is_reported(self, tmp_path, capsys):
(tmp_path / "a.md").write_text("{.include doc.md}\n", encoding="utf-8")
assert main([str(_write(tmp_path, "{.include a.md}\n"))]) == 2
assert "Circular include" in capsys.readouterr().err
def _write(directory, text: str, name: str = "doc.md"):
path = directory / name
path.write_text(text, encoding="utf-8")
return path
class TestExamples:
@pytest.mark.parametrize(
"name", sorted(p.name for p in EXAMPLES.glob("*.md"))
)
def test_every_example_converts(self, name, tmp_path, capsys):
assert main([str(EXAMPLES / name), "-o", str(tmp_path / "out.wml")]) == 0

84
tests/test_config.py Normal file
View file

@ -0,0 +1,84 @@
"""Configuration layering: CLI flag > frontmatter > default."""
from __future__ import annotations
import pytest
from wapdown.config import DEFAULT_MAX_CARD_BYTES, ConfigError, DeckConfig
class TestPrecedence:
def test_defaults_when_nothing_is_given(self):
config = DeckConfig.resolve()
assert config.split_level == 0
assert config.max_card_bytes == DEFAULT_MAX_CARD_BYTES
assert config.menu is True
assert config.menu_style == "links"
assert config.title is None
def test_frontmatter_beats_default(self):
config = DeckConfig.resolve({"split_level": "2", "title": "Trail"})
assert config.split_level == 2
assert config.title == "Trail"
def test_cli_beats_frontmatter(self):
config = DeckConfig.resolve({"split_level": "2"}, {"split_level": 3})
assert config.split_level == 3
def test_absent_cli_option_does_not_clobber_frontmatter(self):
# argparse hands every unset option through as None; that must mean
# "not given" rather than "set to nothing".
config = DeckConfig.resolve({"title": "Trail"}, {"title": None, "menu": None})
assert config.title == "Trail"
assert config.menu is True
def test_unknown_frontmatter_key_warns_but_does_not_fail(self, capsys):
config = DeckConfig.resolve({"hyphenate": "true", "title": "T"})
assert config.title == "T"
assert "unknown frontmatter key 'hyphenate'" in capsys.readouterr().err
class TestCoercion:
@pytest.mark.parametrize("raw", ["true", "yes", "1", "on", "TRUE"])
def test_truthy_strings(self, raw):
assert DeckConfig.resolve({"menu": raw}).menu is True
@pytest.mark.parametrize("raw", ["false", "no", "0", "off", "FALSE"])
def test_falsy_strings(self, raw):
assert DeckConfig.resolve({"menu": raw}).menu is False
def test_menu_style_is_case_insensitive(self):
assert DeckConfig.resolve({"menu_style": "SELECT"}).menu_style == "select"
def test_hyphenated_frontmatter_key_is_accepted(self):
assert DeckConfig.resolve({"split-level": "2"}).split_level == 2
def test_empty_title_becomes_none(self):
assert DeckConfig.resolve({"title": ""}).title is None
class TestValidation:
@pytest.mark.parametrize("value", [-1, 7])
def test_split_level_out_of_range(self, value):
with pytest.raises(ConfigError, match="split_level"):
DeckConfig.resolve(overrides={"split_level": value})
def test_negative_byte_budget(self):
with pytest.raises(ConfigError, match="max_card_bytes"):
DeckConfig.resolve(overrides={"max_card_bytes": -1})
def test_unknown_menu_style(self):
with pytest.raises(ConfigError, match="menu_style"):
DeckConfig.resolve(overrides={"menu_style": "carousel"})
def test_unknown_image_policy(self):
with pytest.raises(ConfigError, match="images"):
DeckConfig.resolve(overrides={"images": "wbmp"})
def test_non_numeric_split_level_in_frontmatter(self):
# Previously an uncaught ValueError escaping as a traceback.
with pytest.raises(ConfigError, match="split_level must be a number"):
DeckConfig.resolve({"split_level": "deep"})
def test_non_boolean_menu_in_frontmatter(self):
with pytest.raises(ConfigError, match="menu must be a boolean"):
DeckConfig.resolve({"menu": "sometimes"})

213
tests/test_deck.py Normal file
View file

@ -0,0 +1,213 @@
"""Document topology: sectioning, byte packing, and navigation wiring."""
from __future__ import annotations
import re
import pytest
from wapdown.renderers.wml.deck import greedy_pack, slugify
TWO_SECTIONS = """\
Intro text.
{.card Weather}
Cold and clear.
{.card Status}
North Loop open.
"""
THREE_HEADINGS = """\
# Title
Intro text.
## Alpha
First section.
## Beta
Second section.
## Gamma
Third section.
"""
def card_ids(markup: str) -> list[str]:
return re.findall(r'<card id="([^"]+)"', markup)
def cards(markup: str) -> dict[str, str]:
"""Map each card id to that card's markup."""
chunks = re.split(r"(?=<card id=)", markup)
out = {}
for chunk in chunks:
match = re.match(r'<card id="([^"]+)"', chunk)
if match:
out[match.group(1)] = chunk.split("</card>")[0]
return out
class TestSectioning:
def test_plain_document_stays_one_card(self, render):
markup = render("# Title\n\nJust a paragraph.\n")
assert card_ids(markup) == ["card1"]
def test_headings_alone_do_not_split(self, render):
# Splitting is opt-in: a document full of headings is still one card
# until split_level says otherwise.
assert card_ids(render(THREE_HEADINGS)) == ["card1"]
def test_split_level_splits_at_that_heading(self, render):
markup = render(THREE_HEADINGS, split_level=2)
assert card_ids(markup) == ["menu", "card1", "card2", "card3"]
def test_split_level_that_matches_nothing_stays_one_card(self, render):
assert card_ids(render(THREE_HEADINGS, split_level=5)) == ["card1"]
def test_card_markers_split(self, render):
assert card_ids(render(TWO_SECTIONS)) == ["menu", "card1", "card2"]
def test_card_markers_win_over_split_level(self, render):
# An explicit boundary in the document is a stronger statement of
# intent than a structural rule passed on the command line.
markup = render(
"Intro.\n\n{.card One}\n## Heading\n\nBody.\n\n{.card Two}\nMore.\n",
split_level=2,
)
assert card_ids(markup) == ["menu", "card1", "card2"]
def test_marker_title_is_used(self, render):
assert '<card id="card1" title="Weather">' in render(TWO_SECTIONS)
def test_untitled_marker_falls_back_to_first_heading(self, render):
markup = render("Intro.\n\n{.card}\n## Weather\n\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Weather">' in markup
def test_marker_without_any_heading_is_untitled(self, render):
markup = render("Intro.\n\n{.card}\nCold.\n\n{.card Two}\nx\n")
assert '<card id="card1" title="Untitled">' in markup
def test_preamble_lands_on_the_menu_card(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS))["menu"]
class TestHubAndSpoke:
def test_menu_links_to_every_section(self, render):
menu = cards(render(TWO_SECTIONS))["menu"]
assert '<a href="#card1">Weather</a>' in menu
assert '<a href="#card2">Status</a>' in menu
def test_select_style_menu(self, render):
menu = cards(render(TWO_SECTIONS, menu_style="select"))["menu"]
assert '<select title="Menu">' in menu
assert '<option onpick="#card1">Weather</option>' in menu
assert "<a href=" not in menu
def test_spokes_have_no_forward_link(self, render):
# In hub-and-spoke the only way onward is back through the menu.
spoke = cards(render(TWO_SECTIONS))["card1"]
assert 'type="accept"' not in spoke
def test_menu_card_has_no_back_control(self, render):
assert '<do type="prev"><noop/></do>' in cards(render(TWO_SECTIONS))["menu"]
def test_home_softkey_is_opt_in(self, render):
assert 'type="options"' not in render(TWO_SECTIONS)
assert 'type="options"' in render(TWO_SECTIONS, home_label="Home")
class TestLinear:
def test_cards_chain_forward(self, render):
markup = render(TWO_SECTIONS, menu=False)
assert card_ids(markup) == ["card1", "card2"]
assert '<do type="accept" label="More"><go href="#card2"/></do>' in markup
def test_last_card_has_no_forward_link(self, render):
assert 'type="accept"' not in cards(render(TWO_SECTIONS, menu=False))["card2"]
def test_first_card_carries_the_preamble(self, render):
assert "Intro text." in cards(render(TWO_SECTIONS, menu=False))["card1"]
def test_custom_nav_labels(self, render):
markup = render(TWO_SECTIONS, menu=False, nav_next_label="Onward")
assert 'label="Onward"' in markup
class TestTemplateNav:
def test_shared_back_control_is_hoisted(self, render):
markup = render(TWO_SECTIONS)
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 1
assert "<template>" in markup
def test_disabling_repeats_nav_per_card(self, render):
markup = render(TWO_SECTIONS, template_nav=False)
assert "<template>" not in markup
assert markup.count('<do type="prev" label="Back"><prev/></do>') == 2
def test_single_card_deck_needs_no_template(self, render):
assert "<template>" not in render("# Title\n\nBody.\n")
def test_template_precedes_the_first_card(self, render):
markup = render(TWO_SECTIONS)
assert markup.index("<template>") < markup.index("<card id=")
class TestPacking:
def test_budget_of_zero_disables_packing(self):
assert greedy_pack(["a" * 500, "b" * 500], "card1", 0, 160) == [
["a" * 500, "b" * 500]
]
def test_fragments_are_grouped_up_to_the_budget(self):
chunks = greedy_pack(["a" * 40] * 4, "card1", 100, 0)
assert [len(c) for c in chunks] == [2, 2]
def test_oversized_single_block_gets_its_own_card(self, capsys):
chunks = greedy_pack(["x" * 300, "y"], "card1", 100, 0)
assert chunks[0] == ["x" * 300]
assert "over the 100-byte budget" in capsys.readouterr().err
def test_budget_counts_utf8_bytes_not_characters(self):
# Two 3-byte characters exceed a 4-byte budget even though len() is 2.
assert len(greedy_pack(["中", "文"], "card1", 4, 0)) == 2
def test_overflow_cards_are_chained(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
ids = card_ids(markup)
assert "menu-p2" in ids
assert '<go href="#menu-p2"/>' in markup
def test_menu_card_is_subject_to_the_budget(self, render):
# Regression: the menu was built directly and never packed, so a long
# preamble plus many choices could blow past the budget unnoticed.
long_preamble = "\n\n".join(f"Paragraph number {i} with filler." for i in range(12))
markup = render(f"{long_preamble}\n\n{TWO_SECTIONS}", max_card_bytes=200)
assert "menu-p2" in card_ids(markup)
def test_overflow_parts_use_the_prev_label(self, render):
markup = render(TWO_SECTIONS, max_card_bytes=60)
assert 'label="Prev"' in markup
class TestSlugify:
@pytest.mark.parametrize(
("text", "expected"),
[
("Trail Status", "trail-status"),
("Weather!", "weather"),
(" Spaced Out ", "spaced-out"),
("Ünicode", "nicode"),
],
)
def test_slugs(self, text, expected):
assert slugify(text, "fallback") == expected
def test_empty_falls_back(self):
assert slugify("", "card1") == "card1"
assert slugify(None, "card1") == "card1"
assert slugify("!!!", "card1") == "card1"

55
tests/test_golden.py Normal file
View file

@ -0,0 +1,55 @@
"""Byte-for-byte comparison against committed reference output.
These catch unintended drift in whole-document output, which unit tests on
individual constructs cannot. When a change is deliberate, regenerate with
`uv run python tests/regenerate_golden.py` and review the resulting diff.
"""
from __future__ import annotations
from xml.etree import ElementTree
import pytest
from conftest import EXAMPLES, GOLDEN, render_file
from golden_cases import CASES, MULTI_CASES
REGENERATE_HINT = (
"Output changed. If that was intended, run "
"`uv run python tests/regenerate_golden.py` and review the diff."
)
@pytest.mark.parametrize("name", sorted(CASES))
def test_matches_golden(name):
source, options = CASES[name]
golden = GOLDEN / f"{name}.wml"
assert golden.exists(), f"missing golden for '{name}'; {REGENERATE_HINT}"
actual = render_file(EXAMPLES / source, **options).text
assert actual == golden.read_text(encoding="utf-8"), REGENERATE_HINT
@pytest.mark.parametrize("name", sorted(MULTI_CASES))
def test_matches_golden_directory(name):
source, options = MULTI_CASES[name]
directory = GOLDEN / name
assert directory.is_dir(), f"missing golden dir for '{name}'; {REGENERATE_HINT}"
documents = {
doc.name or "index.wml": doc.text
for doc in render_file(EXAMPLES / source, **options).documents
}
expected = {path.name: path.read_text(encoding="utf-8") for path in directory.iterdir()}
assert sorted(documents) == sorted(expected), REGENERATE_HINT
for filename, text in documents.items():
assert text == expected[filename], f"{filename}: {REGENERATE_HINT}"
@pytest.mark.parametrize("path", sorted(GOLDEN.glob("**/*.wml")), ids=lambda p: p.name)
def test_golden_files_are_well_formed(path):
ElementTree.fromstring(path.read_text(encoding="utf-8"))
def test_every_example_has_a_case():
"""A new examples/*.md without a golden case would go unverified."""
covered = {source for source, _ in list(CASES.values()) + list(MULTI_CASES.values())}
assert {path.name for path in EXAMPLES.glob("*.md")} == covered

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")

115
tests/test_validity.py Normal file
View file

@ -0,0 +1,115 @@
"""Validate generated decks against the real WML 1.3 DTD.
The DTD is vendored at `tests/dtd/wml13.dtd` (fetched from
http://www.wapforum.org/DTD/wml13.dtd) so the suite never depends on a
network round trip to a twenty-year-old URL. Validation needs lxml, which
is an optional extra: `uv run --extra dtd pytest`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
lxml_etree = pytest.importorskip(
"lxml.etree", reason="install the 'dtd' extra for DTD validation"
)
DTD_PATH = Path(__file__).resolve().parent / "dtd" / "wml13.dtd"
@pytest.fixture(scope="module")
def dtd():
with DTD_PATH.open("rb") as handle:
return lxml_etree.DTD(handle)
def validation_errors(dtd, markup: str) -> list[str]:
# The document's own DOCTYPE points at a dead URL, so parse with the
# external DTD left alone and validate against the vendored copy.
parser = lxml_etree.XMLParser(resolve_entities=False, load_dtd=False)
tree = lxml_etree.fromstring(markup.encode("utf-8"), parser)
if dtd.validate(tree):
return []
return [str(error) for error in dtd.error_log.filter_from_errors()]
def assert_valid(dtd, markup: str) -> None:
errors = validation_errors(dtd, markup)
if errors:
pytest.fail("\n".join(errors))
SOURCES = {
"single_card": "# Title\n\nA paragraph with **bold** and a [link](http://x.test/).\n",
"two_sections": "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n",
"table": "| A | B |\n| --- | --- |\n| 1 | 2 |\n",
"code_block": "```\n<wml> & $HOME\n```\n",
"blockquote_and_lists": "> quoted\n\n- a\n- b\n\n1. one\n2. two\n\n---\n",
"image": "![Map](map.wbmp)\n",
"entities": 'Fish & chips < > " $5\n',
}
class TestDeckValidity:
@pytest.mark.parametrize("name", sorted(SOURCES))
def test_sources_are_valid(self, dtd, render, name):
assert_valid(dtd, render(SOURCES[name]))
@pytest.mark.parametrize(
"options",
[
{},
{"menu": False},
{"menu_style": "select"},
{"template_nav": False},
{"home_label": "Home"},
{"cache_control": 0},
{"access_domain": "example.com", "access_path": "/wap"},
{"max_card_bytes": 80},
{"title": "Deck"},
],
ids=[
"defaults",
"linear",
"select-menu",
"no-template",
"home-softkey",
"cache-control",
"access",
"paginated",
"titled",
],
)
def test_option_combinations_are_valid(self, dtd, render, options):
assert_valid(dtd, render(SOURCES["two_sections"], **options))
def test_every_deck_file_is_valid(self, dtd, render_output):
output = render_output(SOURCES["two_sections"], deck_per_card=True, home_label="Home")
for document in output.documents:
assert_valid(dtd, document.text)
def test_example_documents_are_valid(self, dtd, example_decks):
for name, markup in example_decks.items():
assert_valid(dtd, markup)
class TestDetectsInvalidMarkup:
"""The validator has to actually reject things, or it proves nothing."""
def test_table_outside_a_paragraph_is_rejected(self, dtd):
# The shape the renderer used to emit before the <p> wrapper landed.
bad = (
'<wml><card id="c"><table columns="1"><tr><td>x</td></tr></table>'
"</card></wml>"
)
assert validation_errors(dtd, bad)
def test_emphasis_inside_an_anchor_is_rejected(self, dtd):
# The shape the renderer used to emit for `[**go**](url)`.
bad = '<wml><card id="c"><p><a href="#x"><b>no</b></a></p></card></wml>'
assert validation_errors(dtd, bad)
def test_a_valid_deck_reports_no_errors(self, dtd):
good = '<wml><card id="c"><p>ok</p></card></wml>'
assert validation_errors(dtd, good) == []

178
tests/test_wml.py Normal file
View file

@ -0,0 +1,178 @@
"""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>"),
("---\n", "<p>------------</p>"),
],
)
def test_blocks(self, render, source, expected):
assert expected in render(source)
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 "&lt;wml&gt;" 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 &amp; &quot;Chips&quot;"' 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)

178
uv.lock generated Normal file
View file

@ -0,0 +1,178 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "lxml"
version = "6.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
{ url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
{ url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
{ url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
{ url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
{ url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
{ url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
{ url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
{ url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
{ url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
{ url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
{ url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
{ url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
{ url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
{ url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
{ url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
{ url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
{ url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
{ url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
{ url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
{ url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
{ url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
{ url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
{ url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
{ url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
{ url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
{ url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
{ url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
{ url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
{ url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
{ url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
{ url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
{ url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
{ url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
{ url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
{ url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
{ url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
{ url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
{ url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
{ url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
{ url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
{ url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
{ url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
{ url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "wapdown"
version = "0.1.0"
source = { editable = "." }
[package.optional-dependencies]
dtd = [
{ name = "lxml" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "lxml", marker = "extra == 'dtd'", specifier = ">=5.0" }]
provides-extras = ["dtd"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]