use crate::cli::Cli;
use crate::config::Marmite;
use crate::highlight::MarmiteHighlighter;
use crate::image_provider;
use crate::parser::{
append_references, get_html_with_options, get_links_to, get_media_links_to,
get_table_of_contents_from_html, parse_front_matter,
};
use crate::re;
use crate::site::{get_content_folder, Data};
use chrono::{NaiveDate, NaiveDateTime};
use frontmatter_gen::{Frontmatter, Value};
use log::error;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize)]
pub enum Kind {
Tag,
Archive,
Author,
Stream,
Series,
Language,
}
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Clone, Serialize)]
pub struct GroupedContent {
pub kind: Kind,
pub map: HashMap<String, Vec<Content>>,
}
impl GroupedContent {
pub fn new(kind: Kind) -> Self {
Self {
kind,
map: HashMap::new(),
}
}
pub fn entry(&mut self, key: String) -> Entry<'_, String, Vec<Content>> {
self.map.entry(key)
}
pub fn sort_all(&mut self) {
for contents in self.map.values_mut() {
match self.kind {
Kind::Series => {
contents.sort_by_key(|a| a.date);
}
_ => {
contents.sort_by_key(|a| std::cmp::Reverse(a.date));
}
}
}
}
pub fn iter(&self) -> impl Iterator<Item = (&String, Vec<Content>)> {
let mut vec: Vec<(&String, Vec<Content>)> =
self.map.iter().map(|(k, v)| (k, v.clone())).collect();
match self.kind {
Kind::Tag => {
vec.sort_by_key(|a| std::cmp::Reverse(a.1.len()));
}
Kind::Archive => {
vec.sort_by(|a, b| b.0.cmp(a.0));
}
Kind::Author | Kind::Stream | Kind::Series | Kind::Language => {
vec.sort_by(|a, b| a.0.cmp(b.0));
}
}
vec.into_iter()
}
}
#[derive(Debug, Deserialize, Clone, Serialize, Default)]
pub struct TranslationRef {
pub lang: String,
pub name: String,
pub slug: String,
pub title: String,
}
#[derive(Debug, Deserialize, Clone, Serialize, Default)]
pub struct Content {
pub title: String,
pub description: Option<String>,
pub slug: String,
pub html: String,
pub tags: Vec<String>,
pub date: Option<NaiveDateTime>,
pub extra: Option<Value>,
pub links_to: Option<Vec<String>>,
pub media_links_to: Option<Vec<String>>,
pub back_links: Vec<Self>,
pub card_image: Option<String>,
pub banner_image: Option<String>,
pub authors: Vec<String>,
pub stream: Option<String>,
pub series: Option<String>,
pub pinned: bool,
pub toc: Option<String>,
pub modified_time: Option<i64>,
pub comments: Option<bool>,
pub next: Option<Box<Content>>,
pub previous: Option<Box<Content>>,
pub source_path: Option<std::path::PathBuf>,
pub at_uri: Option<String>,
pub aliases: Vec<String>,
pub language: Option<String>,
pub translations: Vec<TranslationRef>,
pub translates: Option<String>,
}
impl Content {
#[allow(clippy::too_many_lines)]
pub fn from_markdown(
path: &Path,
fragments: Option<&HashMap<String, String>>,
site: &Marmite,
modified_time: Option<i64>,
highlighter: Option<&MarmiteHighlighter>,
folder_defaults: Option<&Frontmatter>,
content_dir: Option<&Path>,
) -> Result<Content, String> {
let file_content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (mut frontmatter, raw_markdown) = parse_front_matter(&file_content)?;
let page_mermaid_config: Option<serde_yaml::Value> = frontmatter
.remove("mermaid_config")
.and_then(|v| serde_yaml::to_value(&v).ok());
let folder_mermaid_config: Option<serde_yaml::Value> = folder_defaults
.and_then(|fd| fd.get("mermaid_config"))
.and_then(|v| serde_yaml::to_value(v).ok());
if let Some(defaults) = folder_defaults {
merge_frontmatter(defaults, &mut frontmatter);
}
let merged_mermaid_config = merge_mermaid_configs(
site.mermaid_config.as_ref(),
folder_mermaid_config.as_ref(),
page_mermaid_config.as_ref(),
);
let (title, markdown_without_title) = get_title(&frontmatter, raw_markdown);
let slug = get_slug(&frontmatter, path);
let is_fragment = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|s| s.starts_with('_'));
let default_parser_options = crate::config::ParserOptions::default();
let parser_options = site
.markdown_parser
.as_ref()
.unwrap_or(&default_parser_options);
let html = if is_fragment {
let references_path = path.with_file_name("_references.md");
let mut raw_markdown = raw_markdown.to_string();
if path != references_path {
raw_markdown = append_references(&raw_markdown, &references_path);
}
get_html_with_options(&raw_markdown, parser_options, highlighter)
} else if fragments.is_some() {
let mut markdown_without_title = markdown_without_title.clone();
if let Some(header) = fragments.and_then(|f| f.get("markdown_header")) {
markdown_without_title.insert_str(0, format!("{header}\n\n").as_str());
}
if let Some(footer) = fragments.and_then(|f| f.get("markdown_footer")) {
markdown_without_title.push_str(format!("\n\n{footer}").as_str());
}
if let Some(references) = fragments.and_then(|f| f.get("references")) {
markdown_without_title.push_str(format!("\n\n{references}").as_str());
}
get_html_with_options(&markdown_without_title, parser_options, highlighter)
} else {
get_html_with_options(&markdown_without_title, parser_options, highlighter)
};
let html = if is_fragment {
html
} else {
fix_at_media_refs(&html, &site.media_path, &slug, path, content_dir)
};
let html = if site.native_mermaid_render && !is_fragment {
crate::parser::render_native_mermaid(&html, &slug, merged_mermaid_config.as_ref())
} else {
html
};
let description = get_description(&frontmatter);
let tags = get_tags(&frontmatter);
let date = get_date(&frontmatter, path);
let extra = frontmatter.get("extra").map(std::borrow::ToOwned::to_owned);
let links_to = get_links_to(&html);
let media_links_to = get_media_links_to(&html);
let back_links = Vec::new();
if date.is_some() {
let media_root = content_dir.or(path.parent()).unwrap_or(path);
let _ =
image_provider::download_banner_image(site, &frontmatter, media_root, &slug, &tags);
}
let card_image = get_card_image(
&frontmatter,
&html,
path,
&slug,
&site.media_path,
content_dir,
);
let banner_image =
get_banner_image(&frontmatter, path, &slug, &site.media_path, content_dir);
let authors = get_authors(&frontmatter, Some(site.default_author.clone()));
let pinned = frontmatter
.get("pinned")
.is_some_and(|p| p.as_bool().unwrap_or(false));
let toc = if frontmatter
.get("toc")
.map_or(site.toc, |t| t.as_bool().unwrap_or(site.toc))
{
Some(get_table_of_contents_from_html(&html))
} else {
None
};
let stream = if date.is_some() {
Some(determine_stream(&frontmatter, path))
} else {
None
};
let series = determine_series(&frontmatter);
let comments = get_comments(&frontmatter);
let aliases = get_aliases(&frontmatter);
let language = get_language(&frontmatter);
let translates = get_translates(&frontmatter);
let frontmatter_translations = get_frontmatter_translations(&frontmatter);
let content = Content {
title,
description,
slug,
html,
tags,
date,
extra,
links_to,
media_links_to,
back_links,
card_image,
banner_image,
authors,
stream,
series,
pinned,
toc,
modified_time,
comments,
next: None,
previous: None,
source_path: Some(path.to_path_buf()),
at_uri: None,
aliases,
language,
translations: frontmatter_translations,
translates,
};
Ok(content)
}
}
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Clone, Default)]
pub struct ContentBuilder {
title: Option<String>,
description: Option<String>,
slug: Option<String>,
html: Option<String>,
tags: Option<Vec<String>>,
date: Option<NaiveDateTime>,
extra: Option<Value>,
links_to: Option<Vec<String>>,
media_links_to: Option<Vec<String>>,
back_links: Option<Vec<Content>>,
card_image: Option<String>,
banner_image: Option<String>,
authors: Option<Vec<String>>,
stream: Option<String>,
series: Option<String>,
pinned: Option<bool>,
toc: Option<String>,
comments: Option<bool>,
source_path: Option<std::path::PathBuf>,
at_uri: Option<String>,
aliases: Option<Vec<String>>,
language: Option<String>,
translations: Option<Vec<TranslationRef>>,
translates: Option<String>,
}
#[allow(dead_code)]
impl ContentBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, title: String) -> Self {
self.title = Some(title);
self
}
pub fn description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
pub fn slug(mut self, slug: String) -> Self {
self.slug = Some(slug);
self
}
pub fn html(mut self, html: String) -> Self {
self.html = Some(html);
self
}
pub fn tags(mut self, tags: Vec<String>) -> Self {
self.tags = Some(tags);
self
}
pub fn date(mut self, date: NaiveDateTime) -> Self {
self.date = Some(date);
self
}
pub fn extra(mut self, extra: Value) -> Self {
self.extra = Some(extra);
self
}
pub fn links_to(mut self, links_to: Vec<String>) -> Self {
self.links_to = Some(links_to);
self
}
pub fn media_links_to(mut self, media_links_to: Vec<String>) -> Self {
self.media_links_to = Some(media_links_to);
self
}
pub fn back_links(mut self, back_links: Vec<Content>) -> Self {
self.back_links = Some(back_links);
self
}
pub fn card_image(mut self, card_image: String) -> Self {
self.card_image = Some(card_image);
self
}
pub fn banner_image(mut self, banner_image: String) -> Self {
self.banner_image = Some(banner_image);
self
}
pub fn authors(mut self, authors: Vec<String>) -> Self {
self.authors = Some(authors);
self
}
pub fn stream(mut self, stream: String) -> Self {
self.stream = Some(stream);
self
}
pub fn series(mut self, series: String) -> Self {
self.series = Some(series);
self
}
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = Some(pinned);
self
}
pub fn toc(mut self, toc: String) -> Self {
self.toc = Some(toc);
self
}
pub fn comments(mut self, comments: bool) -> Self {
self.comments = Some(comments);
self
}
pub fn source_path(mut self, source_path: std::path::PathBuf) -> Self {
self.source_path = Some(source_path);
self
}
pub fn at_uri(mut self, at_uri: String) -> Self {
self.at_uri = Some(at_uri);
self
}
pub fn aliases(mut self, aliases: Vec<String>) -> Self {
self.aliases = Some(aliases);
self
}
pub fn language(mut self, language: String) -> Self {
self.language = Some(language);
self
}
pub fn translations(mut self, translations: Vec<TranslationRef>) -> Self {
self.translations = Some(translations);
self
}
pub fn translates(mut self, translates: String) -> Self {
self.translates = Some(translates);
self
}
pub fn build(self) -> Content {
Content {
title: self.title.unwrap_or_default(),
description: self.description,
slug: self.slug.unwrap_or_default(),
html: self.html.unwrap_or_default(),
tags: self.tags.unwrap_or_default(),
date: self.date,
extra: self.extra,
links_to: self.links_to,
media_links_to: self.media_links_to,
back_links: self.back_links.unwrap_or_default(),
card_image: self.card_image,
banner_image: self.banner_image,
authors: self.authors.unwrap_or_default(),
stream: self.stream,
series: self.series,
pinned: self.pinned.unwrap_or_default(),
toc: self.toc,
modified_time: None,
comments: self.comments,
next: None,
previous: None,
source_path: self.source_path,
at_uri: self.at_uri,
aliases: self.aliases.unwrap_or_default(),
language: self.language,
translations: self.translations.unwrap_or_default(),
translates: self.translates,
}
}
}
pub fn get_title<'a>(frontmatter: &'a Frontmatter, markdown: &'a str) -> (String, String) {
let title = match frontmatter.get("title") {
Some(Value::String(t)) => t.clone(),
_ => markdown
.lines()
.find(|line| !line.trim().is_empty() && !line.trim().starts_with("<!"))
.unwrap_or("")
.trim_start_matches('#')
.trim()
.to_string(),
};
let markdown = markdown
.lines()
.skip_while(|line| line.trim().is_empty() || line.trim_start_matches('#').trim() == title)
.collect::<Vec<&str>>()
.join("\n");
(title, markdown)
}
pub fn get_description(frontmatter: &Frontmatter) -> Option<String> {
if let Some(description) = frontmatter.get("description") {
return Some(description.to_string());
}
None
}
pub fn get_comments(frontmatter: &Frontmatter) -> Option<bool> {
if let Some(comments) = frontmatter.get("comments") {
return comments.as_bool();
}
None
}
pub fn get_slug<'a>(frontmatter: &'a Frontmatter, path: &'a Path) -> String {
let stream = determine_stream(frontmatter, path);
let mut final_slug: String;
if let Some(slug) = frontmatter.get("slug") {
final_slug = crate::slugify::slugify(slug.to_string());
} else if let Some(title) = frontmatter.get("title") {
final_slug = crate::slugify::slugify(title.to_string());
} else {
final_slug = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("untitled")
.to_string();
final_slug = remove_stream_and_date_from_filename(&final_slug);
}
if stream != "index" {
final_slug = format!("{stream}-{final_slug}");
}
final_slug
}
fn determine_stream(frontmatter: &Frontmatter, path: &Path) -> String {
if let Some(stream) = frontmatter.get("stream") {
return stream
.as_str()
.unwrap_or("index")
.trim_matches('"')
.to_string();
}
if let Some(filename_stream) = get_stream_from_filename(path) {
return filename_stream;
}
"index".to_string()
}
fn determine_series(frontmatter: &Frontmatter) -> Option<String> {
frontmatter
.get("series")
.and_then(|s| s.as_str().map(|s| s.trim_matches('"').to_string()))
}
pub fn remove_date_from_filename(filename: &str) -> String {
let date_prefix_re =
Regex::new(re::MATCH_DATE_PREFIX_FROM_FILENAME).expect("Date prefix regex should compile");
date_prefix_re.replace(filename, "").to_string()
}
fn remove_stream_and_date_from_filename(filename: &str) -> String {
let stream_date_pattern = Regex::new(re::CAPTURE_SLUG_FROM_STREAM_DATED_FILENAME)
.expect("Stream date pattern regex should compile");
if let Some(captures) = stream_date_pattern.captures(filename) {
if let Some(slug_match) = captures.get(1) {
return slug_match.as_str().to_string();
}
}
let stream_s_pattern = Regex::new(re::CAPTURE_SLUG_FROM_STREAM_S_FILENAME)
.expect("Stream S pattern regex should compile");
if let Some(captures) = stream_s_pattern.captures(filename) {
if let Some(slug_match) = captures.get(1) {
return slug_match.as_str().to_string();
}
}
remove_date_from_filename(filename)
}
pub fn get_stream_from_filename(path: &Path) -> Option<String> {
if let Some(filename) = path.file_stem().and_then(|stem| stem.to_str()) {
if let Some(stream) = extract_stream_from_date_pattern(filename) {
return Some(stream);
}
if let Some(stream) = extract_stream_from_s_pattern(filename) {
return Some(stream);
}
}
None
}
pub const ISO_639_1_CODES: &[&str] = &[
"aa", "ab", "af", "ak", "am", "an", "ar", "as", "av", "ay", "az", "ba", "be", "bg", "bh", "bi",
"bm", "bn", "bo", "br", "bs", "ca", "ce", "ch", "co", "cr", "cs", "cu", "cv", "cy", "da", "de",
"dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fj", "fo", "fr", "fy",
"ga", "gd", "gl", "gn", "gu", "gv", "ha", "he", "hi", "ho", "hr", "ht", "hu", "hy", "hz", "ia",
"id", "ie", "ig", "ii", "ik", "io", "is", "it", "iu", "ja", "jv", "ka", "kg", "ki", "kj", "kk",
"kl", "km", "kn", "ko", "kr", "ks", "ku", "kv", "kw", "ky", "la", "lb", "lg", "li", "ln", "lo",
"lt", "lu", "lv", "mg", "mh", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my", "na", "nb", "nd",
"ne", "ng", "nl", "nn", "no", "nr", "nv", "ny", "oc", "oj", "om", "or", "os", "pa", "pi", "pl",
"ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sc", "sd", "se", "sg", "si", "sk", "sl",
"sm", "sn", "so", "sq", "sr", "ss", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "ti", "tk",
"tl", "tn", "to", "tr", "ts", "tt", "tw", "ty", "ug", "uk", "ur", "uz", "ve", "vi", "vo", "wa",
"wo", "xh", "yi", "yo", "za", "zh", "zu",
];
pub fn is_iso_639_1_code(code: &str) -> bool {
ISO_639_1_CODES.contains(&code)
}
pub fn detect_language_from_path(path: &Path, content_dir: &Path) -> Option<String> {
let relative = path.strip_prefix(content_dir).ok()?;
let components: Vec<_> = relative.components().collect();
if components.len() < 2 {
return None;
}
let filename_stem = path.file_stem()?.to_str()?;
for &lang_code in ISO_639_1_CODES {
let prefix = format!("{lang_code}-");
if filename_stem.starts_with(&prefix) {
return Some(lang_code.to_string());
}
}
None
}
fn extract_stream_from_date_pattern(filename: &str) -> Option<String> {
let date_pattern = Regex::new(re::CAPTURE_STREAM_AND_DATE_FROM_FILENAME)
.expect("Date pattern regex should compile");
if let Some(captures) = date_pattern.captures(filename) {
if let Some(stream_match) = captures.get(1) {
return Some(stream_match.as_str().to_string());
}
}
None
}
fn extract_stream_from_s_pattern(filename: &str) -> Option<String> {
let s_pattern =
Regex::new(re::CAPTURE_STREAM_FROM_S_FILENAME).expect("S pattern regex should compile");
if let Some(captures) = s_pattern.captures(filename) {
if let Some(stream_match) = captures.get(1) {
return Some(stream_match.as_str().to_string());
}
}
None
}
pub fn get_tags(frontmatter: &Frontmatter) -> Vec<String> {
let tags: Vec<String> = match frontmatter.get("tags") {
Some(Value::Array(tags)) => tags
.iter()
.map(Value::to_string)
.map(|t| t.trim_matches('"').to_string())
.collect(),
Some(Value::String(tags)) => tags.split(',').map(str::trim).map(String::from).collect(),
_ => Vec::new(),
};
tags.iter()
.filter(|tag| !tag.is_empty())
.map(|t| t.trim().to_string())
.collect()
}
pub fn get_authors(frontmatter: &Frontmatter, default_author: Option<String>) -> Vec<String> {
let mut authors: Vec<String> = match frontmatter.get("authors") {
Some(Value::Array(authors)) => authors
.iter()
.map(Value::to_string)
.map(|t| t.trim_matches('"').to_string())
.collect(),
Some(Value::String(authors)) => authors
.split(',')
.map(str::trim)
.map(String::from)
.collect(),
_ => Vec::new(),
};
if authors.is_empty() {
authors = match frontmatter.get("author") {
Some(Value::Array(authors)) => authors
.iter()
.map(Value::to_string)
.map(|t| t.trim_matches('"').to_string())
.collect(),
Some(Value::String(authors)) => authors
.split(',')
.map(str::trim)
.map(String::from)
.collect(),
_ => Vec::new(),
};
}
if authors.is_empty() {
if let Some(default_author) = default_author {
if !default_author.is_empty() {
authors.push(default_author);
}
}
}
authors
}
pub fn get_aliases(frontmatter: &Frontmatter) -> Vec<String> {
let aliases: Vec<String> = match frontmatter.get("aliases") {
Some(Value::Array(aliases)) => aliases
.iter()
.map(Value::to_string)
.map(|t| t.trim_matches('"').to_string())
.collect(),
Some(Value::String(aliases)) => aliases
.split(',')
.map(str::trim)
.map(String::from)
.collect(),
_ => Vec::new(),
};
aliases
.iter()
.filter(|alias| !alias.is_empty())
.map(|a| a.trim().to_string())
.collect()
}
pub fn merge_frontmatter(defaults: &Frontmatter, file_fm: &mut Frontmatter) {
for (key, value) in defaults.iter() {
if key == "title" || key == "slug" {
continue;
}
if !file_fm.contains_key(key) {
file_fm.insert(key.clone(), value.clone());
}
}
}
fn merge_mermaid_configs(
site: Option<&serde_yaml::Value>,
folder: Option<&serde_yaml::Value>,
page: Option<&serde_yaml::Value>,
) -> Option<serde_yaml::Value> {
let mut result: Option<serde_yaml::Value> = None;
for overlay in [site, folder, page].into_iter().flatten() {
result = Some(match result {
Some(base) => crate::workspace::deep_merge_yaml(base, overlay.clone()),
None => overlay.clone(),
});
}
result
}
fn get_language(frontmatter: &Frontmatter) -> Option<String> {
frontmatter
.get("language")
.and_then(|v| v.as_str())
.map(|s| s.trim_matches('"').to_string())
}
fn get_translates(frontmatter: &Frontmatter) -> Option<String> {
frontmatter
.get("translates")
.and_then(|v| v.as_str())
.map(|s| s.trim_matches('"').to_string())
}
fn get_frontmatter_translations(frontmatter: &Frontmatter) -> Vec<TranslationRef> {
let slugs: Vec<String> = match frontmatter.get("translations") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.trim_matches('"').to_string()))
.filter(|s| !s.is_empty())
.collect(),
Some(Value::String(items)) => items
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
_ => Vec::new(),
};
slugs
.into_iter()
.map(|slug| TranslationRef {
slug,
lang: String::new(),
name: String::new(),
title: String::new(),
})
.collect()
}
pub fn get_date(frontmatter: &Frontmatter, path: &Path) -> Option<NaiveDateTime> {
if let Some(input) = frontmatter.get("date").and_then(|v| v.as_str()) {
match try_to_parse_date(input) {
Ok(date) => return Some(date),
Err(e) => {
error!(
"ERROR: Invalid date format {} when parsing {}, {}",
input,
path.display(),
e
);
process::exit(1);
}
}
}
extract_date_from_filename(path)
}
fn try_to_parse_date(input: &str) -> Result<NaiveDateTime, chrono::ParseError> {
let re = Regex::new(re::CAPTURE_DATE_PREFIX_FROM_TEXT)
.expect("Date extraction regex should compile");
let input = re.find(input).map_or("", |m| m.as_str());
input
.parse::<NaiveDateTime>()
.or_else(|_| NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S"))
.or_else(|_| NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M"))
.or_else(|_| {
NaiveDate::parse_from_str(input, "%Y-%m-%d").map(|d| {
d.and_hms_opt(0, 0, 0).unwrap_or_else(|| {
NaiveDate::from_ymd_opt(1970, 1, 1)
.unwrap_or_default()
.and_hms_opt(0, 0, 0)
.unwrap_or_default()
})
})
})
}
fn extract_date_from_filename(path: &Path) -> Option<NaiveDateTime> {
if let Some(filename) = path.file_stem().and_then(|stem| stem.to_str()) {
if let Ok(date) = try_to_parse_date(filename) {
return Some(date);
}
let stream_date_pattern = Regex::new(re::CAPTURE_DATE_FROM_STREAM_DATED_FILENAME)
.expect("Stream date extraction regex should compile");
if let Some(captures) = stream_date_pattern.captures(filename) {
if let Some(date_match) = captures.get(1) {
if let Ok(date) = try_to_parse_date(date_match.as_str()) {
return Some(date);
}
}
}
}
if let Some(parent_name) = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
if let Ok(date) = try_to_parse_date(parent_name) {
return Some(date);
}
}
None
}
pub fn check_for_duplicate_slugs(contents: &Vec<&Content>) -> Result<(), String> {
let mut seen = HashSet::new();
for content in contents {
if !seen.insert(&content.slug) {
return Err(content.slug.clone());
}
}
Ok(())
}
pub fn find_file_by_slug(content_folder: &Path, target_slug: &str) -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
for entry in walkdir::WalkDir::new(content_folder)
.into_iter()
.filter_map(std::result::Result::ok)
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let candidate = remove_date_from_filename(stem);
if candidate == target_slug {
return Some(path.to_path_buf());
}
if let Some((prefix, rest)) = candidate.split_once('-') {
if is_iso_639_1_code(prefix) && rest == target_slug {
return Some(path.to_path_buf());
}
}
}
candidates.push(path.to_path_buf());
}
for path in &candidates {
if let Ok(content) = fs::read_to_string(path) {
if let Ok((fm, _)) = crate::parser::parse_front_matter(&content) {
if let Some(slug_val) = fm.get("slug") {
let slug = slug_val
.as_str()
.unwrap_or("")
.trim_matches('"')
.to_string();
if slug == target_slug {
return Some(path.clone());
}
}
}
}
}
None
}
#[derive(Debug, Clone)]
pub struct CreateContentParams {
pub title: String,
pub tags: Option<String>,
pub directory: Option<String>,
pub page: bool,
pub lang: Option<String>,
pub translates: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CreateContentResult {
pub file_path: std::path::PathBuf,
pub title: String,
pub slug: String,
pub date: Option<String>,
pub tags: Option<String>,
pub lang: Option<String>,
pub translates: Option<String>,
pub is_page: bool,
}
pub fn create_content(
input_folder: &Path,
config_path: &Path,
params: &CreateContentParams,
) -> Result<CreateContentResult, String> {
let content_folder = get_content_folder(&Data::from_file(config_path).site, input_folder);
let mut path = content_folder.clone();
if let Some(ref lang) = params.lang {
if !is_iso_639_1_code(lang) {
return Err(format!(
"Invalid language code: {lang}. Must be a valid ISO 639-1 code."
));
}
}
let slug = crate::slugify::slugify(¶ms.title);
let now = chrono::Local::now();
let mut in_subfolder = false;
if let Some(existing) = find_file_by_slug(&content_folder, &slug) {
return Err(format!(
"Slug '{slug}' already exists: {}",
existing.display()
));
}
if let Some(ref translates_slug) = params.translates {
let lang = params
.lang
.as_ref()
.ok_or("--lang is required with --translates")?;
if let Some(original_path) = find_file_by_slug(&content_folder, translates_slug) {
if let Ok(relative) = original_path.strip_prefix(&content_folder) {
let parent = original_path.parent().ok_or("file should have parent")?;
let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
let components: Vec<_> = relative.components().collect();
if components.len() > 1 && parent_name == translates_slug {
in_subfolder = true;
path = parent.to_path_buf();
path.push(format!("{lang}-{slug}.md"));
} else {
path = parent.to_path_buf();
path.push(format!("{slug}.md"));
}
}
} else {
return Err(format!(
"Cannot find content with slug '{translates_slug}' in {}",
content_folder.display()
));
}
} else {
if let Some(ref dir) = params.directory {
path.push(dir);
std::fs::create_dir_all(&path)
.map_err(|e| format!("Failed to create directory: {e}"))?;
} else if params.page {
if content_folder.join("pages").is_dir() {
path.push("pages");
}
} else if content_folder.join("posts").is_dir() {
path.push("posts");
}
path.push(format!("{slug}.md"));
}
if path.exists() {
return Err(format!("File already exists: {}", path.display()));
}
let tags = params.tags.as_deref();
let lang = params.lang.as_deref();
let translates = params.translates.as_deref();
let needs_translates_field = translates.is_some() && !in_subfolder;
let date_str = if params.page {
None
} else {
Some(now.format("%Y-%m-%d %H:%M:%S").to_string())
};
let body = build_new_content_body(
¶ms.title,
date_str.as_deref(),
tags,
lang,
translates,
needs_translates_field,
);
let mut file =
std::fs::File::create(&path).map_err(|e| format!("Failed to create file: {e}"))?;
file.write_all(body.as_bytes())
.map_err(|e| format!("Failed to write to file: {e}"))?;
let date = if params.page {
None
} else {
Some(now.format("%Y-%m-%d %H:%M:%S").to_string())
};
Ok(CreateContentResult {
file_path: path,
title: params.title.clone(),
slug,
date,
tags: params.tags.clone(),
lang: params.lang.clone(),
translates: params.translates.clone(),
is_page: params.page,
})
}
pub fn new(input_folder: &Path, text: &str, cli_args: &Arc<Cli>, config_path: &Path) {
let params = CreateContentParams {
title: text.to_string(),
tags: cli_args.create.tags.clone(),
directory: cli_args.create.directory.clone(),
page: cli_args.create.page,
lang: cli_args.create.lang.clone(),
translates: cli_args.create.translates.clone(),
};
match create_content(input_folder, config_path, ¶ms) {
Ok(result) => {
print_new_content_json(
&result.file_path,
text,
&result.slug,
result.date.as_deref(),
params.tags.as_deref(),
params.lang.as_deref(),
params.translates.as_deref(),
);
if cli_args.create.edit {
open_in_editor(&result.file_path);
}
}
Err(e) => error!("{e}"),
}
}
fn json_to_fm_value(v: &serde_json::Value) -> Option<frontmatter_gen::Value> {
match v {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(frontmatter_gen::Value::String(s.clone())),
serde_json::Value::Bool(b) => Some(frontmatter_gen::Value::Boolean(*b)),
serde_json::Value::Number(n) => n.as_f64().map(frontmatter_gen::Value::Number),
serde_json::Value::Array(arr) => {
let items: Vec<frontmatter_gen::Value> =
arr.iter().filter_map(json_to_fm_value).collect();
Some(frontmatter_gen::Value::Array(items))
}
serde_json::Value::Object(map) => {
let mut fm = Frontmatter::new();
for (k, v) in map {
if let Some(fv) = json_to_fm_value(v) {
fm.insert(k.clone(), fv);
}
}
Some(frontmatter_gen::Value::Object(Box::new(fm)))
}
}
}
fn fm_value_to_json(v: &frontmatter_gen::Value) -> serde_json::Value {
match v {
frontmatter_gen::Value::Null => serde_json::Value::Null,
frontmatter_gen::Value::String(s) => serde_json::Value::String(s.clone()),
frontmatter_gen::Value::Boolean(b) => serde_json::Value::Bool(*b),
frontmatter_gen::Value::Number(n) => serde_json::Value::Number(
serde_json::Number::from_f64(*n).unwrap_or_else(|| serde_json::Number::from(0)),
),
frontmatter_gen::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(fm_value_to_json).collect())
}
frontmatter_gen::Value::Object(fm) => {
let map: serde_json::Map<String, serde_json::Value> = fm
.iter()
.map(|(k, v)| (k.clone(), fm_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
frontmatter_gen::Value::Tagged(tag, inner) => {
serde_json::json!({ tag: fm_value_to_json(inner) })
}
}
}
pub fn update_frontmatter(
file_path: &Path,
updates: &serde_json::Map<String, serde_json::Value>,
) -> Result<serde_json::Value, String> {
let file_content = fs::read_to_string(file_path).map_err(|e| e.to_string())?;
let (mut frontmatter, raw_markdown) = crate::parser::parse_front_matter(&file_content)?;
for (key, value) in updates {
if value.is_null() {
frontmatter.remove(key);
} else if let Some(fm_val) = json_to_fm_value(value) {
frontmatter.insert(key.clone(), fm_val);
}
}
let yaml_str = serde_yaml::to_string(&frontmatter)
.map_err(|e| format!("Failed to serialize frontmatter: {e}"))?;
let new_content = format!("---\n{yaml_str}---\n{raw_markdown}");
fs::write(file_path, new_content).map_err(|e| format!("Failed to write file: {e}"))?;
let result: serde_json::Map<String, serde_json::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), fm_value_to_json(v)))
.collect();
Ok(serde_json::Value::Object(result))
}
pub fn get_raw_content(
content_folder: &Path,
slug: &str,
) -> Result<(serde_json::Value, String, std::path::PathBuf, usize), String> {
let file_path = find_file_by_slug(content_folder, slug)
.ok_or_else(|| format!("Content with slug '{slug}' not found"))?;
let file_content = fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
let (frontmatter, raw_markdown) = crate::parser::parse_front_matter(&file_content)?;
let fm_byte_len = file_content.len() - raw_markdown.len();
let frontmatter_lines = file_content[..fm_byte_len]
.chars()
.filter(|c| *c == '\n')
.count();
let fm_json: serde_json::Map<String, serde_json::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), fm_value_to_json(v)))
.collect();
Ok((
serde_json::Value::Object(fm_json),
raw_markdown.to_string(),
file_path,
frontmatter_lines,
))
}
pub fn update_content_body(
content_folder: &Path,
slug: &str,
new_body: &str,
frontmatter_updates: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<serde_json::Value, String> {
let file_path = find_file_by_slug(content_folder, slug)
.ok_or_else(|| format!("Content with slug '{slug}' not found"))?;
let file_content = fs::read_to_string(&file_path).map_err(|e| e.to_string())?;
let (mut frontmatter, _) = crate::parser::parse_front_matter(&file_content)?;
if let Some(updates) = frontmatter_updates {
for (key, value) in updates {
if value.is_null() {
frontmatter.remove(key);
} else if let Some(fm_val) = json_to_fm_value(value) {
frontmatter.insert(key.clone(), fm_val);
}
}
}
let yaml_str = serde_yaml::to_string(&frontmatter)
.map_err(|e| format!("Failed to serialize frontmatter: {e}"))?;
let new_content = format!("---\n{yaml_str}---\n{new_body}");
fs::write(&file_path, new_content).map_err(|e| format!("Failed to write file: {e}"))?;
let result: serde_json::Map<String, serde_json::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), fm_value_to_json(v)))
.collect();
Ok(serde_json::Value::Object(result))
}
pub fn clone_content(
content_folder: &Path,
source_slug: &str,
new_title: &str,
new_slug: Option<&str>,
) -> Result<(std::path::PathBuf, String), String> {
let source_path = find_file_by_slug(content_folder, source_slug)
.ok_or_else(|| format!("Content with slug '{source_slug}' not found"))?;
let slug = new_slug.map_or_else(|| crate::slugify::slugify(new_title), String::from);
if find_file_by_slug(content_folder, &slug).is_some() {
return Err(format!("Slug '{slug}' already exists"));
}
let parent = source_path
.parent()
.ok_or_else(|| "Source file has no parent directory".to_string())?;
let dest_path = parent.join(format!("{slug}.md"));
if dest_path.exists() {
return Err(format!("File already exists: {}", dest_path.display()));
}
fs::copy(&source_path, &dest_path).map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
source_path.display(),
dest_path.display()
)
})?;
let mut updates = serde_json::Map::new();
updates.insert(
"title".to_string(),
serde_json::Value::String(new_title.to_string()),
);
updates.insert("slug".to_string(), serde_json::Value::String(slug.clone()));
updates.insert("aliases".to_string(), serde_json::Value::Null);
updates.insert("translates".to_string(), serde_json::Value::Null);
update_frontmatter(&dest_path, &updates)
.map_err(|e| format!("Failed to update cloned frontmatter: {e}"))?;
Ok((dest_path, slug))
}
pub fn delete_content(content_folder: &Path, slug: &str) -> Result<std::path::PathBuf, String> {
let file_path = find_file_by_slug(content_folder, slug)
.ok_or_else(|| format!("Content with slug '{slug}' not found"))?;
fs::remove_file(&file_path)
.map_err(|e| format!("Failed to delete {}: {e}", file_path.display()))?;
Ok(file_path)
}
pub fn move_content(
content_folder: &Path,
slug: &str,
new_filename: &str,
) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
let file_path = find_file_by_slug(content_folder, slug)
.ok_or_else(|| format!("Content with slug '{slug}' not found"))?;
if !std::path::Path::new(new_filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
return Err("New filename must end with .md".to_string());
}
let new_path = if new_filename.contains('/') || new_filename.contains(std::path::MAIN_SEPARATOR)
{
let full = content_folder.join(new_filename);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
full
} else {
let parent = file_path
.parent()
.ok_or_else(|| "File has no parent directory".to_string())?;
parent.join(new_filename)
};
if new_path.exists() {
return Err(format!(
"Target file already exists: {}",
new_path.display()
));
}
fs::rename(&file_path, &new_path).map_err(|e| {
format!(
"Failed to move {} to {}: {e}",
file_path.display(),
new_path.display()
)
})?;
Ok((file_path, new_path))
}
fn open_in_editor(path: &Path) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
"notepad".to_string()
} else {
"nano".to_string()
}
});
let status = std::process::Command::new(editor).arg(path).status();
if let Err(e) = status {
error!("Failed to open editor: {e:?}");
}
}
fn build_new_content_body(
text: &str,
date: Option<&str>,
tags: Option<&str>,
lang: Option<&str>,
translates: Option<&str>,
needs_translates_field: bool,
) -> String {
use std::fmt::Write;
let has_frontmatter =
date.is_some() || tags.is_some() || lang.is_some() || needs_translates_field;
if !has_frontmatter {
return format!("# {text}\n");
}
let mut fm = String::from("---\n");
if lang.is_some() {
let _ = writeln!(fm, "title: \"{}\"", text.replace('"', "\\\""));
}
if let Some(date) = date {
let _ = writeln!(fm, "date: {date}");
}
if let Some(lang) = lang {
let _ = writeln!(fm, "language: {lang}");
}
if needs_translates_field {
let _ = writeln!(fm, "translates: {}", translates.expect("checked above"));
}
if let Some(tags) = tags {
let _ = writeln!(fm, "tags: {tags}");
}
fm.push_str("---\n");
format!("{fm}# {text}\n")
}
fn print_new_content_json(
path: &Path,
text: &str,
slug: &str,
date: Option<&str>,
tags: Option<&str>,
lang: Option<&str>,
translates: Option<&str>,
) {
let mut output = serde_json::Map::new();
output.insert(
"file".to_string(),
serde_json::Value::String(path.display().to_string()),
);
output.insert(
"title".to_string(),
serde_json::Value::String(text.to_string()),
);
output.insert(
"slug".to_string(),
serde_json::Value::String(slug.to_string()),
);
if let Some(date) = date {
output.insert(
"date".to_string(),
serde_json::Value::String(date.to_string()),
);
}
if let Some(tags) = tags {
output.insert(
"tags".to_string(),
serde_json::Value::String(tags.to_string()),
);
}
if let Some(lang) = lang {
output.insert(
"language".to_string(),
serde_json::Value::String(lang.to_string()),
);
}
if let Some(translates_slug) = translates {
output.insert(
"translates".to_string(),
serde_json::Value::String(translates_slug.to_string()),
);
}
println!(
"{}",
serde_json::to_string(&output).expect("JSON serialization should not fail")
);
}
pub fn get_card_image(
frontmatter: &Frontmatter,
html: &str,
path: &Path,
slug: &str,
media_path: &str,
content_dir: Option<&Path>,
) -> Option<String> {
if let Some(card_image) = frontmatter.get("card_image") {
return Some(card_image.to_string());
}
if let Some(value) = find_matching_file(
slug,
path,
"card",
&["png", "jpg", "jpeg"],
media_path,
content_dir,
) {
return Some(value);
}
if let Some(banner_image) = get_banner_image(frontmatter, path, slug, media_path, content_dir) {
return Some(banner_image);
}
let img_regex =
Regex::new(re::CAPTURE_SRC_FROM_IMG_HTMLTAG).expect("Image src regex should compile");
img_regex
.captures(html)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
}
fn find_matching_file(
slug: &str,
path: &Path,
kind: &str,
exts: &[&str],
media_folder_name: &str,
content_dir: Option<&Path>,
) -> Option<String> {
let parent_path = path.parent().unwrap_or(path);
let media_path = parent_path.join(media_folder_name);
for ext in exts {
let content_subfolder_media = parent_path.join(slug).join(media_folder_name);
if content_subfolder_media.is_dir() {
for subfolder_filename in [format!("{kind}.{ext}"), format!("{slug}.{ext}")] {
let file_path = content_subfolder_media.join(&subfolder_filename);
if file_path.exists() {
return Some(format!("{media_folder_name}/{slug}/{subfolder_filename}"));
}
}
}
let slug_subfolder = media_path.join(slug);
if slug_subfolder.is_dir() {
for subfolder_filename in [format!("{kind}.{ext}"), format!("{slug}.{ext}")] {
let file_path = slug_subfolder.join(&subfolder_filename);
if file_path.exists() {
return Some(format!("{media_folder_name}/{slug}/{subfolder_filename}"));
}
}
}
for image_filename in [format!("{slug}.{kind}.{ext}"), format!("{slug}.{ext}")] {
let file_path = media_path.join(&image_filename);
if file_path.exists() {
return Some(format!("{media_folder_name}/{image_filename}"));
}
let file_path = parent_path.join(&image_filename);
if file_path.exists() {
return Some(image_filename.clone());
}
}
}
if media_path.is_dir() {
let is_subfolder = content_dir.is_some_and(|cd| parent_path != cd);
if is_subfolder {
if let Some(subfolder_name) = parent_path.file_name().and_then(|n| n.to_str()) {
for ext in exts {
let generic_filename = format!("{kind}.{ext}");
let file_path = media_path.join(&generic_filename);
if file_path.exists() {
return Some(format!(
"{media_folder_name}/{subfolder_name}/{generic_filename}"
));
}
}
}
}
}
if let Some(cd) = content_dir {
if parent_path != cd {
let root_media = cd.join(media_folder_name);
if root_media.is_dir() {
for ext in exts {
for image_filename in [format!("{slug}.{kind}.{ext}"), format!("{slug}.{ext}")]
{
if root_media.join(&image_filename).exists() {
return Some(format!("{media_folder_name}/{image_filename}"));
}
}
}
}
}
}
None
}
fn get_banner_image(
frontmatter: &Frontmatter,
path: &Path,
slug: &str,
media_path: &str,
content_dir: Option<&Path>,
) -> Option<String> {
if let Some(banner_image) = frontmatter.get("banner_image") {
return Some(
banner_image
.as_str()
.unwrap_or("")
.trim_matches('"')
.to_string(),
);
}
if let Some(value) = find_matching_file(
slug,
path,
"banner",
&["png", "jpg", "jpeg"],
media_path,
content_dir,
) {
return Some(value);
}
if let Some(extra) = frontmatter.get("extra") {
if let Some(extra) = extra.as_object() {
if let Some(banner_image) = extra.get("banner_image") {
let url = banner_image.to_string();
return Some(url.trim_matches('"').to_string());
}
}
}
None
}
fn fix_at_media_refs(
html: &str,
media_path: &str,
slug: &str,
path: &Path,
content_dir: Option<&Path>,
) -> String {
let re =
Regex::new(re::REPLACE_AT_MEDIA_REF_IN_HTML).expect("At-media HTML regex should compile");
let parent = path.parent().unwrap_or(path);
let has_slug_media = content_dir.is_some_and(|cd| cd.join(media_path).join(slug).is_dir())
|| content_dir.is_some_and(|cd| cd.join(slug).join(media_path).is_dir());
let parent_has_media =
content_dir.is_some_and(|cd| parent != cd) && parent.join(media_path).is_dir();
let replacement = if has_slug_media {
format!("${{attr}}=\"{media_path}/{slug}/")
} else if parent_has_media {
let folder = parent.file_name().and_then(|n| n.to_str()).unwrap_or(slug);
format!("${{attr}}=\"{media_path}/{folder}/")
} else {
format!("${{attr}}=\"{media_path}/")
};
re.replace_all(html, replacement.as_str()).into_owned()
}
#[cfg(test)]
#[path = "tests/content.rs"]
mod tests;