diff --git a/src/wapdown/cli.py b/src/wapdown/cli.py index eea4cfd..b3b91ce 100644 --- a/src/wapdown/cli.py +++ b/src/wapdown/cli.py @@ -19,6 +19,7 @@ from .plugins import ( register_parser, ) from .renderers import wml as _wml # noqa: F401 # registers the WML renderer plugin +from .renderers import xhtmlmp as _xhtmlmp # noqa: F401 # registers the XHTML-MP renderer plugin from .renderers.wml.renderer import DeckOutput @@ -211,8 +212,21 @@ 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.""" +def write_output(output: "DeckOutput | str", path: Optional[Path]) -> None: + """Write the renderer's output. WML/XHTML-MP are XML served over HTTP, + so lines end with LF. + + A plain string (as XHTML-MP produces -- it has no deck/card topology + to report) is written as-is; a DeckOutput may fan out to several files + in --deck-per-card mode. + """ + if isinstance(output, str): + if path is None: + sys.stdout.write(output) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(output, encoding="utf-8") + return multi = len(output.documents) > 1 or any(doc.name for doc in output.documents) if multi: if path is None: diff --git a/src/wapdown/renderers/__init__.py b/src/wapdown/renderers/__init__.py index b7bbab1..fffa975 100644 --- a/src/wapdown/renderers/__init__.py +++ b/src/wapdown/renderers/__init__.py @@ -1,5 +1,6 @@ """Bundled renderer implementations.""" from .wml import WMLRenderer +from .xhtmlmp import XhtmlMpRenderer -__all__ = ["WMLRenderer"] +__all__ = ["WMLRenderer", "XhtmlMpRenderer"] diff --git a/src/wapdown/renderers/xhtmlmp/__init__.py b/src/wapdown/renderers/xhtmlmp/__init__.py new file mode 100644 index 0000000..26c31bb --- /dev/null +++ b/src/wapdown/renderers/xhtmlmp/__init__.py @@ -0,0 +1,24 @@ +"""XHTML-MP renderer, registered under the name `xhtmlmp`.""" + +from typing import Any + +from ...plugins import register_renderer +from .inline import escape_xhtml, process_inline +from .renderer import XHTML_PROLOG, XhtmlMpRenderer + + +def _xhtmlmp_renderer_factory(**options: Any) -> XhtmlMpRenderer: + return XhtmlMpRenderer(**options) + + +try: + register_renderer("xhtmlmp", _xhtmlmp_renderer_factory) +except ValueError: # pragma: no cover - already registered on re-import + pass + +__all__ = [ + "XHTML_PROLOG", + "XhtmlMpRenderer", + "escape_xhtml", + "process_inline", +] diff --git a/src/wapdown/renderers/xhtmlmp/inline.py b/src/wapdown/renderers/xhtmlmp/inline.py new file mode 100644 index 0000000..e473255 --- /dev/null +++ b/src/wapdown/renderers/xhtmlmp/inline.py @@ -0,0 +1,95 @@ +"""Inline Markdown -> XHTML-MP markup mapping, plus XML escaping. + +Mirrors wml/inline.py's stash-then-substitute structure and reuses its +Markdown-parsing regexes (a URL like `/a_b_c` must survive intact, which is +what the stash step protects against), but the two output grammars differ +enough that the escaping and link/code handling are not shared: + + - XHTML-MP has no '$' variable-substitution quirk. + - may contain inline markup, unlike WML's `(#PCDATA | br | img)*`, so + a link label is run through the same emphasis pass as ordinary text + instead of having its markers stripped. + - exists, so a code span becomes an element instead of plain text. +""" +from __future__ import annotations + +from typing import List + +from ..wml.inline import ( + BOLD_RE, + CODE_STASH_RE, + IMAGE_RE, + ITALIC_RE, + LINK_RE, + STRIKETHROUGH_RE, + UNDERLINE_EM_RE, + UNDERLINE_STRONG_RE, +) + + +_STASH = "\u0000{kind}{index}\u0000" +_CODE = "CODE" +_TAG = "TAG" + + +def escape_xhtml(text: str) -> str: + """XML-escape text for XHTML-MP element content or a double-quoted + attribute value. Applied once, up front, to the whole raw string before + any inline markup is turned into tags, matching wml/inline.py's + ordering, so link/image URLs captured out of the already-escaped text + are correctly escaped for attribute use too. + """ + text = text.replace("&", "&") + return text.replace("<", "<").replace(">", ">").replace('"', """) + + +def process_inline(text: str) -> str: + """Convert one run of inline Markdown into XHTML-MP markup. + + Links and images are resolved *before* the emphasis patterns run and + stashed behind sentinels, for the same reason as in wml/inline.py: a + path like `/a_b_c` or `/a*b*c` would otherwise be shredded into + tags by the emphasis regexes before LINK_RE ever saw it. + """ + escaped = escape_xhtml(text) + + code_segments: List[str] = [] + tag_segments: List[str] = [] + + def stash_code(match): + 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): + alt = match.group(1).strip() + src = match.group(2).strip() + return stash(f'{alt}') + + def stash_link(match): + label = match.group(1).strip() + url = match.group(2).strip() + return stash(f'{_apply_emphasis(label)}') + + escaped = CODE_STASH_RE.sub(stash_code, escaped) + escaped = IMAGE_RE.sub(stash_image, escaped) # before LINK_RE: `![x](y)` is not a link + escaped = LINK_RE.sub(stash_link, escaped) + escaped = _apply_emphasis(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), f"{code}") + return escaped + + +def _apply_emphasis(text: str) -> str: + text = STRIKETHROUGH_RE.sub(lambda m: m.group(1), text) # no XHTML-MP equivalent + text = BOLD_RE.sub(lambda m: f"{m.group(1)}", text) + text = UNDERLINE_STRONG_RE.sub(lambda m: f"{m.group(1)}", text) + text = ITALIC_RE.sub(lambda m: f"{m.group(1)}", text) + text = UNDERLINE_EM_RE.sub(lambda m: f"{m.group(1)}", text) + return text diff --git a/src/wapdown/renderers/xhtmlmp/renderer.py b/src/wapdown/renderers/xhtmlmp/renderer.py new file mode 100644 index 0000000..ddaa670 --- /dev/null +++ b/src/wapdown/renderers/xhtmlmp/renderer.py @@ -0,0 +1,157 @@ +"""The XHTML-MP renderer.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from ...config import DeckConfig +from ...models import BlockEvent, BlockKind, StyleUpdateEvent +from .inline import escape_xhtml, process_inline + + +XHTML_PROLOG = ( + '\n' + '\n' +) + + +@dataclass +class _ListFrame: + """One open