#[cfg(feature = "npm")]
use napi_derive::napi;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
#[cfg_attr(feature = "npm", napi(object))]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageObject {
pub layout: String,
pub file_name: String,
pub slug: String,
pub html: String,
pub out_file_path: String,
pub input_file_path: String,
pub title: Option<String>,
pub summary: Option<String>,
pub tags: Option<Vec<String>>,
pub publish_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra: Option<HashMap<String, String>>,
}
#[cfg_attr(feature = "npm", napi(object))]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetsObject {
pub file_name: String,
pub slug: String,
pub raw: String,
pub out_file_path: String,
pub input_file_path: String,
pub ext: String,
pub file_type: String,
}
pub fn asset_file_type(ext: &str) -> &'static str {
match ext {
"js" | "mjs" | "cjs" => "Js",
"css" => "Css",
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "bmp" | "tiff" => {
"Image"
}
"mp3" | "wav" | "ogg" | "mp4" | "webm" => "Video",
"woff" | "woff2" | "ttf" | "eot" => "Font",
_ => "Other",
}
}
pub fn file_name_no_ext(file_name: &str) -> String {
match file_name.rfind('.') {
Some(0) => file_name.to_string(),
Some(i) => file_name[..i].to_string(),
None => file_name.to_string(),
}
}
pub fn markdown_slug(path: &Path, base_dir: &Path, base_url: &str) -> String {
let rel = rel_to_base(path, base_dir);
let mut s = rel;
s.set_extension("");
let mut slug = s.to_string_lossy().replace('\\', "/");
if slug == "index" {
slug = String::new();
} else if let Some(stripped) = slug.strip_suffix("/index") {
slug = stripped.to_string();
}
if slug.is_empty() {
base_url.trim_end_matches('/').to_string() + "/"
} else {
format!("{}/{}", base_url.trim_end_matches('/'), slug)
}
}
pub fn page_out_file(path: &Path, base_dir: &Path, out_dir: &str) -> String {
let rel = rel_to_base(path, base_dir);
let mut rel = rel;
rel.set_extension("html");
let rel_str = rel.to_string_lossy().replace('\\', "/");
let root = out_dir.trim_end_matches('/');
if rel_str == "index.html" {
format!("{root}/index.html")
} else if let Some(stripped) = rel_str.strip_suffix(".html") {
format!("{root}/{stripped}/index.html")
} else {
format!("{root}/{rel_str}")
}
}
pub fn asset_slug(path: &Path, base_dir: &Path, base_url: &str) -> String {
let rel = rel_to_base(path, base_dir)
.to_string_lossy()
.replace('\\', "/");
format!("{}/{}", base_url.trim_end_matches('/'), rel)
}
pub fn asset_out_file(path: &Path, base_dir: &Path, out_dir: &str) -> String {
let rel = rel_to_base(path, base_dir)
.to_string_lossy()
.replace('\\', "/");
format!("{}/{}", out_dir.trim_end_matches('/'), rel)
}
pub(crate) fn rel_to_base(path: &Path, base_dir: &Path) -> PathBuf {
match path.strip_prefix(base_dir) {
Ok(rel) => rel.to_path_buf(),
Err(_) => {
let mut rel = PathBuf::new();
rel.extend(path.components().filter_map(|c| match c {
std::path::Component::Normal(p) => Some(p),
_ => None,
}));
rel
}
}
}
pub fn extra_frontmatter_fields(
frontmatter: &serde_json::Value,
) -> Option<HashMap<String, String>> {
let map = frontmatter.as_object()?;
let named = [
"layout",
"title",
"summary",
"tags",
"date",
"publish_date",
"publishDate",
];
let mut extra: HashMap<String, String> = HashMap::new();
for (k, v) in map {
if named.contains(&k.as_str()) {
continue;
}
let rendered = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
extra.insert(k.clone(), rendered);
}
if extra.is_empty() { None } else { Some(extra) }
}
#[derive(Clone, Default)]
pub struct ObjectCache<V> {
inner: Arc<Mutex<HashMap<String, Vec<V>>>>,
}
impl<V: Clone> ObjectCache<V> {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn insert(&self, key: String, value: Vec<V>) -> Option<Vec<V>> {
self.inner.lock().unwrap().insert(key, value)
}
pub fn get(&self, key: &str) -> Option<Vec<V>> {
self.inner.lock().unwrap().get(key).cloned()
}
pub fn remove(&self, key: &str) -> Option<Vec<V>> {
self.inner.lock().unwrap().remove(key)
}
pub fn contains(&self, key: &str) -> bool {
self.inner.lock().unwrap().contains_key(key)
}
pub fn push_item(&self, key: &str, item: V) -> bool {
let mut map = self.inner.lock().unwrap();
if let Some(array) = map.get_mut(key) {
array.push(item);
true
} else {
false }
}
pub fn remove_item_at(&self, key: &str, index: usize) -> Option<V> {
let mut map = self.inner.lock().unwrap();
if let Some(array) = map.get_mut(key)
&& index < array.len()
{
return Some(array.remove(index));
}
None
}
pub fn replace_item_at(&self, key: &str, index: usize, new_item: V) -> bool {
let mut map = self.inner.lock().unwrap();
if let Some(array) = map.get_mut(key)
&& index < array.len()
{
array[index] = new_item;
return true;
}
false
}
pub fn replace_item_by<F>(&self, key: &str, predicate: F, new_item: V) -> bool
where
F: Fn(&V) -> bool,
{
let mut map = self.inner.lock().unwrap();
if let Some(array) = map.get_mut(key) {
if let Some(index) = array.iter().position(predicate) {
array[index] = new_item;
return true;
}
}
false
}
pub fn update_items<F>(&self, key: &str, mut update_fn: F) -> bool
where
F: FnMut(&mut [V]),
{
let mut map = self.inner.lock().unwrap();
if let Some(array) = map.get_mut(key) {
update_fn(array);
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_type_classification() {
assert_eq!(asset_file_type("png"), "Image");
assert_eq!(asset_file_type("js"), "Js");
assert_eq!(asset_file_type("css"), "Css");
assert_eq!(asset_file_type("woff2"), "Font");
assert_eq!(asset_file_type("mp4"), "Video");
assert_eq!(asset_file_type("map"), "Other");
}
#[test]
fn strips_extensions_from_file_names() {
assert_eq!(file_name_no_ext("foo.md"), "foo");
assert_eq!(file_name_no_ext("foo.bar.md"), "foo.bar");
assert_eq!(file_name_no_ext("noext"), "noext");
assert_eq!(file_name_no_ext(".hidden"), ".hidden");
}
#[test]
fn slugs_collapse_index_pages() {
let base = PathBuf::from("/site/contents");
let root_index = PathBuf::from("/site/contents/index.md");
let nested = PathBuf::from("/site/contents/docs/intro.md");
let nested_index = PathBuf::from("/site/contents/docs/index.md");
assert_eq!(markdown_slug(&root_index, &base, "/"), "/");
assert_eq!(markdown_slug(&nested, &base, ""), "/docs/intro");
assert_eq!(markdown_slug(&nested_index, &base, "/blog"), "/blog/docs");
}
#[test]
fn out_files_use_pretty_urls() {
let base = PathBuf::from("/site/contents");
let root_index = PathBuf::from("/site/contents/index.md");
let nested = PathBuf::from("/site/contents/docs/intro.md");
assert_eq!(page_out_file(&root_index, &base, "out"), "out/index.html");
assert_eq!(
page_out_file(&nested, &base, "out"),
"out/docs/intro/index.html"
);
}
#[test]
fn asset_paths_keep_extension() {
let base = PathBuf::from("/site/public");
let img = PathBuf::from("/site/public/img/logo.png");
assert_eq!(asset_slug(&img, &base, "/"), "/img/logo.png");
assert_eq!(asset_out_file(&img, &base, "dist"), "dist/img/logo.png");
}
#[test]
fn extra_fields_skip_named_keys() {
let fm = serde_json::json!({
"title": "T",
"author": "someone",
"weight": 3,
});
let extra = extra_frontmatter_fields(&fm).unwrap();
assert_eq!(extra["author"], "someone");
assert_eq!(extra["weight"], "3");
assert!(!extra.contains_key("title"));
let only_named = serde_json::json!({ "title": "T" });
assert!(extra_frontmatter_fields(&only_named).is_none());
}
#[test]
fn object_cache_array_ops() {
let cache: ObjectCache<u32> = ObjectCache::new();
assert!(!cache.push_item("k", 1)); cache.insert("k".into(), vec![10, 20]);
assert!(cache.push_item("k", 30));
assert_eq!(cache.get("k"), Some(vec![10, 20, 30]));
assert_eq!(cache.remove_item_at("k", 0), Some(10));
assert!(cache.replace_item_at("k", 0, 99));
assert_eq!(cache.get("k"), Some(vec![99, 30]));
assert!(cache.replace_item_by("k", |v| *v == 30, 33));
assert_eq!(cache.get("k"), Some(vec![99, 33]));
assert!(cache.update_items("k", |arr| { arr.iter_mut().for_each(|v| *v += 1) }));
assert_eq!(cache.get("k"), Some(vec![100, 34]));
assert!(cache.contains("k"));
assert_eq!(cache.remove("k"), Some(vec![100, 34]));
assert!(!cache.contains("k"));
}
}