Add WML renderer for WAP decks
Adds a `wml` renderer plugin converting Markdown into WML decks for
old WAP browsers, following the existing parser -> BlockEvent stream
-> renderer plugin architecture (see gemini/micron/ama renderers).
WML content is idiomatically short and button/link-navigated rather
than one long page, so unlike the other renderers, `wml` can split a
document into multiple <card>s -- but only opt-in:
- Manual `{.card}` / `{.card Title}` divider directives (new,
format-agnostic BlockKind.CARD_BREAK; silently ignored by every
other renderer via their existing unhandled-kind no-op path).
- Heading-level splitting via `--renderer-option split_level=N`,
ignored when manual dividers are present.
- Plain documents with neither stay a single card (the safe default).
When a document resolves to 2+ cards, a hub-and-spoke menu card is
built by default (button/link choices -> section, Back to menu), or
`menu=false` switches to a linear Next/Back chain. A byte-overflow
safety net (`max_card_bytes`, default 1400) paginates any single
section that's still too large.
Also adds GFM pipe-table support (BlockKind.TABLE) via the same
sentinel-injection preprocessing trick already used for ASCII art
blocks, so the parser's line-by-line loop needed no lookahead rework.
Rendered as `<table>` in wml, silently ignored elsewhere.
No new dependencies. No devbox/jj/test-suite additions -- decided
against for this repo (see README for the full option reference).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b539d037c6
commit
d5046543b3
9 changed files with 641 additions and 3 deletions
39
README.md
39
README.md
|
|
@ -19,7 +19,7 @@ uv tool install --from git+https://codeberg.org/randogoth/md2txt/ md2txt
|
|||
|
||||
## CLI
|
||||
|
||||
- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, and `gemini` renderers for Micron/Ancient Machine Book or Gemtext output, and exposes the core pipeline so you can add your own parser or renderer modules:
|
||||
- `md2txt` – converts Markdown into plain text with extensive formatting support. It ships with the default `markdown` parser and `text` renderer plugins, registers optional `micron`, `ama`, `gemini`, and `wml` renderers for Micron/Ancient Machine Book, Gemtext, or WML (WAP) output, and exposes the core pipeline so you can add your own parser or renderer modules:
|
||||
- FIGlet-rendered headings.
|
||||
- Emphasis styles converted to spaced or delimited characters.
|
||||
- Optional alignment and margin controls via HTML `<p>` attributes or MultiMarkdown attribute blocks (e.g. `{:.center margin=20px}`) applied as leading spaces.
|
||||
|
|
@ -36,6 +36,7 @@ md2txt input.md --width 72 # override column width
|
|||
md2txt input.md --renderer micron # emit Micron-formatted output for NomadNet
|
||||
md2txt input.md --renderer ama # emit AMA markup for Ancient Machine Book
|
||||
md2txt input.md --renderer gemini # emit Gemtext for Gemini capsules
|
||||
md2txt input.md --renderer wml # emit a single-card WML deck for WAP browsers
|
||||
md2txt input.md --renderer-option width=68 # pass column width to a renderer
|
||||
```
|
||||
|
||||
|
|
@ -122,6 +123,40 @@ Inline or block-level ASCII art can be embedded directly in Markdown:
|
|||
|
||||
Generated text uses CRLF line endings to maintain DOS compatibility.
|
||||
|
||||
## WML Decks
|
||||
|
||||
The `wml` renderer converts Markdown into a [WML](https://en.wikipedia.org/wiki/Wireless_Markup_Language) deck for old WAP browsers. WML screens are idiomatically short and button/link-navigated rather than one long scrolling page, so unlike the other renderers, `wml` can split a document into multiple `<card>`s — but only when you ask it to; a plain document with no options stays a single card.
|
||||
|
||||
Two independent, both-optional ways to define card boundaries:
|
||||
|
||||
- **Manual dividers** — insert a `{.card}` or `{.card Title}` line wherever you want a new card to start, regardless of heading structure:
|
||||
|
||||
```markdown
|
||||
Intro shown on the menu card...
|
||||
|
||||
{.card Weather}
|
||||
Cold and clear...
|
||||
|
||||
{.card Trail Status}
|
||||
- North Loop: open
|
||||
```
|
||||
|
||||
- **Heading-level splitting** — opt in with `--renderer-option split_level=N` to start a new card at every heading of level `N` (e.g. `split_level=2` for the common one-H1-title-plus-H2-sections pattern). Ignored if the document contains any `{.card}` markers.
|
||||
|
||||
When a document resolves to two or more cards, `wml` builds a "menu" card by default — the classic WAP-portal pattern: a list of button/link choices leading to each section, each with a Back control (`--renderer-option menu=false` switches to a linear book instead, chaining cards with Next/Back). A section whose content would still be too large for one card even after splitting is automatically paginated further (`--renderer-option max_card_bytes=N`, default 1400 — a nod to classic WAP 1.x per-deck cache limits; `0` disables it).
|
||||
|
||||
| Renderer option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `title` | *(none)* | Overall deck title, used on the menu card (or the single card, if never split). |
|
||||
| `split_level` | `0` (disabled) | Heading level that starts a new card. Ignored if `{.card}` markers are present. |
|
||||
| `max_card_bytes` | `1400` | Byte safety-net per card; `0` disables. |
|
||||
| `menu` | `true` | Hub-and-spoke menu card vs. linear Next/Back chaining (`menu=false`). |
|
||||
| `nav_next_label` | `More` | Label for forward navigation (linear mode / overflow pagination). |
|
||||
| `nav_prev_label` | `Prev` | Label for backward navigation between overflow pagination parts. |
|
||||
| `nav_back_label` | `Back` | Label for backward navigation from a section back to the menu/previous section. |
|
||||
|
||||
WML 1.3 is the only supported DTD (fixed by design, not a flag). Since WML uses `$` for variable substitution, a literal `$` in your Markdown is escaped to `$$` in the output automatically. Images pass through as `<img src="..." alt="..."/>` referencing the original path/URL — no WBMP conversion is performed.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
The conversion pipeline lives in `src/md2txt/conversion/core.py` and is exposed via `run_conversion`. It is designed around lightweight factories:
|
||||
|
|
@ -149,7 +184,7 @@ register_renderer("ansi", my_renderer_factory)
|
|||
|
||||
Once registered (for example in a small module that imports `md2txt.cli`), the new plugins are available via `--parser my-markdown` or `--renderer ansi`. The CLI lists registered plugin names in `--help`, and the helper functions `available_parsers()` / `available_renderers()` return the sorted names if you need to build higher-level tooling.
|
||||
|
||||
The shared preprocessing helpers—YAML frontmatter parsing, recursive include expansion, and ASCII art sentinels—also live in `src/md2txt/conversion/core.py`, allowing alternate front-ends to reuse exactly the same behaviour without duplicating code.
|
||||
The shared preprocessing helpers—YAML frontmatter parsing, recursive include expansion, ASCII art sentinels, and pipe-table sentinels—also live in `src/md2txt/conversion/core.py`, allowing alternate front-ends to reuse exactly the same behaviour without duplicating code.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
14
examples/wml-manual.md
Normal file
14
examples/wml-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/wml.md
Normal file
19
examples/wml.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)
|
||||
|
||||

|
||||
17
examples/wml.wml
Normal file
17
examples/wml.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>
|
||||
|
|
@ -25,6 +25,7 @@ from .renderers.text import TextRenderer
|
|||
from .renderers import ama # noqa: F401 # register AMA renderer plugin
|
||||
from .renderers import gemini # noqa: F401 # register Gemini renderer plugin
|
||||
from .renderers import micron # noqa: F401 # register Micron renderer plugin
|
||||
from .renderers import wml # noqa: F401 # register WML renderer plugin
|
||||
|
||||
|
||||
def _split_option(token: str) -> Tuple[str, str]:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ ASCII_BLOCK_PATTERN = re.compile(
|
|||
ASCII_INLINE_PATTERN = re.compile(r"#\[(?P<label>[^\]]+)\]\((?P<target>[^)]+)\)")
|
||||
MMD_ATTR_TAIL_RE = re.compile(r"(.*?)\s*\{\s*:(.+?)\}\s*$")
|
||||
ASCII_SENTINEL_PREFIX = "\u0000ASCII:"
|
||||
TABLE_SENTINEL_PREFIX = "\u0000TABLE:"
|
||||
SEPARATOR_CELL_PATTERN = re.compile(r"^:?-{1,}:?$")
|
||||
|
||||
Event = BlockEvent | StyleUpdateEvent
|
||||
RendererOutput = TypeVar("RendererOutput")
|
||||
|
|
@ -269,6 +271,62 @@ def _parse_ascii_label(label: str) -> Tuple[str, str, Optional[str]]:
|
|||
return block_type, block_name, align
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Mirrors the ASCII-art sentinel trick above: 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(ASCII_SENTINEL_PREFIX) or 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],
|
||||
*,
|
||||
|
|
@ -278,6 +336,7 @@ def run_pipeline(
|
|||
) -> 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()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ class BlockKind(Enum):
|
|||
BLOCKQUOTE = "blockquote"
|
||||
LIST_ITEM = "list_item"
|
||||
HORIZONTAL_RULE = "horizontal_rule"
|
||||
TABLE = "table"
|
||||
BLANK_LINE = "blank_line"
|
||||
CUSTOM_BLOCK = "custom_block"
|
||||
CARD_BREAK = "card_break"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -96,6 +98,23 @@ class AsciiArtPayload:
|
|||
pieces: List[AsciiArtPiece]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TablePayload:
|
||||
rows: List[List[str]] # rows[0] is the header row
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardBreakPayload:
|
||||
"""Marks an explicit, renderer-agnostic 'start a new page/card here' hint.
|
||||
|
||||
Emitted for the `{.card}` / `{.card Title}` directive. Formats without a
|
||||
notion of pages (plain text, Gemtext, Micron, AMA) simply ignore this
|
||||
event kind via their existing unhandled-BlockKind no-op path.
|
||||
"""
|
||||
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockEvent:
|
||||
kind: BlockKind
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import re
|
|||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Iterator, List, Optional, Union
|
||||
|
||||
from ..conversion.core import ASCII_SENTINEL_PREFIX
|
||||
from ..conversion.core import ASCII_SENTINEL_PREFIX, TABLE_SENTINEL_PREFIX
|
||||
from ..models import (
|
||||
AsciiArtPayload,
|
||||
AsciiArtPiece,
|
||||
|
|
@ -13,12 +13,14 @@ from ..models import (
|
|||
BlockKind,
|
||||
BlockQuotePayload,
|
||||
BlockStyle,
|
||||
CardBreakPayload,
|
||||
CodeBlockPayload,
|
||||
HeadingPayload,
|
||||
ListItemPayload,
|
||||
ParagraphPayload,
|
||||
StyleSpec,
|
||||
StyleUpdateEvent,
|
||||
TablePayload,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ 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)
|
||||
|
|
@ -71,6 +74,23 @@ class MarkdownParser:
|
|||
)
|
||||
continue
|
||||
|
||||
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)
|
||||
|
|
@ -97,6 +117,22 @@ class MarkdownParser:
|
|||
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)
|
||||
|
|
@ -336,6 +372,15 @@ class MarkdownParser:
|
|||
)
|
||||
return AsciiArtPayload(pieces=pieces)
|
||||
|
||||
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 _decode_ascii_sentinel(self, line: str) -> List[Dict[str, str]]:
|
||||
payload = line[len(ASCII_SENTINEL_PREFIX) :]
|
||||
try:
|
||||
|
|
|
|||
429
src/md2txt/renderers/wml.py
Normal file
429
src/md2txt/renderers/wml.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from ..models import (
|
||||
BlockEvent,
|
||||
BlockKind,
|
||||
FrontMatter,
|
||||
StyleUpdateEvent,
|
||||
)
|
||||
from ..plugins import register_renderer
|
||||
from .text import (
|
||||
BOLD_RE,
|
||||
CODE_STASH_RE,
|
||||
IMAGE_RE,
|
||||
ITALIC_RE,
|
||||
LINK_RE,
|
||||
STRIKETHROUGH_RE,
|
||||
UNDERLINE_EM_RE,
|
||||
UNDERLINE_STRONG_RE,
|
||||
)
|
||||
|
||||
|
||||
WML_PROLOG = (
|
||||
'<?xml version="1.0"?>\n'
|
||||
'<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" '
|
||||
'"http://www.wapforum.org/DTD/wml13.dtd">\n'
|
||||
)
|
||||
|
||||
# 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 §-based splitting,
|
||||
# not the primary splitting mechanism. Reserves headroom for the nav <do>
|
||||
# markup each continuation card also carries.
|
||||
DEFAULT_MAX_CARD_BYTES = 1400
|
||||
_NAV_HEADROOM_BYTES = 160
|
||||
|
||||
|
||||
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 (see
|
||||
`WMLRenderer._process_inline`), 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 _coerce_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() not in {"false", "no", "0", "off"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Section:
|
||||
title: Optional[str]
|
||||
events: List[BlockEvent]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Card:
|
||||
id: str
|
||||
title: Optional[str]
|
||||
fragments: List[str]
|
||||
back_label: Optional[str] = None
|
||||
next_id: Optional[str] = None
|
||||
next_label: Optional[str] = None
|
||||
|
||||
|
||||
class WMLRenderer:
|
||||
"""Render Markdown events as a WML deck for WAP browsers.
|
||||
|
||||
Unlike TextRenderer/MicronRenderer/AmaRenderer, which emit output
|
||||
incrementally per `handle_event`, this renderer buffers the whole event
|
||||
stream and does the real work in `finalize()` — card boundaries, the
|
||||
menu card's link list, and forward/backward nav targets all depend on
|
||||
the full document's structure being known up front. It still satisfies
|
||||
the same Renderer protocol (`handle_event`/`finalize`), so it plugs into
|
||||
`run_pipeline` unchanged; `finalize()` just returns a single WML string
|
||||
instead of a list of lines.
|
||||
"""
|
||||
|
||||
def __init__(self, frontmatter: FrontMatter, **options: Any) -> None:
|
||||
self.frontmatter = frontmatter
|
||||
self._events: List[BlockEvent] = []
|
||||
# 0 = disabled. Heading-based splitting is opt-in, not a default —
|
||||
# a document with headings but no explicit split_level stays one card.
|
||||
self.split_level = int(options.get("split_level", 0) or 0)
|
||||
self.max_card_bytes = int(options.get("max_card_bytes", DEFAULT_MAX_CARD_BYTES) or 0)
|
||||
self.menu = _coerce_bool(options.get("menu"), True)
|
||||
self.nav_next_label = str(options.get("nav_next_label", "More"))
|
||||
self.nav_prev_label = str(options.get("nav_prev_label", "Prev"))
|
||||
self.nav_back_label = str(options.get("nav_back_label", "Back"))
|
||||
title = options.get("title")
|
||||
self.title: Optional[str] = str(title) if title else None
|
||||
|
||||
# -- 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) -> str:
|
||||
preamble_events, sections = self._split_sections(self._events)
|
||||
if len(sections) < 2:
|
||||
all_events = list(preamble_events)
|
||||
if sections:
|
||||
all_events.extend(sections[0].events)
|
||||
fragments = self._render_events(all_events)
|
||||
cards = self._pack_section(
|
||||
"card1",
|
||||
self.title,
|
||||
fragments,
|
||||
entry_back_label=None,
|
||||
exit_next_id=None,
|
||||
exit_next_label=None,
|
||||
)
|
||||
elif self.menu:
|
||||
cards = self._build_hub_and_spoke(preamble_events, sections)
|
||||
else:
|
||||
cards = self._build_linear(preamble_events, sections)
|
||||
body = "\n".join(self._render_card(card) for card in cards)
|
||||
return f"{WML_PROLOG}<wml>\n{body}\n</wml>\n"
|
||||
|
||||
# -- Splitting into sections ----------------------------------------------
|
||||
|
||||
def _split_sections(self, events: List[BlockEvent]) -> Tuple[List[BlockEvent], List[_Section]]:
|
||||
card_break_indices = [i for i, e in enumerate(events) if e.kind == BlockKind.CARD_BREAK]
|
||||
if card_break_indices:
|
||||
return self._split_by_card_breaks(events, card_break_indices)
|
||||
if self.split_level > 0:
|
||||
heading_indices = [
|
||||
i
|
||||
for i, e in enumerate(events)
|
||||
if e.kind == BlockKind.HEADING and e.payload.level == self.split_level
|
||||
]
|
||||
if heading_indices:
|
||||
return self._split_by_heading(events, heading_indices)
|
||||
return list(events), []
|
||||
|
||||
def _split_by_card_breaks(
|
||||
self, 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
|
||||
end = boundaries[idx + 1]
|
||||
section_events = events[start:end]
|
||||
marker_title = events[indices[idx]].payload.title
|
||||
title = marker_title or self._first_heading_text(section_events)
|
||||
sections.append(_Section(title=title, events=section_events))
|
||||
return preamble, sections
|
||||
|
||||
def _split_by_heading(
|
||||
self, 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
|
||||
end = boundaries[idx + 1]
|
||||
section_events = events[start:end]
|
||||
title = events[indices[idx]].payload.text
|
||||
sections.append(_Section(title=title, events=section_events))
|
||||
return preamble, sections
|
||||
|
||||
@staticmethod
|
||||
def _first_heading_text(events: List[BlockEvent]) -> Optional[str]:
|
||||
for event in events:
|
||||
if event.kind == BlockKind.HEADING:
|
||||
return event.payload.text
|
||||
return None
|
||||
|
||||
# -- Card/menu/navigation topology ---------------------------------------
|
||||
|
||||
def _build_hub_and_spoke(
|
||||
self, preamble_events: List[BlockEvent], sections: List[_Section]
|
||||
) -> List[_Card]:
|
||||
menu_fragments = self._render_events(preamble_events, big_first_heading=True)
|
||||
for i, section in enumerate(sections):
|
||||
label = escape_wml(section.title or "Untitled")
|
||||
menu_fragments.append(f'<p><a href="#card{i + 1}">{label}</a></p>')
|
||||
menu_title = self.title or self._first_heading_text(preamble_events)
|
||||
cards: List[_Card] = [
|
||||
_Card(id="menu", title=menu_title, fragments=menu_fragments)
|
||||
]
|
||||
for i, section in enumerate(sections):
|
||||
fragments = self._render_events(section.events, big_first_heading=True)
|
||||
cards.extend(
|
||||
self._pack_section(
|
||||
f"card{i + 1}",
|
||||
section.title or "Untitled",
|
||||
fragments,
|
||||
entry_back_label=self.nav_back_label,
|
||||
exit_next_id=None,
|
||||
exit_next_label=None,
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
def _build_linear(
|
||||
self, preamble_events: List[BlockEvent], sections: List[_Section]
|
||||
) -> List[_Card]:
|
||||
cards: List[_Card] = []
|
||||
total = len(sections)
|
||||
for i, section in enumerate(sections):
|
||||
events = section.events
|
||||
is_first = i == 0
|
||||
if is_first:
|
||||
events = list(preamble_events) + list(events)
|
||||
fragments = self._render_events(events, big_first_heading=True)
|
||||
# Card 1 also carries the preamble, so prefer the deck-level
|
||||
# title / preamble's own heading over this section's heading.
|
||||
card_title = (
|
||||
(self.title or self._first_heading_text(preamble_events) 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_section(
|
||||
f"card{i + 1}",
|
||||
card_title,
|
||||
fragments,
|
||||
entry_back_label=None if is_first else self.nav_back_label,
|
||||
exit_next_id=next_id,
|
||||
exit_next_label=self.nav_next_label if next_id else None,
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
def _pack_section(
|
||||
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],
|
||||
) -> List[_Card]:
|
||||
"""Split one section's rendered fragments into one or more physical
|
||||
<card>s if they'd exceed max_card_bytes (0 disables). This is the
|
||||
secondary byte-overflow safety net, not the primary splitting
|
||||
mechanism (that's §-based or manual {.card} splitting upstream).
|
||||
"""
|
||||
chunks = self._greedy_pack(fragments, base_id)
|
||||
cards: List[_Card] = []
|
||||
last = len(chunks) - 1
|
||||
for i, chunk in enumerate(chunks):
|
||||
part_id = base_id if i == 0 else f"{base_id}-p{i + 1}"
|
||||
is_first = i == 0
|
||||
is_last = i == last
|
||||
back_label = entry_back_label if is_first else self.nav_prev_label
|
||||
next_id = f"{base_id}-p{i + 2}" if not is_last else exit_next_id
|
||||
next_label = self.nav_next_label if not is_last else exit_next_label
|
||||
cards.append(
|
||||
_Card(
|
||||
id=part_id,
|
||||
title=title if is_first else None,
|
||||
fragments=chunk,
|
||||
back_label=back_label,
|
||||
next_id=next_id,
|
||||
next_label=next_label,
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
def _greedy_pack(self, fragments: List[str], base_id: str) -> List[List[str]]:
|
||||
if self.max_card_bytes <= 0:
|
||||
return [fragments]
|
||||
budget = max(1, self.max_card_bytes - _NAV_HEADROOM_BYTES)
|
||||
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"wml: warning: a single block in '{base_id}' is {frag_size} bytes, "
|
||||
f"exceeding max_card_bytes ({self.max_card_bytes}); 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 [[]]
|
||||
|
||||
def _render_card(self, card: _Card) -> str:
|
||||
attrs = f' title="{escape_wml(card.title)}"' if card.title else ""
|
||||
parts = [f'<card id="{card.id}"{attrs}>']
|
||||
if card.back_label:
|
||||
parts.append(f'<do type="prev" label="{escape_wml(card.back_label)}"><prev/></do>')
|
||||
if card.next_id:
|
||||
label = escape_wml(card.next_label or self.nav_next_label)
|
||||
parts.append(f'<do type="accept" label="{label}"><go href="#{card.next_id}"/></do>')
|
||||
parts.extend(card.fragments)
|
||||
parts.append("</card>")
|
||||
return "\n".join(parts)
|
||||
|
||||
# -- Block markup mapping -------------------------------------------------
|
||||
|
||||
def _render_events(self, events: List[BlockEvent], *, big_first_heading: bool = True) -> List[str]:
|
||||
fragments: List[str] = []
|
||||
seen_heading = not big_first_heading
|
||||
list_counter = 0
|
||||
for event in events:
|
||||
kind = event.kind
|
||||
if kind == BlockKind.PARAGRAPH:
|
||||
fragments.append(f"<p>{self._process_inline(event.payload.text)}</p>")
|
||||
list_counter = 0
|
||||
elif kind == BlockKind.HEADING:
|
||||
text = self._process_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._process_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._process_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, CUSTOM_BLOCK: 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._process_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>")
|
||||
return f'<table columns="{columns}">{"".join(tr_parts)}</table>'
|
||||
|
||||
# -- Inline markup mapping -------------------------------------------------
|
||||
|
||||
def _process_inline(self, text: str) -> str:
|
||||
# Escape once, up front; the markdown syntax markers below aren't
|
||||
# XML-significant characters, so inline tag insertion on top of the
|
||||
# already-escaped text is always safe (see `escape_wml`'s docstring).
|
||||
escaped = escape_wml(text)
|
||||
|
||||
code_segments: List[str] = []
|
||||
|
||||
def stash_code(match) -> str:
|
||||
code_segments.append(match.group(0)[1:-1])
|
||||
return f"\u0000CODE{len(code_segments) - 1}\u0000"
|
||||
|
||||
escaped = CODE_STASH_RE.sub(stash_code, 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)
|
||||
escaped = LINK_RE.sub(self._replace_link, escaped)
|
||||
escaped = IMAGE_RE.sub(self._replace_image, escaped)
|
||||
|
||||
for idx, code in enumerate(code_segments):
|
||||
escaped = escaped.replace(f"\u0000CODE{idx}\u0000", code)
|
||||
return escaped
|
||||
|
||||
@staticmethod
|
||||
def _replace_link(match) -> str:
|
||||
label = match.group(1).strip()
|
||||
url = match.group(2).strip()
|
||||
return f'<a href="{url}">{label}</a>'
|
||||
|
||||
@staticmethod
|
||||
def _replace_image(match) -> str:
|
||||
alt = match.group(1).strip()
|
||||
src = match.group(2).strip()
|
||||
return f'<img src="{src}" alt="{alt}"/>'
|
||||
|
||||
|
||||
def _wml_renderer_factory(*, frontmatter: FrontMatter, **options: Any) -> WMLRenderer:
|
||||
return WMLRenderer(frontmatter, **options)
|
||||
|
||||
|
||||
try:
|
||||
register_renderer("wml", _wml_renderer_factory)
|
||||
except ValueError:
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue