use std::collections;
use std::fs;
use std::io::Write;
use std::path;
use std::sync::LazyLock;
use anyhow::Context as _;
use cobalt::cobalt_model;
use crate::args;
use crate::error::Result;
#[derive(Clone, Debug, PartialEq, Eq, clap::Args)]
pub(crate) struct InitArgs {
#[arg(default_value = "./")]
pub(crate) directory: path::PathBuf,
}
impl InitArgs {
pub(crate) fn run(&self) -> Result<()> {
create_new_project(&self.directory)
.with_context(|| anyhow::format_err!("Could not create a new cobalt project"))?;
log::info!("Created new project at {}", self.directory.display());
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, clap::Args)]
pub(crate) struct NewArgs {
pub(crate) title: Option<String>,
#[arg(short, long, value_name = "DIR_OR_FILE")]
pub(crate) file: Option<path::PathBuf>,
#[arg(long, value_name = "EXT")]
pub(crate) with_ext: Option<String>,
#[arg(long)]
pub(crate) edit: bool,
#[command(flatten, next_help_heading = "Config")]
pub(crate) config: args::ConfigArgs,
}
impl NewArgs {
pub(crate) fn run(&self) -> Result<()> {
let mut config = self.config.load_config()?;
config.include_drafts = true;
let config = cobalt_model::Config::from_config(config)?;
let title = self.title.as_deref();
let mut file = path::Path::new(".").to_owned();
if let Some(rel_file) = self.file.as_deref() {
file.push(rel_file);
}
let ext = self.with_ext.as_deref();
create_new_document(&config, title, file, ext, self.edit)
.with_context(|| anyhow::format_err!("Could not create document"))?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, clap::Args)]
pub(crate) struct RenameArgs {
#[arg(value_name = "FILE")]
pub(crate) src: path::PathBuf,
pub(crate) title: String,
#[arg(short, long, value_name = "DIR_OR_FILE")]
pub(crate) file: Option<path::PathBuf>,
#[command(flatten, next_help_heading = "Config")]
pub(crate) config: args::ConfigArgs,
}
impl RenameArgs {
pub(crate) fn run(&self) -> Result<()> {
let mut config = self.config.load_config()?;
config.include_drafts = true;
let config = cobalt_model::Config::from_config(config)?;
let source = self.src.clone();
let title = self.title.as_ref();
let mut file = path::Path::new(".").to_owned();
if let Some(rel_file) = self.file.as_deref() {
file.push(rel_file);
}
rename_document(&config, source, title, file)
.with_context(|| anyhow::format_err!("Could not rename `{}`", title))?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, clap::Args)]
pub(crate) struct PublishArgs {
#[arg(value_name = "FILE")]
pub(crate) filename: path::PathBuf,
#[command(flatten, next_help_heading = "Config")]
pub(crate) config: args::ConfigArgs,
}
impl PublishArgs {
pub(crate) fn run(&self) -> Result<()> {
let mut config = self.config.load_config()?;
config.include_drafts = true;
let config = cobalt_model::Config::from_config(config)?;
let filename = self.filename.as_path();
let mut file = path::Path::new(".").to_owned();
file.push(path::Path::new(filename));
publish_document(&config, &file)
.with_context(|| anyhow::format_err!("Could not publish `{:?}`", file))?;
Ok(())
}
}
const COBALT_YML: &str = "
site:
title: cobalt blog
description: Blog Posts Go Here
base_url: http://example.com
posts:
rss: rss.xml
";
const DEFAULT_LAYOUT: &str = "<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<title>{{ page.title }}</title>
</head>
<body>
<div>
<h2>{{ page.title }}</h2>
{{ page.content }}
</div>
</body>
</html>
";
const POST_MD: &str = "---
layout: default.liquid
title: First Post
is_draft: true
---
# This is our first Post!
Welcome to the first post ever on cobalt.rs!
";
const INDEX_MD: &str = "---
layout: default.liquid
---
## Blog!
{% for post in collections.posts.pages %}
#### {{post.title}}
[{{ post.title }}]({{ post.permalink }})
{% endfor %}
";
static DEFAULT: LazyLock<collections::HashMap<&'static str, &'static str>> = LazyLock::new(|| {
[("pages", INDEX_MD), ("posts", POST_MD)]
.iter()
.cloned()
.collect()
});
pub(crate) fn create_new_project<P: AsRef<path::Path>>(dest: P) -> Result<()> {
create_new_project_for_path(dest.as_ref())
}
pub(crate) fn create_new_project_for_path(dest: &path::Path) -> Result<()> {
fs::create_dir_all(dest)?;
create_file(dest.join("_cobalt.yml"), COBALT_YML)?;
create_file(dest.join("index.md"), INDEX_MD)?;
fs::create_dir_all(dest.join("_layouts"))?;
create_file(dest.join("_layouts/default.liquid"), DEFAULT_LAYOUT)?;
fs::create_dir_all(dest.join("posts"))?;
create_file(dest.join("posts/post-1.md"), POST_MD)?;
fs::create_dir_all(dest.join("_defaults"))?;
create_file(dest.join("_defaults/pages.md"), INDEX_MD)?;
create_file(dest.join("_defaults/posts.md"), POST_MD)?;
Ok(())
}
pub(crate) fn create_new_document(
config: &cobalt_model::Config,
title: Option<&str>,
mut file: path::PathBuf,
extension: Option<&str>,
edit: bool,
) -> Result<()> {
let is_file = if file.exists() {
file.is_file()
} else {
let trailing_sep = file
.as_os_str()
.as_encoded_bytes()
.last()
.copied()
.map(|b| path::is_separator(b as char))
.unwrap_or(false);
!trailing_sep && file.extension().is_some()
};
let (parent_dir, filename, extension) = if is_file {
let filename = file.file_name().unwrap().to_string_lossy().into_owned();
let ext = extension
.map(|e| e.to_owned())
.or_else(|| file.extension().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "md".to_owned());
file.pop();
let parent_dir = file.clone();
(parent_dir, Some(filename), ext)
} else {
let parent_dir = file.clone();
let filename = None;
let ext = extension
.map(|e| e.to_owned())
.unwrap_or_else(|| "md".to_owned());
(parent_dir, filename, ext)
};
let interim_path = parent_dir.join(format!("NON_EXISTENT.{extension}"));
let interim_path = cobalt_core::SourcePath::from_root(&config.source, &interim_path)
.ok_or_else(|| {
anyhow::format_err!(
"New file {} not in project in directory ({})",
file.display(),
config.source.display()
)
})?;
let source_files =
cobalt_core::Source::new(&config.source, config.ignore.iter().map(|s| s.as_str()))?;
let collection_slug = if source_files.includes_file(&interim_path.abs_path) {
match cobalt::classify_path(
&interim_path.rel_path,
&config.pages,
&config.posts,
&config.page_extensions,
) {
Some((slug, _)) => slug,
None => anyhow::bail!("Target file is an asset: {}", file.display()),
}
} else {
anyhow::bail!("Target file is ignored: {}", file.display());
};
let source_path = config
.source
.join(format!("_defaults/{collection_slug}.{extension}"));
let source = if source_path.is_file() {
cobalt_model::files::read_file(&source_path)
.with_context(|| anyhow::format_err!("Failed to read default: {:?}", source_path))?
} else {
log::debug!("No custom default provided ({source_path:?}), falling back to built-in");
if extension != "md" {
anyhow::bail!("No builtin default for `{}` files, only `md`", extension,);
}
let default = *DEFAULT.get(collection_slug).unwrap_or(&POST_MD);
default.to_string()
};
let parsed = cobalt_model::Document::parse(&source)?;
let (mut front, content) = parsed.into_parts();
if let Some(title) = title {
front.title = Some(liquid::model::KString::from_ref(title));
} else {
front.title = Some(liquid::model::KString::from_ref("Untitled"));
}
let doc = cobalt_model::Document::new(front.clone(), content);
let mut doc = doc.to_string();
if edit || title.is_none() {
doc = scrawl::editor::new()
.ext(extension.as_str())
.open(scrawl::Contents::FromString(&doc.as_str()))
.map_err(|e| anyhow::format_err!("{}", e))?
.to_string()
.map_err(|e| anyhow::format_err!("{}", e))?;
let parsed = cobalt_model::Document::parse(&doc)?;
front = parsed.into_parts().0;
}
let title = title
.map(|t| t.to_owned())
.or_else(|| front.title.map(|s| s.into_string()))
.ok_or_else(|| anyhow::format_err!("Title is missing"))?;
let filename = filename
.unwrap_or_else(|| format!("{}.{}", cobalt_model::slug::slugify(&title), extension));
let mut file = interim_path;
file.pop();
file.push(&filename);
if let Some(parent) = file.abs_path.parent() {
fs::create_dir_all(parent)?;
}
create_file(&file.abs_path, &doc)?;
log::info!("Created new {} {}", collection_slug, file.rel_path);
Ok(())
}
fn create_file<P: AsRef<path::Path>>(path: P, content: &str) -> Result<()> {
create_file_for_path(path.as_ref(), content)
}
fn create_file_for_path(path: &path::Path, content: &str) -> Result<()> {
log::trace!("Creating file {path:?}");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| anyhow::format_err!("Failed to create file {}", path.display()))?;
file.write_all(content.as_bytes())?;
Ok(())
}
pub(crate) fn rename_document(
config: &cobalt_model::Config,
source: path::PathBuf,
title: &str,
file: path::PathBuf,
) -> Result<()> {
let target = if file.extension().is_none() || file.is_dir() {
let extension = source.extension().and_then(|s| s.to_str()).unwrap_or("md");
let file_name = format!("{}.{}", cobalt_model::slug::slugify(title), extension);
let mut file = file;
file.push(path::Path::new(&file_name));
file
} else {
file
};
let doc = cobalt_model::files::read_file(&source)?;
let doc = cobalt_model::Document::parse(&doc)?;
let (mut front, content) = doc.into_parts();
let target = cobalt_core::SourcePath::from_root(&config.source, &target).ok_or_else(|| {
anyhow::format_err!(
"New file {} not in project directory ({})",
target.display(),
config.source.display()
)
})?;
let source_files =
cobalt_core::Source::new(&config.source, config.ignore.iter().map(|s| s.as_str()))?;
let collection = if source_files.includes_file(&target.abs_path) {
match cobalt::classify_path(
&target.rel_path,
&config.pages,
&config.posts,
&config.page_extensions,
) {
Some((slug, _)) if config.pages.slug == slug => &config.pages,
Some((slug, _)) if config.posts.slug == slug => &config.posts,
Some((slug, _)) => unreachable!("Unknown collection: {}", slug),
None => anyhow::bail!("Target file is an asset: {}", target.rel_path),
}
} else {
anyhow::bail!("Target file is ignored: {}", target.rel_path);
};
let full_front = front
.clone()
.merge_path(&target.rel_path)
.merge(&collection.default);
let full_front = cobalt_model::Frontmatter::from_config(full_front)?;
front.title = Some(liquid::model::KString::from_ref(title));
let doc = cobalt_model::Document::new(front, content);
let doc = doc.to_string();
cobalt_model::files::write_document_file(doc, &target.abs_path)?;
if !full_front.is_draft {
log::warn!("Renaming a published page might invalidate links");
}
fs::remove_file(source)?;
Ok(())
}
fn prepend_date_to_filename(
config: &cobalt_model::Config,
file: &path::Path,
date: cobalt_model::DateTime,
) -> Result<()> {
let file_stem = file
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let (_, file_stem) = cobalt_config::path::parse_file_stem(file_stem);
let date_prefix = date.format("%Y-%m-%d-").expect("valid format");
let file_name = format!(
"{}{}.{}",
date_prefix,
file_stem,
file.extension()
.and_then(|os| os.to_str())
.unwrap_or_else(|| config
.page_extensions
.first()
.expect("at least one element is enforced by config validator"))
);
log::trace!(
"`publish_date_in_filename` setting is activated, prefix filename with date, new filename: {file_name}"
);
fs::rename(file, file.with_file_name(file_name))?;
Ok(())
}
fn move_from_drafts_to_posts(
config: &cobalt_model::Config,
file: &path::Path,
) -> Result<path::PathBuf> {
if let Some(drafts_dir) = config.posts.drafts_dir.as_ref() {
let drafts_root = drafts_dir.to_path(&config.source);
if let Ok(relpath) = file.strip_prefix(drafts_root) {
let target = config.posts.dir.to_path(&config.source).join(relpath);
log::trace!(
"post is in `drafts_dir`; moving it to `posts` directory: {}",
target.display()
);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).with_context(|| {
anyhow::format_err!("Could not create {}", parent.display())
})?;
}
fs::rename(file, &target)?;
return Ok(target);
}
}
Ok(file.to_owned())
}
pub(crate) fn publish_document(config: &cobalt_model::Config, file: &path::Path) -> Result<()> {
let doc = cobalt_model::files::read_file(file)?;
let doc = cobalt_model::Document::parse(&doc)?;
let (mut front, content) = doc.into_parts();
let date = cobalt_model::DateTime::now();
front.is_draft = Some(false);
front.published_date = Some(date);
let doc = cobalt_model::Document::new(front, content);
let doc = doc.to_string();
cobalt_model::files::write_document_file(doc, file)?;
let file = move_from_drafts_to_posts(config, file)?;
let file = cobalt_core::SourcePath::from_root(&config.source, &file).ok_or_else(|| {
anyhow::format_err!(
"New file {} not in project directory ({})",
file.display(),
config.source.display()
)
})?;
let source_files =
cobalt_core::Source::new(&config.source, config.ignore.iter().map(|s| s.as_str()))?;
let collection = if source_files.includes_file(&file.abs_path) {
match cobalt::classify_path(
&file.rel_path,
&config.pages,
&config.posts,
&config.page_extensions,
) {
Some((slug, _)) if config.pages.slug == slug => &config.pages,
Some((slug, _)) if config.posts.slug == slug => &config.posts,
Some((slug, _)) => unreachable!("Unknown collection: {}", slug),
None => anyhow::bail!("Target file is an asset: {}", file.rel_path),
}
} else {
anyhow::bail!("Target file is ignored: {}", file.rel_path);
};
if collection.publish_date_in_filename {
prepend_date_to_filename(config, &file.abs_path, date)?;
}
Ok(())
}