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(§ions, &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(§ions, &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(§ions, &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(§ions, &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(§ions, &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(§ions, &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}