use std::path::Path;
use super::bundle::{require_builtin, shared_file, BundleDef};
use super::{
dir_theme_name, read_local_theme_manifest, read_theme_files, resolve_theme_chain, ThemeLayer,
ThemeSelection,
};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HtmlScope {
Document,
Site,
}
impl HtmlScope {
fn entry_file(self) -> &'static str {
match self {
Self::Document => "layouts/document.html",
Self::Site => "layouts/site.html",
}
}
}
#[derive(Debug, Clone)]
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)>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ThemeManifest {
#[allow(dead_code)]
extends: Option<String>,
shared: SharedImports,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct SharedImports {
partials: Vec<String>,
css: Vec<String>,
js: Vec<String>,
}
pub fn resolve_html_entry(
selection: &ThemeSelection,
scope: HtmlScope,
) -> Result<Option<HtmlEntry>> {
let entry = scope.entry_file();
let chain = resolve_theme_chain(selection)?;
chain_html_entry(&chain.layers, entry, chain.terminal_typst)
}
pub fn resolve_explicit_site_html_entry(
selection: &ThemeSelection,
layout_path: &str,
) -> Result<Option<HtmlEntry>> {
validate_explicit_html_layout_path(layout_path)?;
let chain = resolve_theme_chain(selection)?;
if chain.layers.is_empty() && chain.terminal_typst {
return Err(anyhow!(
"page layout `{layout_path}` requires an HTML theme, but theme is `typst`"
));
}
chain_html_entry(&chain.layers, layout_path, chain.terminal_typst)
}
fn validate_explicit_html_layout_path(value: &str) -> Result<()> {
let path = Path::new(value);
if value.trim() != value || value.is_empty() {
return Err(anyhow!(
"page layout path must be a non-empty relative path"
));
}
if path.extension().and_then(|extension| extension.to_str()) != Some("html") {
return Err(anyhow!(
"page layout path must name an .html file: `{value}`"
));
}
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(anyhow!(
"page layout path must stay inside the active theme: `{value}`"
));
}
Ok(())
}
fn chain_html_entry(
layers: &[ThemeLayer],
entry: &str,
terminal_typst: bool,
) -> Result<Option<HtmlEntry>> {
if layers.is_empty() && terminal_typst {
return Ok(None);
}
let Some((_layout_index, layout)) = find_chain_layout(layers, entry)? else {
let theme = layers
.last()
.map(layer_name)
.unwrap_or_else(|| "typst".to_string());
return Err(anyhow!(
"theme `{theme}` does not contain page layout `{entry}`"
));
};
let mut partials = Vec::new();
let mut styles = Vec::new();
let mut scripts = Vec::new();
for layer in layers {
merge_named_assets(&mut partials, layer_partials(layer)?);
merge_named_assets(&mut styles, layer_styles(layer)?);
merge_named_assets(&mut scripts, layer_scripts(layer)?);
}
Ok(Some(HtmlEntry {
theme_name: layer_name(layers.last().unwrap()),
layout,
partials,
styles,
scripts,
}))
}
fn find_chain_layout(layers: &[ThemeLayer], entry: &str) -> Result<Option<(usize, String)>> {
for (index, layer) in layers.iter().enumerate().rev() {
match layer {
ThemeLayer::Builtin(name) => {
let bundle = require_builtin(name)?;
if let Some(source) = bundle.file(entry) {
return Ok(Some((index, source.to_string())));
}
}
ThemeLayer::Dir(dir) => {
let path = dir.join(entry);
if path.is_file() {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
return Ok(Some((index, source)));
}
}
}
}
Ok(None)
}
fn layer_name(layer: &ThemeLayer) -> String {
match layer {
ThemeLayer::Builtin(name) => (*name).to_string(),
ThemeLayer::Dir(dir) => dir_theme_name(dir),
}
}
fn merge_named_assets(target: &mut Vec<(String, String)>, incoming: Vec<(String, String)>) {
for (name, source) in incoming {
if let Some((_, existing_source)) =
target.iter_mut().find(|(existing, _)| existing == &name)
{
*existing_source = source;
} else {
target.push((name, source));
}
}
}
fn layer_partials(layer: &ThemeLayer) -> Result<Vec<(String, String)>> {
match layer {
ThemeLayer::Builtin(name) => {
let bundle = require_builtin(name)?;
let manifest = bundle_manifest(bundle)?;
Ok(
bundle_assets(bundle, &manifest.shared.partials, "partials/", "html")?
.into_iter()
.map(|(name, source)| (format!("partials/{name}"), source))
.collect(),
)
}
ThemeLayer::Dir(dir) => {
let manifest = read_local_theme_manifest(dir)?;
Ok(
dir_assets(dir, &manifest.shared.partials, "partials", "html")?
.into_iter()
.map(|(name, source)| (format!("partials/{name}"), source))
.collect(),
)
}
}
}
fn style_imports(shared: &SharedImports) -> Vec<String> {
shared.css.clone()
}
fn script_imports(shared: &SharedImports) -> Vec<String> {
shared.js.clone()
}
fn layer_styles(layer: &ThemeLayer) -> Result<Vec<(String, String)>> {
match layer {
ThemeLayer::Builtin(name) => {
let bundle = require_builtin(name)?;
let manifest = bundle_manifest(bundle)?;
bundle_assets(bundle, &style_imports(&manifest.shared), "css/", "css")
}
ThemeLayer::Dir(dir) => {
let manifest = read_local_theme_manifest(dir)?;
dir_assets(dir, &manifest.shared.css, "css", "css")
}
}
}
fn layer_scripts(layer: &ThemeLayer) -> Result<Vec<(String, String)>> {
match layer {
ThemeLayer::Builtin(name) => {
let bundle = require_builtin(name)?;
let manifest = bundle_manifest(bundle)?;
bundle_assets(bundle, &script_imports(&manifest.shared), "js/", "js")
}
ThemeLayer::Dir(dir) => {
let manifest = read_local_theme_manifest(dir)?;
dir_assets(dir, &manifest.shared.js, "js", "js")
}
}
}
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 collect_shared_assets(
local_files: Vec<(String, String)>,
imports: &[String],
label: &str,
extension: Option<&str>,
mut resolve_import: impl FnMut(&str) -> Result<Option<String>>,
) -> Result<Vec<(String, String)>> {
let mut imported = std::collections::BTreeSet::new();
let mut files = Vec::new();
for name in imports {
validate_shared_import(name, extension)?;
if !imported.insert(name.clone()) {
return Err(anyhow!("shared import `{name}` is listed more than once"));
}
if let Some((_, source)) = local_files.iter().find(|(file, _)| file == name) {
files.push((name.clone(), source.clone()));
continue;
}
let source = resolve_import(name)?
.ok_or_else(|| anyhow!("shared {label} import `{name}` was not found"))?;
files.push((name.clone(), source));
}
files.extend(
local_files
.into_iter()
.filter(|(name, _)| !imported.contains(name)),
);
Ok(files)
}
fn bundle_assets(
bundle: &BundleDef,
imports: &[String],
prefix: &str,
ext: &str,
) -> Result<Vec<(String, String)>> {
let local_files = bundle_files(bundle, prefix, Some(ext));
collect_shared_assets(local_files, imports, ext, Some(ext), |name| {
let path = format!("{prefix}{name}");
Ok(bundle
.file(&path)
.or_else(|| shared_file(&path))
.map(str::to_string))
})
}
fn bundle_files(bundle: &BundleDef, prefix: &str, ext: Option<&str>) -> Vec<(String, String)> {
let mut files: Vec<(String, String)> = bundle
.files
.iter()
.filter(|file| {
if !file.path.starts_with(prefix) {
return false;
}
match ext {
Some(ext) => file.path.ends_with(&format!(".{ext}")),
None => true,
}
})
.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
}
fn dir_assets(
dir: &Path,
imports: &[String],
subdir: &str,
ext: &str,
) -> Result<Vec<(String, String)>> {
let local_files = read_theme_files(&dir.join(subdir), ext)?;
collect_shared_assets(local_files, imports, ext, Some(ext), |name| {
let path = format!("{subdir}/{name}");
Ok(dir_shared_file(dir, &path)?.or_else(|| shared_file(&path).map(str::to_string)))
})
}
fn dir_shared_file(dir: &Path, relative: &str) -> Result<Option<String>> {
let Some(parent) = dir.parent() else {
return Ok(None);
};
let path = parent.join("shared").join(relative);
if !path.is_file() {
return Ok(None);
}
std::fs::read_to_string(&path)
.map(Some)
.with_context(|| format!("failed to read {}", path.display()))
}
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(())
}