diff --git a/README.md b/README.md index 1c4886c..746d8f4 100644 --- a/README.md +++ b/README.md @@ -26,27 +26,46 @@ 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. +A document with no dividers becomes a single card. There are three ways to ask for more. -**Manual dividers** — put a `{.card}` or `{.card Title}` line wherever a new card should start: +**Thematic breaks** — a plain `---` starts a new card. WML has no rule element to draw (its entire layout vocabulary is `
`), and a row of ASCII dashes is noise on a small screen, so the glyph is put to better use as the divider it already looks like: ```markdown Intro shown on the menu card... -{.card Weather} +--- + +## Weather + Cold and clear. +--- + +## Trail Status + +- North Loop: open +``` + +Each card takes its title from its own first heading. `***` and `___` work identically — any Markdown thematic break divides. A trailing `---`, or two in a row, adds no empty card. `--no-split-on-rule` turns this off; breaks are then ignored rather than drawn, because there is still nothing to draw them with. + +`---` under a line of text is a setext heading, not a break, and is parsed as one — so a document written in that style keeps its headings instead of shattering into cards. + +**Named dividers** — `{.card Title}` is the same mechanism with an explicit title, for when a section has no heading to borrow one from: + +```markdown {.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: +The two compose freely in one document. + +**Heading-level splitting** — `--split-level 2` starts a new card at every `##`, which fits a document that is already structured by headings and has no dividers in it: ```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. +Explicit dividers win over `--split-level` when both are present: a boundary written into the document is a stronger statement of intent than a rule passed on the command line. ## Navigation @@ -112,6 +131,7 @@ cache_control: 0 | `--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. | +| `--split-on-rule` / `--no-split-on-rule` | `split_on_rule` | `true` | Start a new card at every thematic break. | | `--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. | @@ -128,7 +148,9 @@ cache_control: 0 | Markdown | WML | | --- | --- | | Headings | `

` for the first on a card, `

` after | -| Paragraphs, lists, rules | `

` — WML has no list element, so bullets and numbers become literal text | +| Paragraphs, lists | `

` — WML has no list element, so bullets and numbers become literal text | +| `---` / `***` / `___` | a card divider; never drawn, as WML has no rule element | +| Setext headings (`===` / `---` underlines) | same as `#` / `##` | | `**bold**`, `*italic*` | ``, `` | | `~~strike~~`, `` `code` `` | markers stripped; WML has no equivalent | | Links | `` | @@ -136,6 +158,7 @@ cache_control: 0 | Fenced/indented code | `

` with `
` between lines | | Blockquotes | `

` | | Pipe tables | `

` with a bold header row | +| `{.card Title}` | a card divider carrying a title | | `{.include file.md}` | inlined before conversion | Two consequences of WML's own grammar are worth knowing: diff --git a/examples/trail-rules.md b/examples/trail-rules.md new file mode 100644 index 0000000..b968f78 --- /dev/null +++ b/examples/trail-rules.md @@ -0,0 +1,24 @@ +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) + +--- diff --git a/src/wapdown/cli.py b/src/wapdown/cli.py index db3a05d..eea4cfd 100644 --- a/src/wapdown/cli.py +++ b/src/wapdown/cli.py @@ -105,6 +105,21 @@ def build_parser() -> argparse.ArgumentParser: choices=MENU_STYLES, help="Render menu choices as anchors or as a keypad-pickable

- - -

Links and emphasis

+

Links and emphasis

Tricky URL: release notes and a starred path build log.

Bold, italic, inline code, struck, and a literal $$5 fee.

- - -

A table

+

A table

TrailStatusFee
North Loopopen$$2
Summit Spuricy$$5

- - -

Other blocks

+

Other blocks

A quoted warning.

1. First

2. Second

-

------------

+

+
+

indented code $$HOME

diff --git a/tests/golden/trail-rules-kept.wml b/tests/golden/trail-rules-kept.wml new file mode 100644 index 0000000..4cc3e3b --- /dev/null +++ b/tests/golden/trail-rules-kept.wml @@ -0,0 +1,15 @@ + + + + +

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

+
+
diff --git a/tests/golden/trail-rules-linear.wml b/tests/golden/trail-rules-linear.wml new file mode 100644 index 0000000..463e344 --- /dev/null +++ b/tests/golden/trail-rules-linear.wml @@ -0,0 +1,25 @@ + + + + + + + +

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

+
+
diff --git a/tests/golden/trail-rules.wml b/tests/golden/trail-rules.wml new file mode 100644 index 0000000..398f693 --- /dev/null +++ b/tests/golden/trail-rules.wml @@ -0,0 +1,28 @@ + + + + + + +

Live updates for the ridge trail network. Reception is spotty past the tree line, check before you go.

+

Weather

+

Trail Status

+

Contact

+
+ +

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

+
+
diff --git a/tests/golden_cases.py b/tests/golden_cases.py index c6db0a9..74f849d 100644 --- a/tests/golden_cases.py +++ b/tests/golden_cases.py @@ -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", {}), } diff --git a/tests/test_cli.py b/tests/test_cli.py index 4eee914..dfbf4c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 '' in markup + assert '' 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 '' 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 "

My Heading

" in markup + + def test_equals_make_a_level_one_heading(self, render): + assert "

Big Title

" 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"] diff --git a/tests/test_wml.py b/tests/test_wml.py index 816a6a3..f564d00 100644 --- a/tests/test_wml.py +++ b/tests/test_wml.py @@ -71,12 +71,16 @@ class TestBlockMapping: ("Just text.\n", "

Just text.

"), ("- item\n", "

- item

"), ("> quoted\n", "

quoted

"), - ("---\n", "

------------

"), ], ) 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
-- 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 "

Title

" in markup