krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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(§ion.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) = §ion.heading {
200 push_words(&plain_text(&heading.title), out);
201 }
202 elements_text(§ion.content, out);
203 for child in §ion.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 §ion.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}