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

@ -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",

View file

@ -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"}

View file

@ -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

View file

@ -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:

View file

@ -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]]:

View file

@ -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: