mod assets;
mod syntax;
mod theme;
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use syntax::HtmlSyntaxTheme;
const HTML_INPUT_LIGHT_THEME_PATH: &str = ".calepin/calepin-input-light.tmTheme";
const HTML_INPUT_LIGHT_THEME_REF: &str = "/.calepin/calepin-input-light.tmTheme";
#[derive(Debug, Clone)]
pub(crate) struct PreparedHtmlTheme {
pub(crate) syntax_theme: HtmlSyntaxTheme,
pub(crate) raw_theme_input: Option<String>,
}
pub(crate) fn prepare_html_theme(
root: &Path,
format: Option<&str>,
html_theme: Option<&str>,
html_theme_light: Option<&str>,
html_theme_dark: Option<&str>,
) -> Result<PreparedHtmlTheme> {
if format != Some("html") {
return Ok(PreparedHtmlTheme {
syntax_theme: HtmlSyntaxTheme::builtin(),
raw_theme_input: None,
});
}
match (html_theme_light, html_theme_dark) {
(None, None) => Ok(PreparedHtmlTheme {
syntax_theme: HtmlSyntaxTheme::builtin(),
raw_theme_input: None,
}),
(Some(light), Some(dark)) => {
if html_theme.is_none() {
return Err(anyhow!(
"`html-theme-light` and `html-theme-dark` require `html-theme`"
));
}
let light_path = resolve_setup_theme_path(root, light);
let dark_path = resolve_setup_theme_path(root, dark);
let light_source = std::fs::read_to_string(&light_path)
.with_context(|| format!("failed to read {}", light_path.display()))?;
let dark_source = std::fs::read_to_string(&dark_path)
.with_context(|| format!("failed to read {}", dark_path.display()))?;
let syntax_theme = HtmlSyntaxTheme::from_tmtheme_sources(&light_source, &dark_source)?;
let prepared_path = root.join(HTML_INPUT_LIGHT_THEME_PATH);
if let Some(parent) = prepared_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&prepared_path, light_source)
.with_context(|| format!("failed to write {}", prepared_path.display()))?;
Ok(PreparedHtmlTheme {
syntax_theme,
raw_theme_input: Some(HTML_INPUT_LIGHT_THEME_REF.to_string()),
})
}
_ => Err(anyhow!(
"`html-theme-light` and `html-theme-dark` must be supplied together"
)),
}
}
pub(crate) fn apply_html_theme_file(
path: &Path,
html_theme: Option<&str>,
themes_dir: &Path,
syntax_theme: &HtmlSyntaxTheme,
root: &Path,
) -> Result<()> {
let html = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let themed = theme::apply_html_theme(
&html,
html_theme,
themes_dir,
syntax_theme,
Some(path),
Some(root),
)?;
if themed != html {
std::fs::write(path, themed)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}
pub(crate) fn inline_html_images_file(path: &Path, root: &Path) -> Result<()> {
let html = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let base_dir = path.parent().unwrap_or(root);
let inlined = assets::inline_html_images(&html, root, base_dir)?;
if inlined != html {
std::fs::write(path, inlined)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}
fn resolve_setup_theme_path(root: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_HTML: &str = "<html><head><title>Standard Title</title></head><body><h1>Standard Title</h1></body></html>";
fn apply_html_theme(html: &str, html_theme: Option<&str>) -> Result<String> {
let dir = tempfile::tempdir().unwrap();
theme::apply_html_theme(
html,
html_theme,
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
}
fn write_theme(themes_dir: &Path, name: &str, layout: &str) {
let dir = themes_dir.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("layout.html"), layout).unwrap();
}
#[test]
fn pico_html_theme_preserves_title_and_wraps_body() {
let themed = apply_html_theme(SAMPLE_HTML, Some("pico")).unwrap();
assert!(themed.contains("<title>Standard Title</title>"));
assert!(themed.contains("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"));
assert!(themed.contains("<main class=\"container\">"));
assert!(themed.contains(".sourceCode,"));
assert!(themed.contains(".cell-output {"));
assert!(themed.contains("calepin-copy-code"));
assert!(themed.contains(r#"<nav class="calepin-theme-switcher""#));
assert!(themed.contains("const themeOrder = [\"\", \"light\", \"dark\"]"));
assert!(themed.contains(r#"<h1 id="standard-title">Standard Title</h1>"#));
}
#[test]
fn user_html_theme_gets_docs_navigation_and_toc_context() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs-src2");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("index.typ"), "").unwrap();
std::fs::write(docs.join("cli.typ"), "").unwrap();
write_theme(
dir.path(),
"zensical",
r#"{{ doc.body_open }}<aside>{% for item in site.nav %}<a href="{{ item.href }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.label }}</a>{% endfor %}</aside><nav>{% for item in site.toc %}<a href="{{ item.href }}">{{ item.label }}</a>{% endfor %}</nav><main>{{ doc.body }}</main>{{ doc.body_close }}"#,
);
let output = dir.path().join("docs-src2-build/html/cli.html");
let themed = theme::apply_html_theme(
SAMPLE_HTML,
Some("zensical"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
Some(&output),
Some(dir.path()),
)
.unwrap();
assert!(themed.contains("index.html"));
assert!(themed.contains("cli.html"));
assert!(themed.contains("cli.typ"));
assert!(themed.contains(r#"aria-current="page""#));
assert!(themed.contains("href=\"#standard-title\""));
assert!(themed.contains(r#"<h1 id="standard-title">Standard Title</h1>"#));
}
#[test]
fn basic_html_theme_wraps_body_without_pico_artifacts() {
let themed = apply_html_theme(SAMPLE_HTML, Some("basic")).unwrap();
assert!(themed.contains("Standard Title"));
assert!(themed.contains("sourceCode"));
assert!(!themed.contains("cdn.jsdelivr.net/npm/@picocss/pico"));
assert!(themed.contains("calepin-syntax-foreground"));
}
#[test]
fn no_html_theme_returns_raw_typst_html_without_calepin_css_or_template() {
let themed = apply_html_theme(SAMPLE_HTML, None).unwrap();
assert_eq!(themed, SAMPLE_HTML);
assert!(!themed.contains("calepin-copy-code"));
assert!(!themed.contains("cdn.jsdelivr.net/npm/@picocss/pico"));
assert!(!themed.contains("calepin-theme-switcher"));
}
#[test]
fn pico_theme_applies_to_bare_html_fragment() {
let fragment = "<h1>Hello</h1><p>World</p>";
let themed = apply_html_theme(fragment, Some("pico")).unwrap();
assert!(themed.contains("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"));
assert!(themed.contains("<main class=\"container\">"));
assert!(themed.contains(r#"<h1 id="hello">Hello</h1>"#));
}
#[test]
fn html_image_inliner_embeds_root_relative_images() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join(".calepin/paper/figures/fig.svg");
std::fs::create_dir_all(image.parent().unwrap()).unwrap();
std::fs::write(&image, "<svg></svg>").unwrap();
let html = r#"<figure><img src="/.calepin/paper/figures/fig.svg" alt=""></figure>"#;
let inlined = assets::inline_html_images(html, dir.path(), dir.path()).unwrap();
assert!(inlined.contains(r#"src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=""#));
}
#[test]
fn html_image_inliner_embeds_relative_images() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("fig.png");
std::fs::write(&image, [0_u8, 1, 2]).unwrap();
let html = r#"<img alt="x" src='fig.png'>"#;
let inlined = assets::inline_html_images(html, dir.path(), dir.path()).unwrap();
assert!(inlined.contains("src='data:image/png;base64,AAEC'"));
}
#[test]
fn html_image_inliner_leaves_external_and_data_images() {
let dir = tempfile::tempdir().unwrap();
let html = concat!(
r#"<img src="https://example.com/fig.png">"#,
r#"<img src="data:image/png;base64,AA==">"#
);
let inlined = assets::inline_html_images(html, dir.path(), dir.path()).unwrap();
assert_eq!(inlined, html);
}
#[test]
#[test]
fn html_custom_syntax_themes_require_html_theme() {
let dir = tempfile::tempdir().unwrap();
let err = prepare_html_theme(
dir.path(),
Some("html"),
None,
Some("light.tmTheme"),
Some("dark.tmTheme"),
)
.unwrap_err()
.to_string();
assert!(err.contains("require `html-theme`"));
}
#[test]
fn unknown_html_theme_errors() {
let err = apply_html_theme(SAMPLE_HTML, Some("nope"))
.unwrap_err()
.to_string();
assert!(err.contains("unknown HTML theme `nope`"));
}
#[test]
fn user_theme_directory_shadows_builtin() {
let dir = tempfile::tempdir().unwrap();
write_theme(
dir.path(),
"pico",
"<custom-shell>{{ doc.body }}</custom-shell>",
);
let themed = theme::apply_html_theme(
SAMPLE_HTML,
Some("pico"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
.unwrap();
assert!(themed.contains("<custom-shell>"));
assert!(themed.contains(r#"<h1 id="standard-title">Standard Title</h1>"#));
assert!(!themed.contains("cdn.jsdelivr.net/npm/@picocss/pico"));
}
#[test]
fn user_theme_loops_styles_scripts_and_includes_partials() {
let dir = tempfile::tempdir().unwrap();
let theme_dir = dir.path().join("mini");
std::fs::create_dir_all(theme_dir.join("partials")).unwrap();
std::fs::create_dir_all(theme_dir.join("styles")).unwrap();
std::fs::create_dir_all(theme_dir.join("scripts")).unwrap();
std::fs::write(
theme_dir.join("layout.html"),
"{{ doc.head }}{% for s in styles %}<style>{{ s.css }}</style>{% endfor %}{{ doc.body_open }}{% include \"partials/banner.html\" %}{{ doc.body }}{% for s in scripts %}<script>{{ s.content }}</script>{% endfor %}{{ doc.body_close }}",
)
.unwrap();
std::fs::write(
theme_dir.join("partials/banner.html"),
"<header>hi</header>",
)
.unwrap();
std::fs::write(theme_dir.join("styles/main.css"), "body{color:red}").unwrap();
std::fs::write(theme_dir.join("scripts/main.js"), "console.log(1)").unwrap();
let themed = theme::apply_html_theme(
SAMPLE_HTML,
Some("mini"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
.unwrap();
assert!(themed.contains("<header>hi</header>"));
assert!(themed.contains("<style>body{color:red}</style>"));
assert!(themed.contains("<script>console.log(1)</script>"));
}
#[test]
fn theme_directory_missing_layout_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("bare")).unwrap();
let err = theme::apply_html_theme(
SAMPLE_HTML,
Some("bare"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
.unwrap_err()
.to_string();
assert!(err.contains("bare"));
assert!(err.contains("layout.html"));
}
#[test]
fn theme_template_error_names_the_theme() {
let dir = tempfile::tempdir().unwrap();
write_theme(
dir.path(),
"broken",
"{% include \"partials/missing.html\" %}",
);
let err = theme::apply_html_theme(
SAMPLE_HTML,
Some("broken"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
.unwrap_err()
.to_string();
assert!(err.contains("broken"));
}
#[test]
fn title_is_not_double_escaped() {
let dir = tempfile::tempdir().unwrap();
write_theme(
dir.path(),
"title-only",
"<h1>{{ doc.title }}</h1>{{ doc.body_open }}{{ doc.body }}{{ doc.body_close }}",
);
let html = "<html><head><title>Foo & Bar</title></head><body><p>x</p></body></html>";
let themed = theme::apply_html_theme(
html,
Some("title-only"),
dir.path(),
&HtmlSyntaxTheme::builtin(),
None,
None,
)
.unwrap();
assert!(themed.contains("<h1>Foo & Bar</h1>"));
assert!(!themed.contains("&amp;"));
}
}