#[derive(Debug)]
pub struct Theme {
pub name: &'static str,
pub files: &'static [(&'static str, &'static [u8])],
pub entrypoint: &'static str,
}
const TYPST_JSONRESUME_CV_PREFIX: &str = "/themes/typst-jsonresume-cv";
const FANTASTIC_CV_PREFIX: &str = "/themes/fantastic-cv";
const MODERN_CV_PREFIX: &str = "/themes/modern-cv";
const BASIC_RESUME_PREFIX: &str = "/themes/basic-resume";
pub const TYPST_JSONRESUME_CV: Theme = Theme {
name: "typst-jsonresume-cv",
files: &[
(
concat!("/themes/typst-jsonresume-cv", "/base.typ"),
include_bytes!("../assets/themes/typst-jsonresume-cv/base.typ"),
),
(
concat!("/themes/typst-jsonresume-cv", "/resume.typ"),
include_bytes!("../assets/themes/typst-jsonresume-cv/resume.typ"),
),
],
entrypoint: concat!("/themes/typst-jsonresume-cv", "/resume.typ"),
};
const _: () = {
assert!(!TYPST_JSONRESUME_CV_PREFIX.is_empty());
};
pub const FANTASTIC_CV: Theme = Theme {
name: "fantastic-cv",
files: &[
(
concat!("/themes/fantastic-cv", "/fantastic-cv.typ"),
include_bytes!("../assets/themes/fantastic-cv/fantastic-cv.typ"),
),
(
concat!("/themes/fantastic-cv", "/resume.typ"),
include_bytes!("../assets/themes/fantastic-cv/resume.typ"),
),
],
entrypoint: concat!("/themes/fantastic-cv", "/resume.typ"),
};
const _: () = {
assert!(!FANTASTIC_CV_PREFIX.is_empty());
};
pub const MODERN_CV: Theme = Theme {
name: "modern-cv",
files: &[
(
concat!("/themes/modern-cv", "/lib.typ"),
include_bytes!("../assets/themes/modern-cv/lib.typ"),
),
(
concat!("/themes/modern-cv", "/resume.typ"),
include_bytes!("../assets/themes/modern-cv/resume.typ"),
),
],
entrypoint: concat!("/themes/modern-cv", "/resume.typ"),
};
const _: () = {
assert!(!MODERN_CV_PREFIX.is_empty());
};
pub const BASIC_RESUME: Theme = Theme {
name: "basic-resume",
files: &[
(
concat!("/themes/basic-resume", "/basic-resume.typ"),
include_bytes!("../assets/themes/basic-resume/basic-resume.typ"),
),
(
concat!("/themes/basic-resume", "/resume.typ"),
include_bytes!("../assets/themes/basic-resume/resume.typ"),
),
],
entrypoint: concat!("/themes/basic-resume", "/resume.typ"),
};
const _: () = {
assert!(!BASIC_RESUME_PREFIX.is_empty());
};
const TEXT_MINIMAL_RESUME_PATH: &str = "/themes/text-minimal/resume.typ";
pub const TEXT_MINIMAL: Theme = Theme {
name: "text-minimal",
files: &[(
TEXT_MINIMAL_RESUME_PATH,
include_bytes!("../assets/themes/text-minimal/resume.typ"),
)],
entrypoint: TEXT_MINIMAL_RESUME_PATH,
};
const HTML_MINIMAL_RESUME_PATH: &str = "/themes/html-minimal/resume.typ";
pub const HTML_MINIMAL: Theme = Theme {
name: "html-minimal",
files: &[(
HTML_MINIMAL_RESUME_PATH,
include_bytes!("../assets/themes/html-minimal/resume.typ"),
)],
entrypoint: HTML_MINIMAL_RESUME_PATH,
};
pub const THEMES: &[&Theme] = &[
&TYPST_JSONRESUME_CV,
&FANTASTIC_CV,
&MODERN_CV,
&BASIC_RESUME,
&TEXT_MINIMAL,
&HTML_MINIMAL,
];
pub fn find_theme(name: &str) -> Option<&'static Theme> {
THEMES.iter().copied().find(|t| t.name == name)
}
const LOCAL_THEME_ENTRYPOINT: &str = "/themes/local/resume.typ";
#[derive(Debug, Clone)]
pub struct OwnedTheme {
pub name: String,
pub files: Vec<(String, Vec<u8>)>,
pub entrypoint: String,
}
#[derive(Debug, Clone)]
pub enum ResolvedTheme {
Bundled(&'static Theme),
Owned(OwnedTheme),
}
impl ResolvedTheme {
pub fn name(&self) -> &str {
match self {
ResolvedTheme::Bundled(t) => t.name,
ResolvedTheme::Owned(o) => &o.name,
}
}
pub fn entrypoint(&self) -> &str {
match self {
ResolvedTheme::Bundled(t) => t.entrypoint,
ResolvedTheme::Owned(o) => &o.entrypoint,
}
}
pub fn files(&self) -> Box<dyn Iterator<Item = (&str, &[u8])> + '_> {
match self {
ResolvedTheme::Bundled(t) => Box::new(t.files.iter().map(|(p, b)| (*p, *b as &[u8]))),
ResolvedTheme::Owned(o) => {
Box::new(o.files.iter().map(|(p, b)| (p.as_str(), b.as_slice())))
}
}
}
}
#[derive(Debug)]
pub enum ThemeResolveError {
NotFound {
name: String,
available: Vec<&'static str>,
},
LocalPathNotAFile {
path: std::path::PathBuf,
},
LocalPathNotFound {
path: std::path::PathBuf,
},
LocalPathIoError {
path: std::path::PathBuf,
source: std::io::Error,
},
LocalPathNotUtf8 {
path: std::path::PathBuf,
},
PreviewCacheMiss {
spec: String,
expected_path: std::path::PathBuf,
},
PreviewCacheCorrupt {
spec: String,
path: std::path::PathBuf,
reason: String,
},
PreviewSpecRequiresInstallFeature {
spec: String,
},
PreviewSpecInvalid {
spec: String,
reason: String,
},
}
impl std::fmt::Display for ThemeResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeResolveError::NotFound { name, .. } => {
write!(f, "unknown theme `{name}`")
}
ThemeResolveError::LocalPathNotAFile { path } => write!(
f,
"local-path theme must point to a .typ file, not a directory or non-.typ file: {} \
(directory-based local themes are tracked as a follow-up on issue #41; \
for now concatenate your theme into a single .typ file)",
path.display()
),
ThemeResolveError::LocalPathNotFound { path } => {
write!(f, "local-path theme not found: {}", path.display())
}
ThemeResolveError::LocalPathIoError { path, source } => write!(
f,
"failed to read local-path theme {}: {source}",
path.display()
),
ThemeResolveError::LocalPathNotUtf8 { path } => write!(
f,
"local-path theme {} is not valid UTF-8 (Typst source files must be UTF-8)",
path.display()
),
ThemeResolveError::PreviewCacheMiss {
spec,
expected_path,
} => write!(
f,
"theme '{spec}' not found in cache at {}. \
Run: ferrocv themes install {spec}",
expected_path.display(),
),
ThemeResolveError::PreviewCacheCorrupt { spec, path, reason } => write!(
f,
"cached theme {spec} is corrupt at {}: {reason}. \
Remove the cache directory and re-run `ferrocv themes install {spec}`.",
path.display(),
),
ThemeResolveError::PreviewSpecRequiresInstallFeature { spec } => write!(
f,
"theme '{spec}' requires a build with the `install` Cargo feature. \
Rebuild with `cargo install ferrocv --features install`, \
then run `ferrocv themes install {spec}` before this render."
),
ThemeResolveError::PreviewSpecInvalid { spec, reason } => write!(
f,
"invalid Typst Universe spec '{spec}': {reason}. \
Expected '@preview/<name>:<version>' (e.g. '@preview/basic-resume:0.2.8')."
),
}
}
}
impl std::error::Error for ThemeResolveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ThemeResolveError::LocalPathIoError { source, .. } => Some(source),
_ => None,
}
}
}
enum ThemeSpecKind {
PreviewPackage,
LocalPath,
BundledName,
}
fn classify_spec(spec: &str) -> ThemeSpecKind {
if spec.starts_with("@preview/") {
return ThemeSpecKind::PreviewPackage;
}
let looks_like_path = spec.starts_with('.')
|| spec.starts_with('/')
|| spec.contains('/')
|| spec.contains('\\')
|| spec.ends_with(".typ");
if looks_like_path {
return ThemeSpecKind::LocalPath;
}
ThemeSpecKind::BundledName
}
pub fn resolve_theme(spec: &str) -> Result<ResolvedTheme, ThemeResolveError> {
match classify_spec(spec) {
ThemeSpecKind::PreviewPackage => resolve_preview_package(spec),
ThemeSpecKind::LocalPath => resolve_local_path(spec),
ThemeSpecKind::BundledName => match find_theme(spec) {
Some(theme) => Ok(ResolvedTheme::Bundled(theme)),
None => Err(ThemeResolveError::NotFound {
name: spec.to_owned(),
available: THEMES.iter().map(|t| t.name).collect(),
}),
},
}
}
#[cfg(not(feature = "install"))]
fn resolve_preview_package(spec: &str) -> Result<ResolvedTheme, ThemeResolveError> {
Err(ThemeResolveError::PreviewSpecRequiresInstallFeature {
spec: spec.to_owned(),
})
}
#[cfg(feature = "install")]
fn resolve_preview_package(spec: &str) -> Result<ResolvedTheme, ThemeResolveError> {
let parsed = match crate::install::spec::parse_spec(spec) {
Ok(p) => p,
Err(err) => {
return Err(ThemeResolveError::PreviewSpecInvalid {
spec: spec.to_owned(),
reason: format!("{err}"),
});
}
};
crate::package_cache::resolve_preview_spec_from_cache(&parsed).map(ResolvedTheme::Owned)
}
fn resolve_local_path(spec: &str) -> Result<ResolvedTheme, ThemeResolveError> {
let path = std::path::PathBuf::from(spec);
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(ThemeResolveError::LocalPathNotFound { path });
}
Err(err) => {
return Err(ThemeResolveError::LocalPathIoError { path, source: err });
}
};
let has_typ_extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("typ"))
.unwrap_or(false);
if !metadata.is_file() || !has_typ_extension {
return Err(ThemeResolveError::LocalPathNotAFile { path });
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(err) => return Err(ThemeResolveError::LocalPathIoError { path, source: err }),
};
if std::str::from_utf8(&bytes).is_err() {
return Err(ThemeResolveError::LocalPathNotUtf8 { path });
}
let display_path = std::fs::canonicalize(&path).unwrap_or(path);
let name = format!("local:{}", display_path.display());
Ok(ResolvedTheme::Owned(OwnedTheme {
name,
files: vec![(LOCAL_THEME_ENTRYPOINT.to_owned(), bytes)],
entrypoint: LOCAL_THEME_ENTRYPOINT.to_owned(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_spec_bundled_names() {
for theme in THEMES {
assert!(
matches!(classify_spec(theme.name), ThemeSpecKind::BundledName),
"bundled theme `{}` must classify as BundledName",
theme.name,
);
}
}
#[test]
fn classify_spec_preview_packages() {
assert!(matches!(
classify_spec("@preview/basic-resume:0.2.8"),
ThemeSpecKind::PreviewPackage
));
assert!(matches!(
classify_spec("@preview/foo:1.0.0"),
ThemeSpecKind::PreviewPackage
));
}
#[test]
fn classify_spec_local_paths() {
for spec in [
"./resume.typ",
"../themes/mine.typ",
"/abs/path/to/theme.typ",
"subdir/theme.typ",
"theme.typ",
".\\win\\path.typ",
"C:\\Users\\me\\theme.typ",
] {
assert!(
matches!(classify_spec(spec), ThemeSpecKind::LocalPath),
"spec `{spec}` must classify as LocalPath",
);
}
}
#[test]
fn classify_spec_unknown_bundled_name_is_bundled() {
assert!(matches!(
classify_spec("not-a-real-theme"),
ThemeSpecKind::BundledName
));
}
#[test]
fn resolve_theme_bundled_name_hits_registry() {
let resolved = resolve_theme("text-minimal").expect("text-minimal is bundled");
assert_eq!(resolved.name(), "text-minimal");
match resolved {
ResolvedTheme::Bundled(_) => {}
_ => panic!("expected Bundled variant"),
}
}
#[test]
fn resolve_theme_unknown_bundled_name_returns_not_found() {
let err =
resolve_theme("definitely-not-a-theme").expect_err("unknown bundled names must error");
match err {
ThemeResolveError::NotFound { name, available } => {
assert_eq!(name, "definitely-not-a-theme");
assert!(!available.is_empty(), "available list must be non-empty");
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[cfg(not(feature = "install"))]
#[test]
fn resolve_theme_preview_spec_requires_install_feature() {
let err = resolve_theme("@preview/basic-resume:0.2.8")
.expect_err("preview specs need the install feature on default builds");
match err {
ThemeResolveError::PreviewSpecRequiresInstallFeature { spec } => {
assert_eq!(spec, "@preview/basic-resume:0.2.8");
}
other => panic!("expected PreviewSpecRequiresInstallFeature, got {other:?}"),
}
}
#[cfg(feature = "install")]
#[test]
fn resolve_theme_preview_spec_cache_miss_under_install_feature() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let _lock = crate::test_env::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
struct Guard(Option<String>);
impl Drop for Guard {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(v) => std::env::set_var("FERROCV_CACHE_DIR", v),
None => std::env::remove_var("FERROCV_CACHE_DIR"),
}
}
}
}
let _guard = Guard(std::env::var("FERROCV_CACHE_DIR").ok());
unsafe {
std::env::set_var("FERROCV_CACHE_DIR", tmp.path());
}
let err =
resolve_theme("@preview/missing-pkg-xyz:0.0.0").expect_err("empty cache must miss");
match err {
ThemeResolveError::PreviewCacheMiss { spec, .. } => {
assert_eq!(spec, "@preview/missing-pkg-xyz:0.0.0");
}
other => panic!("expected PreviewCacheMiss, got {other:?}"),
}
}
#[cfg(feature = "install")]
#[test]
fn resolve_theme_malformed_preview_spec_returns_preview_spec_invalid() {
let err = resolve_theme("@preview/foo").expect_err("malformed spec must error");
match err {
ThemeResolveError::PreviewSpecInvalid { spec, reason } => {
assert_eq!(spec, "@preview/foo");
assert!(
!reason.is_empty(),
"PreviewSpecInvalid reason must be non-empty"
);
let rendered =
format!("{}", ThemeResolveError::PreviewSpecInvalid { spec, reason });
assert!(
rendered.contains("Expected '@preview/<name>:<version>'"),
"user-facing message must point at correct syntax; got: {rendered}"
);
assert!(
!rendered.to_lowercase().contains("themes install"),
"must not produce a circular 'themes install' hint; got: {rendered}"
);
}
other => panic!("expected PreviewSpecInvalid, got {other:?}"),
}
}
#[test]
fn resolve_theme_missing_local_path_errors() {
let err = resolve_theme("/nonexistent/path/definitely-not-there.typ")
.expect_err("missing local paths must error");
match err {
ThemeResolveError::LocalPathNotFound { path } => {
assert!(path.to_string_lossy().contains("definitely-not-there.typ"));
}
other => panic!("expected LocalPathNotFound, got {other:?}"),
}
}
}