krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
1//! INDEX stage (spec §2.1, §4.3): collect link targets across all documents into a
2//! global symbol table. This is the only inherently global stage before RESOLVE.
3
4use std::collections::HashMap;
5
6use camino::{Utf8Path, Utf8PathBuf};
7use serde::{Deserialize, Serialize};
8
9use crate::model::{Document, Section};
10use crate::util::{heading_anchor, output_path, plain_text};
11
12/// Identity of a link target. A target is owned by exactly one file (spec §4.3).
13///
14/// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips
15/// through the on-disk cache manifest (spec §4.5).
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum TargetId {
18 Id(String),
19 CustomId(String),
20 Heading(String),
21 File(Utf8PathBuf),
22}
23
24/// How a target is written in org source, so a broken-link warning names something the
25/// author can search for.
26impl std::fmt::Display for TargetId {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 TargetId::Id(s) => write!(f, "[[id:{s}]]"),
30 TargetId::CustomId(s) => write!(f, "[[#{s}]]"),
31 TargetId::Heading(s) => write!(f, "[[*{s}]]"),
32 TargetId::File(p) => write!(f, "[[file:{p}]]"),
33 }
34 }
35}
36
37impl TargetId {
38 /// A stable string form used to order targets deterministically when hashing
39 /// (so a page's `resolved_links_hash` does not depend on `HashSet` iteration order).
40 pub fn sort_key(&self) -> String {
41 match self {
42 TargetId::Id(s) => format!("id:{s}"),
43 TargetId::CustomId(s) => format!("custom:{s}"),
44 TargetId::Heading(s) => format!("heading:{s}"),
45 TargetId::File(p) => format!("file:{p}"),
46 }
47 }
48}
49
50/// Where a resolved target lives, once INDEX has seen its defining file.
51#[derive(Debug, Clone)]
52pub struct TargetLocation {
53 pub source_path: Utf8PathBuf,
54 /// The page this target is emitted into. Recorded at INDEX time because it depends
55 /// on the defining document's `#+SLUG:`, which only that document knows.
56 pub output_path: Utf8PathBuf,
57 /// Final URL fragment/anchor for the target, filled during resolution.
58 pub anchor: Option<String>,
59}
60
61/// Maps every collected target to its owning location (spec §4.3 "defines" edges).
62#[derive(Debug, Default)]
63pub struct SymbolTable {
64 pub targets: HashMap<TargetId, TargetLocation>,
65}
66
67impl SymbolTable {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 /// Walk one document, registering every `:ID:`/`:CUSTOM_ID:`/heading/file target.
73 /// A target is owned by exactly one file (spec §4.3); the anchor is the fragment
74 /// the renderer emits for that target's heading.
75 pub fn index_document(&mut self, doc: &Document) {
76 let path = &doc.source_path;
77 let out = output_path(path, &doc.keywords);
78 self.targets.insert(
79 TargetId::File(path.clone()),
80 TargetLocation {
81 source_path: path.clone(),
82 output_path: out.clone(),
83 anchor: None,
84 },
85 );
86 index_section(&doc.root, path, &out, &mut self.targets);
87 }
88}
89
90/// The set of link targets a single document *defines* (owns) — the `defines` edges of
91/// the dependency graph (spec §4.3). Mirrors [`SymbolTable::index_document`] but returns
92/// the targets for one file in isolation, which is what the incremental layer records
93/// per-file in the cache manifest.
94pub fn document_targets(doc: &Document) -> Vec<TargetId> {
95 let mut out = vec![TargetId::File(doc.source_path.clone())];
96 collect_targets(&doc.root, &mut out);
97 out
98}
99
100fn collect_targets(section: &Section, out: &mut Vec<TargetId>) {
101 if let Some(h) = §ion.heading {
102 if let Some(cid) = &h.custom_id {
103 out.push(TargetId::CustomId(cid.clone()));
104 }
105 if let Some(id) = &h.id {
106 out.push(TargetId::Id(id.clone()));
107 }
108 out.push(TargetId::Heading(plain_text(&h.title)));
109 }
110 for child in §ion.children {
111 collect_targets(child, out);
112 }
113}
114
115fn index_section(
116 section: &Section,
117 path: &Utf8Path,
118 out: &Utf8Path,
119 targets: &mut HashMap<TargetId, TargetLocation>,
120) {
121 if let Some(h) = §ion.heading {
122 let anchor = heading_anchor(h);
123 let mut record = |id: TargetId, anchor: Option<String>| {
124 targets.insert(
125 id,
126 TargetLocation {
127 source_path: path.to_owned(),
128 output_path: out.to_owned(),
129 anchor,
130 },
131 );
132 };
133 if let Some(cid) = &h.custom_id {
134 record(TargetId::CustomId(cid.clone()), Some(cid.clone()));
135 }
136 if let Some(id) = &h.id {
137 record(TargetId::Id(id.clone()), Some(id.clone()));
138 }
139 record(TargetId::Heading(plain_text(&h.title)), Some(anchor));
140 }
141 for child in §ion.children {
142 index_section(child, path, out, targets);
143 }
144}