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:
commit
b83e24e2f7
51 changed files with 4016 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
187
README.md
Normal 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
34
examples/kitchen-sink.md
Normal 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
14
examples/trail-manual.md
Normal 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)
|
||||
|
||||

|
||||
19
examples/trail.md
Normal file
19
examples/trail.md
Normal 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)
|
||||
|
||||

|
||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal 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
6
src/wapdown/__init__.py
Normal 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
7
src/wapdown/__main__.py
Normal 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
250
src/wapdown/cli.py
Normal 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
130
src/wapdown/config.py
Normal 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
|
||||
19
src/wapdown/conversion/__init__.py
Normal file
19
src/wapdown/conversion/__init__.py
Normal 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",
|
||||
]
|
||||
200
src/wapdown/conversion/core.py
Normal file
200
src/wapdown/conversion/core.py
Normal 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
91
src/wapdown/models.py
Normal 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
|
||||
5
src/wapdown/parsers/__init__.py
Normal file
5
src/wapdown/parsers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Bundled parser implementations."""
|
||||
|
||||
from .markdown import MarkdownParser
|
||||
|
||||
__all__ = ["MarkdownParser"]
|
||||
589
src/wapdown/parsers/markdown.py
Normal file
589
src/wapdown/parsers/markdown.py
Normal 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
|
||||
44
src/wapdown/plugins/__init__.py
Normal file
44
src/wapdown/plugins/__init__.py
Normal 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",
|
||||
]
|
||||
25
src/wapdown/plugins/registry.py
Normal file
25
src/wapdown/plugins/registry.py
Normal 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())
|
||||
5
src/wapdown/renderers/__init__.py
Normal file
5
src/wapdown/renderers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Bundled renderer implementations."""
|
||||
|
||||
from .wml import WMLRenderer
|
||||
|
||||
__all__ = ["WMLRenderer"]
|
||||
28
src/wapdown/renderers/wml/__init__.py
Normal file
28
src/wapdown/renderers/wml/__init__.py
Normal 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",
|
||||
]
|
||||
194
src/wapdown/renderers/wml/deck.py
Normal file
194
src/wapdown/renderers/wml/deck.py
Normal 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
|
||||
125
src/wapdown/renderers/wml/inline.py
Normal file
125
src/wapdown/renderers/wml/inline.py
Normal 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("&", "&")
|
||||
text = text.replace("<", "<").replace(">", ">").replace('"', """)
|
||||
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: `` 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,
|
||||
)
|
||||
434
src/wapdown/renderers/wml/renderer.py
Normal file
434
src/wapdown/renderers/wml/renderer.py
Normal 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
59
tests/conftest.py
Normal 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
4
tests/dtd/wml13.dtd
Normal file
File diff suppressed because one or more lines are too long
30
tests/golden/kitchen-sink.wml
Normal file
30
tests/golden/kitchen-sink.wml
Normal 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>
|
||||
12
tests/golden/trail-decks/contact.wml
Normal file
12
tests/golden/trail-decks/contact.wml
Normal 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>
|
||||
16
tests/golden/trail-decks/index.wml
Normal file
16
tests/golden/trail-decks/index.wml
Normal 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>
|
||||
13
tests/golden/trail-decks/trail-status.wml
Normal file
13
tests/golden/trail-decks/trail-status.wml
Normal 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>
|
||||
11
tests/golden/trail-decks/weather.wml
Normal file
11
tests/golden/trail-decks/weather.wml
Normal 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>
|
||||
30
tests/golden/trail-head.wml
Normal file
30
tests/golden/trail-head.wml
Normal 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>
|
||||
28
tests/golden/trail-home.wml
Normal file
28
tests/golden/trail-home.wml
Normal 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>
|
||||
26
tests/golden/trail-images-alt.wml
Normal file
26
tests/golden/trail-images-alt.wml
Normal 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>
|
||||
27
tests/golden/trail-linear.wml
Normal file
27
tests/golden/trail-linear.wml
Normal 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>
|
||||
26
tests/golden/trail-manual.wml
Normal file
26
tests/golden/trail-manual.wml
Normal 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>
|
||||
25
tests/golden/trail-no-template.wml
Normal file
25
tests/golden/trail-no-template.wml
Normal 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>
|
||||
38
tests/golden/trail-paginated.wml
Normal file
38
tests/golden/trail-paginated.wml
Normal 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>
|
||||
24
tests/golden/trail-select.wml
Normal file
24
tests/golden/trail-select.wml
Normal 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>
|
||||
30
tests/golden/trail-split.wml
Normal file
30
tests/golden/trail-split.wml
Normal 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
17
tests/golden/trail.wml
Normal 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
29
tests/golden_cases.py
Normal 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"}),
|
||||
}
|
||||
44
tests/regenerate_golden.py
Normal file
44
tests/regenerate_golden.py
Normal 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
143
tests/test_cli.py
Normal 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
84
tests/test_config.py
Normal 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
213
tests/test_deck.py
Normal 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
55
tests/test_golden.py
Normal 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
94
tests/test_inline.py
Normal 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 & b < c > d "e""
|
||||
|
||||
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("<") == "<"
|
||||
assert "&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&b=2">q</a>'
|
||||
)
|
||||
|
||||
|
||||
class TestImages:
|
||||
def test_keep_is_the_default(self):
|
||||
assert process_inline("") == '<img src="map.png" alt="Map"/>'
|
||||
|
||||
def test_alt_policy_replaces_non_wbmp(self, capsys):
|
||||
assert process_inline("", images="alt") == "Map"
|
||||
assert "non-WBMP" in capsys.readouterr().err
|
||||
|
||||
def test_drop_policy_removes_non_wbmp(self, capsys):
|
||||
assert process_inline("", images="drop") == ""
|
||||
assert "dropped" in capsys.readouterr().err
|
||||
|
||||
@pytest.mark.parametrize("policy", ["alt", "drop"])
|
||||
def test_wbmp_always_survives(self, policy):
|
||||
result = process_inline("", images=policy)
|
||||
assert result == '<img src="icon.wbmp" alt="Icon"/>'
|
||||
|
||||
def test_wbmp_detected_past_a_query_string(self):
|
||||
assert '<img' in process_inline("", images="drop")
|
||||
|
||||
def test_image_is_not_parsed_as_a_link(self):
|
||||
assert process_inline("").startswith("<img")
|
||||
115
tests/test_validity.py
Normal file
115
tests/test_validity.py
Normal 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": "\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
178
tests/test_wml.py
Normal 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 "<wml>" in render("```\n<wml>\n```\n")
|
||||
|
||||
def test_table_header_row_is_bold(self, render):
|
||||
markup = render("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
|
||||
assert "<td><b>A</b></td>" in markup
|
||||
assert "<td>1</td>" in markup
|
||||
|
||||
def test_ragged_table_rows_are_padded(self, render):
|
||||
markup = render("| A | B | C |\n| --- | --- | --- |\n| 1 |\n")
|
||||
assert '<table columns="3">' in markup
|
||||
assert markup.count("<td>") % 3 == 0
|
||||
|
||||
|
||||
class TestHead:
|
||||
def test_no_head_by_default(self, render):
|
||||
assert "<head>" not in render("Body.\n")
|
||||
|
||||
def test_cache_control(self, render):
|
||||
markup = render("Body.\n", cache_control=0)
|
||||
assert '<meta http-equiv="Cache-Control" content="max-age=0"/>' in markup
|
||||
parse(markup)
|
||||
|
||||
def test_access_control(self, render):
|
||||
markup = render("Body.\n", access_domain="example.com", access_path="/wap")
|
||||
assert '<access domain="example.com" path="/wap"/>' in markup
|
||||
parse(markup)
|
||||
|
||||
def test_head_precedes_template_and_cards(self, render):
|
||||
markup = render(TWO_SECTIONS, cache_control=60)
|
||||
assert [child.tag for child in parse(markup)][:2] == ["head", "template"]
|
||||
|
||||
|
||||
class TestTitles:
|
||||
def test_explicit_title_reaches_the_menu_card(self, render):
|
||||
assert '<card id="menu" title="Deck">' in render(TWO_SECTIONS, title="Deck")
|
||||
|
||||
def test_single_card_is_untitled_without_an_explicit_title(self, render):
|
||||
# The document's own heading already renders into the card body.
|
||||
assert '<card id="card1">' in render("# Heading\n\nBody.\n")
|
||||
|
||||
def test_title_is_escaped(self, render):
|
||||
markup = render(TWO_SECTIONS, title='Fish & "Chips"')
|
||||
assert 'title="Fish & "Chips""' in markup
|
||||
parse(markup)
|
||||
|
||||
def test_dollar_in_a_title_is_doubled(self, render):
|
||||
assert 'title="$$5"' in render(TWO_SECTIONS, title="$5")
|
||||
|
||||
|
||||
class TestMultiDeck:
|
||||
def test_one_file_per_section(self, render_output):
|
||||
output = render_output(TWO_SECTIONS, deck_per_card=True)
|
||||
assert sorted(doc.name for doc in output.documents) == [
|
||||
"index.wml",
|
||||
"one.wml",
|
||||
"two.wml",
|
||||
]
|
||||
|
||||
def test_menu_links_across_files(self, render_output):
|
||||
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
|
||||
assert '<a href="one.wml#card1">One</a>' in docs["index.wml"]
|
||||
|
||||
def test_links_within_the_same_file_stay_local(self, render_output):
|
||||
docs = {d.name: d.text for d in render_output(TWO_SECTIONS, deck_per_card=True).documents}
|
||||
assert "index.wml#" not in docs["one.wml"].split("</template>")[-1]
|
||||
|
||||
def test_every_file_is_well_formed(self, render_output):
|
||||
for doc in render_output(TWO_SECTIONS, deck_per_card=True).documents:
|
||||
parse(doc.text)
|
||||
|
||||
def test_single_section_document_stays_one_file(self, render_output):
|
||||
output = render_output("# Title\n\nBody.\n", deck_per_card=True)
|
||||
assert len(output.documents) == 1
|
||||
assert output.documents[0].name is None
|
||||
|
||||
def test_duplicate_section_titles_get_distinct_filenames(self, render_output):
|
||||
source = "Intro.\n\n{.card Same}\nA.\n\n{.card Same}\nB.\n"
|
||||
names = [d.name for d in render_output(source, deck_per_card=True).documents]
|
||||
assert len(set(names)) == len(names)
|
||||
178
uv.lock
generated
Normal file
178
uv.lock
generated
Normal 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" }]
|
||||
Loading…
Add table
Add a link
Reference in a new issue