use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use minijinja::{AutoEscape, Environment};
use serde::Serialize;
use serde_json::Value;
pub const DEFAULT_THEME_NAME: &str = "calepin";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ThemeSelection {
#[default]
Default,
Disabled,
Builtin(&'static str),
Dir(PathBuf),
}
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],
}
static CALEPIN: BundleDef = BundleDef {
name: "calepin",
files: &[
BundleFile {
path: "document.html",
source: include_str!("assets/themes/calepin/document.html"),
},
BundleFile {
path: "site.html",
source: include_str!("assets/themes/calepin/site.html"),
},
BundleFile {
path: "paged.typ.jinja",
source: include_str!("assets/themes/calepin/paged.typ.jinja"),
},
BundleFile {
path: "partials/theme-switcher.html",
source: include_str!("assets/themes/calepin/partials/theme-switcher.html"),
},
BundleFile {
path: "partials/navbar-item.html",
source: include_str!("assets/themes/calepin/partials/navbar-item.html"),
},
BundleFile {
path: "styles/00-theme.css",
source: include_str!("assets/snippets/css/theme.css"),
},
BundleFile {
path: "styles/01-code.css",
source: include_str!("assets/snippets/css/code.css"),
},
BundleFile {
path: "styles/02-widgets.css",
source: include_str!("assets/snippets/css/widgets.css"),
},
BundleFile {
path: "styles/document.css",
source: include_str!("assets/themes/calepin/styles/document.css"),
},
BundleFile {
path: "styles/site.css",
source: include_str!("assets/themes/calepin/styles/site.css"),
},
BundleFile {
path: "scripts/00-theme-toggle.js",
source: include_str!("assets/snippets/js/theme-toggle.js"),
},
BundleFile {
path: "scripts/01-language-picker.js",
source: include_str!("assets/snippets/js/language-picker.js"),
},
BundleFile {
path: "scripts/02-copy-code.js",
source: include_str!("assets/snippets/js/copy-code.js"),
},
BundleFile {
path: "scripts/site.js",
source: include_str!("assets/themes/calepin/scripts/site.js"),
},
],
};
static ACADEMIC: BundleDef = BundleDef {
name: "academic",
files: &[
BundleFile {
path: "site.html",
source: include_str!("assets/themes/academic/site.html"),
},
BundleFile {
path: "partials/navbar-item.html",
source: include_str!("assets/themes/academic/partials/navbar-item.html"),
},
BundleFile {
path: "styles/00-theme.css",
source: include_str!("assets/snippets/css/theme.css"),
},
BundleFile {
path: "styles/01-code.css",
source: include_str!("assets/snippets/css/code.css"),
},
BundleFile {
path: "styles/02-widgets.css",
source: include_str!("assets/snippets/css/widgets.css"),
},
BundleFile {
path: "styles/main.css",
source: include_str!("assets/themes/academic/styles/main.css"),
},
BundleFile {
path: "scripts/00-theme-toggle.js",
source: include_str!("assets/snippets/js/theme-toggle.js"),
},
BundleFile {
path: "scripts/01-language-picker.js",
source: include_str!("assets/snippets/js/language-picker.js"),
},
BundleFile {
path: "scripts/02-copy-code.js",
source: include_str!("assets/snippets/js/copy-code.js"),
},
BundleFile {
path: "scripts/main.js",
source: include_str!("assets/themes/academic/scripts/main.js"),
},
],
};
static BUILTINS: [&BundleDef; 2] = [&CALEPIN, &ACADEMIC];
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 fn eject_builtin(name: &str, themes_dir: &Path, force: bool) -> Result<PathBuf> {
let bundle = builtin_bundle(name).ok_or_else(|| {
anyhow!(
"unknown theme `{name}`; use one of {}",
builtin_names().join(", ")
)
})?;
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 = builtin_bundle(name).ok_or_else(|| {
anyhow!(
"unknown theme `{name}`; use one of {}",
builtin_names().join(", ")
)
})?;
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)?;
}
Ok(dest.to_path_buf())
}
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()))
}
impl ThemeSelection {
pub fn parse(value: &str, base_dir: &Path) -> Result<Self> {
if value == "false" || value == "none" {
return Ok(Self::Disabled);
}
if let Some(name) = builtin_names().into_iter().find(|name| *name == value) {
return Ok(Self::Builtin(name));
}
if is_path_like(value) {
let path = Path::new(value);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
};
if !path.is_dir() {
return Err(anyhow!("theme directory not found: {}", path.display()));
}
return Ok(Self::Dir(path));
}
Err(anyhow!(
"unknown theme `{value}`; use one of {} or a path to a theme directory",
builtin_names().join(", ")
))
}
}
fn is_path_like(value: &str) -> bool {
let path = Path::new(value);
path.is_absolute()
|| path.components().count() > 1
|| value.starts_with('.')
|| value.contains('\\')
|| value.ends_with('/')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HtmlScope {
Document,
Site,
}
impl HtmlScope {
fn entry_file(self) -> &'static str {
match self {
Self::Document => "document.html",
Self::Site => "site.html",
}
}
}
pub struct HtmlEntry {
pub theme_name: String,
pub layout: String,
pub partials: Vec<(String, String)>,
pub styles: Vec<(String, String)>,
pub scripts: Vec<(String, String)>,
pub is_default: bool,
}
#[derive(Debug, Clone)]
pub struct PagedTemplateContext {
pub input_path: String,
pub input_dir: String,
pub input_stem: String,
pub body: String,
pub page_meta: Value,
pub params: Value,
}
impl Default for PagedTemplateContext {
fn default() -> Self {
Self {
input_path: String::new(),
input_dir: String::new(),
input_stem: String::new(),
body: String::new(),
page_meta: Value::Null,
params: Value::Object(serde_json::Map::new()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PagedSource {
pub source: String,
pub owns_body: bool,
}
#[derive(Serialize)]
struct PagedTemplateRenderContext<'a> {
theme: &'a str,
target: &'static str,
document: PagedDocumentContext<'a>,
params: &'a Value,
}
#[derive(Serialize)]
struct PagedDocumentContext<'a> {
path: &'a str,
dir: &'a str,
stem: &'a str,
body: &'a str,
meta: &'a Value,
}
pub fn resolve_html_entry(
selection: &ThemeSelection,
scope: HtmlScope,
) -> Result<Option<HtmlEntry>> {
let entry = scope.entry_file();
match selection {
ThemeSelection::Disabled => Ok(None),
ThemeSelection::Default => Ok(Some(bundle_entry(&CALEPIN, entry, true)?)),
ThemeSelection::Builtin(name) => {
let bundle = builtin_bundle(name).ok_or_else(|| {
anyhow!(
"unknown theme `{name}`; use one of {}",
builtin_names().join(", ")
)
})?;
if bundle.files.iter().any(|file| file.path == entry) {
Ok(Some(bundle_entry(
bundle,
entry,
bundle.name == DEFAULT_THEME_NAME,
)?))
} else {
Ok(Some(bundle_entry(&CALEPIN, entry, true)?))
}
}
ThemeSelection::Dir(dir) => {
validate_theme_dir(dir)?;
if dir.join(entry).is_file() {
Ok(Some(dir_entry(dir, entry)?))
} else {
Ok(Some(bundle_entry(&CALEPIN, entry, true)?))
}
}
}
}
pub fn paged_source(
selection: &ThemeSelection,
context: &PagedTemplateContext,
) -> Result<Option<PagedSource>> {
let default_source = || {
CALEPIN
.files
.iter()
.find(|file| file.path == "paged.typ.jinja")
.map(|file| file.source.to_string())
.expect("builtin calepin bundle ships paged.typ.jinja")
};
let render = |name: &str, source: String| {
let owns_body = source.contains("document.body");
render_paged_template(name, source, context).map(|source| PagedSource { source, owns_body })
};
match selection {
ThemeSelection::Disabled => Ok(None),
ThemeSelection::Default => render(DEFAULT_THEME_NAME, default_source()).map(Some),
ThemeSelection::Builtin(name) => {
let bundle = builtin_bundle(name).ok_or_else(|| {
anyhow!(
"unknown theme `{name}`; use one of {}",
builtin_names().join(", ")
)
})?;
let source = bundle
.files
.iter()
.find(|file| file.path == "paged.typ.jinja")
.map(|file| file.source.to_string())
.unwrap_or_else(default_source);
render(bundle.name, source).map(Some)
}
ThemeSelection::Dir(dir) => {
validate_theme_dir(dir)?;
let template_path = dir.join("paged.typ.jinja");
if template_path.is_file() {
let source = std::fs::read_to_string(&template_path)
.with_context(|| format!("failed to read {}", template_path.display()))?;
let name = dir_theme_name(dir);
render(&name, source).map(Some)
} else {
render(DEFAULT_THEME_NAME, default_source()).map(Some)
}
}
}
}
fn render_paged_template(
theme_name: &str,
source: String,
context: &PagedTemplateContext,
) -> Result<String> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| AutoEscape::None);
env.add_template_owned("paged.typ.jinja", source)
.map_err(|error| paged_template_error(theme_name, error))?;
let template = env
.get_template("paged.typ.jinja")
.map_err(|error| paged_template_error(theme_name, error))?;
template
.render(PagedTemplateRenderContext {
theme: theme_name,
target: "paged",
document: PagedDocumentContext {
path: &context.input_path,
dir: &context.input_dir,
stem: &context.input_stem,
body: &context.body,
meta: &context.page_meta,
},
params: &context.params,
})
.map_err(|error| paged_template_error(theme_name, error))
}
fn paged_template_error(name: &str, error: minijinja::Error) -> anyhow::Error {
anyhow!("theme `{name}` paged.typ.jinja: {error}")
}
fn validate_theme_dir(dir: &Path) -> Result<()> {
let has_entry = ["paged.typ.jinja", "document.html", "site.html"]
.iter()
.any(|file| dir.join(file).is_file());
if !has_entry {
return Err(anyhow!(
"theme directory {} contains none of paged.typ.jinja, document.html, site.html",
dir.display()
));
}
Ok(())
}
fn bundle_entry(bundle: &'static BundleDef, entry: &str, is_default: bool) -> Result<HtmlEntry> {
let layout = bundle
.files
.iter()
.find(|file| file.path == entry)
.map(|file| file.source.to_string())
.ok_or_else(|| anyhow!("builtin theme `{}` is missing `{entry}`", bundle.name))?;
let collect = |prefix: &str, ext: &str| -> Vec<(String, String)> {
let mut files: Vec<(String, String)> = bundle
.files
.iter()
.filter(|file| file.path.starts_with(prefix) && file.path.ends_with(ext))
.map(|file| {
let name = file
.path
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(file.path);
(name.to_string(), file.source.to_string())
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
};
Ok(HtmlEntry {
theme_name: bundle.name.to_string(),
layout,
partials: collect("partials/", ".html")
.into_iter()
.map(|(name, source)| (format!("partials/{name}"), source))
.collect(),
styles: collect("styles/", ".css"),
scripts: collect("scripts/", ".js"),
is_default,
})
}
fn dir_entry(dir: &Path, entry: &str) -> Result<HtmlEntry> {
let layout_path = dir.join(entry);
let layout = std::fs::read_to_string(&layout_path)
.with_context(|| format!("failed to read {}", layout_path.display()))?;
let name = dir_theme_name(dir);
Ok(HtmlEntry {
theme_name: name,
layout,
partials: read_theme_files(&dir.join("partials"), "html")?
.into_iter()
.map(|(file, source)| (format!("partials/{file}"), source))
.collect(),
styles: read_theme_files(&dir.join("styles"), "css")?,
scripts: read_theme_files(&dir.join("scripts"), "js")?,
is_default: false,
})
}
fn dir_theme_name(dir: &Path) -> String {
dir.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| dir.display().to_string())
}
fn read_theme_files(dir: &Path, ext: &str) -> Result<Vec<(String, String)>> {
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.with_context(|| format!("failed to read {}", dir.display()))?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some(ext))
.collect();
paths.sort();
let mut files = Vec::with_capacity(paths.len());
for path in paths {
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
files.push((name, contents));
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn parse_builtin_names() {
let base = Path::new("/tmp");
assert_eq!(
ThemeSelection::parse("calepin", base).unwrap(),
ThemeSelection::Builtin("calepin")
);
assert_eq!(
ThemeSelection::parse("academic", base).unwrap(),
ThemeSelection::Builtin("academic")
);
}
#[test]
fn parse_false_and_none_disable() {
let base = Path::new("/tmp");
assert_eq!(
ThemeSelection::parse("false", base).unwrap(),
ThemeSelection::Disabled
);
assert_eq!(
ThemeSelection::parse("none", base).unwrap(),
ThemeSelection::Disabled
);
}
#[test]
fn parse_existing_dir_resolves_relative_to_base() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("mytheme")).unwrap();
let sel = ThemeSelection::parse("mytheme/", dir.path()).unwrap();
assert_eq!(sel, ThemeSelection::Dir(dir.path().join("mytheme/")));
}
#[test]
fn parse_unknown_name_errors_with_builtin_list() {
let err = ThemeSelection::parse("zensical", Path::new("/tmp")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("calepin"), "{msg}");
assert!(msg.contains("academic"), "{msg}");
}
#[test]
fn parse_missing_dir_errors() {
let dir = tempfile::tempdir().unwrap();
let err = ThemeSelection::parse("does/not/exist", dir.path()).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn academic_falls_back_to_default_for_document_scope() {
let sel = ThemeSelection::Builtin("academic");
let entry = resolve_html_entry(&sel, HtmlScope::Document)
.unwrap()
.unwrap();
assert_eq!(entry.theme_name, "calepin");
assert!(entry.is_default);
let site = resolve_html_entry(&sel, HtmlScope::Site).unwrap().unwrap();
assert_eq!(site.theme_name, "academic");
assert!(!site.is_default);
}
#[test]
fn disabled_selection_resolves_to_no_entry_and_no_paged_source() {
assert!(
resolve_html_entry(&ThemeSelection::Disabled, HtmlScope::Site)
.unwrap()
.is_none()
);
assert!(
paged_source(&ThemeSelection::Disabled, &PagedTemplateContext::default())
.unwrap()
.is_none()
);
}
#[test]
fn dir_theme_uses_own_entry_and_falls_back_per_missing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("site.html"),
"<html><head></head><body>X</body></html>",
)
.unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
let site = resolve_html_entry(&sel, HtmlScope::Site).unwrap().unwrap();
assert!(!site.is_default);
let document = resolve_html_entry(&sel, HtmlScope::Document)
.unwrap()
.unwrap();
assert!(document.is_default);
assert_eq!(
paged_source(&sel, &PagedTemplateContext::default()).unwrap(),
paged_source(&ThemeSelection::Default, &PagedTemplateContext::default()).unwrap()
);
}
#[test]
fn empty_paged_typ_jinja_means_no_styling_not_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("paged.typ.jinja"), "").unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
assert_eq!(
paged_source(&sel, &PagedTemplateContext::default()).unwrap(),
Some(PagedSource {
source: String::new(),
owns_body: false,
})
);
}
#[test]
fn paged_typ_jinja_can_use_calepin_context() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("paged.typ.jinja"),
r#"#let title = "{{ document.meta.title }}"
#let species = "{{ params.Species }}"
"#,
)
.unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
let context = PagedTemplateContext {
input_path: "reports/iris.typ".to_string(),
input_dir: "reports".to_string(),
input_stem: "iris".to_string(),
body: "#include \"/.calepin/reports/iris/source.typ\"".to_string(),
page_meta: serde_json::json!({"title": "Iris Report"}),
params: serde_json::json!({"Species": "setosa"}),
};
let source = paged_source(&sel, &context).unwrap().unwrap();
assert_eq!(
source.source,
"#let title = \"Iris Report\"\n#let species = \"setosa\""
);
assert!(!source.owns_body);
}
#[test]
fn paged_typ_jinja_can_place_document_body() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("paged.typ.jinja"),
r#"#set text(size: 11pt)
{{ document.body }}
[#emph[Generated footer]]
"#,
)
.unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
let context = PagedTemplateContext {
input_path: "paper.typ".to_string(),
input_dir: String::new(),
input_stem: "paper".to_string(),
body: "#include \"/.calepin/paper/source.typ\"".to_string(),
page_meta: serde_json::Value::Null,
params: serde_json::json!({}),
};
let source = paged_source(&sel, &context).unwrap().unwrap();
assert_eq!(
source.source,
"#set text(size: 11pt)\n#include \"/.calepin/paper/source.typ\"\n[#emph[Generated footer]]"
);
assert!(source.owns_body);
}
#[test]
fn theme_dir_without_any_entry_file_errors() {
let dir = tempfile::tempdir().unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
assert!(resolve_html_entry(&sel, HtmlScope::Site).is_err());
}
#[test]
fn theme_dir_with_only_legacy_paged_typ_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("paged.typ"), "#set text(size: 10pt)").unwrap();
let sel = ThemeSelection::Dir(dir.path().to_path_buf());
assert!(paged_source(&sel, &PagedTemplateContext::default()).is_err());
}
#[test]
fn eject_builtin_copies_default_bundle() {
let dir = tempfile::tempdir().unwrap();
let dest = eject_builtin(DEFAULT_THEME_NAME, &dir.path().join("themes"), false).unwrap();
assert_eq!(dest, dir.path().join("themes/calepin"));
assert!(dest.join("document.html").is_file());
assert!(dest.join("site.html").is_file());
assert!(dest.join("paged.typ.jinja").is_file());
assert!(!dest.join("paged.typ").exists());
assert!(dest.join("partials/navbar-item.html").is_file());
assert!(dest.join("partials/theme-switcher.html").is_file());
assert!(dest.join("styles/00-theme.css").is_file());
assert!(dest.join("styles/01-code.css").is_file());
assert!(dest.join("styles/02-widgets.css").is_file());
assert!(dest.join("styles/site.css").is_file());
assert!(dest.join("scripts/00-theme-toggle.js").is_file());
assert!(dest.join("scripts/01-language-picker.js").is_file());
assert!(dest.join("scripts/02-copy-code.js").is_file());
assert!(dest.join("scripts/site.js").is_file());
assert!(std::fs::read_to_string(dest.join("styles/02-widgets.css"))
.unwrap()
.contains("[data-calepin-theme-toggle]"));
assert!(
std::fs::read_to_string(dest.join("scripts/02-copy-code.js"))
.unwrap()
.contains("window.CalepinCopyCode")
);
}
#[test]
fn eject_builtin_to_copies_into_requested_directory() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("custom-theme");
let wrote = eject_builtin_to("academic", &dest, false).unwrap();
assert_eq!(wrote, dest);
assert!(wrote.join("site.html").is_file());
assert!(wrote.join("partials/navbar-item.html").is_file());
assert!(wrote.join("styles/00-theme.css").is_file());
assert!(wrote.join("styles/01-code.css").is_file());
assert!(wrote.join("styles/02-widgets.css").is_file());
assert!(wrote.join("styles/main.css").is_file());
assert!(wrote.join("scripts/00-theme-toggle.js").is_file());
assert!(wrote.join("scripts/01-language-picker.js").is_file());
assert!(wrote.join("scripts/02-copy-code.js").is_file());
assert!(wrote.join("scripts/main.js").is_file());
}
#[test]
fn eject_builtin_refuses_existing_destination() {
let dir = tempfile::tempdir().unwrap();
let themes = dir.path().join("themes");
std::fs::create_dir_all(themes.join("calepin")).unwrap();
let err = eject_builtin(DEFAULT_THEME_NAME, &themes, false).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn eject_builtin_unknown_name_lists_builtins() {
let dir = tempfile::tempdir().unwrap();
let err = eject_builtin("zensical", &dir.path().join("themes"), false).unwrap_err();
assert!(err.to_string().contains("academic"));
}
}