Skip to main content

quarb_text/
lib.rs

1//! The text level: a shared, source-independent semantics for
2//! written documents — sections, paragraphs, quotes, lists, and
3//! verbatim blocks — produced by format crates and served by this
4//! crate's single adapter.
5//!
6//! The block model follows the atrep markup language (litogramma's
7//! koine core): every block is `(kind, taxis?, lemma?, body,
8//! hypograph?)` — the lemma is the head or title, the hypograph the
9//! footer or attribution, and a paragraph is the degenerate
10//! lemma-less, hypograph-less block. The format adapters (`quarb-text-html`,
11//! `quarb-text-markdown`, the built-in plain-text reader) lower
12//! their format into the [`Block`] event stream; this crate derives
13//! the section tree and implements the adapter once, so
14//! `//section[::lemma ...]`, `//paragraph`, and `//blockquote` read
15//! identically over any text substrate — including an atrep
16//! document mounted by `quarb-atrep`.
17//!
18//! - Node names are the structural kinds: `section`, `paragraph`,
19//!   `blockquote`, `unordered-list`, `ordered-list`,
20//!   `unordered-item`, `ordered-item`, `verbatim` — plus the
21//!   apparatus: `footnote` and `endnote` (callout and body share
22//!   the family name, ruling #35), `aside` (litogramma's fourth
23//!   deixis-attached family — anchored content, not apparatus),
24//!   `index-mark` (ruling #36), and the verse vocabulary
25//!   (ruling #37): `verse` holding `strophe`s holding `stichos`
26//!   lines, the stichos `::taxis` the citation coordinate.
27//! - `::lemma`, `::hypograph`, and `::taxis` are properties; bare
28//!   `::` (and `::text`) is the flattened prose of the subtree,
29//!   lemma first, hypograph last.
30//! - `::::level` on a section is the source heading level;
31//!   `::::lang` on a verbatim block is its declared language.
32//! - Sections are derived from the flat heading stream by the
33//!   outline rule: a heading closes every open section at its
34//!   level or deeper, then opens a section under the nearest
35//!   shallower one. Content before the first heading belongs to
36//!   the document root. A heading inside an open container
37//!   (blockquote, list) is decorative, not sectioning: it lowers
38//!   to a paragraph of its text.
39//! - Every kind admits `::lemma`, `::taxis`, and `::hypograph` —
40//!   the atrep model, where these are universal affordances of a
41//!   block rather than privileges of particular kinds.
42//! - Tables denormalize into nested lists: an `ordered-list`
43//!   carrying the `<table>` trait (`::lemma` = the caption), one
44//!   `ordered-item` per row (`::taxis` = row number, `<row>`
45//!   trait), one `unordered-item` per cell (`<cell>` trait) whose
46//!   `::lemma` is the column name — from the header row in grids,
47//!   from the row's `th` label otherwise; headerless cells carry
48//!   no lemma. A lemma'd item flattens as `lemma: prose`, so a
49//!   row exists, the bare cell text otherwise. Empty cells are
50//!   skipped.
51
52use quarb::{AstAdapter, NodeId, Value};
53
54pub mod render;
55pub use render::{Render, render_node, render_nodes};
56
57/// A block-level event in the text-level vocabulary — what a format
58/// adapter emits. Headings arrive flat; the section tree is
59/// derived here, once, for every adapter.
60#[derive(Debug, Clone, PartialEq)]
61pub enum Block {
62    /// A flat heading: `level` is the source level (`h2` → 2, a
63    /// LaTeX `\section` → its depth), `lemma` its text.
64    Heading { level: u8, lemma: String },
65    /// A plain paragraph — the implicit, lemma-less block.
66    Paragraph { text: String },
67    /// Inline content belonging directly to the open container (a
68    /// list item's own text, a bare-text blockquote). With no open
69    /// container it is read as a paragraph.
70    Text { text: String },
71    /// Open a nesting container. Items take their `unordered-` /
72    /// `ordered-` flavor (and taxis) from the enclosing list.
73    Open {
74        kind: Container,
75        lemma: Option<String>,
76    },
77    /// Close the innermost open container, optionally with its
78    /// hypograph (footer or attribution).
79    Close { hypograph: Option<String> },
80    /// A verbatim block — code or other preformatted lines, kept
81    /// as authored.
82    Verbatim { lang: Option<String>, text: String },
83    /// An in-text note callout (the deixis, ruling #35): `onym`
84    /// names the body it cites within its family. Emitted after
85    /// the flow block it sits in; it becomes that block's child,
86    /// in order, and the block's own text stays clean of markers.
87    /// `family: None` means the source declares none (an HTML
88    /// noteref): the callout takes its resolved body's family,
89    /// and a dangling one defaults to footnote.
90    NoteRef {
91        onym: String,
92        family: Option<NoteFamily>,
93        /// The declared spelling placed this pair in the margin
94        /// (a Tufte-style sidenote): surfaced as `::::form =
95        /// "margin"` on both ends — the family stays footnote,
96        /// placement is presentation.
97        margin: bool,
98    },
99    /// An index mark (ruling #36): an invisible anchor declaring
100    /// this place concerns `term`. The back-of-book index is a
101    /// query over these, never a stored structure.
102    IndexMark { term: String },
103    /// A reference mark — the text-level mention (html's in-prose
104    /// `<a href>`, markdown's `[text](url)`, LaTeX's `\ref`):
105    /// `target` is the identifier as written, `text` the authored
106    /// link text (LaTeX `\ref` has none), and `internal` is the
107    /// producer's declaration that the target names a label in
108    /// *this* document (`#fragment`, a `\ref` key) rather than
109    /// another document. A mention, not an attachment: refs do
110    /// not carry `<deixis>`.
111    Ref {
112        target: String,
113        text: Option<String>,
114        internal: bool,
115    },
116    /// A point anchor — an invisible in-flow node bearing an
117    /// `onym` at this position (LaTeX's mid-flow `\label`, an
118    /// inline html `id=`). The IndexMark construction: child of
119    /// its flow block, document order.
120    Anchor { onym: String },
121    /// A citation mark — the bibliography's mention (LaTeX's
122    /// `\cite`, koine's cite monosim): `target` is the authored
123    /// key, resolved against the `bib` bearers — a namespace of
124    /// its own, separate from labels (LaTeX precedent).
125    Cite { target: String },
126    /// One bibliographic entry: a block bearing its authored
127    /// `key`. Unstructured sources (\bibitem) carry the full
128    /// reference as `text`; structured ones (bibliogramma,
129    /// BibTeX/BibLaTeX through it) carry `fields` — canonical
130    /// campus name → value, Latin per the bibliogramma
131    /// vocabulary — and the entry's `genus` (liber,
132    /// commentarius, …).
133    Bib {
134        key: String,
135        text: String,
136        fields: Vec<(String, String)>,
137        genus: Option<String>,
138    },
139    /// A block label: the innermost open section takes `onym` as
140    /// the name it bears (a heading's `id`; a `\label` attached
141    /// to a sectioning command — the promotion rule). With no
142    /// open section it degrades to a point [`Block::Anchor`].
143    Label { onym: String },
144    /// A verse block (ruling #37 — litogramma's stichoi model):
145    /// strophes of lines, denormalized like [`Block::Table`].
146    /// Lines arrive flattened by the adapter and are kept as
147    /// authored; the stichos `::taxis` numbers lines 1-based,
148    /// CONTINUOUSLY across strophes — the citation coordinate.
149    Verse {
150        lemma: Option<String>,
151        strophes: Vec<Vec<String>>,
152        hypograph: Option<String>,
153    },
154    /// A table, denormalized here into nested lists (rows =
155    /// ordered items with the `<row>` trait, cells = unordered
156    /// items with the `<cell>` trait and the column name as
157    /// `::lemma`). Header *detection* is the adapter's job; the
158    /// lowering rule lives here. A cell's own `label` (a row's
159    /// `th`) wins over the positional `headers` entry.
160    Table {
161        lemma: Option<String>,
162        headers: Option<Vec<String>>,
163        rows: Vec<Vec<Cell>>,
164    },
165}
166
167/// One table cell as an adapter hands it over: the text, plus the
168/// label a row-shaped dialect attaches directly (an infobox row's
169/// `th`). Grid dialects leave `label` empty and let the lowering
170/// zip the header row on by position.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Cell {
173    pub label: Option<String>,
174    pub text: String,
175}
176
177impl From<&str> for Cell {
178    fn from(text: &str) -> Self {
179        Cell {
180            label: None,
181            text: text.to_string(),
182        }
183    }
184}
185
186impl From<String> for Cell {
187    fn from(text: String) -> Self {
188        Cell { label: None, text }
189    }
190}
191
192/// The nesting containers an adapter opens and closes explicitly.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Container {
195    Blockquote,
196    UnorderedList,
197    /// `start` is the first item's ordinal (Markdown's `3.` lists).
198    OrderedList {
199        start: i64,
200    },
201    /// A list item; flavor and taxis come from the enclosing list.
202    Item,
203    /// A note body (the noted, ruling #35): opens a note node of
204    /// its family at the document root — litogramma's canonical
205    /// document-end placement — holding its own blocks. `margin`
206    /// as on [`Block::NoteRef`].
207    Note {
208        onym: String,
209        family: NoteFamily,
210        margin: bool,
211    },
212}
213
214/// The deixis-attached families litogramma's canon names
215/// (ruling #35, as amended): each family's callout and body
216/// share its name. `Aside` rides the same construction —
217/// koine's `aside` sim, an anchored body whose insertion point
218/// is a deixis — but is content, not apparatus: its bodies do
219/// not carry `<note>`.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221pub enum NoteFamily {
222    Footnote,
223    Endnote,
224    Aside,
225}
226
227impl NoteFamily {
228    fn kind(self) -> Kind {
229        match self {
230            NoteFamily::Footnote => Kind::Footnote,
231            NoteFamily::Endnote => Kind::Endnote,
232            NoteFamily::Aside => Kind::Aside,
233        }
234    }
235}
236
237/// The structural kind of a node — also its name.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239enum Kind {
240    Document,
241    Section,
242    Paragraph,
243    Blockquote,
244    UnorderedList,
245    OrderedList,
246    UnorderedItem,
247    OrderedItem,
248    Verbatim,
249    /// Callout and body both — they share the name by
250    /// construction (litogramma F5): `//footnote` gathers the
251    /// whole apparatus, `<deixis>`/`<note>` tell them apart.
252    Footnote,
253    /// The second note family, same construction.
254    Endnote,
255    /// The anchored-content family, same construction
256    /// (litogramma's aside): body + insertion-point deixis. Content, not
257    /// apparatus — no `<note>` on its bodies.
258    Aside,
259    /// An index mark (ruling #36): `::term`, in flow position.
260    IndexMark,
261    /// A reference mark: `::target`, `-->` resolves it.
262    Ref,
263    /// A citation mark: `::target` the authored key, resolved
264    /// against the `bib` namespace.
265    Cit,
266    /// One bibliographic entry: `::onym` the key it bears, its
267    /// projection the full reference text.
268    Bib,
269    /// A point anchor: `::onym`, an invisible in-flow bearer.
270    Anchor,
271    /// The verse vocabulary (ruling #37): the stichoi container…
272    Verse,
273    /// …its strophes…
274    Strophe,
275    /// …and its lines, `::taxis` the citation coordinate.
276    Stichos,
277}
278
279impl Kind {
280    fn name(self) -> Option<&'static str> {
281        Some(match self {
282            Kind::Document => return None,
283            Kind::Section => "section",
284            Kind::Paragraph => "paragraph",
285            Kind::Blockquote => "blockquote",
286            Kind::Footnote => "footnote",
287            Kind::Endnote => "endnote",
288            Kind::Aside => "aside",
289            Kind::IndexMark => "index-mark",
290            Kind::Ref => "ref",
291            Kind::Cit => "cit",
292            Kind::Bib => "bib",
293            Kind::Anchor => "anchor",
294            Kind::Verse => "verse",
295            Kind::Strophe => "strophe",
296            Kind::Stichos => "stichos",
297            Kind::UnorderedList => "unordered-list",
298            Kind::OrderedList => "ordered-list",
299            Kind::UnorderedItem => "unordered-item",
300            Kind::OrderedItem => "ordered-item",
301            Kind::Verbatim => "verbatim",
302        })
303    }
304}
305
306struct Node {
307    kind: Kind,
308    lemma: Option<String>,
309    hypograph: Option<String>,
310    taxis: Option<i64>,
311    /// Source heading level, on sections.
312    level: Option<u8>,
313    /// Declared language, on verbatim blocks.
314    lang: Option<String>,
315    /// First ordinal of an ordered list (not exposed; feeds the
316    /// items' taxis).
317    start: i64,
318    /// The node's own (direct) text, before subtree flattening.
319    text: String,
320    /// The flattened prose of the subtree — the `::` projection.
321    prose: String,
322    /// The node heads a denormalized table (`<table>` trait).
323    table: bool,
324    /// The node is a denormalized table row (`<row>` trait).
325    row: bool,
326    /// The node is a denormalized table cell (`<cell>` trait).
327    cell: bool,
328    /// The apparatus (ruling #35): the note name as written —
329    /// and, on an index mark (ruling #36), the term.
330    onym: Option<String>,
331    /// A footnote callout (`<deixis>`) rather than a body.
332    deixis: bool,
333    /// A callout whose body is missing (`<dangling>`).
334    dangling: bool,
335    /// A callout whose source declared no family: resolution may
336    /// re-kind it from the body it reaches.
337    family_open: bool,
338    /// The declared spelling was a margin form (`::::form`).
339    margin: bool,
340    /// Callout -> body edge, once resolved.
341    note_edge: Option<NodeId>,
342    /// Body <- callouts (the reverse index).
343    cites: Vec<NodeId>,
344    /// A ref's target identifier, as written.
345    target: Option<String>,
346    /// The producer declared the target in-document.
347    internal: bool,
348    /// A structured bib entry's fields (campus → value) and its
349    /// genus, per the bibliogramma vocabulary.
350    fields: Vec<(String, String)>,
351    genus: Option<String>,
352    /// Ref -> bearer edge, once resolved (in-document).
353    ref_edge: Option<NodeId>,
354    /// Bearer <- refs (the reverse index).
355    ref_cites: Vec<NodeId>,
356    parent: Option<NodeId>,
357    children: Vec<NodeId>,
358}
359
360impl Node {
361    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
362        Node {
363            kind,
364            lemma: None,
365            hypograph: None,
366            taxis: None,
367            level: None,
368            lang: None,
369            start: 1,
370            text: String::new(),
371            prose: String::new(),
372            table: false,
373            row: false,
374            cell: false,
375            onym: None,
376            deixis: false,
377            dangling: false,
378            family_open: false,
379            margin: false,
380            note_edge: None,
381            cites: Vec::new(),
382            target: None,
383            internal: false,
384            fields: Vec::new(),
385            genus: None,
386            ref_edge: None,
387            ref_cites: Vec::new(),
388            parent,
389            children: Vec::new(),
390        }
391    }
392}
393
394/// The reference pass: collect every borne name (block labels
395/// and point anchors — deixis callouts pair within their own
396/// families and stay out of this namespace), then land each
397/// internal ref on its bearer, `<dangling>` when nothing bears
398/// the name. External refs resolve at query time, through the
399/// engine's reference machinery.
400fn resolve_refs(nodes: &mut [Node]) -> std::collections::HashMap<String, NodeId> {
401    let mut onyms: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
402    // Citation keys are a namespace of their own, separate from
403    // labels (LaTeX's \bibcite vs \newlabel precedent): `cit`
404    // resolves against `bib` bearers only.
405    let mut bibs: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
406    for (i, n) in nodes.iter().enumerate() {
407        if n.deixis || matches!(n.kind, Kind::IndexMark | Kind::Ref | Kind::Cit) {
408            continue;
409        }
410        if n.kind == Kind::Bib {
411            if let Some(k) = &n.onym {
412                bibs.entry(k.clone()).or_insert(NodeId(i as u64));
413            }
414            continue;
415        }
416        let bearer = n.kind == Kind::Anchor
417            || (!matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
418                && n.onym.is_some());
419        if bearer && let Some(o) = &n.onym {
420            onyms.entry(o.clone()).or_insert(NodeId(i as u64));
421        }
422    }
423    for i in 0..nodes.len() {
424        let (map, key) = match nodes[i].kind {
425            Kind::Ref if nodes[i].internal => (
426                &onyms,
427                nodes[i]
428                    .target
429                    .as_deref()
430                    .map(|t| t.trim_start_matches('#').to_string())
431                    .unwrap_or_default(),
432            ),
433            Kind::Cit => (&bibs, nodes[i].target.clone().unwrap_or_default()),
434            _ => continue,
435        };
436        match map.get(&key) {
437            Some(&bearer) => {
438                nodes[i].ref_edge = Some(bearer);
439                let me = NodeId(i as u64);
440                nodes[bearer.0 as usize].ref_cites.push(me);
441            }
442            None => nodes[i].dangling = true,
443        }
444    }
445    onyms
446}
447
448/// Collapse whitespace runs to single spaces and trim — the prose
449/// normalization adapters apply to inline content. Verbatim text
450/// is the exception: it is kept as authored.
451pub fn normalize_ws(s: &str) -> String {
452    s.split_whitespace().collect::<Vec<_>>().join(" ")
453}
454
455/// What a document declares about itself, in a standard
456/// vocabulary its format provides — HTML's `<head>` (the `meta`
457/// name/property pairs, `title`, the `lang` attribute, the
458/// canonical link, `rel="tag"` links) and a schema.org JSON-LD
459/// block. Two layers: every declaration lands verbatim under
460/// `::::name` (`::::description`, `::::og:type`,
461/// `::::article:section`, `::::schema:@type`), so nothing a page
462/// says is lost; and the standard vocabularies feed a curated
463/// core — the tags (`keywords`, `article:tag`, `rel=tag`, JSON-LD
464/// `keywords`) become traits on the document and `::tags`, the
465/// one classification (`article:section` / `articleSection`)
466/// becomes `::category` and a trait too, `description`, `author`,
467/// and the `published` / `modified` instants. Declared identity
468/// only: a format adapter reads a cited vocabulary, never a class
469/// name or a layout.
470#[derive(Debug, Clone, Default)]
471pub struct HeadMeta {
472    /// Every declaration, name → values, in document order; a
473    /// repeated name accumulates.
474    pub declared: Vec<(String, Vec<String>)>,
475    pub title: Option<String>,
476    pub description: Option<String>,
477    pub author: Option<String>,
478    /// The one classification (a section of the site, a category).
479    pub category: Option<String>,
480    /// The tags, as written, first appearance wins.
481    pub tags: Vec<String>,
482    pub published: Option<Value>,
483    pub modified: Option<Value>,
484}
485
486impl HeadMeta {
487    /// Record a declaration verbatim (the lossless layer).
488    pub fn declare(&mut self, name: &str, value: &str) {
489        let value = normalize_ws(value);
490        if name.is_empty() || value.is_empty() {
491            return;
492        }
493        match self.declared.iter_mut().find(|(k, _)| k == name) {
494            Some((_, vs)) => vs.push(value),
495            None => self.declared.push((name.to_string(), vec![value])),
496        }
497    }
498
499    /// Add a tag unless already present (case-insensitively).
500    pub fn tag(&mut self, tag: &str) {
501        let tag = normalize_ws(tag);
502        if tag.is_empty() || self.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
503            return;
504        }
505        self.tags.push(tag);
506    }
507
508    /// A declaration's value: the string when declared once, the
509    /// list when repeated.
510    pub fn declared_value(&self, name: &str) -> Option<Value> {
511        let (_, vs) = self.declared.iter().find(|(k, _)| k == name)?;
512        Some(match vs.as_slice() {
513            [one] => Value::Str(one.clone()),
514            many => Value::List(many.iter().cloned().map(Value::Str).collect()),
515        })
516    }
517
518    /// The trait spelling of a tag or category: as declared, case
519    /// kept, with runs of anything a name cannot carry (spaces,
520    /// slashes, parentheses) collapsed to one dash — `Execution
521    /// Model` is `<tag:Execution-Model>`, `now()` is `<tag:now>`.
522    pub fn trait_name(s: &str) -> String {
523        let mut out = String::new();
524        let mut dash = false;
525        for c in s.chars() {
526            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '+') {
527                out.push(c);
528                dash = c == '-';
529            } else if !dash && !out.is_empty() {
530                out.push('-');
531                dash = true;
532            }
533        }
534        while out.ends_with('-') {
535            out.pop();
536        }
537        out
538    }
539}
540
541/// A Quarb adapter over a text-level document.
542pub struct TextModel {
543    nodes: Vec<Node>,
544    root: NodeId,
545    /// Every borne name → its bearer (block labels and point
546    /// anchors, one namespace, first bearer in document order
547    /// wins) — the landing map for `-->` and for a sibling
548    /// document's `#fragment`.
549    onyms: std::collections::HashMap<String, NodeId>,
550    /// The document's own URL, when the mount knows it.
551    document_url: Option<url::Url>,
552    /// The base relative reference targets join against when the
553    /// document declares one (html's `<base href>`); else the
554    /// document's own URL.
555    link_base: Option<url::Url>,
556    /// Where the document is served from when no absolute URL is
557    /// known — the mount's path, an archive member's path — the
558    /// base `::href` links against.
559    document_path: Option<String>,
560    /// Alias → canonical campus, for bib field lookup: the
561    /// bibliogramma vocabulary's census (BibLaTeX's field names
562    /// are its English rows), so `::author` answers as
563    /// `::auctor` — in any covered language. Set by the koine
564    /// route, which holds the dialektos.
565    bib_aliases: std::collections::HashMap<String, String>,
566    /// What the document declares about itself (see [`HeadMeta`]);
567    /// answered on the document node.
568    head: HeadMeta,
569}
570
571impl TextModel {
572    /// Assemble the document tree from a adapter's event stream.
573    ///
574    /// Iterative throughout (the stream is flat; prose flattening
575    /// runs over indices), so pathological nesting cannot overflow
576    /// the call stack. Lenient on malformed streams: a stray
577    /// `Close` is ignored, unclosed containers close at the end.
578    pub fn build(blocks: Vec<Block>) -> Self {
579        let mut nodes = vec![Node::new(Kind::Document, None)];
580        let root = NodeId(0);
581        // Innermost-last stack of open *sections* (outline-derived).
582        let mut sections: Vec<NodeId> = Vec::new();
583        // Innermost-last stack of open explicit containers.
584        let mut containers: Vec<NodeId> = Vec::new();
585        // The block a NoteRef callout attaches to: the last flow
586        // node created or appended to (ruling #35).
587        let mut last_flow: Option<NodeId> = None;
588        // The block a Label names: the most recently opened block
589        // of any kind — a heading's section, a paragraph, a
590        // container, a verbatim — the html-id / attached-\label
591        // parity rule.
592        let mut last_block: Option<NodeId> = None;
593
594        for block in blocks {
595            match block {
596                Block::Heading { level, lemma } => {
597                    let lemma = normalize_ws(&lemma);
598                    if !containers.is_empty() {
599                        // Decorative heading inside a container:
600                        // not sectioning — lower to a paragraph.
601                        if !lemma.is_empty() {
602                            let parent = *containers.last().unwrap();
603                            let id = push(&mut nodes, Kind::Paragraph, parent);
604                            nodes[id.0 as usize].text = lemma;
605                        }
606                        continue;
607                    }
608                    while let Some(&open) = sections.last() {
609                        if nodes[open.0 as usize].level >= Some(level) {
610                            sections.pop();
611                        } else {
612                            break;
613                        }
614                    }
615                    let parent = sections.last().copied().unwrap_or(root);
616                    let id = push(&mut nodes, Kind::Section, parent);
617                    let n = &mut nodes[id.0 as usize];
618                    n.lemma = Some(lemma);
619                    n.level = Some(level);
620                    sections.push(id);
621                    last_flow = Some(id);
622                    last_block = Some(id);
623                }
624                Block::Paragraph { text } => {
625                    let text = normalize_ws(&text);
626                    if text.is_empty() {
627                        continue;
628                    }
629                    let parent = cursor(&sections, &containers, root);
630                    let id = push(&mut nodes, Kind::Paragraph, parent);
631                    nodes[id.0 as usize].text = text;
632                    last_flow = Some(id);
633                    last_block = Some(id);
634                }
635                Block::Text { text } => {
636                    let text = normalize_ws(&text);
637                    if text.is_empty() {
638                        continue;
639                    }
640                    match containers.last() {
641                        Some(&open) => {
642                            let own = &mut nodes[open.0 as usize].text;
643                            if !own.is_empty() {
644                                own.push(' ');
645                            }
646                            own.push_str(&text);
647                            last_flow = Some(open);
648                        }
649                        None => {
650                            let parent = sections.last().copied().unwrap_or(root);
651                            let id = push(&mut nodes, Kind::Paragraph, parent);
652                            nodes[id.0 as usize].text = text;
653                            last_flow = Some(id);
654                        }
655                    }
656                }
657                Block::Open { kind, lemma } => {
658                    // A note body opens at the document root —
659                    // litogramma's canonical document-end
660                    // placement — whatever else is open.
661                    let kind = match kind {
662                        Container::Note {
663                            onym,
664                            family,
665                            margin,
666                        } => {
667                            let id = push(&mut nodes, family.kind(), root);
668                            let n = &mut nodes[id.0 as usize];
669                            n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
670                            n.margin = margin;
671                            containers.push(id);
672                            last_flow = Some(id);
673                            continue;
674                        }
675                        other => other,
676                    };
677                    let parent = cursor(&sections, &containers, root);
678                    let (nkind, start) = match kind {
679                        Container::Blockquote => (Kind::Blockquote, None),
680                        Container::UnorderedList => (Kind::UnorderedList, None),
681                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
682                        Container::Item => (
683                            match nodes[parent.0 as usize].kind {
684                                Kind::OrderedList => Kind::OrderedItem,
685                                _ => Kind::UnorderedItem,
686                            },
687                            None,
688                        ),
689                        Container::Note { .. } => unreachable!("handled above"),
690                    };
691                    let id = push(&mut nodes, nkind, parent);
692                    last_block = Some(id);
693                    nodes[id.0 as usize].lemma =
694                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
695                    if let Some(start) = start {
696                        nodes[id.0 as usize].start = start;
697                    }
698                    if nkind == Kind::OrderedItem {
699                        // `push` already appended this item, so the
700                        // count includes it.
701                        let nth = nodes[parent.0 as usize]
702                            .children
703                            .iter()
704                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
705                            .count() as i64;
706                        let start = nodes[parent.0 as usize].start;
707                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
708                    }
709                    containers.push(id);
710                }
711                Block::Close { hypograph } => {
712                    if let Some(open) = containers.pop() {
713                        nodes[open.0 as usize].hypograph = hypograph
714                            .map(|h| normalize_ws(&h))
715                            .filter(|h| !h.is_empty());
716                    }
717                }
718                // The callout: a `footnote` child of the flow
719                // block it sits in, `<deixis>`-traited; the edge
720                // to its body resolves after the stream.
721                Block::NoteRef {
722                    onym,
723                    family,
724                    margin,
725                } => {
726                    let parent = last_flow.unwrap_or(root);
727                    // A declared family names the callout now; an
728                    // undeclared one is settled at resolution from
729                    // the body it reaches (footnote when dangling).
730                    let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
731                    let id = push(&mut nodes, kind, parent);
732                    let n = &mut nodes[id.0 as usize];
733                    n.deixis = true;
734                    n.family_open = family.is_none();
735                    n.margin = margin;
736                    n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
737                }
738                Block::Ref {
739                    target,
740                    text,
741                    internal,
742                } => {
743                    let parent = last_flow.unwrap_or(root);
744                    let id = push(&mut nodes, Kind::Ref, parent);
745                    let n = &mut nodes[id.0 as usize];
746                    n.internal = internal;
747                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
748                    n.text = text.map(|t| normalize_ws(&t)).unwrap_or_default();
749                }
750                Block::Cite { target } => {
751                    let parent = last_flow.unwrap_or(root);
752                    let id = push(&mut nodes, Kind::Cit, parent);
753                    let n = &mut nodes[id.0 as usize];
754                    n.internal = true;
755                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
756                }
757                Block::Bib {
758                    key,
759                    text,
760                    fields,
761                    genus,
762                } => {
763                    let parent = cursor(&sections, &containers, root);
764                    let id = push(&mut nodes, Kind::Bib, parent);
765                    last_block = Some(id);
766                    let n = &mut nodes[id.0 as usize];
767                    n.onym = Some(key.trim().to_string()).filter(|k| !k.is_empty());
768                    n.text = normalize_ws(&text);
769                    n.fields = fields
770                        .into_iter()
771                        .map(|(k, v)| (k.trim().to_string(), normalize_ws(&v)))
772                        .filter(|(k, v)| !k.is_empty() && !v.is_empty())
773                        .collect();
774                    n.genus = genus
775                        .map(|g| g.trim().to_string())
776                        .filter(|g| !g.is_empty());
777                }
778                Block::Anchor { onym } => {
779                    let parent = last_flow.unwrap_or(root);
780                    let id = push(&mut nodes, Kind::Anchor, parent);
781                    nodes[id.0 as usize].onym =
782                        Some(onym.trim().to_string()).filter(|o| !o.is_empty());
783                }
784                Block::Label { onym } => {
785                    let onym = onym.trim().to_string();
786                    if onym.is_empty() {
787                        continue;
788                    }
789                    // The most recently opened block bears the
790                    // name — a heading's section, a paragraph, a
791                    // blockquote: the html-id / attached-\label
792                    // parity rule. A label before any block, or on
793                    // a block already named, degrades to a point.
794                    let free = last_block
795                        .map(|b| nodes[b.0 as usize].onym.is_none())
796                        .unwrap_or(false);
797                    match (last_block, free) {
798                        (Some(b), true) => {
799                            nodes[b.0 as usize].onym = Some(onym);
800                        }
801                        _ => {
802                            let parent = last_flow.unwrap_or(root);
803                            let id = push(&mut nodes, Kind::Anchor, parent);
804                            nodes[id.0 as usize].onym = Some(onym);
805                        }
806                    }
807                }
808                Block::IndexMark { term } => {
809                    let parent = last_flow.unwrap_or(root);
810                    let id = push(&mut nodes, Kind::IndexMark, parent);
811                    nodes[id.0 as usize].onym = Some(quarb_term(&term)).filter(|t| !t.is_empty());
812                }
813                Block::Verbatim { lang, text } => {
814                    let parent = cursor(&sections, &containers, root);
815                    let id = push(&mut nodes, Kind::Verbatim, parent);
816                    last_block = Some(id);
817                    let n = &mut nodes[id.0 as usize];
818                    n.lang = lang.filter(|l| !l.is_empty());
819                    n.text = text;
820                }
821                Block::Table {
822                    lemma,
823                    headers,
824                    rows,
825                } => {
826                    let parent = cursor(&sections, &containers, root);
827                    lower_table(&mut nodes, parent, lemma, headers, rows);
828                }
829                Block::Verse {
830                    lemma,
831                    strophes,
832                    hypograph,
833                } => {
834                    let parent = cursor(&sections, &containers, root);
835                    let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
836                    last_flow = Some(id);
837                    last_block = Some(id);
838                }
839            }
840        }
841
842        resolve_notes(&mut nodes);
843        let onyms = resolve_refs(&mut nodes);
844        flatten_prose(&mut nodes);
845        TextModel {
846            nodes,
847            root,
848            onyms,
849            document_url: None,
850            link_base: None,
851            document_path: None,
852            bib_aliases: Default::default(),
853            head: HeadMeta::default(),
854        }
855    }
856
857    /// Register the bib-field alias census (alias → canonical
858    /// campus), lowercased keys — the friction remover: a field
859    /// authored or queried under any covered name answers.
860    pub fn set_bib_aliases(&mut self, map: std::collections::HashMap<String, String>) {
861        self.bib_aliases = map;
862    }
863
864    /// Declare the document's own URL — the base a relative ref
865    /// target joins against when `-->` reaches across documents.
866    pub fn set_document_url(&mut self, url: &str) {
867        self.document_url = url::Url::parse(url).ok();
868    }
869
870    /// Declare the base relative reference targets join against
871    /// — html's `<base href>` — when it differs from the
872    /// document's own URL.
873    pub fn set_link_base(&mut self, url: &str) {
874        self.link_base = url::Url::parse(url).ok();
875    }
876
877    /// Declare where the document is served from when it declares
878    /// no absolute URL of its own: the base `::href` links against
879    /// (a mount path, an archive member's path).
880    pub fn set_document_path(&mut self, path: &str) {
881        self.document_path = Some(path.to_string());
882    }
883
884    /// The base `::href` links against: the document's URL, else
885    /// its declared path.
886    fn href_base(&self) -> Option<String> {
887        if let Some(u) = &self.document_url {
888            return Some(u.to_string());
889        }
890        self.document_path.clone()
891    }
892
893    /// The nearest bearer of a name, the node itself first, then
894    /// its ancestors — the anchor a link into this node lands on.
895    /// `exact` says the node bears it itself.
896    fn nearest_onym(&self, node: NodeId) -> Option<(String, bool)> {
897        let mut cur = Some(node);
898        let mut exact = true;
899        while let Some(id) = cur {
900            let n = &self.nodes[id.0 as usize];
901            if n.kind != Kind::Document && !n.deixis && let Some(o) = &n.onym {
902                return Some((o.clone(), exact));
903            }
904            exact = false;
905            cur = n.parent;
906        }
907        None
908    }
909
910    /// A link into `node`, as far as the served form can narrow
911    /// it. The base is the document's URL (its canonical link,
912    /// else the mount's path). An HTML page — a base ending in
913    /// `.html` / `.htm` / `/` or without an extension — narrows
914    /// with the nearest anchor and, for a block below the anchored
915    /// one, a text fragment (`#:~:text=start,end`) built from the
916    /// node's own prose. A PDF narrows to nothing yet; any other
917    /// form links the document alone, since no browser takes a
918    /// fragment into it. The narrowing stays available as data
919    /// beside the link (`::anchor`, the prose) for a client that
920    /// renders the document itself.
921    fn href(&self, node: NodeId) -> Option<String> {
922        let base = self.href_base()?;
923        let n = &self.nodes[node.0 as usize];
924        if n.kind == Kind::Document {
925            return Some(base);
926        }
927        let served = base.split(['?', '#']).next().unwrap_or(&base);
928        let last = served.rsplit('/').next().unwrap_or(served);
929        let html = served.ends_with('/')
930            || !last.contains('.')
931            || last.ends_with(".html")
932            || last.ends_with(".htm");
933        if !html {
934            return Some(base);
935        }
936        let mut out = base;
937        let (anchor, exact) = match self.nearest_onym(node) {
938            Some((a, exact)) => (Some(a), exact),
939            None => (None, false),
940        };
941        if let Some(a) = &anchor {
942            out.push('#');
943            out.push_str(a);
944        }
945        if !exact {
946            let words: Vec<&str> = n.prose.split_whitespace().collect();
947            if !words.is_empty() {
948                use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
949                let enc = |ws: &[&str]| utf8_percent_encode(&ws.join(" "), NON_ALPHANUMERIC).to_string();
950                if anchor.is_none() {
951                    out.push('#');
952                }
953                out.push_str(":~:text=");
954                if words.len() <= 12 {
955                    out.push_str(&enc(&words));
956                } else {
957                    out.push_str(&enc(&words[..6]));
958                    out.push(',');
959                    out.push_str(&enc(&words[words.len() - 6..]));
960                }
961            }
962        }
963        Some(out)
964    }
965
966    /// The document's title for a link: what its head declares,
967    /// else its first top-level section's lemma.
968    fn document_title(&self) -> Option<String> {
969        if let Some(t) = &self.head.title {
970            return Some(t.clone());
971        }
972        self.nodes[self.root.0 as usize]
973            .children
974            .iter()
975            .map(|c| &self.nodes[c.0 as usize])
976            .find(|n| n.kind == Kind::Section)
977            .and_then(|n| n.lemma.clone())
978    }
979
980    /// Attach what the document declares about itself; the format
981    /// adapter reads it from the source's own metadata vocabulary.
982    pub fn set_head_meta(&mut self, head: HeadMeta) {
983        self.head = head;
984    }
985
986    /// What the document declares about itself.
987    pub fn head_meta(&self) -> &HeadMeta {
988        &self.head
989    }
990
991    /// The document's text as one string: every section's lemma
992    /// and every block's prose, one per line, in document order —
993    /// the lowering a substrate stores beside the document so a
994    /// substring predicate inside the graft can be prefiltered on
995    /// it. The invariant a prefilter relies on: every grafted
996    /// node's prose (and every lemma) is a substring of this.
997    pub fn plain_text(&self) -> String {
998        let mut out = String::new();
999        let mut stack = vec![self.root];
1000        while let Some(id) = stack.pop() {
1001            let n = &self.nodes[id.0 as usize];
1002            if let Some(l) = &n.lemma
1003                && !l.is_empty()
1004            {
1005                out.push_str(l);
1006                out.push('\n');
1007            }
1008            // Every node's own prose, leaf or not: a paragraph that
1009            // holds a ref or an anchor still has prose of its own,
1010            // and the invariant covers it too.
1011            if !n.prose.is_empty() && n.kind != Kind::Document {
1012                out.push_str(&n.prose);
1013                out.push('\n');
1014            }
1015            for c in n.children.iter().rev() {
1016                stack.push(*c);
1017            }
1018        }
1019        out
1020    }
1021
1022    /// How many nodes the document holds (the root included) — the
1023    /// bound a host needs to pack this document's node ids into a
1024    /// wider id space.
1025    pub fn node_count(&self) -> usize {
1026        self.nodes.len()
1027    }
1028
1029    /// Read plain text: blank-line-separated paragraphs, each
1030    /// collapsed to one line — the atramento paragraph rule. No
1031    /// headings, no markup.
1032    pub fn parse_plain(text: &str) -> Self {
1033        let mut blocks = Vec::new();
1034        let mut para: Vec<&str> = Vec::new();
1035        for line in text.lines() {
1036            if line.trim().is_empty() {
1037                if !para.is_empty() {
1038                    blocks.push(Block::Paragraph {
1039                        text: para.join(" "),
1040                    });
1041                    para.clear();
1042                }
1043            } else {
1044                para.push(line);
1045            }
1046        }
1047        if !para.is_empty() {
1048            blocks.push(Block::Paragraph {
1049                text: para.join(" "),
1050            });
1051        }
1052        Self::build(blocks)
1053    }
1054
1055    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
1056    /// for rendering. A `[n]` index is added only to disambiguate
1057    /// same-name siblings.
1058    pub fn locator(&self, node: NodeId) -> String {
1059        let mut segments = Vec::new();
1060        let mut cur = Some(node);
1061        while let Some(id) = cur {
1062            let n = &self.nodes[id.0 as usize];
1063            if let Some(name) = n.kind.name() {
1064                segments.push(self.segment(id, name));
1065            }
1066            cur = n.parent;
1067        }
1068        segments.reverse();
1069        format!("/{}", segments.join("/"))
1070    }
1071
1072    fn segment(&self, node: NodeId, name: &str) -> String {
1073        let Some(parent) = self.nodes[node.0 as usize].parent else {
1074            return name.to_string();
1075        };
1076        let siblings = &self.nodes[parent.0 as usize].children;
1077        let same_name: Vec<NodeId> = siblings
1078            .iter()
1079            .copied()
1080            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
1081            .collect();
1082        if same_name.len() > 1 {
1083            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
1084            format!("{name}[{n}]")
1085        } else {
1086            name.to_string()
1087        }
1088    }
1089}
1090
1091/// Where the next block lands: the innermost open container, else
1092/// the innermost open section, else the root.
1093/// Ruling #35: wire each callout to its body by (family, onym) —
1094/// first body wins on a duplicate — build the reverse index, and
1095/// mark the dangling: a callout with no body keeps its node,
1096/// carries `<dangling>` and `::::resolved = false`, and emits no
1097/// edge.
1098fn resolve_notes(nodes: &mut [Node]) {
1099    let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
1100    for (i, n) in nodes.iter().enumerate() {
1101        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1102            && !n.deixis
1103            && let Some(o) = &n.onym
1104        {
1105            bodies
1106                .entry((n.kind, o.clone()))
1107                .or_insert(NodeId(i as u64));
1108        }
1109    }
1110    for i in 0..nodes.len() {
1111        if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1112            || !nodes[i].deixis
1113        {
1114            continue;
1115        }
1116        let Some(onym) = nodes[i].onym.clone() else {
1117            nodes[i].dangling = true;
1118            continue;
1119        };
1120        let hit = if nodes[i].family_open {
1121            // No declared family (an HTML noteref): the body's
1122            // family is the callout's; footnote when dangling.
1123            [Kind::Footnote, Kind::Endnote, Kind::Aside]
1124                .into_iter()
1125                .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
1126        } else {
1127            bodies
1128                .get(&(nodes[i].kind, onym))
1129                .map(|b| (nodes[i].kind, *b))
1130        };
1131        match hit {
1132            Some((family, body)) => {
1133                nodes[i].kind = family;
1134                nodes[i].note_edge = Some(body);
1135                let callout = NodeId(i as u64);
1136                nodes[body.0 as usize].cites.push(callout);
1137            }
1138            None => nodes[i].dangling = true,
1139        }
1140    }
1141}
1142
1143/// An index term as written, with the `|...` formatting and range
1144/// directives stripped (v1 of ruling #36; ranges and
1145/// see-references are recorded follow-ups).
1146fn quarb_term(term: &str) -> String {
1147    normalize_ws(term.split('|').next().unwrap_or(term))
1148}
1149
1150fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
1151    containers
1152        .last()
1153        .or(sections.last())
1154        .copied()
1155        .unwrap_or(root)
1156}
1157
1158fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
1159    let id = NodeId(nodes.len() as u64);
1160    nodes.push(Node::new(kind, Some(parent)));
1161    nodes[parent.0 as usize].children.push(id);
1162    id
1163}
1164
1165/// Denormalize a table into nested lists (see the module doc).
1166/// The column name lands as the cell's `::lemma` — a cell's own
1167/// label (a row's `th`) wins over the positional header entry —
1168/// and never as folded text: addressing is property projection,
1169/// the flattening rule alone spells `lemma: value`.
1170fn lower_table(
1171    nodes: &mut Vec<Node>,
1172    parent: NodeId,
1173    lemma: Option<String>,
1174    headers: Option<Vec<String>>,
1175    rows: Vec<Vec<Cell>>,
1176) {
1177    let list = push(nodes, Kind::OrderedList, parent);
1178    {
1179        let n = &mut nodes[list.0 as usize];
1180        n.table = true;
1181        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1182    }
1183    for (i, row) in rows.into_iter().enumerate() {
1184        let item = push(nodes, Kind::OrderedItem, list);
1185        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
1186        nodes[item.0 as usize].row = true;
1187        let cells = push(nodes, Kind::UnorderedList, item);
1188        for (j, cell) in row.into_iter().enumerate() {
1189            let value = normalize_ws(&cell.text);
1190            if value.is_empty() {
1191                continue;
1192            }
1193            let label = cell
1194                .label
1195                .as_deref()
1196                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
1197                .map(normalize_ws)
1198                .filter(|h| !h.is_empty());
1199            let cell_item = push(nodes, Kind::UnorderedItem, cells);
1200            let n = &mut nodes[cell_item.0 as usize];
1201            n.cell = true;
1202            n.lemma = label;
1203            n.text = value;
1204        }
1205    }
1206}
1207
1208/// Lower a verse block (ruling #37): `verse` → `strophe`s →
1209/// `stichos` lines. The stichos taxis numbers lines 1-based,
1210/// continuously across strophes — Iliad 1.34 is a taxis; the
1211/// strophe taxis is its ordinal. Empty lines are dropped (the
1212/// strophe boundary carries the separation).
1213fn lower_verse(
1214    nodes: &mut Vec<Node>,
1215    parent: NodeId,
1216    lemma: Option<String>,
1217    strophes: Vec<Vec<String>>,
1218    hypograph: Option<String>,
1219) -> NodeId {
1220    let verse = push(nodes, Kind::Verse, parent);
1221    {
1222        let n = &mut nodes[verse.0 as usize];
1223        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1224        n.hypograph = hypograph
1225            .map(|h| normalize_ws(&h))
1226            .filter(|h| !h.is_empty());
1227    }
1228    let mut line_no = 0i64;
1229    for (i, strophe) in strophes.into_iter().enumerate() {
1230        let sid = push(nodes, Kind::Strophe, verse);
1231        nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
1232        for line in strophe {
1233            let line = line.trim_end().to_string();
1234            if line.trim().is_empty() {
1235                continue;
1236            }
1237            line_no += 1;
1238            let lid = push(nodes, Kind::Stichos, sid);
1239            let n = &mut nodes[lid.0 as usize];
1240            n.taxis = Some(line_no);
1241            n.text = line;
1242        }
1243    }
1244    verse
1245}
1246
1247/// Compute every node's flattened prose: lemma first, then the
1248/// node's own text, then its children's prose in order, then the
1249/// hypograph, block-joined with newlines. On a list *item*, the
1250/// lemma joins the rest with `: ` instead — an item's lemma names
1251/// its content inline (a table cell reads `Outcome: Emus won`, a
1252/// definition reads `term: description`), where a section's lemma
1253/// opens its block. Children always carry larger indices than
1254/// their parents (nodes are interned in document order), so one
1255/// reverse index scan suffices — no recursion.
1256fn flatten_prose(nodes: &mut [Node]) {
1257    for i in (0..nodes.len()).rev() {
1258        let inline_lemma = matches!(nodes[i].kind, Kind::UnorderedItem | Kind::OrderedItem);
1259        let mut lemma_part: Option<String> = None;
1260        let mut parts: Vec<String> = Vec::new();
1261        if let Some(lemma) = &nodes[i].lemma
1262            && !lemma.is_empty()
1263        {
1264            if inline_lemma {
1265                lemma_part = Some(lemma.clone());
1266            } else {
1267                parts.push(lemma.clone());
1268            }
1269        }
1270        if !nodes[i].text.is_empty() {
1271            parts.push(nodes[i].text.clone());
1272        }
1273        let mut child_parts: Vec<String> = Vec::new();
1274        for &child in nodes[i].children.clone().iter() {
1275            // A ref's recorded link text is already part of the
1276            // flow it was met in; an anchor is invisible. Neither
1277            // adds prose (the deixis/index-mark rule).
1278            if matches!(
1279                nodes[child.0 as usize].kind,
1280                Kind::Ref | Kind::Cit | Kind::Anchor
1281            ) {
1282                continue;
1283            }
1284            let prose = &nodes[child.0 as usize].prose;
1285            if !prose.is_empty() {
1286                child_parts.push(prose.clone());
1287            }
1288        }
1289        // Ruling #37: a verse block separates its strophes by a
1290        // blank line (lemma and hypograph still join normally) —
1291        // byte-compatible with the old verbatim lowering.
1292        if nodes[i].kind == Kind::Verse {
1293            let joined = child_parts.join("\n\n");
1294            if !joined.is_empty() {
1295                parts.push(joined);
1296            }
1297        } else {
1298            parts.extend(child_parts);
1299        }
1300        if let Some(hypograph) = &nodes[i].hypograph
1301            && !hypograph.is_empty()
1302        {
1303            parts.push(hypograph.clone());
1304        }
1305        let mut prose = parts.join("\n");
1306        if let Some(lemma) = lemma_part {
1307            prose = if prose.is_empty() {
1308                lemma
1309            } else {
1310                format!("{lemma}: {prose}")
1311            };
1312        }
1313        nodes[i].prose = prose;
1314    }
1315}
1316
1317impl TextModel {
1318    /// The body prose: everything between the lemma and the
1319    /// hypograph — the simmere anatomy's third member, derived
1320    /// from the flattened prose by construction (the lemma joins
1321    /// a block on its own line, an item with `: `; the hypograph
1322    /// closes on its own line).
1323    fn grammata(&self, node: NodeId) -> String {
1324        let n = &self.nodes[node.0 as usize];
1325        let mut s = n.prose.as_str();
1326        if let Some(lemma) = &n.lemma
1327            && !lemma.is_empty()
1328            && let Some(rest) = s.strip_prefix(lemma.as_str())
1329        {
1330            s = rest
1331                .strip_prefix(": ")
1332                .or_else(|| rest.strip_prefix('\n'))
1333                .unwrap_or(rest);
1334        }
1335        if let Some(h) = &n.hypograph
1336            && !h.is_empty()
1337            && let Some(rest) = s.strip_suffix(h.as_str())
1338        {
1339            s = rest.strip_suffix('\n').unwrap_or(rest);
1340        }
1341        s.trim_end().to_string()
1342    }
1343}
1344
1345impl AstAdapter for TextModel {
1346    fn root(&self) -> NodeId {
1347        self.root
1348    }
1349
1350    fn children(&self, node: NodeId) -> Vec<NodeId> {
1351        self.nodes[node.0 as usize].children.clone()
1352    }
1353
1354    fn name(&self, node: NodeId) -> Option<String> {
1355        self.nodes[node.0 as usize].kind.name().map(str::to_string)
1356    }
1357
1358    fn parent(&self, node: NodeId) -> Option<NodeId> {
1359        self.nodes[node.0 as usize].parent
1360    }
1361
1362    /// The `<block>` family on every block node, plus `<table>` on
1363    /// a list that denormalizes a table. Kinds are node names, not
1364    /// traits.
1365    fn traits(&self, node: NodeId) -> Vec<String> {
1366        let n = &self.nodes[node.0 as usize];
1367        let mut out = Vec::new();
1368        // The document wears its declared classification: one
1369        // trait per tag and one for the category — the LDAP rule,
1370        // where a multi-valued classifying attribute is the node's
1371        // traits — each in its own namespace (`<tag:pandas>`,
1372        // `<category:Guides>`), so a tag can never collide with a
1373        // structural trait like `<table>` or `<note>`, and spelled
1374        // as declared, case kept.
1375        if n.kind == Kind::Document {
1376            for (ns, t) in self
1377                .head
1378                .tags
1379                .iter()
1380                .map(|t| ("tag", t))
1381                .chain(self.head.category.iter().map(|c| ("category", c)))
1382            {
1383                let name = HeadMeta::trait_name(t);
1384                if name.is_empty() {
1385                    continue;
1386                }
1387                let name = format!("{ns}:{name}");
1388                if !out.contains(&name) {
1389                    out.push(name);
1390                }
1391            }
1392        }
1393        // A callout is inline apparatus, not a block (ruling #35);
1394        // so is an index mark (ruling #36). Strophes and stichos
1395        // lines are sub-block structure (ruling #37, litogramma's
1396        // own family rule) — the verse block carries the trait.
1397        if n.kind != Kind::Document
1398            && !n.deixis
1399            && !matches!(
1400                n.kind,
1401                Kind::IndexMark
1402                    | Kind::Ref
1403                    | Kind::Cit
1404                    | Kind::Anchor
1405                    | Kind::Strophe
1406                    | Kind::Stichos
1407            )
1408        {
1409            out.push("block".to_string());
1410        }
1411        // The reference vocabulary (atrep's semantic traits): a
1412        // dangling mention keeps its node and the linter finds it;
1413        // `<target>` marks what bears a referable name — anchors
1414        // and labeled blocks, never the apparatus families (their
1415        // onyms pair within the family, not in this namespace).
1416        if matches!(n.kind, Kind::Ref | Kind::Cit) && n.dangling {
1417            out.push("dangling".to_string());
1418        }
1419        // A structured entry's genus (liber, commentarius, …)
1420        // surfaces as its trait, the genos rule.
1421        if n.kind == Kind::Bib
1422            && let Some(g) = &n.genus
1423        {
1424            out.push(g.clone());
1425        }
1426        if n.kind == Kind::Anchor
1427            || (n.onym.is_some()
1428                && !n.deixis
1429                && !matches!(
1430                    n.kind,
1431                    Kind::Footnote
1432                        | Kind::Endnote
1433                        | Kind::Aside
1434                        | Kind::IndexMark
1435                        | Kind::Ref
1436                        | Kind::Cit
1437                        | Kind::Bib
1438                ))
1439        {
1440            out.push("target".to_string());
1441        }
1442        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
1443            // The body IS the note — `<note>` marks it, whichever
1444            // note family; the callout carries `<deixis>` alone.
1445            // An aside body is content, not apparatus: no
1446            // `<note>`. No both-ends trait: `//footnote` already
1447            // gathers a family whole, `//*<deixis>` the callouts
1448            // across families, `//*<note>` the note bodies.
1449            if n.deixis {
1450                out.push("deixis".to_string());
1451            } else if n.kind != Kind::Aside {
1452                out.push("note".to_string());
1453            }
1454            if n.dangling {
1455                out.push("dangling".to_string());
1456            }
1457        }
1458        if n.table {
1459            out.push("table".to_string());
1460        }
1461        if n.row {
1462            out.push("row".to_string());
1463        }
1464        if n.cell {
1465            out.push("cell".to_string());
1466        }
1467        out
1468    }
1469
1470    /// `::lemma` (title), `::hypograph` (footer or attribution),
1471    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
1472    /// the bare projection).
1473    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
1474    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
1475    /// `::attribution`, `::ord`), answered here because this
1476    /// adapter's property surface IS the vocabulary; on data
1477    /// adapters those spellings stay ordinary field names. The
1478    /// Greek is canon in docs and reflection preserves whichever
1479    /// spelling was written.
1480    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
1481        let n = &self.nodes[node.0 as usize];
1482        // The document node answers its declared identity: the
1483        // curated core of what the head says.
1484        if n.kind == Kind::Document {
1485            let h = &self.head;
1486            match name {
1487                "title" | "lemma" => return h.title.clone().map(Value::Str),
1488                "description" => return h.description.clone().map(Value::Str),
1489                "author" => return h.author.clone().map(Value::Str),
1490                "category" => return h.category.clone().map(Value::Str),
1491                "tags" => {
1492                    return (!h.tags.is_empty())
1493                        .then(|| Value::List(h.tags.iter().cloned().map(Value::Str).collect()));
1494                }
1495                "published" => return h.published.clone(),
1496                "modified" => return h.modified.clone(),
1497                _ => {}
1498            }
1499        }
1500        // A structured entry answers its campi first — under the
1501        // canonical Latin name or any census alias (BibLaTeX's
1502        // field names included), case-insensitively — ahead of
1503        // the general vocabulary, so `::title` on a bib means
1504        // titulus, not the lemma alias.
1505        if n.kind == Kind::Bib && !n.fields.is_empty() {
1506            let canon = self
1507                .bib_aliases
1508                .get(&name.to_lowercase())
1509                .map(String::as_str)
1510                .unwrap_or(name);
1511            if let Some((_, v)) = n.fields.iter().find(|(k, _)| k == canon) {
1512                return Some(Value::Str(v.clone()));
1513            }
1514        }
1515        match name {
1516            // A link into the node, as far as the served form can
1517            // narrow it; the anchor it lands on; and the record a
1518            // client renders as a clickable result.
1519            "href" => return self.href(node).map(Value::Str),
1520            "anchor" => return self.nearest_onym(node).map(|(a, _)| Value::Str(a)),
1521            "link" => {
1522                let href = self.href(node)?;
1523                let text = match self.default_value(node) {
1524                    Some(Value::Str(t)) => t,
1525                    _ => String::new(),
1526                };
1527                return Some(Value::Record(vec![
1528                    ("title".to_string(), self.document_title().map(Value::Str).unwrap_or(Value::Null)),
1529                    ("href".to_string(), Value::Str(href)),
1530                    ("text".to_string(), Value::Str(text)),
1531                ]));
1532            }
1533            _ => {}
1534        }
1535        match name {
1536            "lemma" | "title" => n.lemma.clone().map(Value::Str),
1537            "onym" => n.onym.clone().map(Value::Str),
1538            // On an index mark the onym IS the term (ruling #36).
1539            "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
1540            // A ref's word: the identifier as written. Never the
1541            // resolved content — that is what `-->` is for.
1542            "target" if matches!(n.kind, Kind::Ref | Kind::Cit) => n.target.clone().map(Value::Str),
1543            // A structured entry answers its campi (auctor,
1544            // titulus, annus, …) — the bibliogramma vocabulary,
1545            // canonicalized upstream.
1546            _ if n.kind == Kind::Bib => {
1547                let canon = self
1548                    .bib_aliases
1549                    .get(&name.to_lowercase())
1550                    .map(String::as_str)
1551                    .unwrap_or(name);
1552                n.fields
1553                    .iter()
1554                    .find(|(k, _)| k == canon)
1555                    .map(|(_, v)| Value::Str(v.clone()))
1556            }
1557            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
1558            "taxis" | "ord" => n.taxis.map(Value::Int),
1559            "grammata" | "body" => {
1560                let g = self.grammata(node);
1561                if g.is_empty() {
1562                    None
1563                } else {
1564                    Some(Value::Str(g))
1565                }
1566            }
1567            "text" => Some(Value::Str(n.prose.clone())),
1568            _ => None,
1569        }
1570    }
1571
1572    /// The default projection is the flattened prose of the
1573    /// subtree — lemma first, hypograph last.
1574    fn default_value(&self, node: NodeId) -> Option<Value> {
1575        let n = &self.nodes[node.0 as usize];
1576        if n.kind == Kind::IndexMark {
1577            // Invisible in the surrounding prose; its own
1578            // projection is the term it declares.
1579            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1580        }
1581        if n.deixis {
1582            // The atrep rule: a resolved reference projects as its
1583            // target's rendered form, degrading to the raw onym
1584            // when dangling.
1585            return Some(Value::Str(match n.note_edge {
1586                Some(body) => self.nodes[body.0 as usize].prose.clone(),
1587                None => n.onym.clone().unwrap_or_default(),
1588            }));
1589        }
1590        if n.kind == Kind::Ref {
1591            // The authored link text; LaTeX \ref has none, so the
1592            // atrep projection rule applies — the target's
1593            // rendered form (its lemma, else its prose), degrading
1594            // to the raw target when dangling.
1595            if !n.text.is_empty() {
1596                return Some(Value::Str(n.text.clone()));
1597            }
1598            return Some(Value::Str(match n.ref_edge {
1599                Some(t) => {
1600                    let b = &self.nodes[t.0 as usize];
1601                    b.lemma.clone().unwrap_or_else(|| b.prose.clone())
1602                }
1603                None => n.target.clone().unwrap_or_default(),
1604            }));
1605        }
1606        if n.kind == Kind::Bib && n.prose.is_empty() && !n.fields.is_empty() {
1607            // A structured entry's plain form: its field values in
1608            // source order — the full data, no citation styling.
1609            return Some(Value::Str(
1610                n.fields
1611                    .iter()
1612                    .map(|(_, v)| v.as_str())
1613                    .collect::<Vec<_>>()
1614                    .join(". "),
1615            ));
1616        }
1617        if n.kind == Kind::Cit {
1618            // The mark's own identity: the raw key. The entry is
1619            // one arrow away (`//cit--> ::`); rendered citation
1620            // styles are presentation, not structure.
1621            return Some(Value::Str(n.target.clone().unwrap_or_default()));
1622        }
1623        if n.kind == Kind::Anchor {
1624            // Invisible in the prose; its own projection is the
1625            // name it bears.
1626            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1627        }
1628        Some(Value::Str(n.prose.clone()))
1629    }
1630
1631    /// The edge label is the family name both ends share —
1632    /// `->footnote` / `->endnote` — the node's own kind, since
1633    /// resolution re-kinds an open-family callout to its body.
1634    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1635        let n = &self.nodes[node.0 as usize];
1636        if let Some(t) = n.ref_edge {
1637            // The atrep rule: a resolved mention emits its typed
1638            // crosslink — `->ref`, or `->cit` for a citation.
1639            let label = if n.kind == Kind::Cit { "cit" } else { "ref" };
1640            return vec![(label.to_string(), t)];
1641        }
1642        match n.note_edge {
1643            Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
1644            None => Vec::new(),
1645        }
1646    }
1647
1648    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1649        let n = &self.nodes[node.0 as usize];
1650        let mut out: Vec<(String, NodeId)> = n
1651            .ref_cites
1652            .iter()
1653            .map(|&c| {
1654                let label = if self.nodes[c.0 as usize].kind == Kind::Cit {
1655                    "cit"
1656                } else {
1657                    "ref"
1658                };
1659                (label.to_string(), c)
1660            })
1661            .collect();
1662        let label = n.kind.name().unwrap_or("footnote");
1663        out.extend(n.cites.iter().map(|&c| (label.to_string(), c)));
1664        out
1665    }
1666
1667    /// `//ref-->`: land on the bearer — the block if a block
1668    /// bears the name, the point anchor if a point does. The
1669    /// text-level hints choose the reading: `-->block` normalizes
1670    /// a point landing to its enclosing block, `-->point` selects
1671    /// point targets only; bare is the honest exact landing.
1672    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
1673        let n = &self.nodes[node.0 as usize];
1674        if !matches!(n.kind, Kind::Ref | Kind::Cit) || property != "target" {
1675            return None;
1676        }
1677        let landed = n.ref_edge?;
1678        let is_point = self.nodes[landed.0 as usize].kind == Kind::Anchor;
1679        match hint {
1680            None | Some("*") => Some(landed),
1681            Some("point") => is_point.then_some(landed),
1682            Some("block") => {
1683                if !is_point {
1684                    return Some(landed);
1685                }
1686                // The anchor's enclosing block.
1687                let mut at = self.nodes[landed.0 as usize].parent;
1688                while let Some(b) = at {
1689                    let bn = &self.nodes[b.0 as usize];
1690                    if bn.kind != Kind::Document
1691                        && !matches!(bn.kind, Kind::Ref | Kind::Anchor | Kind::IndexMark)
1692                    {
1693                        return Some(b);
1694                    }
1695                    at = bn.parent;
1696                }
1697                None
1698            }
1699            Some(_) => None,
1700        }
1701    }
1702
1703    /// The bare arrow's property: a ref resolves its target.
1704    fn ref_property(&self, node: NodeId) -> Option<String> {
1705        matches!(self.nodes[node.0 as usize].kind, Kind::Ref | Kind::Cit)
1706            .then(|| "target".to_string())
1707    }
1708
1709    /// A ref the producer declared external: the absolute URL,
1710    /// relative targets joined against the document's own URL —
1711    /// the same acquisition rung the html DOM reading speaks.
1712    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
1713        if !matches!(hint, None | Some("*")) {
1714            return None;
1715        }
1716        let n = &self.nodes[node.0 as usize];
1717        if n.kind != Kind::Ref || property != "target" || n.internal {
1718            return None;
1719        }
1720        let t = n.target.as_deref()?.trim();
1721        if t.is_empty() || t.starts_with('#') {
1722            return None;
1723        }
1724        let u = match url::Url::parse(t) {
1725            Ok(u) => u,
1726            Err(url::ParseError::RelativeUrlWithoutBase) => self
1727                .link_base
1728                .as_ref()
1729                .or(self.document_url.as_ref())?
1730                .join(t)
1731                .ok()?,
1732            Err(_) => return None,
1733        };
1734        if u.scheme() != "http" && u.scheme() != "https" {
1735            return None;
1736        }
1737        Some(u.into())
1738    }
1739
1740    /// A sibling document's `#fragment` lands here: the bearer of
1741    /// that name — a labeled block, or a point anchor.
1742    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
1743        self.onyms.get(fragment).copied()
1744    }
1745
1746    /// Ruling #29: the text level's surface is the vocabulary
1747    /// itself — no document can introduce a property name — so
1748    /// its two annotations answer at `::` as well.
1749    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
1750        &["level", "lang", "form"]
1751    }
1752
1753    /// `::::level` on sections (the source heading level) and
1754    /// `::::lang` on verbatim blocks (the declared language).
1755    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1756        let n = &self.nodes[node.0 as usize];
1757        // The lossless layer: on the document node, every
1758        // declaration under the name it was declared with.
1759        if n.kind == Kind::Document
1760            && let Some(v) = self.head.declared_value(key)
1761        {
1762            return Some(v);
1763        }
1764        match key {
1765            "level" => n.level.map(|l| Value::Int(l as i64)),
1766            "lang" => n.lang.clone().map(Value::Str),
1767            // `::::resolved` on a callout: the broken-apparatus
1768            // linter's fact (ruling #35).
1769            "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
1770            // …and on an internal ref: the broken-cross-reference
1771            // linter's fact. External refs resolve at query time,
1772            // so the fact is not theirs to answer.
1773            "resolved" if n.kind == Kind::Cit || (n.kind == Kind::Ref && n.internal) => {
1774                Some(Value::Bool(n.ref_edge.is_some()))
1775            }
1776            // The declared spelling was a margin form (a Tufte
1777            // sidenote): family footnote, placement preserved.
1778            "form" if n.margin => Some(Value::Str("margin".to_string())),
1779            // The entry's genus, also queryable as an annotation.
1780            "genus" if n.kind == Kind::Bib => n.genus.clone().map(Value::Str),
1781            _ => None,
1782        }
1783    }
1784}