blog_rs/commands/
compile.rs1use 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: ®ex::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 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: ®ex::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 let html = html
94 .replace("{{ .Posts }}", posts)
95 .replace("{{ .Title }}", &config.title);
96
97 match_inner(&html, config)
98}
99
100fn 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
115fn 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 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}