blog_rs/commands/
compile.rs

1use crate::{BlogResult, Config, Run};
2use derive_builder::Builder;
3use regex::Regex;
4use std::path::Path;
5use std::{collections::HashMap, fs, path::PathBuf, time::UNIX_EPOCH};
6use tracing::warn;
7
8fn find_file(dir: &PathBuf, file_name: &str) -> Option<String> {
9    let entries = fs::read_dir(dir).ok()?;
10    for entry in entries.flatten() {
11        let path = entry.path();
12
13        if path.is_file()
14            && path.file_stem().unwrap().to_str().unwrap().to_lowercase()
15                == file_name.to_lowercase()
16        {
17            return Some(path.to_str().unwrap().to_string());
18        }
19    }
20    None
21}
22
23fn match_imports(html: &str, config: &Config) -> String {
24    let template_path = config.template_dir();
25    let re_with_quotes = Regex::new(r"\[\[(.*?)\]\]").unwrap();
26    let result_with_quotes = re_with_quotes.replace_all(html, |caps: &regex::Captures| {
27        let path_parts: Vec<&str> = caps[1].split('.').map(str::trim).collect();
28        if !path_parts.contains(&"") {
29            let file_name = path_parts.last().unwrap();
30            let dir_path = template_path.join(path_parts[..path_parts.len() - 1].join("/"));
31
32            let full_path = find_file(&dir_path, file_name);
33            if let Some(full_path) = full_path {
34                // Move file to static_directory
35                let extension = PathBuf::from(&full_path)
36                    .extension()
37                    .unwrap_or_default()
38                    .to_str()
39                    .unwrap_or_default()
40                    .to_string();
41                let out_path = config.output.join(file_name).with_extension(&extension);
42
43                fs::copy(&full_path, out_path).unwrap();
44                return PathBuf::from(file_name)
45                    .with_extension(&extension)
46                    .to_str()
47                    .unwrap()
48                    .to_string();
49            }
50        }
51        caps[0].to_string()
52    });
53    result_with_quotes.to_string()
54}
55
56fn match_inner(html: &str, config: &Config) -> String {
57    let template_path = config.template_dir();
58    let re_without_quotes = Regex::new(r"\{\{ (.*?) \}\}").unwrap();
59    let mut html = html.to_string();
60
61    loop {
62        let result_without_quotes =
63            re_without_quotes.replace_all(&html, |caps: &regex::Captures| {
64                let path_parts: Vec<&str> = caps[1].split('.').map(str::trim).collect();
65                let file_name = path_parts.last().unwrap();
66                let dir_path: String = path_parts[..path_parts.len() - 1].join("/");
67                let dir_path = template_path.join(dir_path);
68                let full_path = find_file(&dir_path, file_name);
69                if let Some(full_path) = full_path {
70                    fs::read_to_string(full_path).unwrap()
71                } else {
72                    caps[0].to_string()
73                }
74            });
75
76        let match_imports = match_imports(&result_without_quotes, config);
77        if html == match_imports {
78            break;
79        }
80
81        html = match_imports;
82    }
83
84    html.to_string()
85}
86
87fn index_template(posts: &str, config: &Config) -> String {
88    let layouts_path = config.template_dir().join("layouts");
89    let index_base_path = layouts_path.join("base").join("posts.html");
90    let html = fs::read_to_string(index_base_path).expect("Unable to read index template");
91    // retrieve any field of type {{ chunks.X }} and replace it with the html in chunks.x
92
93    let html = html
94        .replace("{{ .Posts }}", posts)
95        .replace("{{ .Title }}", &config.title);
96
97    match_inner(&html, config)
98}
99
100/// Creates basic html file for a new post
101fn post_template(matter: &Matter, config: &Config) -> String {
102    let layouts_path = config.template_dir().join("layouts");
103    let post_base_path = layouts_path.join("base").join("post.html");
104    let html = fs::read_to_string(post_base_path).expect("Unable to read post template");
105    let html = match_inner(&html, config);
106    html.replace("{{ matter.Title }}", &matter.title())
107        .replace("{{ matter.Date }}", &matter.date())
108        .replace("{{ matter.Content }}", &matter.content())
109        .replace(
110            "{{ matter.TimeToRead }}",
111            &time_to_read(&matter.content()).to_string(),
112        )
113}
114
115/// Calculate the time to read a given article in minutes
116fn time_to_read(content: &str) -> usize {
117    let words = content.split_whitespace().count();
118    #[allow(clippy::cast_possible_truncation)]
119    #[allow(clippy::cast_precision_loss)]
120    #[allow(clippy::cast_sign_loss)]
121    let time = (words as f64 / 200.0).ceil() as usize;
122    if time == 0 {
123        1
124    } else {
125        time
126    }
127}
128pub struct Compile;
129
130fn get_file_name_without_extension_and_extension(path: &Path) -> Option<(String, String)> {
131    let file_name_without_extension = path.file_stem()?.to_str()?.to_string();
132    let extension = path.extension()?.to_str()?.to_string();
133
134    Some((file_name_without_extension, extension))
135}
136
137fn get_file_creation_date(path: PathBuf) -> Option<String> {
138    let metadata = fs::metadata(path).ok()?;
139    let creation_date = metadata.created().ok()?;
140    // Format the date
141    let chron = chrono::NaiveDateTime::from_timestamp_opt(
142        creation_date
143            .duration_since(UNIX_EPOCH)
144            .unwrap()
145            .as_secs()
146            .try_into()
147            .unwrap(),
148        0,
149    )
150    .unwrap()
151    .format("%Y-%m-%d")
152    .to_string();
153
154    Some(chron)
155}
156
157#[derive(Debug, Clone, Builder)]
158struct FrontMatter {
159    #[builder(setter(custom))]
160    pub date: String,
161    #[builder(setter(custom))]
162    pub title: String,
163}
164
165impl FrontMatterBuilder {
166    fn date(&mut self, date: Option<String>, path: Option<PathBuf>) -> &mut Self {
167        if self.date.is_some() {
168            return self;
169        }
170        let date = date.unwrap_or_else(|| {
171            get_file_creation_date(path.unwrap()).unwrap_or_else(|| {
172                warn!("Unable to get file creation date");
173                "Unknown".to_string()
174            })
175        });
176        self.date = Some(date);
177        self
178    }
179
180    fn title(&mut self, title: Option<String>, path: Option<PathBuf>) -> &mut Self {
181        if self.title.is_some() {
182            return self;
183        }
184        let title = title.unwrap_or_else(|| {
185            get_file_name_without_extension_and_extension(&path.unwrap())
186                .unwrap_or_else(|| ("Unknown".to_string(), "Unknown".to_string()))
187                .0
188        });
189        self.title = Some(title);
190        self
191    }
192}
193
194#[derive(Debug, Clone)]
195struct Matter {
196    #[allow(clippy::struct_field_names)]
197    front_matter: FrontMatter,
198    content: String,
199    extension: String,
200    output_path: PathBuf,
201}
202
203impl Matter {
204    fn content(&self) -> String {
205        markdown::to_html(&self.content)
206    }
207
208    fn title(&self) -> String {
209        self.front_matter.title.clone()
210    }
211
212    fn date(&self) -> String {
213        self.front_matter.date.clone()
214    }
215
216    fn split_file(path: &Path) -> BlogResult<(String, String)> {
217        let post = fs::read_to_string(path).map_err(|e| {
218            crate::BlogError::io(
219                e,
220                path.to_path_buf().to_str().unwrap_or_default().to_string(),
221            )
222        })?;
223
224        let re = Regex::new(r"(?s)\+\+\+(.*?)\+\+\+(.*)").unwrap();
225        let caps = re.captures(&post).unwrap();
226
227        let front_matter = caps.get(1).map_or("", |m| m.as_str());
228        let content = caps.get(2).map_or("", |m| m.as_str());
229
230        Ok((front_matter.to_string(), content.to_string()))
231    }
232
233    fn new(path: &Path, out_path: &Path) -> BlogResult<Self> {
234        let (front_text, content) = Matter::split_file(path)?;
235        let front_matter = FrontMatter::parse(&front_text, path)?;
236        let (name, extension) =
237            get_file_name_without_extension_and_extension(path).ok_or_else(|| {
238                crate::BlogError::InvalidFileName(path.to_str().unwrap_or_default().to_string())
239            })?;
240
241        let output_path = out_path.join(format!("{name}.html"));
242        Ok(Self {
243            front_matter,
244            content,
245            extension,
246            output_path,
247        })
248    }
249
250    fn valid_type(&self) -> bool {
251        self.extension == "md"
252    }
253}
254
255impl FrontMatter {
256    fn parse(input: &str, path: &Path) -> BlogResult<Self> {
257        let mut s = FrontMatterBuilder::default();
258        let tokens = input.split('\n');
259        let mut date = None;
260        let mut title = None;
261        for token in tokens {
262            if token.is_empty() {
263                continue;
264            }
265            let (key, value) = token.split_once(':').ok_or_else(|| {
266                crate::BlogError::InvalidFrontMatter(format!(
267                    "Invalid seperator front matter token in {} expected a ':'",
268                    path.to_str().unwrap(),
269                ))
270            })?;
271            match key {
272                "date" => {
273                    date = Some(value.trim().to_string());
274                }
275                "title" => {
276                    title = Some(value.trim().to_string());
277                }
278                _ => {}
279            }
280        }
281
282        s.date(date, Some((path).to_path_buf()))
283            .title(title, Some((path).to_path_buf()))
284            .build()
285            .map_err(|e| crate::BlogError::InvalidFrontMatter(e.to_string()))
286    }
287}
288
289impl Run for Compile {
290    fn run(&self) -> Result<(), crate::BlogError> {
291        let mut index: HashMap<String, Vec<Matter>> = HashMap::new();
292        let config = Config::load_toml()?;
293        for path in Config::load_posts()? {
294            let matter = Matter::new(&path, &config.output)?;
295            if !matter.valid_type() {
296                continue;
297            }
298            let output = post_template(&matter, &config);
299            fs::write(matter.output_path.clone(), output)
300                .map_err(|e| crate::BlogError::io(e, format!("{:?}", matter.output_path)))?;
301            index
302                .entry(matter.front_matter.date.clone())
303                .or_default()
304                .push(matter);
305        }
306
307        create_index(index, &config);
308
309        Ok(())
310    }
311}
312
313fn create_index(content: HashMap<String, Vec<Matter>>, config: &Config) {
314    let mut index = String::new();
315    let mut content: Vec<_> = content.into_iter().collect();
316    content.sort_by(|a, b| b.0.cmp(&a.0));
317
318    for (date, posts) in content {
319        index.push_str(&format!("<h1>{date}</h1>"));
320        for matter in posts {
321            index.push_str(&format!(
322                "<li><a href=\"{}\">{}</a></li>",
323                matter.output_path.file_name().unwrap().to_str().unwrap(),
324                matter.front_matter.title
325            ));
326        }
327    }
328
329    let index = index_template(&index, config);
330    fs::write(config.output.join("posts.html"), index).expect("Unable to write index file");
331    fs::write(config.output.join("index.html"), create_main_page(&config))
332        .expect("Unable to write main page");
333}
334
335fn create_main_page(config: &Config) -> String {
336    let html = fs::read_to_string(
337        config
338            .template_dir()
339            .join("layouts")
340            .join("base")
341            .join("index.html"),
342    )
343    .expect("Unable to read main page template");
344
345    match_inner(&html, config)
346}