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 { kind: Container, lemma: Option<String> },
74    /// Close the innermost open container, optionally with its
75    /// hypograph (footer or attribution).
76    Close { hypograph: Option<String> },
77    /// A verbatim block — code or other preformatted lines, kept
78    /// as authored.
79    Verbatim { lang: Option<String>, text: String },
80    /// An in-text note callout (the deixis, ruling #35): `onym`
81    /// names the body it cites within its family. Emitted after
82    /// the flow block it sits in; it becomes that block's child,
83    /// in order, and the block's own text stays clean of markers.
84    /// `family: None` means the source declares none (an HTML
85    /// noteref): the callout takes its resolved body's family,
86    /// and a dangling one defaults to footnote.
87    NoteRef {
88        onym: String,
89        family: Option<NoteFamily>,
90        /// The declared spelling placed this pair in the margin
91        /// (a Tufte-style sidenote): surfaced as `::::form =
92        /// "margin"` on both ends — the family stays footnote,
93        /// placement is presentation.
94        margin: bool,
95    },
96    /// An index mark (ruling #36): an invisible anchor declaring
97    /// this place concerns `term`. The back-of-book index is a
98    /// query over these, never a stored structure.
99    IndexMark { term: String },
100    /// A reference mark — the text-level mention (html's in-prose
101    /// `<a href>`, markdown's `[text](url)`, LaTeX's `\ref`):
102    /// `target` is the identifier as written, `text` the authored
103    /// link text (LaTeX `\ref` has none), and `internal` is the
104    /// producer's declaration that the target names a label in
105    /// *this* document (`#fragment`, a `\ref` key) rather than
106    /// another document. A mention, not an attachment: refs do
107    /// not carry `<deixis>`.
108    Ref {
109        target: String,
110        text: Option<String>,
111        internal: bool,
112    },
113    /// A point anchor — an invisible in-flow node bearing an
114    /// `onym` at this position (LaTeX's mid-flow `\label`, an
115    /// inline html `id=`). The IndexMark construction: child of
116    /// its flow block, document order.
117    Anchor { onym: String },
118    /// A citation mark — the bibliography's mention (LaTeX's
119    /// `\cite`, koine's cite monosim): `target` is the authored
120    /// key, resolved against the `bib` bearers — a namespace of
121    /// its own, separate from labels (LaTeX precedent).
122    Cite { target: String },
123    /// One bibliographic entry: a block bearing its authored
124    /// `key`. Unstructured sources (\bibitem) carry the full
125    /// reference as `text`; structured ones (bibliogramma,
126    /// BibTeX/BibLaTeX through it) carry `fields` — canonical
127    /// campus name → value, Latin per the bibliogramma
128    /// vocabulary — and the entry's `genus` (liber,
129    /// commentarius, …).
130    Bib {
131        key: String,
132        text: String,
133        fields: Vec<(String, String)>,
134        genus: Option<String>,
135    },
136    /// A block label: the innermost open section takes `onym` as
137    /// the name it bears (a heading's `id`; a `\label` attached
138    /// to a sectioning command — the promotion rule). With no
139    /// open section it degrades to a point [`Block::Anchor`].
140    Label { onym: String },
141    /// A verse block (ruling #37 — litogramma's stichoi model):
142    /// strophes of lines, denormalized like [`Block::Table`].
143    /// Lines arrive flattened by the adapter and are kept as
144    /// authored; the stichos `::taxis` numbers lines 1-based,
145    /// CONTINUOUSLY across strophes — the citation coordinate.
146    Verse {
147        lemma: Option<String>,
148        strophes: Vec<Vec<String>>,
149        hypograph: Option<String>,
150    },
151    /// A table, denormalized here into nested lists (rows =
152    /// ordered items with the `<row>` trait, cells = unordered
153    /// items with the `<cell>` trait and the column name as
154    /// `::lemma`). Header *detection* is the adapter's job; the
155    /// lowering rule lives here. A cell's own `label` (a row's
156    /// `th`) wins over the positional `headers` entry.
157    Table {
158        lemma: Option<String>,
159        headers: Option<Vec<String>>,
160        rows: Vec<Vec<Cell>>,
161    },
162}
163
164/// One table cell as an adapter hands it over: the text, plus the
165/// label a row-shaped dialect attaches directly (an infobox row's
166/// `th`). Grid dialects leave `label` empty and let the lowering
167/// zip the header row on by position.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Cell {
170    pub label: Option<String>,
171    pub text: String,
172}
173
174impl From<&str> for Cell {
175    fn from(text: &str) -> Self {
176        Cell {
177            label: None,
178            text: text.to_string(),
179        }
180    }
181}
182
183impl From<String> for Cell {
184    fn from(text: String) -> Self {
185        Cell { label: None, text }
186    }
187}
188
189/// The nesting containers an adapter opens and closes explicitly.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum Container {
192    Blockquote,
193    UnorderedList,
194    /// `start` is the first item's ordinal (Markdown's `3.` lists).
195    OrderedList { start: i64 },
196    /// A list item; flavor and taxis come from the enclosing list.
197    Item,
198    /// A note body (the noted, ruling #35): opens a note node of
199    /// its family at the document root — litogramma's canonical
200    /// document-end placement — holding its own blocks. `margin`
201    /// as on [`Block::NoteRef`].
202    Note {
203        onym: String,
204        family: NoteFamily,
205        margin: bool,
206    },
207}
208
209/// The deixis-attached families litogramma's canon names
210/// (ruling #35, as amended): each family's callout and body
211/// share its name. `Aside` rides the same construction —
212/// koine's `aside` sim, an anchored body whose insertion point
213/// is a deixis — but is content, not apparatus: its bodies do
214/// not carry `<note>`.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
216pub enum NoteFamily {
217    Footnote,
218    Endnote,
219    Aside,
220}
221
222impl NoteFamily {
223    fn kind(self) -> Kind {
224        match self {
225            NoteFamily::Footnote => Kind::Footnote,
226            NoteFamily::Endnote => Kind::Endnote,
227            NoteFamily::Aside => Kind::Aside,
228        }
229    }
230}
231
232/// The structural kind of a node — also its name.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
234enum Kind {
235    Document,
236    Section,
237    Paragraph,
238    Blockquote,
239    UnorderedList,
240    OrderedList,
241    UnorderedItem,
242    OrderedItem,
243    Verbatim,
244    /// Callout and body both — they share the name by
245    /// construction (litogramma F5): `//footnote` gathers the
246    /// whole apparatus, `<deixis>`/`<note>` tell them apart.
247    Footnote,
248    /// The second note family, same construction.
249    Endnote,
250    /// The anchored-content family, same construction
251    /// (litogramma's aside): body + insertion-point deixis. Content, not
252    /// apparatus — no `<note>` on its bodies.
253    Aside,
254    /// An index mark (ruling #36): `::term`, in flow position.
255    IndexMark,
256    /// A reference mark: `::target`, `-->` resolves it.
257    Ref,
258    /// A citation mark: `::target` the authored key, resolved
259    /// against the `bib` namespace.
260    Cit,
261    /// One bibliographic entry: `::onym` the key it bears, its
262    /// projection the full reference text.
263    Bib,
264    /// A point anchor: `::onym`, an invisible in-flow bearer.
265    Anchor,
266    /// The verse vocabulary (ruling #37): the stichoi container…
267    Verse,
268    /// …its strophes…
269    Strophe,
270    /// …and its lines, `::taxis` the citation coordinate.
271    Stichos,
272}
273
274impl Kind {
275    fn name(self) -> Option<&'static str> {
276        Some(match self {
277            Kind::Document => return None,
278            Kind::Section => "section",
279            Kind::Paragraph => "paragraph",
280            Kind::Blockquote => "blockquote",
281            Kind::Footnote => "footnote",
282            Kind::Endnote => "endnote",
283            Kind::Aside => "aside",
284            Kind::IndexMark => "index-mark",
285            Kind::Ref => "ref",
286            Kind::Cit => "cit",
287            Kind::Bib => "bib",
288            Kind::Anchor => "anchor",
289            Kind::Verse => "verse",
290            Kind::Strophe => "strophe",
291            Kind::Stichos => "stichos",
292            Kind::UnorderedList => "unordered-list",
293            Kind::OrderedList => "ordered-list",
294            Kind::UnorderedItem => "unordered-item",
295            Kind::OrderedItem => "ordered-item",
296            Kind::Verbatim => "verbatim",
297        })
298    }
299}
300
301struct Node {
302    kind: Kind,
303    lemma: Option<String>,
304    hypograph: Option<String>,
305    taxis: Option<i64>,
306    /// Source heading level, on sections.
307    level: Option<u8>,
308    /// Declared language, on verbatim blocks.
309    lang: Option<String>,
310    /// First ordinal of an ordered list (not exposed; feeds the
311    /// items' taxis).
312    start: i64,
313    /// The node's own (direct) text, before subtree flattening.
314    text: String,
315    /// The flattened prose of the subtree — the `::` projection.
316    prose: String,
317    /// The node heads a denormalized table (`<table>` trait).
318    table: bool,
319    /// The node is a denormalized table row (`<row>` trait).
320    row: bool,
321    /// The node is a denormalized table cell (`<cell>` trait).
322    cell: bool,
323    /// The apparatus (ruling #35): the note name as written —
324    /// and, on an index mark (ruling #36), the term.
325    onym: Option<String>,
326    /// A footnote callout (`<deixis>`) rather than a body.
327    deixis: bool,
328    /// A callout whose body is missing (`<dangling>`).
329    dangling: bool,
330    /// A callout whose source declared no family: resolution may
331    /// re-kind it from the body it reaches.
332    family_open: bool,
333    /// The declared spelling was a margin form (`::::form`).
334    margin: bool,
335    /// Callout -> body edge, once resolved.
336    note_edge: Option<NodeId>,
337    /// Body <- callouts (the reverse index).
338    cites: Vec<NodeId>,
339    /// A ref's target identifier, as written.
340    target: Option<String>,
341    /// The producer declared the target in-document.
342    internal: bool,
343    /// A structured bib entry's fields (campus → value) and its
344    /// genus, per the bibliogramma vocabulary.
345    fields: Vec<(String, String)>,
346    genus: Option<String>,
347    /// Ref -> bearer edge, once resolved (in-document).
348    ref_edge: Option<NodeId>,
349    /// Bearer <- refs (the reverse index).
350    ref_cites: Vec<NodeId>,
351    parent: Option<NodeId>,
352    children: Vec<NodeId>,
353}
354
355impl Node {
356    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
357        Node {
358            kind,
359            lemma: None,
360            hypograph: None,
361            taxis: None,
362            level: None,
363            lang: None,
364            start: 1,
365            text: String::new(),
366            prose: String::new(),
367            table: false,
368            row: false,
369            cell: false,
370            onym: None,
371            deixis: false,
372            dangling: false,
373            family_open: false,
374            margin: false,
375            note_edge: None,
376            cites: Vec::new(),
377            target: None,
378            internal: false,
379            fields: Vec::new(),
380            genus: None,
381            ref_edge: None,
382            ref_cites: Vec::new(),
383            parent,
384            children: Vec::new(),
385        }
386    }
387}
388
389/// The reference pass: collect every borne name (block labels
390/// and point anchors — deixis callouts pair within their own
391/// families and stay out of this namespace), then land each
392/// internal ref on its bearer, `<dangling>` when nothing bears
393/// the name. External refs resolve at query time, through the
394/// engine's reference machinery.
395fn resolve_refs(nodes: &mut [Node]) -> std::collections::HashMap<String, NodeId> {
396    let mut onyms: std::collections::HashMap<String, NodeId> =
397        std::collections::HashMap::new();
398    // Citation keys are a namespace of their own, separate from
399    // labels (LaTeX's \bibcite vs \newlabel precedent): `cit`
400    // resolves against `bib` bearers only.
401    let mut bibs: std::collections::HashMap<String, NodeId> =
402        std::collections::HashMap::new();
403    for (i, n) in nodes.iter().enumerate() {
404        if n.deixis || matches!(n.kind, Kind::IndexMark | Kind::Ref | Kind::Cit) {
405            continue;
406        }
407        if n.kind == Kind::Bib {
408            if let Some(k) = &n.onym {
409                bibs.entry(k.clone()).or_insert(NodeId(i as u64));
410            }
411            continue;
412        }
413        let bearer = n.kind == Kind::Anchor
414            || (!matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
415                && n.onym.is_some());
416        if bearer && let Some(o) = &n.onym {
417            onyms.entry(o.clone()).or_insert(NodeId(i as u64));
418        }
419    }
420    for i in 0..nodes.len() {
421        let (map, key) = match nodes[i].kind {
422            Kind::Ref if nodes[i].internal => (
423                &onyms,
424                nodes[i]
425                    .target
426                    .as_deref()
427                    .map(|t| t.trim_start_matches('#').to_string())
428                    .unwrap_or_default(),
429            ),
430            Kind::Cit => (
431                &bibs,
432                nodes[i].target.clone().unwrap_or_default(),
433            ),
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/// A Quarb adapter over a text-level document.
456pub struct TextModel {
457    nodes: Vec<Node>,
458    root: NodeId,
459    /// Every borne name → its bearer (block labels and point
460    /// anchors, one namespace, first bearer in document order
461    /// wins) — the landing map for `-->` and for a sibling
462    /// document's `#fragment`.
463    onyms: std::collections::HashMap<String, NodeId>,
464    /// The document's own URL, when the mount knows it.
465    document_url: Option<url::Url>,
466    /// Alias → canonical campus, for bib field lookup: the
467    /// bibliogramma vocabulary's census (BibLaTeX's field names
468    /// are its English rows), so `::author` answers as
469    /// `::auctor` — in any covered language. Set by the koine
470    /// route, which holds the dialektos.
471    bib_aliases: std::collections::HashMap<String, String>,
472}
473
474impl TextModel {
475    /// Assemble the document tree from a adapter's event stream.
476    ///
477    /// Iterative throughout (the stream is flat; prose flattening
478    /// runs over indices), so pathological nesting cannot overflow
479    /// the call stack. Lenient on malformed streams: a stray
480    /// `Close` is ignored, unclosed containers close at the end.
481    pub fn build(blocks: Vec<Block>) -> Self {
482        let mut nodes = vec![Node::new(Kind::Document, None)];
483        let root = NodeId(0);
484        // Innermost-last stack of open *sections* (outline-derived).
485        let mut sections: Vec<NodeId> = Vec::new();
486        // Innermost-last stack of open explicit containers.
487        let mut containers: Vec<NodeId> = Vec::new();
488        // The block a NoteRef callout attaches to: the last flow
489        // node created or appended to (ruling #35).
490        let mut last_flow: Option<NodeId> = None;
491        // The block a Label names: the most recently opened block
492        // of any kind — a heading's section, a paragraph, a
493        // container, a verbatim — the html-id / attached-\label
494        // parity rule.
495        let mut last_block: Option<NodeId> = None;
496
497        for block in blocks {
498            match block {
499                Block::Heading { level, lemma } => {
500                    let lemma = normalize_ws(&lemma);
501                    if !containers.is_empty() {
502                        // Decorative heading inside a container:
503                        // not sectioning — lower to a paragraph.
504                        if !lemma.is_empty() {
505                            let parent = *containers.last().unwrap();
506                            let id = push(&mut nodes, Kind::Paragraph, parent);
507                            nodes[id.0 as usize].text = lemma;
508                        }
509                        continue;
510                    }
511                    while let Some(&open) = sections.last() {
512                        if nodes[open.0 as usize].level >= Some(level) {
513                            sections.pop();
514                        } else {
515                            break;
516                        }
517                    }
518                    let parent = sections.last().copied().unwrap_or(root);
519                    let id = push(&mut nodes, Kind::Section, parent);
520                    let n = &mut nodes[id.0 as usize];
521                    n.lemma = Some(lemma);
522                    n.level = Some(level);
523                    sections.push(id);
524                    last_flow = Some(id);
525                    last_block = Some(id);
526                }
527                Block::Paragraph { text } => {
528                    let text = normalize_ws(&text);
529                    if text.is_empty() {
530                        continue;
531                    }
532                    let parent = cursor(&sections, &containers, root);
533                    let id = push(&mut nodes, Kind::Paragraph, parent);
534                    nodes[id.0 as usize].text = text;
535                    last_flow = Some(id);
536                    last_block = Some(id);
537                }
538                Block::Text { text } => {
539                    let text = normalize_ws(&text);
540                    if text.is_empty() {
541                        continue;
542                    }
543                    match containers.last() {
544                        Some(&open) => {
545                            let own = &mut nodes[open.0 as usize].text;
546                            if !own.is_empty() {
547                                own.push(' ');
548                            }
549                            own.push_str(&text);
550                            last_flow = Some(open);
551                        }
552                        None => {
553                            let parent = sections.last().copied().unwrap_or(root);
554                            let id = push(&mut nodes, Kind::Paragraph, parent);
555                            nodes[id.0 as usize].text = text;
556                            last_flow = Some(id);
557                        }
558                    }
559                }
560                Block::Open { kind, lemma } => {
561                    // A note body opens at the document root —
562                    // litogramma's canonical document-end
563                    // placement — whatever else is open.
564                    let kind = match kind {
565                        Container::Note { onym, family, margin } => {
566                            let id = push(&mut nodes, family.kind(), root);
567                            let n = &mut nodes[id.0 as usize];
568                            n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
569                            n.margin = margin;
570                            containers.push(id);
571                            last_flow = Some(id);
572                            continue;
573                        }
574                        other => other,
575                    };
576                    let parent = cursor(&sections, &containers, root);
577                    let (nkind, start) = match kind {
578                        Container::Blockquote => (Kind::Blockquote, None),
579                        Container::UnorderedList => (Kind::UnorderedList, None),
580                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
581                        Container::Item => (
582                            match nodes[parent.0 as usize].kind {
583                                Kind::OrderedList => Kind::OrderedItem,
584                                _ => Kind::UnorderedItem,
585                            },
586                            None,
587                        ),
588                        Container::Note { .. } => unreachable!("handled above"),
589                    };
590                    let id = push(&mut nodes, nkind, parent);
591                    last_block = Some(id);
592                    nodes[id.0 as usize].lemma =
593                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
594                    if let Some(start) = start {
595                        nodes[id.0 as usize].start = start;
596                    }
597                    if nkind == Kind::OrderedItem {
598                        // `push` already appended this item, so the
599                        // count includes it.
600                        let nth = nodes[parent.0 as usize]
601                            .children
602                            .iter()
603                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
604                            .count() as i64;
605                        let start = nodes[parent.0 as usize].start;
606                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
607                    }
608                    containers.push(id);
609                }
610                Block::Close { hypograph } => {
611                    if let Some(open) = containers.pop() {
612                        nodes[open.0 as usize].hypograph =
613                            hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
614                    }
615                }
616                // The callout: a `footnote` child of the flow
617                // block it sits in, `<deixis>`-traited; the edge
618                // to its body resolves after the stream.
619                Block::NoteRef { onym, family, margin } => {
620                    let parent = last_flow.unwrap_or(root);
621                    // A declared family names the callout now; an
622                    // undeclared one is settled at resolution from
623                    // the body it reaches (footnote when dangling).
624                    let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
625                    let id = push(&mut nodes, kind, parent);
626                    let n = &mut nodes[id.0 as usize];
627                    n.deixis = true;
628                    n.family_open = family.is_none();
629                    n.margin = margin;
630                    n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
631                }
632                Block::Ref { target, text, internal } => {
633                    let parent = last_flow.unwrap_or(root);
634                    let id = push(&mut nodes, Kind::Ref, parent);
635                    let n = &mut nodes[id.0 as usize];
636                    n.internal = internal;
637                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
638                    n.text = text.map(|t| normalize_ws(&t)).unwrap_or_default();
639                }
640                Block::Cite { target } => {
641                    let parent = last_flow.unwrap_or(root);
642                    let id = push(&mut nodes, Kind::Cit, parent);
643                    let n = &mut nodes[id.0 as usize];
644                    n.internal = true;
645                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
646                }
647                Block::Bib {
648                    key,
649                    text,
650                    fields,
651                    genus,
652                } => {
653                    let parent = cursor(&sections, &containers, root);
654                    let id = push(&mut nodes, Kind::Bib, parent);
655                    last_block = Some(id);
656                    let n = &mut nodes[id.0 as usize];
657                    n.onym = Some(key.trim().to_string()).filter(|k| !k.is_empty());
658                    n.text = normalize_ws(&text);
659                    n.fields = fields
660                        .into_iter()
661                        .map(|(k, v)| (k.trim().to_string(), normalize_ws(&v)))
662                        .filter(|(k, v)| !k.is_empty() && !v.is_empty())
663                        .collect();
664                    n.genus = genus.map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
665                }
666                Block::Anchor { onym } => {
667                    let parent = last_flow.unwrap_or(root);
668                    let id = push(&mut nodes, Kind::Anchor, parent);
669                    nodes[id.0 as usize].onym =
670                        Some(onym.trim().to_string()).filter(|o| !o.is_empty());
671                }
672                Block::Label { onym } => {
673                    let onym = onym.trim().to_string();
674                    if onym.is_empty() {
675                        continue;
676                    }
677                    // The most recently opened block bears the
678                    // name — a heading's section, a paragraph, a
679                    // blockquote: the html-id / attached-\label
680                    // parity rule. A label before any block, or on
681                    // a block already named, degrades to a point.
682                    let free = last_block
683                        .map(|b| nodes[b.0 as usize].onym.is_none())
684                        .unwrap_or(false);
685                    match (last_block, free) {
686                        (Some(b), true) => {
687                            nodes[b.0 as usize].onym = Some(onym);
688                        }
689                        _ => {
690                            let parent = last_flow.unwrap_or(root);
691                            let id = push(&mut nodes, Kind::Anchor, parent);
692                            nodes[id.0 as usize].onym = Some(onym);
693                        }
694                    }
695                }
696                Block::IndexMark { term } => {
697                    let parent = last_flow.unwrap_or(root);
698                    let id = push(&mut nodes, Kind::IndexMark, parent);
699                    nodes[id.0 as usize].onym =
700                        Some(quarb_term(&term)).filter(|t| !t.is_empty());
701                }
702                Block::Verbatim { lang, text } => {
703                    let parent = cursor(&sections, &containers, root);
704                    let id = push(&mut nodes, Kind::Verbatim, parent);
705                    last_block = Some(id);
706                    let n = &mut nodes[id.0 as usize];
707                    n.lang = lang.filter(|l| !l.is_empty());
708                    n.text = text;
709                }
710                Block::Table {
711                    lemma,
712                    headers,
713                    rows,
714                } => {
715                    let parent = cursor(&sections, &containers, root);
716                    lower_table(&mut nodes, parent, lemma, headers, rows);
717                }
718                Block::Verse {
719                    lemma,
720                    strophes,
721                    hypograph,
722                } => {
723                    let parent = cursor(&sections, &containers, root);
724                    let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
725                    last_flow = Some(id);
726                    last_block = Some(id);
727                }
728            }
729        }
730
731        resolve_notes(&mut nodes);
732        let onyms = resolve_refs(&mut nodes);
733        flatten_prose(&mut nodes);
734        TextModel {
735            nodes,
736            root,
737            onyms,
738            document_url: None,
739            bib_aliases: Default::default(),
740        }
741    }
742
743    /// Register the bib-field alias census (alias → canonical
744    /// campus), lowercased keys — the friction remover: a field
745    /// authored or queried under any covered name answers.
746    pub fn set_bib_aliases(&mut self, map: std::collections::HashMap<String, String>) {
747        self.bib_aliases = map;
748    }
749
750    /// Declare the document's own URL — the base a relative ref
751    /// target joins against when `-->` reaches across documents.
752    pub fn set_document_url(&mut self, url: &str) {
753        self.document_url = url::Url::parse(url).ok();
754    }
755
756    /// Read plain text: blank-line-separated paragraphs, each
757    /// collapsed to one line — the atramento paragraph rule. No
758    /// headings, no markup.
759    pub fn parse_plain(text: &str) -> Self {
760        let mut blocks = Vec::new();
761        let mut para: Vec<&str> = Vec::new();
762        for line in text.lines() {
763            if line.trim().is_empty() {
764                if !para.is_empty() {
765                    blocks.push(Block::Paragraph {
766                        text: para.join(" "),
767                    });
768                    para.clear();
769                }
770            } else {
771                para.push(line);
772            }
773        }
774        if !para.is_empty() {
775            blocks.push(Block::Paragraph {
776                text: para.join(" "),
777            });
778        }
779        Self::build(blocks)
780    }
781
782    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
783    /// for rendering. A `[n]` index is added only to disambiguate
784    /// same-name siblings.
785    pub fn locator(&self, node: NodeId) -> String {
786        let mut segments = Vec::new();
787        let mut cur = Some(node);
788        while let Some(id) = cur {
789            let n = &self.nodes[id.0 as usize];
790            if let Some(name) = n.kind.name() {
791                segments.push(self.segment(id, name));
792            }
793            cur = n.parent;
794        }
795        segments.reverse();
796        format!("/{}", segments.join("/"))
797    }
798
799    fn segment(&self, node: NodeId, name: &str) -> String {
800        let Some(parent) = self.nodes[node.0 as usize].parent else {
801            return name.to_string();
802        };
803        let siblings = &self.nodes[parent.0 as usize].children;
804        let same_name: Vec<NodeId> = siblings
805            .iter()
806            .copied()
807            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
808            .collect();
809        if same_name.len() > 1 {
810            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
811            format!("{name}[{n}]")
812        } else {
813            name.to_string()
814        }
815    }
816}
817
818/// Where the next block lands: the innermost open container, else
819/// the innermost open section, else the root.
820/// Ruling #35: wire each callout to its body by (family, onym) —
821/// first body wins on a duplicate — build the reverse index, and
822/// mark the dangling: a callout with no body keeps its node,
823/// carries `<dangling>` and `::::resolved = false`, and emits no
824/// edge.
825fn resolve_notes(nodes: &mut [Node]) {
826    let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
827    for (i, n) in nodes.iter().enumerate() {
828        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
829            && !n.deixis
830            && let Some(o) = &n.onym
831        {
832            bodies.entry((n.kind, o.clone())).or_insert(NodeId(i as u64));
833        }
834    }
835    for i in 0..nodes.len() {
836        if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
837            || !nodes[i].deixis
838        {
839            continue;
840        }
841        let Some(onym) = nodes[i].onym.clone() else {
842            nodes[i].dangling = true;
843            continue;
844        };
845        let hit = if nodes[i].family_open {
846            // No declared family (an HTML noteref): the body's
847            // family is the callout's; footnote when dangling.
848            [Kind::Footnote, Kind::Endnote, Kind::Aside]
849                .into_iter()
850                .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
851        } else {
852            bodies.get(&(nodes[i].kind, onym)).map(|b| (nodes[i].kind, *b))
853        };
854        match hit {
855            Some((family, body)) => {
856                nodes[i].kind = family;
857                nodes[i].note_edge = Some(body);
858                let callout = NodeId(i as u64);
859                nodes[body.0 as usize].cites.push(callout);
860            }
861            None => nodes[i].dangling = true,
862        }
863    }
864}
865
866/// An index term as written, with the `|...` formatting and range
867/// directives stripped (v1 of ruling #36; ranges and
868/// see-references are recorded follow-ups).
869fn quarb_term(term: &str) -> String {
870    normalize_ws(term.split('|').next().unwrap_or(term))
871}
872
873fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
874    containers
875        .last()
876        .or(sections.last())
877        .copied()
878        .unwrap_or(root)
879}
880
881fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
882    let id = NodeId(nodes.len() as u64);
883    nodes.push(Node::new(kind, Some(parent)));
884    nodes[parent.0 as usize].children.push(id);
885    id
886}
887
888/// Denormalize a table into nested lists (see the module doc).
889/// The column name lands as the cell's `::lemma` — a cell's own
890/// label (a row's `th`) wins over the positional header entry —
891/// and never as folded text: addressing is property projection,
892/// the flattening rule alone spells `lemma: value`.
893fn lower_table(
894    nodes: &mut Vec<Node>,
895    parent: NodeId,
896    lemma: Option<String>,
897    headers: Option<Vec<String>>,
898    rows: Vec<Vec<Cell>>,
899) {
900    let list = push(nodes, Kind::OrderedList, parent);
901    {
902        let n = &mut nodes[list.0 as usize];
903        n.table = true;
904        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
905    }
906    for (i, row) in rows.into_iter().enumerate() {
907        let item = push(nodes, Kind::OrderedItem, list);
908        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
909        nodes[item.0 as usize].row = true;
910        let cells = push(nodes, Kind::UnorderedList, item);
911        for (j, cell) in row.into_iter().enumerate() {
912            let value = normalize_ws(&cell.text);
913            if value.is_empty() {
914                continue;
915            }
916            let label = cell
917                .label
918                .as_deref()
919                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
920                .map(normalize_ws)
921                .filter(|h| !h.is_empty());
922            let cell_item = push(nodes, Kind::UnorderedItem, cells);
923            let n = &mut nodes[cell_item.0 as usize];
924            n.cell = true;
925            n.lemma = label;
926            n.text = value;
927        }
928    }
929}
930
931/// Lower a verse block (ruling #37): `verse` → `strophe`s →
932/// `stichos` lines. The stichos taxis numbers lines 1-based,
933/// continuously across strophes — Iliad 1.34 is a taxis; the
934/// strophe taxis is its ordinal. Empty lines are dropped (the
935/// strophe boundary carries the separation).
936fn lower_verse(
937    nodes: &mut Vec<Node>,
938    parent: NodeId,
939    lemma: Option<String>,
940    strophes: Vec<Vec<String>>,
941    hypograph: Option<String>,
942) -> NodeId {
943    let verse = push(nodes, Kind::Verse, parent);
944    {
945        let n = &mut nodes[verse.0 as usize];
946        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
947        n.hypograph = hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
948    }
949    let mut line_no = 0i64;
950    for (i, strophe) in strophes.into_iter().enumerate() {
951        let sid = push(nodes, Kind::Strophe, verse);
952        nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
953        for line in strophe {
954            let line = line.trim_end().to_string();
955            if line.trim().is_empty() {
956                continue;
957            }
958            line_no += 1;
959            let lid = push(nodes, Kind::Stichos, sid);
960            let n = &mut nodes[lid.0 as usize];
961            n.taxis = Some(line_no);
962            n.text = line;
963        }
964    }
965    verse
966}
967
968/// Compute every node's flattened prose: lemma first, then the
969/// node's own text, then its children's prose in order, then the
970/// hypograph, block-joined with newlines. On a list *item*, the
971/// lemma joins the rest with `: ` instead — an item's lemma names
972/// its content inline (a table cell reads `Outcome: Emus won`, a
973/// definition reads `term: description`), where a section's lemma
974/// opens its block. Children always carry larger indices than
975/// their parents (nodes are interned in document order), so one
976/// reverse index scan suffices — no recursion.
977fn flatten_prose(nodes: &mut [Node]) {
978    for i in (0..nodes.len()).rev() {
979        let inline_lemma = matches!(
980            nodes[i].kind,
981            Kind::UnorderedItem | Kind::OrderedItem
982        );
983        let mut lemma_part: Option<String> = None;
984        let mut parts: Vec<String> = Vec::new();
985        if let Some(lemma) = &nodes[i].lemma
986            && !lemma.is_empty()
987        {
988            if inline_lemma {
989                lemma_part = Some(lemma.clone());
990            } else {
991                parts.push(lemma.clone());
992            }
993        }
994        if !nodes[i].text.is_empty() {
995            parts.push(nodes[i].text.clone());
996        }
997        let mut child_parts: Vec<String> = Vec::new();
998        for &child in nodes[i].children.clone().iter() {
999            // A ref's recorded link text is already part of the
1000            // flow it was met in; an anchor is invisible. Neither
1001            // adds prose (the deixis/index-mark rule).
1002            if matches!(
1003                nodes[child.0 as usize].kind,
1004                Kind::Ref | Kind::Cit | Kind::Anchor
1005            ) {
1006                continue;
1007            }
1008            let prose = &nodes[child.0 as usize].prose;
1009            if !prose.is_empty() {
1010                child_parts.push(prose.clone());
1011            }
1012        }
1013        // Ruling #37: a verse block separates its strophes by a
1014        // blank line (lemma and hypograph still join normally) —
1015        // byte-compatible with the old verbatim lowering.
1016        if nodes[i].kind == Kind::Verse {
1017            let joined = child_parts.join("\n\n");
1018            if !joined.is_empty() {
1019                parts.push(joined);
1020            }
1021        } else {
1022            parts.extend(child_parts);
1023        }
1024        if let Some(hypograph) = &nodes[i].hypograph
1025            && !hypograph.is_empty()
1026        {
1027            parts.push(hypograph.clone());
1028        }
1029        let mut prose = parts.join("\n");
1030        if let Some(lemma) = lemma_part {
1031            prose = if prose.is_empty() {
1032                lemma
1033            } else {
1034                format!("{lemma}: {prose}")
1035            };
1036        }
1037        nodes[i].prose = prose;
1038    }
1039}
1040
1041impl TextModel {
1042    /// The body prose: everything between the lemma and the
1043    /// hypograph — the simmere anatomy's third member, derived
1044    /// from the flattened prose by construction (the lemma joins
1045    /// a block on its own line, an item with `: `; the hypograph
1046    /// closes on its own line).
1047    fn grammata(&self, node: NodeId) -> String {
1048        let n = &self.nodes[node.0 as usize];
1049        let mut s = n.prose.as_str();
1050        if let Some(lemma) = &n.lemma
1051            && !lemma.is_empty()
1052            && let Some(rest) = s.strip_prefix(lemma.as_str())
1053        {
1054            s = rest
1055                .strip_prefix(": ")
1056                .or_else(|| rest.strip_prefix('\n'))
1057                .unwrap_or(rest);
1058        }
1059        if let Some(h) = &n.hypograph
1060            && !h.is_empty()
1061            && let Some(rest) = s.strip_suffix(h.as_str())
1062        {
1063            s = rest.strip_suffix('\n').unwrap_or(rest);
1064        }
1065        s.trim_end().to_string()
1066    }
1067}
1068
1069impl AstAdapter for TextModel {
1070    fn root(&self) -> NodeId {
1071        self.root
1072    }
1073
1074    fn children(&self, node: NodeId) -> Vec<NodeId> {
1075        self.nodes[node.0 as usize].children.clone()
1076    }
1077
1078    fn name(&self, node: NodeId) -> Option<String> {
1079        self.nodes[node.0 as usize].kind.name().map(str::to_string)
1080    }
1081
1082    fn parent(&self, node: NodeId) -> Option<NodeId> {
1083        self.nodes[node.0 as usize].parent
1084    }
1085
1086    /// The `<block>` family on every block node, plus `<table>` on
1087    /// a list that denormalizes a table. Kinds are node names, not
1088    /// traits.
1089    fn traits(&self, node: NodeId) -> Vec<String> {
1090        let n = &self.nodes[node.0 as usize];
1091        let mut out = Vec::new();
1092        // A callout is inline apparatus, not a block (ruling #35);
1093        // so is an index mark (ruling #36). Strophes and stichos
1094        // lines are sub-block structure (ruling #37, litogramma's
1095        // own family rule) — the verse block carries the trait.
1096        if n.kind != Kind::Document
1097            && !n.deixis
1098            && !matches!(
1099                n.kind,
1100                Kind::IndexMark
1101                    | Kind::Ref
1102                    | Kind::Cit
1103                    | Kind::Anchor
1104                    | Kind::Strophe
1105                    | Kind::Stichos
1106            )
1107        {
1108            out.push("block".to_string());
1109        }
1110        // The reference vocabulary (atrep's semantic traits): a
1111        // dangling mention keeps its node and the linter finds it;
1112        // `<target>` marks what bears a referable name — anchors
1113        // and labeled blocks, never the apparatus families (their
1114        // onyms pair within the family, not in this namespace).
1115        if matches!(n.kind, Kind::Ref | Kind::Cit) && n.dangling {
1116            out.push("dangling".to_string());
1117        }
1118        // A structured entry's genus (liber, commentarius, …)
1119        // surfaces as its trait, the genos rule.
1120        if n.kind == Kind::Bib && let Some(g) = &n.genus {
1121            out.push(g.clone());
1122        }
1123        if n.kind == Kind::Anchor
1124            || (n.onym.is_some()
1125                && !n.deixis
1126                && !matches!(
1127                    n.kind,
1128                    Kind::Footnote
1129                        | Kind::Endnote
1130                        | Kind::Aside
1131                        | Kind::IndexMark
1132                        | Kind::Ref
1133                        | Kind::Cit
1134                        | Kind::Bib
1135                ))
1136        {
1137            out.push("target".to_string());
1138        }
1139        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
1140            // The body IS the note — `<note>` marks it, whichever
1141            // note family; the callout carries `<deixis>` alone.
1142            // An aside body is content, not apparatus: no
1143            // `<note>`. No both-ends trait: `//footnote` already
1144            // gathers a family whole, `//*<deixis>` the callouts
1145            // across families, `//*<note>` the note bodies.
1146            if n.deixis {
1147                out.push("deixis".to_string());
1148            } else if n.kind != Kind::Aside {
1149                out.push("note".to_string());
1150            }
1151            if n.dangling {
1152                out.push("dangling".to_string());
1153            }
1154        }
1155        if n.table {
1156            out.push("table".to_string());
1157        }
1158        if n.row {
1159            out.push("row".to_string());
1160        }
1161        if n.cell {
1162            out.push("cell".to_string());
1163        }
1164        out
1165    }
1166
1167    /// `::lemma` (title), `::hypograph` (footer or attribution),
1168    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
1169    /// the bare projection).
1170    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
1171    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
1172    /// `::attribution`, `::ord`), answered here because this
1173    /// adapter's property surface IS the vocabulary; on data
1174    /// adapters those spellings stay ordinary field names. The
1175    /// Greek is canon in docs and reflection preserves whichever
1176    /// spelling was written.
1177    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
1178        let n = &self.nodes[node.0 as usize];
1179        // A structured entry answers its campi first — under the
1180        // canonical Latin name or any census alias (BibLaTeX's
1181        // field names included), case-insensitively — ahead of
1182        // the general vocabulary, so `::title` on a bib means
1183        // titulus, not the lemma alias.
1184        if n.kind == Kind::Bib && !n.fields.is_empty() {
1185            let canon = self
1186                .bib_aliases
1187                .get(&name.to_lowercase())
1188                .map(String::as_str)
1189                .unwrap_or(name);
1190            if let Some((_, v)) = n.fields.iter().find(|(k, _)| k == canon) {
1191                return Some(Value::Str(v.clone()));
1192            }
1193        }
1194        match name {
1195            "lemma" | "title" => n.lemma.clone().map(Value::Str),
1196            "onym" => n.onym.clone().map(Value::Str),
1197            // On an index mark the onym IS the term (ruling #36).
1198            "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
1199            // A ref's word: the identifier as written. Never the
1200            // resolved content — that is what `-->` is for.
1201            "target" if matches!(n.kind, Kind::Ref | Kind::Cit) => {
1202                n.target.clone().map(Value::Str)
1203            }
1204            // A structured entry answers its campi (auctor,
1205            // titulus, annus, …) — the bibliogramma vocabulary,
1206            // canonicalized upstream.
1207            _ if n.kind == Kind::Bib => {
1208                let canon = self
1209                    .bib_aliases
1210                    .get(&name.to_lowercase())
1211                    .map(String::as_str)
1212                    .unwrap_or(name);
1213                n.fields
1214                    .iter()
1215                    .find(|(k, _)| k == canon)
1216                    .map(|(_, v)| Value::Str(v.clone()))
1217            }
1218            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
1219            "taxis" | "ord" => n.taxis.map(Value::Int),
1220            "grammata" | "body" => {
1221                let g = self.grammata(node);
1222                if g.is_empty() { None } else { Some(Value::Str(g)) }
1223            }
1224            "text" => Some(Value::Str(n.prose.clone())),
1225            _ => None,
1226        }
1227    }
1228
1229    /// The default projection is the flattened prose of the
1230    /// subtree — lemma first, hypograph last.
1231    fn default_value(&self, node: NodeId) -> Option<Value> {
1232        let n = &self.nodes[node.0 as usize];
1233        if n.kind == Kind::IndexMark {
1234            // Invisible in the surrounding prose; its own
1235            // projection is the term it declares.
1236            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1237        }
1238        if n.deixis {
1239            // The atrep rule: a resolved reference projects as its
1240            // target's rendered form, degrading to the raw onym
1241            // when dangling.
1242            return Some(Value::Str(match n.note_edge {
1243                Some(body) => self.nodes[body.0 as usize].prose.clone(),
1244                None => n.onym.clone().unwrap_or_default(),
1245            }));
1246        }
1247        if n.kind == Kind::Ref {
1248            // The authored link text; LaTeX \ref has none, so the
1249            // atrep projection rule applies — the target's
1250            // rendered form (its lemma, else its prose), degrading
1251            // to the raw target when dangling.
1252            if !n.text.is_empty() {
1253                return Some(Value::Str(n.text.clone()));
1254            }
1255            return Some(Value::Str(match n.ref_edge {
1256                Some(t) => {
1257                    let b = &self.nodes[t.0 as usize];
1258                    b.lemma.clone().unwrap_or_else(|| b.prose.clone())
1259                }
1260                None => n.target.clone().unwrap_or_default(),
1261            }));
1262        }
1263        if n.kind == Kind::Bib && n.prose.is_empty() && !n.fields.is_empty() {
1264            // A structured entry's plain form: its field values in
1265            // source order — the full data, no citation styling.
1266            return Some(Value::Str(
1267                n.fields
1268                    .iter()
1269                    .map(|(_, v)| v.as_str())
1270                    .collect::<Vec<_>>()
1271                    .join(". "),
1272            ));
1273        }
1274        if n.kind == Kind::Cit {
1275            // The mark's own identity: the raw key. The entry is
1276            // one arrow away (`//cit--> ::`); rendered citation
1277            // styles are presentation, not structure.
1278            return Some(Value::Str(n.target.clone().unwrap_or_default()));
1279        }
1280        if n.kind == Kind::Anchor {
1281            // Invisible in the prose; its own projection is the
1282            // name it bears.
1283            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
1284        }
1285        Some(Value::Str(n.prose.clone()))
1286    }
1287
1288    /// The edge label is the family name both ends share —
1289    /// `->footnote` / `->endnote` — the node's own kind, since
1290    /// resolution re-kinds an open-family callout to its body.
1291    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1292        let n = &self.nodes[node.0 as usize];
1293        if let Some(t) = n.ref_edge {
1294            // The atrep rule: a resolved mention emits its typed
1295            // crosslink — `->ref`, or `->cit` for a citation.
1296            let label = if n.kind == Kind::Cit { "cit" } else { "ref" };
1297            return vec![(label.to_string(), t)];
1298        }
1299        match n.note_edge {
1300            Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
1301            None => Vec::new(),
1302        }
1303    }
1304
1305    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1306        let n = &self.nodes[node.0 as usize];
1307        let mut out: Vec<(String, NodeId)> = n
1308            .ref_cites
1309            .iter()
1310            .map(|&c| {
1311                let label = if self.nodes[c.0 as usize].kind == Kind::Cit {
1312                    "cit"
1313                } else {
1314                    "ref"
1315                };
1316                (label.to_string(), c)
1317            })
1318            .collect();
1319        let label = n.kind.name().unwrap_or("footnote");
1320        out.extend(n.cites.iter().map(|&c| (label.to_string(), c)));
1321        out
1322    }
1323
1324    /// `//ref-->`: land on the bearer — the block if a block
1325    /// bears the name, the point anchor if a point does. The
1326    /// text-level hints choose the reading: `-->block` normalizes
1327    /// a point landing to its enclosing block, `-->point` selects
1328    /// point targets only; bare is the honest exact landing.
1329    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
1330        let n = &self.nodes[node.0 as usize];
1331        if !matches!(n.kind, Kind::Ref | Kind::Cit) || property != "target" {
1332            return None;
1333        }
1334        let landed = n.ref_edge?;
1335        let is_point = self.nodes[landed.0 as usize].kind == Kind::Anchor;
1336        match hint {
1337            None | Some("*") => Some(landed),
1338            Some("point") => is_point.then_some(landed),
1339            Some("block") => {
1340                if !is_point {
1341                    return Some(landed);
1342                }
1343                // The anchor's enclosing block.
1344                let mut at = self.nodes[landed.0 as usize].parent;
1345                while let Some(b) = at {
1346                    let bn = &self.nodes[b.0 as usize];
1347                    if bn.kind != Kind::Document
1348                        && !matches!(bn.kind, Kind::Ref | Kind::Anchor | Kind::IndexMark)
1349                    {
1350                        return Some(b);
1351                    }
1352                    at = bn.parent;
1353                }
1354                None
1355            }
1356            Some(_) => None,
1357        }
1358    }
1359
1360    /// The bare arrow's property: a ref resolves its target.
1361    fn ref_property(&self, node: NodeId) -> Option<String> {
1362        matches!(self.nodes[node.0 as usize].kind, Kind::Ref | Kind::Cit)
1363            .then(|| "target".to_string())
1364    }
1365
1366    /// A ref the producer declared external: the absolute URL,
1367    /// relative targets joined against the document's own URL —
1368    /// the same acquisition rung the html DOM reading speaks.
1369    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
1370        if !matches!(hint, None | Some("*")) {
1371            return None;
1372        }
1373        let n = &self.nodes[node.0 as usize];
1374        if n.kind != Kind::Ref || property != "target" || n.internal {
1375            return None;
1376        }
1377        let t = n.target.as_deref()?.trim();
1378        if t.is_empty() || t.starts_with('#') {
1379            return None;
1380        }
1381        let u = match url::Url::parse(t) {
1382            Ok(u) => u,
1383            Err(url::ParseError::RelativeUrlWithoutBase) => {
1384                self.document_url.as_ref()?.join(t).ok()?
1385            }
1386            Err(_) => return None,
1387        };
1388        if u.scheme() != "http" && u.scheme() != "https" {
1389            return None;
1390        }
1391        Some(u.into())
1392    }
1393
1394    /// A sibling document's `#fragment` lands here: the bearer of
1395    /// that name — a labeled block, or a point anchor.
1396    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
1397        self.onyms.get(fragment).copied()
1398    }
1399
1400    /// Ruling #29: the text level's surface is the vocabulary
1401    /// itself — no document can introduce a property name — so
1402    /// its two annotations answer at `::` as well.
1403    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
1404        &["level", "lang", "form"]
1405    }
1406
1407    /// `::::level` on sections (the source heading level) and
1408    /// `::::lang` on verbatim blocks (the declared language).
1409    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1410        let n = &self.nodes[node.0 as usize];
1411        match key {
1412            "level" => n.level.map(|l| Value::Int(l as i64)),
1413            "lang" => n.lang.clone().map(Value::Str),
1414            // `::::resolved` on a callout: the broken-apparatus
1415            // linter's fact (ruling #35).
1416            "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
1417            // …and on an internal ref: the broken-cross-reference
1418            // linter's fact. External refs resolve at query time,
1419            // so the fact is not theirs to answer.
1420            "resolved" if n.kind == Kind::Cit || (n.kind == Kind::Ref && n.internal) => {
1421                Some(Value::Bool(n.ref_edge.is_some()))
1422            }
1423            // The declared spelling was a margin form (a Tufte
1424            // sidenote): family footnote, placement preserved.
1425            "form" if n.margin => Some(Value::Str("margin".to_string())),
1426            // The entry's genus, also queryable as an annotation.
1427            "genus" if n.kind == Kind::Bib => n.genus.clone().map(Value::Str),
1428            _ => None,
1429        }
1430    }
1431}