krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

src/resolve.rs

190 lines · 6872 bytes

  1//! RESOLVE stage (spec §2.1, §4.3): rewrite `LinkTarget`s to final URLs using the
  2//! symbol table, AND record which targets each page consumed.
  3//!
  4//! Critical invariant (spec §4.3, R2): RESOLVE returns the used-target list as a
  5//! first-class side output from v1, even before incrementality consumes it. Throwing
  6//! it away would make renamed-heading invalidation impossible to compute later without
  7//! re-resolving everything.
  8//!
  9//! Resolution rewrites each internal link into an [`crate::model::LinkTarget::External`]
 10//! carrying its final URL, so the renderer needs no symbol-table knowledge. Unresolved
 11//! links are left untouched (the renderer falls back to a best-effort anchor) and
 12//! reported as [`BrokenLink`] warnings (spec §4.3.4).
 13
 14use camino::Utf8Path;
 15
 16use crate::index::{SymbolTable, TargetId};
 17use crate::model::{Document, Element, Link, LinkTarget, List, Object, Section, Table, TableRow};
 18use crate::util::{normalize_link_path, output_path, output_url};
 19
 20/// A document whose links have been rewritten to concrete URLs.
 21#[derive(Debug, Clone)]
 22pub struct ResolvedDoc {
 23    pub document: Document,
 24}
 25
 26/// A broken internal link — surfaced as a warning, or an error under `--strict` (spec §4.3.4).
 27#[derive(Debug, Clone)]
 28pub struct BrokenLink {
 29    pub target: TargetId,
 30}
 31
 32/// Result of resolving one page. `used_targets` are the "uses" edges (spec §4.3).
 33#[derive(Debug)]
 34pub struct ResolveOutput {
 35    pub resolved: ResolvedDoc,
 36    /// The "uses" edges — MUST be captured from day one (spec §4.3, R2).
 37    pub used_targets: Vec<TargetId>,
 38    pub broken: Vec<BrokenLink>,
 39}
 40
 41/// `resolve(doc, &SymbolTable) -> (ResolvedDoc, Vec<TargetId used>)` (spec §4.3).
 42pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput {
 43    let mut document = doc.clone();
 44    let from = doc.source_path.clone();
 45    // URLs are computed between *output* paths, which `#+SLUG:` can rename.
 46    let from_out = output_path(&from, &doc.keywords);
 47    let mut used = Vec::new();
 48    let mut broken = Vec::new();
 49    let mut cx = Cx {
 50        from: &from,
 51        from_out: &from_out,
 52        symbols,
 53        used: &mut used,
 54        broken: &mut broken,
 55    };
 56    cx.section(&mut document.root);
 57    ResolveOutput {
 58        resolved: ResolvedDoc { document },
 59        used_targets: used,
 60        broken,
 61    }
 62}
 63
 64/// The text a description-less internal link should display once its target becomes a URL.
 65fn human_text(target: &LinkTarget) -> Option<String> {
 66    match target {
 67        LinkTarget::Heading(t) => Some(t.clone()),
 68        LinkTarget::CustomId(id) | LinkTarget::Id(id) => Some(id.clone()),
 69        LinkTarget::File { path, .. } => Some(path.to_string()),
 70        LinkTarget::External(_) => None,
 71    }
 72}
 73
 74struct Cx<'a> {
 75    from: &'a Utf8Path,
 76    from_out: &'a Utf8Path,
 77    symbols: &'a SymbolTable,
 78    used: &'a mut Vec<TargetId>,
 79    broken: &'a mut Vec<BrokenLink>,
 80}
 81
 82impl Cx<'_> {
 83    fn section(&mut self, section: &mut Section) {
 84        if let Some(h) = &mut section.heading {
 85            self.objects(&mut h.title);
 86        }
 87        for el in &mut section.content {
 88            self.element(el);
 89        }
 90        for child in &mut section.children {
 91            self.section(child);
 92        }
 93    }
 94
 95    fn element(&mut self, el: &mut Element) {
 96        match el {
 97            Element::Paragraph(objs) => self.objects(objs),
 98            Element::List(list) => self.list(list),
 99            Element::Table(table) => self.table(table),
100            Element::Figure { caption, .. } => self.objects(caption),
101            Element::QuoteBlock(inner)
102            | Element::CenterBlock(inner)
103            | Element::SpecialBlock { content: inner, .. }
104            | Element::Drawer { content: inner, .. }
105            | Element::FootnoteDefinition { content: inner, .. } => {
106                for e in inner {
107                    self.element(e);
108                }
109            }
110            _ => {}
111        }
112    }
113
114    fn list(&mut self, list: &mut List) {
115        for item in &mut list.items {
116            if let Some(term) = &mut item.term {
117                self.objects(term);
118            }
119            for e in &mut item.content {
120                self.element(e);
121            }
122        }
123    }
124
125    fn table(&mut self, table: &mut Table) {
126        self.objects(&mut table.caption);
127        for row in &mut table.rows {
128            if let TableRow::Cells(cells) = row {
129                for cell in cells {
130                    self.objects(cell);
131                }
132            }
133        }
134    }
135
136    fn objects(&mut self, objs: &mut [Object]) {
137        for obj in objs {
138            match obj {
139                Object::Link(link) => self.link(link),
140                Object::Bold(i)
141                | Object::Italic(i)
142                | Object::Underline(i)
143                | Object::StrikeThrough(i) => self.objects(i),
144                Object::FootnoteRef {
145                    inline: Some(i), ..
146                } => self.objects(i),
147                _ => {}
148            }
149        }
150    }
151
152    fn link(&mut self, link: &mut Link) {
153        if let Some(desc) = &mut link.description {
154            self.objects(desc);
155        }
156        let tid = match &link.target {
157            LinkTarget::External(_) => return,
158            LinkTarget::CustomId(id) => TargetId::CustomId(id.clone()),
159            LinkTarget::Id(id) => TargetId::Id(id.clone()),
160            LinkTarget::Heading(t) => TargetId::Heading(t.clone()),
161            LinkTarget::File { path, .. } => {
162                // Only `.org` files are pages. A link to an asset (an image, a PDF) is
163                // already a correct relative URL in the output tree, since assets are
164                // copied preserving layout — so it is neither resolved nor reported.
165                if path.extension() != Some("org") {
166                    return;
167                }
168                TargetId::File(normalize_link_path(self.from, path))
169            }
170        };
171        match self.symbols.targets.get(&tid) {
172            Some(loc) => {
173                self.used.push(tid.clone());
174                // Preserve the human-readable text before the target becomes a bare URL,
175                // so a description-less `[[*Heading]]` still renders as the heading text.
176                if link.description.is_none() {
177                    if let Some(text) = human_text(&link.target) {
178                        link.description = Some(vec![Object::Text(text)]);
179                    }
180                }
181                let url = output_url(self.from_out, &loc.output_path, loc.anchor.as_deref());
182                link.target = LinkTarget::External(url);
183            }
184            None => {
185                self.broken.push(BrokenLink { target: tid });
186                // Leave the original target for the renderer's best-effort fallback.
187            }
188        }
189    }
190}