krz/orgo

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

src/util.rs

408 lines · 14931 bytes

  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, List, Object, Section, Table, 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) => list_text(list, out),
213            Element::Table(table) => table_text(table, out),
214            Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out),
215            Element::Figure { caption, .. } => push_words(&plain_text(caption), out),
216            Element::FootnoteDefinition { content, .. } => elements_text(content, out),
217            // Code, drawers, comments, keywords and raw export blocks are not prose.
218            _ => {}
219        }
220    }
221}
222
223fn list_text(list: &List, out: &mut String) {
224    for item in &list.items {
225        if let Some(term) = &item.term {
226            push_words(&plain_text(term), out);
227        }
228        elements_text(&item.content, out);
229    }
230}
231
232fn table_text(table: &Table, out: &mut String) {
233    for row in &table.rows {
234        if let TableRow::Cells(cells) = row {
235            for cell in cells {
236                push_words(&plain_text(cell), out);
237            }
238        }
239    }
240}
241
242fn push_words(text: &str, out: &mut String) {
243    let text = text.trim();
244    if text.is_empty() {
245        return;
246    }
247    if !out.is_empty() {
248        out.push(' ');
249    }
250    out.push_str(text);
251}
252
253/// The document's first paragraph as plain text — the fallback excerpt for a page with
254/// no `#+DESCRIPTION:`.
255pub fn first_paragraph(root: &Section) -> Option<String> {
256    fn find(section: &Section) -> Option<String> {
257        for element in &section.content {
258            if let Element::Paragraph(objs) = element {
259                let text = plain_text(objs);
260                if !text.trim().is_empty() {
261                    return Some(text.trim().to_string());
262                }
263            }
264        }
265        section.children.iter().find_map(find)
266    }
267    find(root)
268}
269
270/// Turn heading text into a URL-safe anchor slug.
271pub fn slugify(text: &str) -> String {
272    let mut out = String::new();
273    let mut prev_dash = false;
274    for c in text.chars() {
275        if c.is_alphanumeric() {
276            out.extend(c.to_lowercase());
277            prev_dash = false;
278        } else if !prev_dash {
279            out.push('-');
280            prev_dash = true;
281        }
282    }
283    out.trim_matches('-').to_string()
284}
285
286/// The URL to reach the page output at `to_out` from the page output at `from_out`,
287/// honoring an optional `anchor`. Same-page links reduce to a bare `#anchor` fragment;
288/// cross-page links become a relative path.
289///
290/// Both arguments are *output* paths, not source paths, because `#+SLUG:` means the two
291/// no longer correspond: deriving the URL here would reintroduce the filename assumption
292/// that [`output_path`] exists to remove.
293pub fn output_url(from_out: &Utf8Path, to_out: &Utf8Path, anchor: Option<&str>) -> String {
294    let path = if from_out == to_out {
295        String::new()
296    } else {
297        let from_dir = from_out.parent().unwrap_or_else(|| Utf8Path::new(""));
298        relative_path(from_dir, to_out)
299    };
300    match anchor {
301        Some(a) if !a.is_empty() => {
302            if path.is_empty() {
303                format!("#{a}")
304            } else {
305                format!("{path}#{a}")
306            }
307        }
308        _ => {
309            if path.is_empty() {
310                "#".to_string()
311            } else {
312                path
313            }
314        }
315    }
316}
317
318/// The `../`-prefix that reaches the site root from the page at `from_rel`. Empty for a
319/// top-level page. Used for site-global assets like the syntax stylesheet.
320pub fn relative_root(from_rel: &Utf8Path) -> String {
321    let depth = from_rel
322        .parent()
323        .map(|p| p.components().count())
324        .unwrap_or(0);
325    "../".repeat(depth)
326}
327
328/// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs.
329fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String {
330    let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect();
331    let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect();
332    let mut i = 0;
333    while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
334        i += 1;
335    }
336    let mut parts: Vec<&str> = vec![".."; from_c.len() - i];
337    parts.extend(&to_c[i..]);
338    parts.join("/")
339}
340
341/// Resolve a `file:` link path (as written) against the linking page's directory,
342/// normalizing `.`/`..` so it can be matched against indexed file targets.
343pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
344    let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
345    let joined = base.join(path);
346    let mut stack: Vec<&str> = Vec::new();
347    for comp in joined.components() {
348        match comp.as_str() {
349            "." => {}
350            ".." => {
351                stack.pop();
352            }
353            other => stack.push(other),
354        }
355    }
356    Utf8PathBuf::from(stack.join("/"))
357}
358
359/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
360/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
361/// needs one key it can sort on.
362/// The `HH:MM` or `HH:MM:SS` in an org timestamp, if it carries one.
363///
364/// Two notes written on the same day are not written at the same moment, and org records
365/// that — `[2026-02-21 Sat 14:01:32]`. A listing that sorts on the date alone puts them
366/// in whatever order the filesystem happened to yield.
367pub fn iso_time(raw: &str) -> Option<String> {
368    let bytes = raw.as_bytes();
369    for i in 0..bytes.len().saturating_sub(4) {
370        let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
371        if !(digits(i..i + 2) && bytes[i + 2] == b':' && digits(i + 3..i + 5)) {
372            continue;
373        }
374        if i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b':') {
375            continue;
376        }
377        let with_seconds = i + 8 <= bytes.len() && bytes[i + 5] == b':' && digits(i + 6..i + 8);
378        let end = if with_seconds { i + 8 } else { i + 5 };
379        return Some(raw[i..end].to_string());
380    }
381    None
382}
383
384/// Whether ten bytes have the shape `YYYY-MM-DD`. Says nothing about whether the date
385/// exists.
386pub fn is_iso_date_shape(window: &[u8]) -> bool {
387    match window {
388        [y1, y2, y3, y4, b'-', m1, m2, b'-', d1, d2] => {
389            [y1, y2, y3, y4, m1, m2, d1, d2].iter().all(|b| b.is_ascii_digit())
390        }
391        _ => false,
392    }
393}
394
395pub fn iso_date(raw: &str) -> Option<String> {
396    let bytes = raw.as_bytes();
397    for i in 0..bytes.len().saturating_sub(9) {
398        if is_iso_date_shape(&bytes[i..i + 10]) {
399            // Must not be part of a longer number, or `123-45-6789` would parse.
400            let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
401            let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit();
402            if before_ok && after_ok {
403                return Some(raw[i..i + 10].to_string());
404            }
405        }
406    }
407    None
408}