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::Object;
8
9/// Flatten inline objects to their plain-text content (markup stripped). Used to
10/// derive heading anchors and `[[*Heading]]` link identities (spec §4.3).
11pub fn plain_text(objs: &[Object]) -> String {
12 let mut out = String::new();
13 plain_text_into(objs, &mut out);
14 out
15}
16
17fn plain_text_into(objs: &[Object], out: &mut String) {
18 for obj in objs {
19 match obj {
20 Object::Text(t) => out.push_str(t),
21 Object::Bold(i)
22 | Object::Italic(i)
23 | Object::Underline(i)
24 | Object::StrikeThrough(i) => plain_text_into(i, out),
25 Object::Verbatim(s) | Object::Code(s) | Object::Entity(s) => out.push_str(s),
26 Object::Link(l) => {
27 if let Some(desc) = &l.description {
28 plain_text_into(desc, out);
29 }
30 }
31 Object::FootnoteRef { .. } | Object::Timestamp(_) | Object::LineBreak => {}
32 }
33 }
34}
35
36/// Turn heading text into a URL-safe anchor slug.
37pub fn slugify(text: &str) -> String {
38 let mut out = String::new();
39 let mut prev_dash = false;
40 for c in text.chars() {
41 if c.is_alphanumeric() {
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/// The output URL to reach `to_rel` (a source `.org` path relative to the site root)
53/// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce
54/// to a bare `#anchor` fragment; cross-file links become a relative `.html` path.
55pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String {
56 let path = if from_rel == to_rel {
57 String::new()
58 } else {
59 let to_html = to_rel.with_extension("html");
60 let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
61 relative_path(from_dir, &to_html)
62 };
63 match anchor {
64 Some(a) if !a.is_empty() => {
65 if path.is_empty() {
66 format!("#{a}")
67 } else {
68 format!("{path}#{a}")
69 }
70 }
71 _ => {
72 if path.is_empty() {
73 "#".to_string()
74 } else {
75 path
76 }
77 }
78 }
79}
80
81/// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs.
82fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String {
83 let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect();
84 let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect();
85 let mut i = 0;
86 while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
87 i += 1;
88 }
89 let mut parts: Vec<&str> = vec![".."; from_c.len() - i];
90 parts.extend(&to_c[i..]);
91 parts.join("/")
92}
93
94/// Resolve a `file:` link path (as written) against the linking page's directory,
95/// normalizing `.`/`..` so it can be matched against indexed file targets.
96pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
97 let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
98 let joined = base.join(path);
99 let mut stack: Vec<&str> = Vec::new();
100 for comp in joined.components() {
101 match comp.as_str() {
102 "." => {}
103 ".." => {
104 stack.pop();
105 }
106 other => stack.push(other),
107 }
108 }
109 Utf8PathBuf::from(stack.join("/"))
110}