use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
pub(crate) struct BundleFile {
pub(crate) path: &'static str,
pub(crate) source: &'static str,
}
pub(crate) struct BundleDef {
pub(crate) name: &'static str,
pub(crate) files: &'static [BundleFile],
}
impl BundleDef {
pub(crate) fn file(&self, path: &str) -> Option<&'static str> {
self.files
.iter()
.find(|file| file.path == path)
.map(|file| file.source)
}
pub(crate) fn has_file(&self, path: &str) -> bool {
self.file(path).is_some()
}
}
include!(concat!(env!("OUT_DIR"), "/theme_assets.rs"));
pub(super) static CALEPIN: BundleDef = BundleDef {
name: "calepin",
files: CALEPIN_FILES,
};
static ACADEMIC: BundleDef = BundleDef {
name: "academic",
files: ACADEMIC_FILES,
};
static BUILTINS: [&BundleDef; 2] = [&CALEPIN, &ACADEMIC];
pub(crate) fn shared_file(path: &str) -> Option<&'static str> {
SHARED_FILES
.iter()
.find(|file| file.path == path)
.map(|file| file.source)
}
pub fn builtin_names() -> Vec<&'static str> {
BUILTINS.iter().map(|bundle| bundle.name).collect()
}
pub(crate) fn builtin_bundle(name: &str) -> Option<&'static BundleDef> {
BUILTINS.iter().copied().find(|bundle| bundle.name == name)
}
pub(super) fn require_builtin(name: &str) -> Result<&'static BundleDef> {
builtin_bundle(name).ok_or_else(|| {
anyhow!(
"unknown theme `{name}`; use one of {}",
builtin_names().join(", ")
)
})
}
#[cfg(test)]
pub(super) fn eject_builtin(name: &str, themes_dir: &Path, force: bool) -> Result<PathBuf> {
let bundle = require_builtin(name)?;
let dest = themes_dir.join(bundle.name);
eject_builtin_to(name, &dest, force)
}
pub fn eject_builtin_to(name: &str, dest: &Path, force: bool) -> Result<PathBuf> {
let bundle = require_builtin(name)?;
if dest.exists() && !force {
return Err(anyhow!(
"{} already exists; pass --force to overwrite",
dest.display()
));
}
for file in bundle.files {
write_theme_file(dest, file.path, file.source)?;
}
write_resolved_shared_files(bundle, dest)?;
Ok(dest.to_path_buf())
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ThemeManifest {
shared: SharedImports,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct SharedImports {
partials: Vec<String>,
css: Vec<String>,
js: Vec<String>,
}
fn write_resolved_shared_files(bundle: &BundleDef, dest: &Path) -> Result<()> {
let manifest = bundle_manifest(bundle)?;
for name in manifest.shared.partials {
write_resolved_shared_file(bundle, dest, "partials", &name, Some("html"))?;
}
for name in manifest.shared.css {
write_resolved_shared_file(bundle, dest, "css", &name, Some("css"))?;
}
for name in manifest.shared.js {
write_resolved_shared_file(bundle, dest, "js", &name, Some("js"))?;
}
Ok(())
}
fn bundle_manifest(bundle: &BundleDef) -> Result<ThemeManifest> {
let Some(source) = bundle.file("theme.toml") else {
return Ok(ThemeManifest::default());
};
toml::from_str(source)
.with_context(|| format!("failed to parse builtin theme `{}` theme.toml", bundle.name))
}
fn write_resolved_shared_file(
bundle: &BundleDef,
dest: &Path,
subdir: &str,
name: &str,
ext: Option<&str>,
) -> Result<()> {
validate_shared_import(name, ext)?;
let relative = format!("{subdir}/{name}");
if bundle.has_file(&relative) {
return Ok(());
}
let source =
shared_file(&relative).ok_or_else(|| anyhow!("shared import `{name}` was not found"))?;
write_theme_file(dest, &relative, source)
}
fn validate_shared_import(name: &str, ext: Option<&str>) -> Result<()> {
if name.trim() != name || name.is_empty() {
return Err(anyhow!("shared import names must be non-empty filenames"));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(anyhow!(
"shared import `{name}` must be a filename, not a path"
));
}
let path = Path::new(name);
if path.components().count() != 1
|| path.file_name().and_then(|file| file.to_str()) != Some(name)
{
return Err(anyhow!(
"shared import `{name}` must be a filename, not a path"
));
}
if let Some(ext) = ext {
if path.extension().and_then(|extension| extension.to_str()) != Some(ext) {
return Err(anyhow!("shared import `{name}` must be a .{ext} file"));
}
}
Ok(())
}
fn write_theme_file(dest: &Path, relative: &str, source: &str) -> Result<()> {
let path = dest.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, source).with_context(|| format!("failed to write {}", path.display()))
}