krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

main: src/util.rs · raw

  1//! Small shared helpers used across INDEX, RESOLVE, RENDER and the site build:
  2//! flattening inline objects to plain text, slugifying heading text into anchors,
  3//! and computing relative output URLs between pages.
  4
  5use camino::{Utf8Path, Utf8PathBuf};
  6
  7use crate::model::{Element, Heading, Keywords, Object, Section, TableRow};
  8
  9/// The output path for a document, relative to the site root.
 10///
 11/// Normally this is the source path with `.org` swapped for `.html`, but a `#+SLUG:`
 12/// keyword renames the file — which is how the target corpus works: 178 of its 179 files
 13/// set one, and `2018-11-28-aes-encryption.org` publishes as `aes-encryption.html`. The
 14/// slug names the *file*, never the directory, so the page stays where its source lives.
 15pub fn output_path(source: &Utf8Path, keywords: &Keywords) -> Utf8PathBuf {
 16    let slug = keywords
 17        .entries
 18        .iter()
 19        .find(|(k, _)| k.eq_ignore_ascii_case("SLUG"))
 20        .map(|(_, v)| sanitize_slug(v))
 21        .filter(|s| !s.is_empty());
 22
 23    match slug {
 24        Some(slug) => {
 25            let dir = source.parent().unwrap_or_else(|| Utf8Path::new(""));
 26            dir.join(format!("{slug}.html"))
 27        }
 28        None => source.with_extension("html"),
 29    }
 30}
 31
 32/// Reduce a slug to a safe single filename component.
 33///
 34/// A slug is author-controlled text that becomes a path we write to, so `../../etc/x`
 35/// has to be impossible by construction rather than by convention: separators and dots
 36/// are folded to `-`, which cannot traverse and cannot produce a hidden file.
 37fn sanitize_slug(raw: &str) -> String {
 38    let mut out = String::with_capacity(raw.len());
 39    let mut prev_dash = false;
 40    for c in raw.trim().chars() {
 41        if c.is_ascii_alphanumeric() || c == '_' {
 42            out.extend(c.to_lowercase());
 43            prev_dash = false;
 44        } else if !prev_dash {
 45            out.push('-');
 46            prev_dash = true;
 47        }
 48    }
 49    out.trim_matches('-').to_string()
 50}
 51
 52/// Flatten inline objects to their plain-text content (markup stripped). Used to
 53/// derive heading anchors and `[[*Heading]]` link identities (spec §4.3).
 54pub fn plain_text(objs: &[Object]) -> String {
 55    let mut out = String::new();
 56    plain_text_into(objs, &mut out);
 57    out
 58}
 59
 60fn plain_text_into(objs: &[Object], out: &mut String) {
 61    for obj in objs {
 62        match obj {
 63            Object::Text(t) => out.push_str(t),
 64            Object::Bold(i)
 65            | Object::Italic(i)
 66            | Object::Underline(i)
 67            | Object::StrikeThrough(i) => plain_text_into(i, out),
 68            Object::Verbatim(s) | Object::Code(s) | Object::Entity(s) => out.push_str(s),
 69            Object::Link(l) => {
 70                if let Some(desc) = &l.description {
 71                    plain_text_into(desc, out);
 72                }
 73            }
 74            Object::FootnoteRef { .. } | Object::Timestamp(_) | Object::LineBreak => {}
 75        }
 76    }
 77}
 78
 79/// The `id` a heading is emitted with, and therefore the fragment anything linking to it
 80/// must use.
 81///
 82/// One function because there are three callers — the renderer emitting the `id`, INDEX
 83/// recording link targets, and the table of contents linking into the page. Any drift
 84/// between them is a link that silently goes nowhere.
 85pub fn heading_anchor(heading: &Heading) -> String {
 86    heading
 87        .custom_id
 88        .clone()
 89        .or_else(|| heading.id.clone())
 90        .unwrap_or_else(|| slugify(&plain_text(&heading.title)))
 91}
 92
 93/// One entry in a page's table of contents.
 94#[derive(Debug, Clone, serde::Serialize)]
 95pub struct TocEntry {
 96    pub title: String,
 97    /// Fragment identifier, without the `#`.
 98    pub anchor: String,
 99    /// Org heading level, 1-based, before any `heading_offset` is applied.
100    pub level: u8,
101    /// This entry's section number — `1.`, `3.1.` — always computed, printed only by a
102    /// template that wants it. A site with `section_numbers` on and an unnumbered
103    /// contents list reads as a mistake, and the numbers cannot be derived in Jinja
104    /// without rebuilding the tree walk that produced them.
105    pub number: String,
106    pub children: Vec<TocEntry>,
107}
108
109/// A page's table of contents, mirroring its heading tree.
110///
111/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from
112/// a flat list of levels inside a template is the kind of thing Jinja is bad at.
113pub fn table_of_contents(root: &Section) -> Vec<TocEntry> {
114    numbered_entries(&root.children, "")
115}
116
117fn numbered_entries(sections: &[Section], prefix: &str) -> Vec<TocEntry> {
118    sections
119        .iter()
120        .enumerate()
121        .map(|(i, section)| {
122            let number = format!("{prefix}{}.", i + 1);
123            let heading = section.heading.as_ref();
124            TocEntry {
125                title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(),
126                anchor: heading.map(heading_anchor).unwrap_or_default(),
127                level: heading.map(|h| h.level).unwrap_or(1),
128                children: numbered_entries(&section.children, &format!("{prefix}{}.", i + 1)),
129                number,
130            }
131        })
132        .collect()
133}
134
135/// Parse `#+OPTIONS:` into its `key:value` switches.
136///
137/// Org's per-file export switches are a space-separated list — `toc:nil num:t` — and
138/// this is the standard way an author turns a feature off for one document.
139pub fn export_options(keywords: &Keywords) -> std::collections::BTreeMap<String, String> {
140    let mut out = std::collections::BTreeMap::new();
141    for (_, value) in keywords
142        .entries
143        .iter()
144        .filter(|(k, _)| k.eq_ignore_ascii_case("OPTIONS"))
145    {
146        for token in value.split_whitespace() {
147            if let Some((key, val)) = token.split_once(':') {
148                if !key.is_empty() {
149                    out.insert(key.to_ascii_lowercase(), val.to_ascii_lowercase());
150                }
151            }
152        }
153    }
154    out
155}
156
157/// Whether an `#+OPTIONS:` switch is on, falling back to the site default when the
158/// document says nothing.
159pub fn option_enabled(keywords: &Keywords, key: &str, default: bool) -> bool {
160    match export_options(keywords).get(key).map(String::as_str) {
161        Some("nil" | "false" | "no" | "0" | "off") => false,
162        Some(_) => true,
163        None => default,
164    }
165}
166
167/// Is this document marked as a draft?
168///
169/// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the
170/// signal — unless the value explicitly says otherwise. Someone who types `#+DRAFT: t`,
171/// `#+DRAFT: yes` or a bare `#+DRAFT:` means the same thing, and publishing an unfinished
172/// post because the value was not the expected spelling is the wrong way to be strict.
173pub fn is_draft(keywords: &Keywords) -> bool {
174    keywords
175        .entries
176        .iter()
177        .find(|(k, _)| k.eq_ignore_ascii_case("DRAFT"))
178        .map(|(_, v)| {
179            !matches!(
180                v.trim().to_ascii_lowercase().as_str(),
181                "nil" | "false" | "no" | "0" | "off"
182            )
183        })
184        .unwrap_or(false)
185}
186
187/// The document's prose as plain text, for word counts and excerpts.
188///
189/// Source and example blocks are excluded on purpose: a reading-time estimate over a
190/// post that is mostly a shell transcript should describe the prose someone reads, not
191/// the code they skim. Headings are included — they are read.
192pub fn document_text(root: &Section) -> String {
193    let mut out = String::new();
194    section_text(root, &mut out);
195    out
196}
197
198fn section_text(section: &Section, out: &mut String) {
199    if let Some(heading) = &section.heading {
200        push_words(&plain_text(&heading.title), out);
201    }
202    elements_text(&section.content, out);
203    for child in &section.children {
204        section_text(child, out);
205    }
206}
207
208fn elements_text(elements: &[Element], out: &mut String) {
209    for element in elements {
210        match element {
211            Element::Paragraph(objs) => push_words(&plain_text(objs), out),
212            Element::List(list) => {
213                for item in &list.items {
214                    if let Some(term) = &item.term {
215                        push_words(&plain_text(term), out);
216                    }
217                    elements_text(&item.content, out);
218                }
219            }
220            Element::Table(table) => {
221                for row in &table.rows {
222                    if let TableRow::Cells(cells) = row {
223                        for cell in cells {
224                            push_words(&plain_text(cell), out);
225                        }
226                    }
227                }
228            }
229            Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out),
230            Element::Figure { caption, .. } => push_words(&plain_text(caption), out),
231            Element::FootnoteDefinition { content, .. } => elements_text(content, out),
232            // Code, drawers, comments, keywords and raw export blocks are not prose.
233            _ => {}
234        }
235    }
236}
237
238fn push_words(text: &str, out: &mut String) {
239    let text = text.trim();
240    if text.is_empty() {
241        return;
242    }
243    if !out.is_empty() {
244        out.push(' ');
245    }
246    out.push_str(text);
247}
248
249/// The document's first paragraph as plain text — the fallback excerpt for a page with
250/// no `#+DESCRIPTION:`.
251pub fn first_paragraph(root: &Section) -> Option<String> {
252    fn find(section: &Section) -> Option<String> {
253        for element in &section.content {
254            if let Element::Paragraph(objs) = element {
255                let text = plain_text(objs);
256                if !text.trim().is_empty() {
257                    return Some(text.trim().to_string());
258                }
259            }
260        }
261        section.children.iter().find_map(find)
262    }
263    find(root)
264}
265
266/// Turn heading text into a URL-safe anchor slug.
267pub fn slugify(text: &str) -> String {
268    let mut out = String::new();
269    let mut prev_dash = false;
270    for c in text.chars() {
271        if c.is_alphanumeric() {
272            out.extend(c.to_lowercase());
273            prev_dash = false;
274        } else if !prev_dash {
275            out.push('-');
276            prev_dash = true;
277        }
278    }
279    out.trim_matches('-').to_string()
280}
281
282/// The URL to reach the page output at `to_out` from the page output at `from_out`,
283/// honoring an optional `anchor`. Same-page links reduce to a bare `#anchor` fragment;
284/// cross-page links become a relative path.
285///
286/// Both arguments are *output* paths, not source paths, because `#+SLUG:` means the two
287/// no longer correspond: deriving the URL here would reintroduce the filename assumption
288/// that [`output_path`] exists to remove.
289pub fn output_url(from_out: &Utf8Path, to_out: &Utf8Path, anchor: Option<&str>) -> String {
290    let path = if from_out == to_out {
291        String::new()
292    } else {
293        let from_dir = from_out.parent().unwrap_or_else(|| Utf8Path::new(""));
294        relative_path(from_dir, to_out)
295    };
296    match anchor {
297        Some(a) if !a.is_empty() => {
298            if path.is_empty() {
299                format!("#{a}")
300            } else {
301                format!("{path}#{a}")
302            }
303        }
304        _ => {
305            if path.is_empty() {
306                "#".to_string()
307            } else {
308                path
309            }
310        }
311    }
312}
313
314/// The `../`-prefix that reaches the site root from the page at `from_rel`. Empty for a
315/// top-level page. Used for site-global assets like the syntax stylesheet.
316pub fn relative_root(from_rel: &Utf8Path) -> String {
317    let depth = from_rel
318        .parent()
319        .map(|p| p.components().count())
320        .unwrap_or(0);
321    "../".repeat(depth)
322}
323
324/// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs.
325fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String {
326    let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect();
327    let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect();
328    let mut i = 0;
329    while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
330        i += 1;
331    }
332    let mut parts: Vec<&str> = vec![".."; from_c.len() - i];
333    parts.extend(&to_c[i..]);
334    parts.join("/")
335}
336
337/// Resolve a `file:` link path (as written) against the linking page's directory,
338/// normalizing `.`/`..` so it can be matched against indexed file targets.
339pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
340    let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
341    let joined = base.join(path);
342    let mut stack: Vec<&str> = Vec::new();
343    for comp in joined.components() {
344        match comp.as_str() {
345            "." => {}
346            ".." => {
347                stack.pop();
348            }
349            other => stack.push(other),
350        }
351    }
352    Utf8PathBuf::from(stack.join("/"))
353}
354
355/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
356/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
357/// needs one key it can sort on.
358/// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one.
359///
360/// Two notes written on the same day are not written at the same moment, and org records
361/// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them
362/// in whatever order the filesystem happened to yield.
363pub fn iso_time(raw: &str) -> Option<String> {
364    let bytes = raw.as_bytes();
365    for i in 0..bytes.len().saturating_sub(4) {
366        let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
367        if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) {
368            continue;
369        }
370        if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') {
371            continue;
372        }
373        let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8);
374        let end = if with_seconds { i + 8 } else { i + 5 };
375        return Some(raw[i..end].to_string());
376    }
377    None
378}
379
380pub fn iso_date(raw: &str) -> Option<String> {
381    let bytes = raw.as_bytes();
382    for i in 0..bytes.len().saturating_sub(9) {
383        let window = &bytes[i..i + 10];
384        let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit);
385        if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) {
386            // Must not be part of a longer number, or `123-45-6789` would parse.
387            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
388            let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit();
389            if before_ok && after_ok {
390                return Some(raw[i..i + 10].to_string());
391            }
392        }
393    }
394    None
395}