use crate::{config::Config, parser, rss, models::Post};
use rayon::prelude::*;
use serde_json::json;
use minify_html::{minify, Cfg};
use std::{
fs, io,
path::{Path},
sync::{Arc, Mutex},
time::{Instant, SystemTime},
};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
const LIVE_RELOAD_SCRIPT: &str = r#"
<script id="novos-live-reload">
(function() {
const socket = new WebSocket('ws://' + window.location.host + '/novos/live');
socket.onmessage = (event) => {
if (event.data === 'reload') {
console.log('novos: Change detected, reloading...');
window.location.reload();
}
};
socket.onclose = () => console.log('novos: Live reload disconnected.');
})();
</script>
"#;
fn process_html(mut html: String, should_minify: bool, is_dev: bool) -> String {
if is_dev {
if let Some(pos) = html.find("</body>") {
html.insert_str(pos, LIVE_RELOAD_SCRIPT);
} else {
html.push_str(LIVE_RELOAD_SCRIPT);
}
}
if !should_minify {
return html;
}
let mut cfg = Cfg::new();
cfg.minify_js = true;
cfg.minify_css = true;
cfg.keep_comments = false;
let minified = minify(html.as_bytes(), &cfg);
String::from_utf8(minified).unwrap_or(html)
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
pub fn compile_sass(config: &Config, verbose: bool) -> io::Result<()> {
let sass_dir = Path::new("sass");
if !sass_dir.exists() {
return Ok(());
}
let css_dir = config.output_dir.join("css");
fs::create_dir_all(&css_dir)?;
let style = match config.build.sass_style.as_str() {
"compressed" => grass::OutputStyle::Compressed,
_ => grass::OutputStyle::Expanded,
};
let options = grass::Options::default().style(style);
for entry in fs::read_dir(sass_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "scss" || ext == "sass") {
let file_name = path.file_name().unwrap().to_str().unwrap();
if file_name.starts_with('_') {
continue;
}
if verbose {
println!("\x1b[2m compiling\x1b[0m {}", file_name);
}
match grass::from_path(&path, &options) {
Ok(css) => {
let mut out_path = css_dir.join(file_name);
out_path.set_extension("css");
fs::write(out_path, css)?;
}
Err(e) => {
return Err(io::Error::new(io::ErrorKind::Other, format!("Sass Error: {}", e)));
}
}
}
}
Ok(())
}
pub fn perform_build(
config: &Config,
last_run_mu: Arc<Mutex<SystemTime>>,
verbose: bool,
is_dev: bool,
) -> io::Result<()> {
let start = Instant::now();
let lr = *last_run_mu.lock().unwrap();
let tera = parser::init_tera("templates");
let ps = SyntaxSet::load_defaults_newlines();
let theme = if let Some(ref custom_path) = config.build.syntax_theme_path {
let theme_data = fs::read_to_string(custom_path)
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, format!("Theme file not found: {}", e)))?;
let mut cursor = io::Cursor::new(theme_data);
syntect::highlighting::ThemeSet::load_from_reader(&mut cursor)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Failed to parse .tmTheme"))?
} else {
let ts = ThemeSet::load_defaults();
ts.themes.get(&config.build.syntax_theme)
.cloned()
.unwrap_or_else(|| ts.themes.get("base16-ocean.dark").unwrap().clone())
};
if config.build.clean_output {
if verbose { println!("\x1b[2m[1/4]\x1b[0m Cleaning output directory..."); }
if config.output_dir.exists() {
let _ = fs::remove_dir_all(&config.output_dir);
}
}
fs::create_dir_all(&config.output_dir)?;
let posts_out_path = config.output_dir.join(&config.posts_outdir);
fs::create_dir_all(&posts_out_path)?;
if config.static_dir.exists() {
copy_dir_all(&config.static_dir, &config.output_dir)?;
}
if verbose { println!("\x1b[2m[2/4]\x1b[0m Compiling stylesheets..."); }
compile_sass(config, verbose)?;
if verbose { println!("\x1b[2m[3/4]\x1b[0m Processing content..."); }
let mut post_paths = Vec::new();
if config.posts_dir.exists() {
for e in fs::read_dir(&config.posts_dir)? {
let p = e?.path();
if p.extension().map(|s| s == "md").unwrap_or(false) {
post_paths.push(p);
}
}
}
let mut posts: Vec<Post> = post_paths
.into_par_iter()
.map(|p| {
let mt = fs::metadata(&p).and_then(|m| m.modified()).unwrap_or(lr);
let raw = fs::read_to_string(&p).unwrap_or_default();
parser::parse_frontmatter(&raw, p.file_stem().unwrap().to_str().unwrap(), mt)
})
.collect();
posts.sort_by(|a, b| b.date.cmp(&a.date));
let mut posts_html = String::from("<ul class='post-list'>\n");
for p in &posts {
let link_path = if config.posts_outdir.is_empty() {
format!("{}{}.html", config.base, p.slug)
} else {
format!("{}{}/{}.html", config.base, config.posts_outdir, p.slug)
};
posts_html.push_str(&format!(" <li>{} - <a href='{}'>{}</a></li>\n", p.date, link_path, p.title));
}
posts_html.push_str("</ul>");
if verbose { println!("\x1b[2m[4/4]\x1b[0m Rendering pages..."); }
posts.par_iter().for_each(|p| {
let dest = posts_out_path.join(format!("{}.html", p.slug));
if p.mtime > lr || !dest.exists() {
let body = parser::render_markdown(&p.raw_content, config.build.use_syntect, &ps, &theme);
let rendered = parser::render_template(&tera, "post.html", p, config, &posts_html, &body);
let final_html = process_html(rendered, config.build.minify_html, is_dev);
fs::write(dest, final_html).ok();
}
});
if config.site.generate_rss {
let rss_xml = rss::generate_rss(&posts, config);
fs::write(config.output_dir.join("rss.xml"), rss_xml)?;
}
if config.site.generate_search {
let search_index: Vec<serde_json::Value> = posts.iter().map(|p| {
let clean_text = parser::strip_markdown(&p.raw_content);
let snippet: String = clean_text.chars().take(140).collect();
json!({ "title": p.title, "slug": p.slug, "date": p.date, "tags": p.tags, "snippet": snippet })
}).collect();
fs::write(config.output_dir.join("search.json"), serde_json::to_string(&search_index)?)?;
}
if config.pages_dir.exists() {
let page_entries: Vec<_> = fs::read_dir(&config.pages_dir).unwrap().filter_map(|e| e.ok()).collect();
page_entries.into_par_iter().for_each(|entry| {
let p = entry.path();
if p.extension().map(|s| s == "html" || s == "md").unwrap_or(false) {
let slug = p.file_stem().unwrap().to_str().unwrap();
let raw = fs::read_to_string(&p).unwrap_or_default();
let mt = fs::metadata(&p).and_then(|m| m.modified()).unwrap_or(lr);
let pd = parser::parse_frontmatter(&raw, slug, mt);
let body = if p.extension().unwrap() == "md" {
parser::render_markdown(&pd.raw_content, config.build.use_syntect, &ps, &theme)
} else {
pd.raw_content.clone()
};
let rendered = parser::render_template(&tera, "page.html", &pd, config, &posts_html, &body);
let final_html = process_html(rendered, config.build.minify_html, is_dev);
fs::write(config.output_dir.join(format!("{}.html", slug)), final_html).ok();
}
});
}
let index_meta = Post {
slug: "index".to_string(),
title: config.site.title.clone(),
date: "".to_string(),
tags: vec![],
raw_content: String::new(),
mtime: SystemTime::now(),
};
let index_page = parser::render_template(&tera, "index.html", &index_meta, config, &posts_html, "");
let final_index = process_html(index_page, config.build.minify_html, is_dev);
fs::write(config.output_dir.join("index.html"), final_index)?;
if let Ok(mut lr_lock) = last_run_mu.lock() {
*lr_lock = SystemTime::now();
}
if verbose {
println!("\x1b[36msuccess\x1b[0m build complete in {:.2}s.", start.elapsed().as_secs_f32());
}
Ok(())
}