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:
parent
b83e24e2f7
commit
3b728d9d74
17 changed files with 383 additions and 36 deletions
35
README.md
35
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 `<br>`), 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 | `<p><b><big>` for the first on a card, `<p><b>` after |
|
||||
| Paragraphs, lists, rules | `<p>` — WML has no list element, so bullets and numbers become literal text |
|
||||
| Paragraphs, lists | `<p>` — 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*` | `<b>`, `<i>` |
|
||||
| `~~strike~~`, `` `code` `` | markers stripped; WML has no equivalent |
|
||||
| Links | `<a href>` |
|
||||
|
|
@ -136,6 +158,7 @@ cache_control: 0
|
|||
| Fenced/indented code | `<p>` with `<br/>` between lines |
|
||||
| Blockquotes | `<p><i>` |
|
||||
| Pipe tables | `<p><table columns="N">` 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:
|
||||
|
|
|
|||
24
examples/trail-rules.md
Normal file
24
examples/trail-rules.md
Normal file
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
@ -105,6 +105,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
choices=MENU_STYLES,
|
||||
help="Render menu choices as anchors or as a keypad-pickable <select> (default: links).",
|
||||
)
|
||||
rule_group = structure.add_mutually_exclusive_group()
|
||||
rule_group.add_argument(
|
||||
"--split-on-rule",
|
||||
dest="split_on_rule",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Start a new card at every thematic break (--- / *** / ___). This is the default.",
|
||||
)
|
||||
rule_group.add_argument(
|
||||
"--no-split-on-rule",
|
||||
dest="split_on_rule",
|
||||
action="store_false",
|
||||
default=None,
|
||||
help="Ignore thematic breaks. They are still never drawn: WML has no rule element.",
|
||||
)
|
||||
structure.add_argument(
|
||||
"--deck-per-card",
|
||||
dest="deck_per_card",
|
||||
|
|
@ -178,6 +193,7 @@ _CONFIG_DESTS = (
|
|||
"max_card_bytes",
|
||||
"menu",
|
||||
"menu_style",
|
||||
"split_on_rule",
|
||||
"deck_per_card",
|
||||
"nav_next_label",
|
||||
"nav_prev_label",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class DeckConfig:
|
|||
max_card_bytes: int = DEFAULT_MAX_CARD_BYTES # 0 = disabled
|
||||
menu: bool = True
|
||||
menu_style: str = "links"
|
||||
split_on_rule: bool = True
|
||||
deck_per_card: bool = False
|
||||
# Navigation
|
||||
nav_next_label: str = "More"
|
||||
|
|
@ -97,7 +98,7 @@ class DeckConfig:
|
|||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
|
||||
|
||||
_BOOL_FIELDS = {"menu", "template_nav", "deck_per_card"}
|
||||
_BOOL_FIELDS = {"menu", "template_nav", "deck_per_card", "split_on_rule"}
|
||||
_INT_FIELDS = {"split_level", "max_card_bytes", "cache_control"}
|
||||
_TRUE = {"true", "yes", "1", "on"}
|
||||
_FALSE = {"false", "no", "0", "off"}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,12 @@ def strip_frontmatter(lines: List[str]) -> Tuple[Dict[str, str], List[str]]:
|
|||
if idx >= len(lines):
|
||||
# Unterminated fence: treat the whole thing as body.
|
||||
return {}, lines
|
||||
if not fields:
|
||||
# A fenced block with no `key: value` line at all is not frontmatter,
|
||||
# it is a thematic break with text under it -- which now matters,
|
||||
# since a `---` divides cards. Treating it as frontmatter would
|
||||
# silently eat the opening of the document.
|
||||
return {}, lines
|
||||
remaining = lines[idx + 1 :] if idx + 1 < len(lines) else []
|
||||
return fields, remaining
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ 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*$")
|
||||
# A setext underline needs only one character (`--` is a valid H2 underline
|
||||
# but not a thematic break), and `***` never underlines a heading.
|
||||
SETEXT_UNDERLINE_PATTERN = re.compile(r"^\s{0,3}(=+|-+)\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)
|
||||
|
|
@ -197,6 +200,33 @@ class MarkdownParser:
|
|||
)
|
||||
continue
|
||||
|
||||
# Setext headings are checked before thematic breaks, because
|
||||
# `---` under a paragraph is an H2 in Markdown, not a rule.
|
||||
# Getting this order wrong matters more here than in a text
|
||||
# exporter: a thematic break starts a new card, so a setext
|
||||
# document would otherwise split at every heading and strand
|
||||
# the heading text as body copy on the card before it.
|
||||
setext_match = SETEXT_UNDERLINE_PATTERN.match(line)
|
||||
if setext_match and current_paragraph:
|
||||
heading_text = " ".join(part.strip() for part in current_paragraph)
|
||||
current_paragraph = []
|
||||
heading_text, inline_spec = self._extract_trailing_attr(heading_text)
|
||||
combined_spec = self._merge_specs(self._pending_block_style_spec, inline_spec)
|
||||
style = self._combine_styles(self._current_style(), combined_spec)
|
||||
self._pending_block_style_spec = None
|
||||
self._paragraph_style_spec = None
|
||||
self._last_stylable_block = True
|
||||
yield BlockEvent(
|
||||
kind=BlockKind.HEADING,
|
||||
payload=HeadingPayload(
|
||||
level=1 if setext_match.group(1).startswith("=") else 2,
|
||||
text=heading_text,
|
||||
),
|
||||
style=style,
|
||||
stylable=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if HORIZONTAL_RULE_PATTERN.match(line):
|
||||
event = self._flush_paragraph(current_paragraph)
|
||||
if event is not None:
|
||||
|
|
|
|||
|
|
@ -49,17 +49,21 @@ class Card:
|
|||
|
||||
|
||||
def split_sections(
|
||||
events: List[BlockEvent], split_level: int
|
||||
events: List[BlockEvent], split_level: int, split_on_rule: bool = True
|
||||
) -> Tuple[List[BlockEvent], List[Section]]:
|
||||
"""Return (preamble, sections).
|
||||
|
||||
Manual `{.card}` markers win over heading-level splitting when both are
|
||||
present: an explicit boundary in the document is a stronger statement of
|
||||
intent than a structural rule passed on the command line.
|
||||
Two kinds of explicit divider start a new card: a `{.card}` marker,
|
||||
which may carry a title, and a thematic break (`---`), which may not.
|
||||
They are the same mechanism and compose freely in one document.
|
||||
|
||||
Explicit dividers win over heading-level splitting when both are
|
||||
present: a boundary written into the document is a stronger statement
|
||||
of intent than a structural rule passed on the command line.
|
||||
"""
|
||||
card_breaks = [i for i, e in enumerate(events) if e.kind == BlockKind.CARD_BREAK]
|
||||
if card_breaks:
|
||||
return _split_by_card_breaks(events, card_breaks)
|
||||
dividers = [i for i, e in enumerate(events) if _is_divider(e, split_on_rule)]
|
||||
if dividers:
|
||||
return _split_by_dividers(events, dividers)
|
||||
if split_level > 0:
|
||||
headings = [
|
||||
i
|
||||
|
|
@ -71,16 +75,31 @@ def split_sections(
|
|||
return list(events), []
|
||||
|
||||
|
||||
def _split_by_card_breaks(
|
||||
def _is_divider(event: BlockEvent, split_on_rule: bool) -> bool:
|
||||
if event.kind == BlockKind.CARD_BREAK:
|
||||
return True
|
||||
return split_on_rule and event.kind == BlockKind.HORIZONTAL_RULE
|
||||
|
||||
|
||||
def _split_by_dividers(
|
||||
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
|
||||
start = indices[idx] + 1 # skip the divider event itself
|
||||
section_events = events[start : boundaries[idx + 1]]
|
||||
marker_title = events[indices[idx]].payload.title
|
||||
divider = events[indices[idx]]
|
||||
# A thematic break carries no payload, so it never names its card;
|
||||
# both kinds fall back to the section's own first heading.
|
||||
marker_title = (
|
||||
divider.payload.title if divider.kind == BlockKind.CARD_BREAK else None
|
||||
)
|
||||
if not _has_content(section_events):
|
||||
# A trailing `---`, or two dividers in a row, would otherwise
|
||||
# add an empty "Untitled" card and a menu entry leading to it.
|
||||
continue
|
||||
sections.append(
|
||||
Section(
|
||||
title=marker_title or first_heading_text(section_events),
|
||||
|
|
@ -90,6 +109,10 @@ def _split_by_card_breaks(
|
|||
return preamble, sections
|
||||
|
||||
|
||||
def _has_content(events: List[BlockEvent]) -> bool:
|
||||
return any(event.kind is not BlockKind.BLANK_LINE for event in events)
|
||||
|
||||
|
||||
def _split_by_heading(
|
||||
events: List[BlockEvent], indices: List[int]
|
||||
) -> Tuple[List[BlockEvent], List[Section]]:
|
||||
|
|
|
|||
|
|
@ -78,14 +78,18 @@ class WMLRenderer:
|
|||
|
||||
def finalize(self) -> DeckOutput:
|
||||
cfg = self.config
|
||||
preamble, sections = split_sections(self._events, cfg.split_level)
|
||||
multi_deck = cfg.deck_per_card and len(sections) >= 2
|
||||
preamble, sections = split_sections(
|
||||
self._events, cfg.split_level, cfg.split_on_rule
|
||||
)
|
||||
multi_deck = cfg.deck_per_card and bool(sections)
|
||||
|
||||
# Filenames must be known before any card is rendered, since the menu's
|
||||
# choices and every cross-deck <go> need to name the target file.
|
||||
self._deck_files = self._plan_deck_files(sections) if multi_deck else {}
|
||||
|
||||
if len(sections) < 2:
|
||||
# Any explicit divider means a multi-card deck. A document with a
|
||||
# single `---` in it asks for two screens, not one.
|
||||
if not sections:
|
||||
cards = self._build_single(preamble, sections)
|
||||
elif cfg.menu:
|
||||
cards = self._build_hub_and_spoke(preamble, sections)
|
||||
|
|
@ -403,13 +407,13 @@ class WMLRenderer:
|
|||
elif kind == BlockKind.BLOCKQUOTE:
|
||||
fragments.append(f"<p><i>{self._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: no WML output of their own.
|
||||
# BLANK_LINE, CARD_BREAK, HORIZONTAL_RULE: no WML output of their
|
||||
# own. WML has no rule element (its whole %layout entity is <br>),
|
||||
# and a row of dashes is noise on a small screen, so a thematic
|
||||
# break is a card divider rather than something to draw.
|
||||
return fragments
|
||||
|
||||
def _render_table(self, rows: List[List[str]]) -> str:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
15
tests/golden/trail-rules-kept.wml
Normal file
15
tests/golden/trail-rules-kept.wml
Normal 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>
|
||||
25
tests/golden/trail-rules-linear.wml
Normal file
25
tests/golden/trail-rules-linear.wml
Normal 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>
|
||||
28
tests/golden/trail-rules.wml
Normal file
28
tests/golden/trail-rules.wml
Normal 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>
|
||||
|
|
@ -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", {}),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue