feat: add xhtmlmp renderer
This commit is contained in:
parent
3ed0b5807c
commit
3af44fb0ac
14 changed files with 524 additions and 7 deletions
|
|
@ -25,11 +25,11 @@ def render_source(text: str, **options: Any) -> str:
|
|||
return convert(body, config=config, base_path=EXAMPLES).text
|
||||
|
||||
|
||||
def render_file(path: Path, **options: Any) -> DeckOutput:
|
||||
def render_file(path: Path, *, renderer_name: str = "wml", **options: Any):
|
||||
lines = read_lines(path)
|
||||
frontmatter, body = strip_frontmatter(lines)
|
||||
config = DeckConfig.resolve(frontmatter, options)
|
||||
return convert(body, config=config, base_path=path.parent)
|
||||
return convert(body, config=config, base_path=path.parent, renderer_name=renderer_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -37,6 +37,19 @@ def render():
|
|||
return render_source
|
||||
|
||||
|
||||
def render_xhtmlmp_source(text: str, **options: Any) -> str:
|
||||
"""Like `render_source`, but through the XHTML-MP renderer."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
frontmatter, body = strip_frontmatter(lines)
|
||||
config = DeckConfig.resolve(frontmatter, options)
|
||||
return convert(body, config=config, base_path=EXAMPLES, renderer_name="xhtmlmp")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def render_xhtmlmp():
|
||||
return render_xhtmlmp_source
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def render_output():
|
||||
"""Like `render`, but exposes every output file (deck-per-card mode)."""
|
||||
|
|
|
|||
6
tests/golden/kitchen-sink-xhtmlmp.xhtml
Normal file
6
tests/golden/kitchen-sink-xhtmlmp.xhtml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" "http://www.wapforum.org/DTD/xhtml-mobile10.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Kitchen Sink</title></head>
|
||||
<body><h1>Kitchen Sink</h1><p>A deck exercising every block and inline construct wapdown understands.</p><h2>Links and emphasis</h2><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><strong>Bold</strong>, <em>italic</em>, <code>inline code</code>, struck, and a literal $5 fee.</p><h2>A table</h2><table><tr><th>Trail</th><th>Status</th><th>Fee</th></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><h2>Other blocks</h2><blockquote><p>A quoted warning.</p></blockquote><ol><li>First</li><li>Second</li></ol><hr/><pre>indented code $HOME</pre></body>
|
||||
</html>
|
||||
6
tests/golden/trail-manual-xhtmlmp.xhtml
Normal file
6
tests/golden/trail-manual-xhtmlmp.xhtml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" "http://www.wapforum.org/DTD/xhtml-mobile10.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title></title></head>
|
||||
<body><p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p><p>Cold and clear. Wind: <strong>15 mph</strong> gusting from the <em>northwest</em>. Permit fee: $5.</p><ul><li>North Loop: open</li><li>South Loop: closed</li><li>Summit Spur: open, ice above 2000m</li></ul><p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p><p><img src="https://example.com/map.png" alt="Trail map"/></p></body>
|
||||
</html>
|
||||
6
tests/golden/trail-rules-xhtmlmp.xhtml
Normal file
6
tests/golden/trail-rules-xhtmlmp.xhtml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" "http://www.wapforum.org/DTD/xhtml-mobile10.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title></title></head>
|
||||
<body><p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p><hr/><h2>Weather</h2><p>Cold and clear. Wind: <strong>15 mph</strong> gusting from the <em>northwest</em>. Permit fee: $5.</p><hr/><h2>Trail Status</h2><ul><li>North Loop: open</li><li>South Loop: closed</li><li>Summit Spur: open, ice above 2000m</li></ul><hr/><h2>Contact</h2><p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p><hr/></body>
|
||||
</html>
|
||||
6
tests/golden/trail-xhtmlmp.xhtml
Normal file
6
tests/golden/trail-xhtmlmp.xhtml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" "http://www.wapforum.org/DTD/xhtml-mobile10.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title></title></head>
|
||||
<body><h1>Trail Conditions</h1><p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p><h2>Weather</h2><p>Cold and clear. Wind: <strong>15 mph</strong> gusting from the <em>northwest</em>. Permit fee: $5.</p><h2>Trail Status</h2><ul><li>North Loop: open</li><li>South Loop: closed</li><li>Summit Spur: open, ice above 2000m</li></ul><h2>Contact</h2><p>Ranger station: <a href="tel:+15555550123">call dispatch</a></p><p><img src="https://example.com/map.png" alt="Trail map"/></p></body>
|
||||
</html>
|
||||
|
|
@ -30,3 +30,14 @@ CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
|
|||
MULTI_CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
|
||||
"trail-decks": ("trail-manual.md", {"deck_per_card": True, "home_label": "Home"}),
|
||||
}
|
||||
|
||||
# name -> (example filename, renderer options), rendered through the
|
||||
# XHTML-MP renderer. A separate table from CASES because XHTML-MP output is
|
||||
# a plain string, not a DeckOutput -- there is no deck/card topology to
|
||||
# vary, so only options that affect ordinary block/inline rendering apply.
|
||||
XHTMLMP_CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
|
||||
"trail-xhtmlmp": ("trail.md", {}),
|
||||
"trail-manual-xhtmlmp": ("trail-manual.md", {}),
|
||||
"trail-rules-xhtmlmp": ("trail-rules.md", {}),
|
||||
"kitchen-sink-xhtmlmp": ("kitchen-sink.md", {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ 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
|
||||
from golden_cases import CASES, MULTI_CASES, XHTMLMP_CASES # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
@ -37,6 +37,11 @@ def main() -> int:
|
|||
document.text, encoding="utf-8"
|
||||
)
|
||||
print(f"wrote {name}/ ({len(list(directory.iterdir()))} files)")
|
||||
|
||||
for name, (source, options) in XHTMLMP_CASES.items():
|
||||
output = render_file(EXAMPLES / source, renderer_name="xhtmlmp", **options)
|
||||
(GOLDEN / f"{name}.xhtml").write_text(output, encoding="utf-8")
|
||||
print(f"wrote {name}.xhtml")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from xml.etree import ElementTree
|
|||
import pytest
|
||||
|
||||
from conftest import EXAMPLES, GOLDEN, render_file
|
||||
from golden_cases import CASES, MULTI_CASES
|
||||
from golden_cases import CASES, MULTI_CASES, XHTMLMP_CASES
|
||||
|
||||
|
||||
REGENERATE_HINT = (
|
||||
|
|
@ -49,6 +49,20 @@ def test_golden_files_are_well_formed(path):
|
|||
ElementTree.fromstring(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(XHTMLMP_CASES))
|
||||
def test_matches_golden_xhtmlmp(name):
|
||||
source, options = XHTMLMP_CASES[name]
|
||||
golden = GOLDEN / f"{name}.xhtml"
|
||||
assert golden.exists(), f"missing golden for '{name}'; {REGENERATE_HINT}"
|
||||
actual = render_file(EXAMPLES / source, renderer_name="xhtmlmp", **options)
|
||||
assert actual == golden.read_text(encoding="utf-8"), REGENERATE_HINT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(GOLDEN.glob("**/*.xhtml")), ids=lambda p: p.name)
|
||||
def test_xhtmlmp_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())}
|
||||
|
|
|
|||
159
tests/test_xhtmlmp.py
Normal file
159
tests/test_xhtmlmp.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""XHTML-MP emission: prolog, head, block mapping, and well-formedness."""
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
|
||||
from wapdown.renderers.xhtmlmp.renderer import XHTML_PROLOG
|
||||
|
||||
|
||||
TWO_SECTIONS = "Intro.\n\n{.card One}\nFirst.\n\n{.card Two}\nSecond.\n"
|
||||
|
||||
|
||||
def parse(markup: str) -> ElementTree.Element:
|
||||
"""Parse the page, proving it is well-formed XML.
|
||||
|
||||
ElementTree honours the DOCTYPE declaration without trying to fetch the
|
||||
external DTD, so this never depends on a network round trip to
|
||||
wapforum.org.
|
||||
"""
|
||||
return ElementTree.fromstring(markup)
|
||||
|
||||
|
||||
class TestDocumentShape:
|
||||
def test_prolog_and_doctype(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("Body.\n")
|
||||
assert markup.startswith(XHTML_PROLOG)
|
||||
assert 'PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN"' in markup
|
||||
|
||||
def test_root_element_is_html(self, render_xhtmlmp):
|
||||
# ElementTree namespace-qualifies the tag because of the xhtml xmlns.
|
||||
assert parse(render_xhtmlmp("Body.\n")).tag == "{http://www.w3.org/1999/xhtml}html"
|
||||
|
||||
def test_output_is_well_formed(self, render_xhtmlmp):
|
||||
parse(render_xhtmlmp(TWO_SECTIONS))
|
||||
|
||||
def test_no_carriage_returns(self, render_xhtmlmp):
|
||||
assert "\r" not in render_xhtmlmp(TWO_SECTIONS)
|
||||
|
||||
def test_card_breaks_do_not_split_the_page(self, render_xhtmlmp):
|
||||
# Cards are a WAP 1.x screen-budget concern; XHTML-MP renders one
|
||||
# continuous page and a {.card} marker draws nothing of its own.
|
||||
markup = render_xhtmlmp(TWO_SECTIONS)
|
||||
assert markup.count("<html") == 1
|
||||
assert "First." in markup and "Second." in markup
|
||||
|
||||
def test_title_is_escaped_into_head(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("Body.\n", title="Fish & Chips")
|
||||
assert "<title>Fish & Chips</title>" in markup
|
||||
|
||||
|
||||
class TestBlockMapping:
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("Just text.\n", "<p>Just text.</p>"),
|
||||
("> quoted\n", "<blockquote><p>quoted</p></blockquote>"),
|
||||
("```\ncode\n```\n", "<pre>code</pre>"),
|
||||
("---\n", "<hr/>"),
|
||||
],
|
||||
)
|
||||
def test_blocks(self, render_xhtmlmp, source, expected):
|
||||
assert expected in render_xhtmlmp(source)
|
||||
|
||||
@pytest.mark.parametrize("level", [1, 2, 3, 4, 5, 6])
|
||||
def test_headings_use_real_heading_elements(self, render_xhtmlmp, level):
|
||||
markup = render_xhtmlmp(f"{'#' * level} Title\n")
|
||||
assert f"<h{level}>Title</h{level}>" in markup
|
||||
assert "<big>" not in markup # no FIGlet/WML-style banner substitute
|
||||
|
||||
def test_heading_level_beyond_six_clamps(self, render_xhtmlmp):
|
||||
# Markdown itself caps at h6, but a manually-crafted payload
|
||||
# shouldn't be able to emit an invalid element name.
|
||||
markup = render_xhtmlmp("###### Deep\n")
|
||||
assert "<h6>Deep</h6>" in markup
|
||||
|
||||
def test_unordered_list(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("- a\n- b\n")
|
||||
assert "<ul><li>a</li><li>b</li></ul>" in markup
|
||||
|
||||
def test_ordered_list(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("1. a\n2. b\n")
|
||||
assert "<ol><li>a</li><li>b</li></ol>" in markup
|
||||
|
||||
def test_nested_list_is_spliced_into_parent_item(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("- a\n - nested\n- b\n")
|
||||
# The sub-list must sit *inside* its parent <li>, not after it, or
|
||||
# the result is not a valid nested list.
|
||||
assert "<li>a<ul><li>nested</li></ul></li>" in markup
|
||||
parsed = parse(f"<root>{markup[markup.index('<ul'):markup.index('</body>')]}</root>")
|
||||
outer_items = parsed.find("ul").findall("li")
|
||||
assert len(outer_items) == 2
|
||||
assert outer_items[0].find("ul/li").text == "nested"
|
||||
|
||||
def test_blank_line_does_not_split_a_list(self, render_xhtmlmp):
|
||||
# A loose Markdown list (blank line between items) is still one
|
||||
# list, matching the WML renderer's own convention.
|
||||
markup = render_xhtmlmp("- a\n\n- b\n")
|
||||
assert markup.count("<ul>") == 1
|
||||
assert "<li>a</li><li>b</li>" in markup
|
||||
|
||||
def test_paragraph_between_list_items_closes_the_list(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("- a\n\nText.\n\n- b\n")
|
||||
assert markup.count("<ul>") == 2
|
||||
|
||||
def test_table(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("| A | B |\n| --- | --- |\n| 1 | 2 |\n")
|
||||
assert "<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>" in markup
|
||||
|
||||
def test_table_is_not_wrapped_in_a_paragraph(self, render_xhtmlmp):
|
||||
# Unlike WML, <table> is valid body-level content in XHTML-MP, so
|
||||
# no <p> wrapper is needed (or wanted).
|
||||
markup = render_xhtmlmp("| A |\n| --- |\n| 1 |\n")
|
||||
assert "<p><table" not in markup
|
||||
|
||||
|
||||
class TestInlineMapping:
|
||||
def test_bold_and_italic(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("**bold** and *italic*\n")
|
||||
assert "<strong>bold</strong>" in markup
|
||||
assert "<em>italic</em>" in markup
|
||||
|
||||
def test_code_span_becomes_code_element(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("Some `code` here.\n")
|
||||
assert "<code>code</code>" in markup
|
||||
|
||||
def test_link(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("[go](http://x.test/)\n")
|
||||
assert '<a href="http://x.test/">go</a>' in markup
|
||||
|
||||
def test_link_label_may_carry_emphasis(self, render_xhtmlmp):
|
||||
# Unlike WML, whose <a> content model is (#PCDATA | br | img)* and
|
||||
# so strips emphasis from the label, XHTML-MP's <a> allows it.
|
||||
markup = render_xhtmlmp("[**go** now](http://x.test/)\n")
|
||||
assert '<a href="http://x.test/"><strong>go</strong> now</a>' in markup
|
||||
|
||||
def test_underscore_in_url_is_not_shredded_into_emphasis(self, render_xhtmlmp):
|
||||
# Regression guard for the stash-before-emphasis ordering: without
|
||||
# it, `/a_b_c` inside the URL would be read as `_b_` -> <em>b</em>.
|
||||
markup = render_xhtmlmp("[link](/a_b_c)\n")
|
||||
assert '<a href="/a_b_c">link</a>' in markup
|
||||
|
||||
def test_asterisk_in_url_is_not_shredded_into_emphasis(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("[link](/a*b*c)\n")
|
||||
assert '<a href="/a*b*c">link</a>' in markup
|
||||
|
||||
def test_image(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp("\n")
|
||||
assert '<img src="pic.png" alt="alt text"/>' in markup
|
||||
|
||||
def test_dollar_sign_is_not_doubled(self, render_xhtmlmp):
|
||||
# XHTML-MP has none of WML's '$' -> '$$' variable-substitution
|
||||
# quirk; a literal dollar sign should pass through as one.
|
||||
markup = render_xhtmlmp("Permit fee: $5\n")
|
||||
assert "Permit fee: $5" in markup
|
||||
|
||||
def test_entities_are_escaped(self, render_xhtmlmp):
|
||||
markup = render_xhtmlmp('Fish & chips < > "\n')
|
||||
assert "Fish & chips < > "" in markup
|
||||
Loading…
Add table
Add a link
Reference in a new issue