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(§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) => 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 §ion.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}