Expand description
“Inline content → plain text”, once.
Two walkers need this operation and they cannot be merged, because they read different inputs at different times:
| walker | input | when | consumer |
|---|---|---|---|
[events_to_text] | &[pulldown_cmark::Event] | during parse, before the AST exists | the <hN id> slug |
crate::ast::plain_text::inlines_to_plain_text | &[Inline] | after parse | super::extract’s autocomplete label |
What they must NOT do is disagree about what a piece of content looks
like as text. They did: collect_heading_text in ast/parser.rs and
inlines_to_text in extract_headings.rs were independent match
arms over independent enums, so a heading’s slug and its autocomplete
label could drift apart — which is exactly what the July 2026 math
cluster found ($f*g$ came out of one and $fg$ out of the other).
So: one policy, two adapters. [crate::ast::plain_text::TextAtom] is
the vocabulary the policy speaks; push_atom IS the policy and is the
only place that decides what an atom’s text is; each walker’s job is
reduced to classifying its own node type into an atom. Changing what math
(or a line break, or code) looks like in plain text is a one-line edit in
one function, and both surfaces move together by construction.
The &[Inline] half of the policy (and its adapter,
crate::ast::plain_text::inlines_to_plain_text) moved to
ast/plain_text.rs (ADR-036) once a third non-heading consumer
(build::page::meta::extract_description) appeared — exactly the
trigger that module’s promotion doc comment named in advance. This
module keeps only [events_to_text], the mid-parse event-stream half
that has no AST to walk yet.
§The one difference that remains, and why it is not a bug to fix here
The two walkers see different vocabularies, not different policies:
the event stream carries SoftBreak/HardBreak events that the slug
walker has always ignored, while the AST folds a SoftBreak into
Inline::Text("\n") and a HardBreak into [Inline::LineBreak]. A
multi-line setext heading therefore slugs as foobar but labels as
foo bar. That predates this module and is a behavioral question —
changing it moves live anchors. It is pinned by
setext_soft_break_divergence_is_pinned_not_fixed below so the next
person meets it as a decision rather than as a surprise.