Make --- divide cards, and stop drawing ASCII rules

WML has no horizontal rule element -- its entire %layout entity is
`<br>` -- so a thematic break was being rendered as `<p>------------</p>`,
a hardcoded twelve-dash guess at screen width that wraps into nonsense on
a narrow device and is short on a wide one. It was the one construct
where the renderer invented a width out of nothing.

A `---` already looks like a divider and reads as one in every Markdown
document ever written, so it now is one: it starts a new card, by
default. Rules are never drawn, whether or not they divide.

Thematic breaks and `{.card Title}` markers are now a single mechanism --
an explicit divider, optionally carrying a title -- and compose freely in
one document. Both still win over --split-level. A section takes its
title from its own first heading when the divider does not supply one, so
the common shape needs no titles at all:

    Intro on the menu.

    ---

    ## Weather

    Cold and clear.

`--no-split-on-rule` / `split_on_rule: false` opts out of the splitting;
it does not bring the dashes back.

Three edges this opened, each fixed here rather than left to bite:

- Setext headings. `Heading` over `---` is an H2 in Markdown, but the
  parser was ATX-only, so it produced a paragraph plus a rule. Once a
  rule divides cards, a setext document would have split at every heading
  and stranded each heading as body copy on the card before it. Setext
  headings are now parsed, so such a document keeps its structure.
- Empty sections. A trailing `---`, or two in a row, produced an empty
  "Untitled" card and a menu entry leading to it. Sections with no
  renderable content are dropped.
- The multi-card threshold was two sections, so a document with a single
  divider collapsed back into one card. Any explicit divider now makes a
  multi-card deck: `A --- B` asks for two screens.

Also fixes a latent frontmatter bug this made far more likely: a document
opening with `---` and no `key: value` lines had its opening swallowed as
if the break were a frontmatter fence. A fenced block with no keys in it
is not frontmatter.

222 tests. Rendering examples/trail.md still matches md2txt's committed
output byte for byte; no golden file contains an ASCII rule any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-23 09:27:49 +03:00
parent b83e24e2f7
commit 3b728d9d74
17 changed files with 383 additions and 36 deletions

View file

@ -8,23 +8,18 @@
<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><b>Links and emphasis</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><b>A table</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><b>Other blocks</b></p>
<p><i>A quoted warning.</i></p>
<p>1. First</p>
<p>2. Second</p>
<p>------------</p>
<p><select title="Kitchen Sink"><option onpick="#card1">Untitled</option></select></p>
</card>
<card id="card1" title="Untitled">
<p>indented code $$HOME</p>
</card>
</wml>

View file

@ -0,0 +1,15 @@
<?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>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<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>
<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>
</card>
</wml>

View file

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</template>
<card id="card1" title="Weather">
<do type="prev"><noop/></do>
<do type="accept" label="More"><go href="#card2"/></do>
<p>Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.</p>
<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">
<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>
</card>
</wml>

View file

@ -0,0 +1,28 @@
<?xml version="1.0"?>
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.3//EN" "http://www.wapforum.org/DTD/wml13.dtd">
<wml>
<template>
<do type="prev" label="Back"><prev/></do>
</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><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>
</card>
</wml>

View file

@ -20,6 +20,9 @@ CASES: Dict[str, Tuple[str, Dict[str, Any]]] = {
"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"}),
"trail-rules": ("trail-rules.md", {}),
"trail-rules-linear": ("trail-rules.md", {"menu": False}),
"trail-rules-kept": ("trail-rules.md", {"split_on_rule": False}),
"kitchen-sink": ("kitchen-sink.md", {}),
}

View file

@ -141,3 +141,36 @@ class TestExamples:
)
def test_every_example_converts(self, name, tmp_path, capsys):
assert main([str(EXAMPLES / name), "-o", str(tmp_path / "out.wml")]) == 0
class TestRuleDividers:
RULES = "Intro.\n\n---\n\n## Alpha\n\nBody.\n"
def test_rules_divide_by_default(self, source, capsys):
main([str(source(self.RULES))])
assert '<card id="menu"' in capsys.readouterr().out
def test_no_split_on_rule_flag(self, source, capsys):
main([str(source(self.RULES)), "--no-split-on-rule"])
out = capsys.readouterr().out
assert '<card id="menu"' not in out
assert "------" not in out
def test_frontmatter_can_disable(self, source, capsys):
path = source("---\nsplit_on_rule: false\n---\n" + self.RULES)
main([str(path)])
assert '<card id="menu"' not in capsys.readouterr().out
def test_leading_rule_without_frontmatter_keeps_content(self, source, capsys):
# Regression: a document opening with `---` and no key: value pairs
# had its opening swallowed as if it were a frontmatter fence.
main([str(source("---\n\nKept text.\n\n---\n\nAlso kept.\n"))])
out = capsys.readouterr().out
assert "Kept text." in out
assert "Also kept." in out
def test_real_frontmatter_is_still_consumed(self, source, capsys):
main([str(source("---\ntitle: Deck\n---\n\nBody.\n"))])
out = capsys.readouterr().out
assert "title: Deck" not in out
assert 'title="Deck"' not in out or "Body." in out

View file

@ -82,3 +82,15 @@ class TestValidation:
def test_non_boolean_menu_in_frontmatter(self):
with pytest.raises(ConfigError, match="menu must be a boolean"):
DeckConfig.resolve({"menu": "sometimes"})
class TestSplitOnRule:
def test_defaults_to_on(self):
assert DeckConfig.resolve().split_on_rule is True
def test_frontmatter_can_disable(self):
assert DeckConfig.resolve({"split_on_rule": "false"}).split_on_rule is False
def test_flag_overrides_frontmatter(self):
config = DeckConfig.resolve({"split_on_rule": "true"}, {"split_on_rule": False})
assert config.split_on_rule is False

View file

@ -211,3 +211,108 @@ class TestSlugify:
assert slugify("", "card1") == "card1"
assert slugify(None, "card1") == "card1"
assert slugify("!!!", "card1") == "card1"
TWO_BY_RULE = """\
Intro text.
---
## Weather
Cold and clear.
---
## Status
North Loop open.
"""
class TestRuleDividers:
"""A thematic break divides cards; WML has no rule element to draw."""
def test_rules_divide_by_default(self, render):
assert card_ids(render(TWO_BY_RULE)) == ["menu", "card1", "card2"]
def test_sections_take_their_own_heading_as_title(self, render):
# A rule carries no title, so the section's first heading supplies it.
markup = render(TWO_BY_RULE)
assert '<card id="card1" title="Weather">' in markup
assert '<card id="card2" title="Status">' in markup
def test_a_single_rule_makes_two_cards(self, render):
# Regression: the multi-card threshold used to be two sections, so
# one divider collapsed back into a single card.
assert card_ids(render("Intro.\n\n---\n\n## Alpha\n\nBody.\n")) == [
"menu",
"card1",
]
def test_no_rule_is_ever_drawn(self, render):
assert "------" not in render(TWO_BY_RULE)
def test_disabling_keeps_one_card_and_still_draws_nothing(self, render):
markup = render(TWO_BY_RULE, split_on_rule=False)
assert card_ids(markup) == ["card1"]
assert "------" not in markup
@pytest.mark.parametrize("glyph", ["---", "***", "___", "- - -", "*****"])
def test_every_thematic_break_glyph_divides(self, render, glyph):
markup = render(f"Intro.\n\n{glyph}\n\n## Alpha\n\nBody.\n")
assert card_ids(markup) == ["menu", "card1"]
def test_rules_and_card_markers_compose(self, render):
markup = render("Intro.\n\n---\n## A\nx\n\n{.card Named}\ny\n")
assert card_ids(markup) == ["menu", "card1", "card2"]
assert '<card id="card2" title="Named">' in markup
def test_explicit_dividers_win_over_split_level(self, render):
markup = render("Intro.\n\n---\n\n## A\n\n## B\n", split_level=2)
assert card_ids(markup) == ["menu", "card1"]
class TestEmptySections:
def test_trailing_rule_adds_no_empty_card(self, render):
# Ending a document with `---` is common and must not produce an
# "Untitled" card with a menu entry leading nowhere.
markup = render("Intro.\n\n---\n\n## A\n\nBody.\n\n---\n")
assert card_ids(markup) == ["menu", "card1"]
assert "Untitled" not in markup
def test_consecutive_rules_collapse(self, render):
markup = render("Intro.\n\n---\n\n---\n\n## A\n\nBody.\n")
assert card_ids(markup) == ["menu", "card1"]
def test_leading_rule_is_harmless(self, render):
markup = render("---\n\n## A\n\nBody.\n")
assert "Untitled" not in markup
def test_document_of_only_a_rule(self, render):
assert card_ids(render("---\n")) == ["card1"]
class TestSetextHeadings:
"""`---` under text is an H2, not a divider -- Markdown says so, and
getting it wrong would split at every heading of a setext document."""
def test_dashes_under_text_make_a_heading_not_a_split(self, render):
markup = render("My Heading\n---\n\nBody text.\n")
assert card_ids(markup) == ["card1"]
assert "<p><b><big>My Heading</big></b></p>" in markup
def test_equals_make_a_level_one_heading(self, render):
assert "<p><b><big>Big Title</big></b></p>" in render("Big Title\n===\n\nBody.\n")
def test_setext_heading_can_drive_split_level(self, render):
markup = render("# Top\n\nIntro.\n\nAlpha\n-----\n\nA.\n\nBeta\n----\n\nB.\n", split_level=2)
assert card_ids(markup) == ["menu", "card1", "card2"]
def test_a_rule_after_a_blank_line_is_still_a_rule(self, render):
# The paragraph has been flushed, so this underlines nothing.
assert card_ids(render("Text.\n\n---\n\n## A\n\nBody.\n")) == ["menu", "card1"]
def test_two_dashes_underline_but_do_not_divide(self, render):
# `--` is a valid setext underline but not a thematic break.
assert card_ids(render("Heading\n--\n\nBody.\n")) == ["card1"]

View file

@ -71,12 +71,16 @@ class TestBlockMapping:
("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_thematic_break_draws_nothing(self, render):
# WML has no rule element -- its whole %layout entity is <br> -- and
# a row of ASCII dashes is noise on a small screen.
assert "---" not in render("Before.\n\n---\n\nAfter.\n")
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