use askama::Template;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeColors {
pub primary: Option<String>,
pub primary_hover: Option<String>,
pub background: Option<String>,
pub background_alt: Option<String>,
pub text: Option<String>,
pub text_muted: Option<String>,
pub border: Option<String>,
pub code_background: Option<String>,
pub code_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeLayout {
pub sidebar_width: Option<String>,
pub header_height: Option<String>,
pub max_content_width: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeFonts {
pub sans: Option<String>,
pub mono: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeEntryPage {
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeHeader {
pub logo: Option<String>,
#[serde(rename = "logoLight")]
pub logo_light: Option<String>,
#[serde(rename = "logoDark")]
pub logo_dark: Option<String>,
#[serde(rename = "showSiteNameText")]
pub show_site_name_text: Option<bool>,
pub logo_width: Option<u32>,
pub logo_height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeFooter {
pub message: Option<String>,
pub copyright: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SocialLinks {
pub github: Option<String>,
pub twitter: Option<String>,
pub discord: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeEmbed {
pub head: Option<String>,
pub header_before: Option<String>,
pub header_after: Option<String>,
pub sidebar_before: Option<String>,
pub sidebar_after: Option<String>,
pub content_before: Option<String>,
pub content_after: Option<String>,
pub footer_before: Option<String>,
pub footer: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeConfig {
pub colors: Option<ThemeColors>,
pub dark_colors: Option<ThemeColors>,
pub fonts: Option<ThemeFonts>,
#[serde(rename = "entryPage")]
pub entry_page: Option<ThemeEntryPage>,
pub layout: Option<ThemeLayout>,
pub header: Option<ThemeHeader>,
pub footer: Option<ThemeFooter>,
pub social_links: Option<SocialLinks>,
pub embed: Option<ThemeEmbed>,
pub css: Option<String>,
pub js: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HeroAction {
pub theme: Option<String>,
pub text: String,
pub link: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HeroImage {
pub src: String,
#[serde(rename = "lightSrc")]
pub light_src: Option<String>,
#[serde(rename = "darkSrc")]
pub dark_src: Option<String>,
pub alt: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HeroConfig {
pub name: Option<String>,
pub text: Option<String>,
pub tagline: Option<String>,
pub notice: Option<HeroNoticeConfig>,
pub image: Option<HeroImage>,
pub actions: Option<Vec<HeroAction>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HeroNoticeConfig {
pub title: Option<String>,
pub body: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeatureConfig {
pub icon: Option<String>,
pub title: String,
pub details: Option<String>,
pub link: Option<String>,
pub link_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EntryPageConfig {
pub hero: Option<HeroConfig>,
pub features: Option<Vec<FeatureConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavItem {
pub title: String,
pub path: String,
pub href: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavGroup {
pub title: String,
pub items: Vec<NavItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TocEntry {
pub depth: u8,
pub text: String,
pub slug: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageData {
pub title: String,
pub description: Option<String>,
pub content: String,
pub toc: Vec<TocEntry>,
pub path: String,
pub entry_page: Option<EntryPageConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsgConfig {
pub site_name: String,
pub base: String,
pub og_image: Option<String>,
pub theme: Option<ThemeConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_locales: Option<Vec<LocaleInfo>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocaleInfo {
pub code: String,
pub name: String,
pub dir: String,
}
#[derive(Template)]
#[template(path = "nav.html")]
struct NavTemplate<'a> {
nav_groups: &'a [NavGroup],
current_path: &'a str,
}
#[derive(Template)]
#[template(path = "social_links.html")]
struct SocialLinksTemplate<'a> {
github: Option<&'a str>,
twitter: Option<&'a str>,
discord: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "mobile_social_links.html")]
struct MobileSocialLinksTemplate<'a> {
github: Option<&'a str>,
twitter: Option<&'a str>,
discord: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "footer.html")]
struct FooterTemplate<'a> {
message: Option<&'a str>,
copyright: Option<&'a str>,
}
pub struct HeroActionView {
pub href: String,
pub theme_class: String,
pub text: String,
}
pub struct FeatureView {
pub tag: &'static str,
pub href_attr: String,
pub icon_html: Option<String>,
pub title: String,
pub details: Option<String>,
pub has_link: bool,
}
pub struct HeroView {
pub name: Option<String>,
pub text: Option<String>,
pub tagline: Option<String>,
pub notice: Option<HeroNoticeConfig>,
pub image: Option<HeroImage>,
pub actions: Option<Vec<HeroActionView>>,
}
#[derive(Template)]
#[template(path = "entry.html")]
struct EntryTemplate<'a> {
hero: Option<&'a HeroView>,
features: Option<&'a [FeatureView]>,
}
#[derive(Template)]
#[template(path = "page.html")]
struct PageTemplate<'a> {
site_name: &'a str,
document_title: &'a str,
description: Option<&'a str>,
og_image: Option<&'a str>,
css: &'a str,
embed_head: &'a str,
body_class: &'a str,
embed_header_before: &'a str,
embed_header_after: &'a str,
base: &'a str,
logo_src: &'a str,
logo_light_src: Option<&'a str>,
logo_dark_src: Option<&'a str>,
show_site_name_text: bool,
logo_width: u32,
logo_height: u32,
social_links: &'a str,
is_entry_page: bool,
embed_sidebar_before: &'a str,
navigation: &'a str,
embed_sidebar_after: &'a str,
embed_content_before: &'a str,
main_content: &'a str,
embed_content_after: &'a str,
embed_footer_before: &'a str,
footer_html: &'a str,
mobile_social_links: &'a str,
js: &'a str,
}
const SSG_CSS: &str = include_str!("ssg.css");
const ENTRY_CSS: &str = include_str!("entry.css");
const TABS_CSS: &str = include_str!("plugins/tabs.css");
const YOUTUBE_CSS: &str = include_str!("plugins/youtube.css");
const GITHUB_CSS: &str = include_str!("plugins/github.css");
const OGP_CSS: &str = include_str!("plugins/ogp.css");
const MERMAID_CSS: &str = include_str!("plugins/mermaid.css");
const ISLAND_CSS: &str = include_str!("plugins/island.css");
const SSG_JS: &str = include_str!("ssg.js");
fn generate_theme_css(theme: &ThemeConfig) -> String {
let mut css = String::new();
if let Some(ref colors) = theme.colors {
let mut vars = Vec::new();
if let Some(ref v) = colors.primary {
vars.push(format!("--octc-color-primary: {v};"));
}
if let Some(ref v) = colors.primary_hover {
vars.push(format!("--octc-color-primary-hover: {v};"));
}
if let Some(ref v) = colors.background {
vars.push(format!("--octc-color-bg: {v};"));
}
if let Some(ref v) = colors.background_alt {
vars.push(format!("--octc-color-bg-alt: {v};"));
}
if let Some(ref v) = colors.text {
vars.push(format!("--octc-color-text: {v};"));
}
if let Some(ref v) = colors.text_muted {
vars.push(format!("--octc-color-text-muted: {v};"));
}
if let Some(ref v) = colors.border {
vars.push(format!("--octc-color-border: {v};"));
}
if let Some(ref v) = colors.code_background {
vars.push(format!("--octc-color-code-bg: {v};"));
}
if let Some(ref v) = colors.code_text {
vars.push(format!("--octc-color-code-text: {v};"));
}
if !vars.is_empty() {
css.push_str(":root {\n ");
css.push_str(&vars.join("\n "));
css.push_str("\n}\n");
}
}
if let Some(ref colors) = theme.dark_colors {
let mut vars = Vec::new();
if let Some(ref v) = colors.primary {
vars.push(format!("--octc-color-primary: {v};"));
}
if let Some(ref v) = colors.primary_hover {
vars.push(format!("--octc-color-primary-hover: {v};"));
}
if let Some(ref v) = colors.background {
vars.push(format!("--octc-color-bg: {v};"));
}
if let Some(ref v) = colors.background_alt {
vars.push(format!("--octc-color-bg-alt: {v};"));
}
if let Some(ref v) = colors.text {
vars.push(format!("--octc-color-text: {v};"));
}
if let Some(ref v) = colors.text_muted {
vars.push(format!("--octc-color-text-muted: {v};"));
}
if let Some(ref v) = colors.border {
vars.push(format!("--octc-color-border: {v};"));
}
if let Some(ref v) = colors.code_background {
vars.push(format!("--octc-color-code-bg: {v};"));
}
if let Some(ref v) = colors.code_text {
vars.push(format!("--octc-color-code-text: {v};"));
}
if !vars.is_empty() {
css.push_str("[data-theme=\"dark\"] {\n ");
css.push_str(&vars.join("\n "));
css.push_str("\n}\n");
css.push_str("@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {\n ");
css.push_str(&vars.join("\n "));
css.push_str("\n }\n}\n");
}
}
if let Some(ref layout) = theme.layout {
let mut vars = Vec::new();
if let Some(ref v) = layout.sidebar_width {
vars.push(format!("--octc-sidebar-width: {v};"));
}
if let Some(ref v) = layout.header_height {
vars.push(format!("--octc-header-height: {v};"));
}
if let Some(ref v) = layout.max_content_width {
vars.push(format!("--octc-max-content-width: {v};"));
}
if !vars.is_empty() {
css.push_str(":root {\n ");
css.push_str(&vars.join("\n "));
css.push_str("\n}\n");
}
}
if let Some(ref fonts) = theme.fonts {
let mut vars = Vec::new();
if let Some(ref v) = fonts.sans {
vars.push(format!("--octc-font-sans: {v};"));
}
if let Some(ref v) = fonts.mono {
vars.push(format!("--octc-font-mono: {v};"));
}
if !vars.is_empty() {
css.push_str(":root {\n ");
css.push_str(&vars.join("\n "));
css.push_str("\n}\n");
}
}
if let Some(ref custom_css) = theme.css {
css.push_str(custom_css);
}
css
}
fn generate_footer_html(theme: &ThemeConfig) -> String {
let footer = match &theme.footer {
Some(f) if f.message.is_some() || f.copyright.is_some() => f,
_ => return String::new(),
};
let template = FooterTemplate {
message: footer.message.as_deref(),
copyright: footer.copyright.as_deref(),
};
template.render().unwrap_or_default()
}
fn convert_entry_link(link: &str, base: &str) -> String {
if link.starts_with("http://") || link.starts_with("https://") || link.starts_with('#') {
return link.to_string();
}
let (path, fragment) = match link.split_once('#') {
Some((p, f)) => (p, Some(f)),
None => (link, None),
};
let is_md =
std::path::Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
if !is_md {
return link.to_string();
}
let stem = &path[..path.len() - 3];
let converted = if stem == "index" || stem.ends_with("/index") {
let dir = stem.trim_end_matches("/index").trim_end_matches("index");
if dir.is_empty() {
format!("{base}index.html")
} else {
format!("{base}{dir}/index.html")
}
} else {
format!("{base}{stem}/index.html")
};
match fragment {
Some(f) => format!("{converted}#{f}"),
None => converted,
}
}
fn generate_entry_html(entry: &EntryPageConfig, base: &str) -> String {
let hero_view = entry.hero.as_ref().map(|hero| {
let actions = hero.actions.as_ref().map(|actions| {
actions
.iter()
.map(|action| {
let theme_class = match action.theme.as_deref() {
Some("brand") | None => "hero-action-brand",
Some("alt") => "hero-action-alt",
_ => "hero-action-brand",
};
let href = convert_entry_link(&action.link, base);
HeroActionView {
href,
theme_class: theme_class.to_string(),
text: action.text.clone(),
}
})
.collect()
});
let image = hero.image.as_ref().map(|img| {
let src = convert_entry_link(&img.src, base);
let light_src = img.light_src.as_ref().map(|src| convert_entry_link(src, base));
let dark_src = img.dark_src.as_ref().map(|src| convert_entry_link(src, base));
HeroImage {
src,
light_src,
dark_src,
alt: img.alt.clone(),
width: img.width,
height: img.height,
}
});
HeroView {
name: hero.name.clone(),
text: hero.text.clone(),
tagline: hero.tagline.clone(),
notice: hero.notice.clone(),
image,
actions,
}
});
let features_view: Option<Vec<FeatureView>> = entry.features.as_ref().map(|features| {
features
.iter()
.map(|feature| {
let has_link = feature.link.is_some();
let tag = if has_link { "a" } else { "div" };
let href_attr = feature
.link
.as_ref()
.map(|link| {
let href = convert_entry_link(link, base);
format!(" href=\"{href}\"")
})
.unwrap_or_default();
let icon_html = feature.icon.as_ref().map(|icon| render_icon(icon, base));
FeatureView {
tag,
href_attr,
icon_html,
title: feature.title.clone(),
details: feature.details.clone(),
has_link,
}
})
.collect()
});
let template = EntryTemplate { hero: hero_view.as_ref(), features: features_view.as_deref() };
template.render().unwrap_or_default()
}
const FOOTER_CSS: &str = r"
.site-footer {
margin-top: 3rem;
padding: 2rem 1.5rem;
border-top: 1px solid var(--octc-color-border);
text-align: center;
color: var(--octc-color-text-muted);
font-size: 0.875rem;
}
.site-footer p {
margin: 0.25rem 0;
}
.site-footer a {
color: var(--octc-color-primary);
}
.site-footer a:hover {
color: var(--octc-color-primary-hover);
}
";
fn wrap_css_section(name: &str, css: &str) -> String {
if css.trim().is_empty() {
return String::new();
}
format!("/* ox-content:css:{name}:start */\n{css}\n/* ox-content:css:{name}:end */\n")
}
fn page_content_contains_any(content: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| content.contains(needle))
}
pub fn generate_html(page_data: &PageData, nav_groups: &[NavGroup], config: &SsgConfig) -> String {
let nav_html = generate_nav_html(nav_groups, &page_data.path);
let theme = config.theme.as_ref();
let embed = theme.and_then(|t| t.embed.as_ref());
let theme_css = theme.map_or(String::new(), generate_theme_css);
let has_footer = theme.is_some_and(|t| {
t.footer.as_ref().is_some_and(|f| f.message.is_some() || f.copyright.is_some())
});
let footer_css = if has_footer { FOOTER_CSS } else { "" };
let is_entry_page = page_data.entry_page.is_some();
let mut css_sections = vec![wrap_css_section("base", SSG_CSS)];
if is_entry_page {
css_sections.push(wrap_css_section("entry", ENTRY_CSS));
}
if page_content_contains_any(&page_data.content, &["ox-tabs", "ox-tab-panel"]) {
css_sections.push(wrap_css_section("plugin-tabs", TABS_CSS));
}
if page_content_contains_any(&page_data.content, &["ox-youtube"]) {
css_sections.push(wrap_css_section("plugin-youtube", YOUTUBE_CSS));
}
if page_content_contains_any(&page_data.content, &["ox-github-card", "ox-github-error"]) {
css_sections.push(wrap_css_section("plugin-github", GITHUB_CSS));
}
if page_content_contains_any(&page_data.content, &["ox-ogp-card", "ox-ogp-simple"]) {
css_sections.push(wrap_css_section("plugin-ogp", OGP_CSS));
}
if page_content_contains_any(&page_data.content, &["ox-mermaid"]) {
css_sections.push(wrap_css_section("plugin-mermaid", MERMAID_CSS));
}
if page_content_contains_any(&page_data.content, &["data-ox-island", "ox-island"]) {
css_sections.push(wrap_css_section("plugin-island", ISLAND_CSS));
}
if has_footer {
css_sections.push(wrap_css_section("footer", footer_css));
}
if !theme_css.is_empty() {
css_sections.push(wrap_css_section("theme", &theme_css));
}
let all_css = css_sections.join("");
let embed_head = embed.and_then(|e| e.head.as_deref()).unwrap_or("");
let embed_header_before = embed.and_then(|e| e.header_before.as_deref()).unwrap_or("");
let embed_header_after = embed.and_then(|e| e.header_after.as_deref()).unwrap_or("");
let embed_sidebar_before = embed.and_then(|e| e.sidebar_before.as_deref()).unwrap_or("");
let embed_sidebar_after = embed.and_then(|e| e.sidebar_after.as_deref()).unwrap_or("");
let embed_content_before = embed.and_then(|e| e.content_before.as_deref()).unwrap_or("");
let embed_content_after = embed.and_then(|e| e.content_after.as_deref()).unwrap_or("");
let embed_footer_before = embed.and_then(|e| e.footer_before.as_deref()).unwrap_or("");
let footer_html = if let Some(embed_footer) = embed.and_then(|e| e.footer.clone()) {
embed_footer
} else if let Some(t) = theme {
generate_footer_html(t)
} else {
String::new()
};
let header_config = theme.and_then(|t| t.header.as_ref());
let logo_url = header_config
.and_then(|h| h.logo.as_ref())
.map_or_else(|| "logo.svg", std::string::String::as_str);
let logo_width = header_config.and_then(|h| h.logo_width).unwrap_or(28);
let logo_height = header_config.and_then(|h| h.logo_height).unwrap_or(28);
let show_site_name_text = header_config.and_then(|h| h.show_site_name_text).unwrap_or(true);
let resolve_theme_asset = |url: &str| {
if url.starts_with("http://") || url.starts_with("https://") || url.starts_with('/') {
url.to_string()
} else {
format!("{}{}", config.base, url)
}
};
let logo_src = resolve_theme_asset(logo_url);
let logo_light_src =
header_config.and_then(|h| h.logo_light.as_deref()).map(resolve_theme_asset);
let logo_dark_src = header_config.and_then(|h| h.logo_dark.as_deref()).map(resolve_theme_asset);
let custom_js = theme.and_then(|t| t.js.as_deref()).unwrap_or("");
let all_js = format!("{}\n{}", SSG_JS.replace("{{base}}", &config.base), custom_js);
let social_links_html = theme
.and_then(|t| t.social_links.as_ref())
.map_or(String::new(), generate_social_links_html);
let mobile_social_links_html = theme
.and_then(|t| t.social_links.as_ref())
.map_or(String::new(), generate_mobile_social_links_html);
let (page_class, main_content) = if let Some(ref entry) = page_data.entry_page {
let entry_html = generate_entry_html(entry, &config.base);
let combined = if page_data.content.trim().is_empty() {
entry_html
} else {
format!(
"{}\n<div class=\"entry-content\">\n <div class=\"content\">\n{}\n </div>\n</div>",
entry_html, page_data.content
)
};
("entry-page", combined)
} else {
("", format!("<article class=\"content\">\n{}\n </article>", page_data.content))
};
let mut body_classes = Vec::new();
if !page_class.is_empty() {
body_classes.push(page_class.to_string());
}
if is_entry_page
&& theme.and_then(|t| t.entry_page.as_ref()).and_then(|entry| entry.mode.as_deref())
== Some("subtle")
{
body_classes.push("entry-page--subtle".to_string());
}
let body_class = body_classes.join(" ");
let document_title = if page_data.title.trim() == config.site_name.trim() {
config.site_name.clone()
} else {
format!("{} - {}", page_data.title, config.site_name)
};
let template = PageTemplate {
site_name: &config.site_name,
document_title: &document_title,
description: page_data.description.as_deref(),
og_image: config.og_image.as_deref(),
css: &all_css,
embed_head,
body_class: &body_class,
embed_header_before,
embed_header_after,
base: &config.base,
logo_src: &logo_src,
logo_light_src: logo_light_src.as_deref(),
logo_dark_src: logo_dark_src.as_deref(),
show_site_name_text,
logo_width,
logo_height,
social_links: &social_links_html,
is_entry_page,
embed_sidebar_before,
navigation: &nav_html,
embed_sidebar_after,
embed_content_before,
main_content: &main_content,
embed_content_after,
embed_footer_before,
footer_html: &footer_html,
mobile_social_links: &mobile_social_links_html,
js: &all_js,
};
template.render().unwrap_or_default()
}
fn render_icon(icon: &str, base: &str) -> String {
if let Some((prefix, name)) = icon.split_once(':') {
if !prefix.contains('/') && !name.starts_with("//") {
let iconify_url = format!("https://api.iconify.design/{prefix}/{name}.svg");
return format!(
"<span class=\"iconify-icon\" style=\"-webkit-mask-image: url('{iconify_url}'); mask-image: url('{iconify_url}')\"></span>"
);
}
}
if icon.starts_with("http://") || icon.starts_with("https://") {
return format!("<img src=\"{icon}\" alt=\"\" />");
}
if icon.ends_with(".svg") || icon.ends_with(".png") {
let icon_src =
if icon.starts_with('/') { icon.to_string() } else { format!("{base}{icon}") };
return format!("<img src=\"{icon_src}\" alt=\"\" />");
}
icon.to_string()
}
fn generate_social_links_html(links: &SocialLinks) -> String {
let template = SocialLinksTemplate {
github: links.github.as_deref(),
twitter: links.twitter.as_deref(),
discord: links.discord.as_deref(),
};
template.render().unwrap_or_default()
}
fn generate_mobile_social_links_html(links: &SocialLinks) -> String {
let template = MobileSocialLinksTemplate {
github: links.github.as_deref(),
twitter: links.twitter.as_deref(),
discord: links.discord.as_deref(),
};
template.render().unwrap_or_default()
}
fn generate_nav_html(nav_groups: &[NavGroup], current_path: &str) -> String {
let template = NavTemplate { nav_groups, current_path };
template.render().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_html() {
let page_data = PageData {
title: "Test Page".to_string(),
description: Some("Test description".to_string()),
content: "<h1>Hello</h1>".to_string(),
toc: vec![TocEntry { depth: 1, text: "Hello".to_string(), slug: "hello".to_string() }],
path: "test".to_string(),
entry_page: None,
};
let nav_groups = vec![NavGroup {
title: "Guide".to_string(),
items: vec![NavItem {
title: "Test Page".to_string(),
path: "test".to_string(),
href: "/docs/test/index.html".to_string(),
}],
}];
let config = SsgConfig {
site_name: "Test Site".to_string(),
base: "/docs/".to_string(),
og_image: None,
theme: None,
locale: None,
available_locales: None,
};
let html = generate_html(&page_data, &nav_groups, &config);
assert!(html.contains("Test Page - Test Site"));
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("Guide"));
}
#[test]
fn test_generate_html_with_theme() {
let page_data = PageData {
title: "Themed Page".to_string(),
description: None,
content: "<p>Content</p>".to_string(),
toc: vec![],
path: "themed".to_string(),
entry_page: None,
};
let nav_groups = vec![];
let config = SsgConfig {
site_name: "Themed Site".to_string(),
base: "/".to_string(),
og_image: None,
locale: None,
available_locales: None,
theme: Some(ThemeConfig {
colors: Some(ThemeColors {
primary: Some("#3498db".to_string()),
..Default::default()
}),
footer: Some(ThemeFooter {
message: Some("Built with ox-content".to_string()),
copyright: Some("2025 Test".to_string()),
}),
..Default::default()
}),
};
let html = generate_html(&page_data, &nav_groups, &config);
assert!(html.contains("--octc-color-primary: #3498db;"));
assert!(html.contains("Built with ox-content"));
assert!(html.contains("2025 Test"));
}
#[test]
fn test_generate_theme_css() {
let theme = ThemeConfig {
colors: Some(ThemeColors {
primary: Some("#ff0000".to_string()),
background: Some("#ffffff".to_string()),
..Default::default()
}),
dark_colors: Some(ThemeColors {
primary: Some("#ff6666".to_string()),
..Default::default()
}),
layout: Some(ThemeLayout {
sidebar_width: Some("300px".to_string()),
..Default::default()
}),
..Default::default()
};
let css = generate_theme_css(&theme);
assert!(css.contains("--octc-color-primary: #ff0000;"));
assert!(css.contains("--octc-color-bg: #ffffff;"));
assert!(css.contains("[data-theme=\"dark\"]"));
assert!(css.contains("--octc-sidebar-width: 300px;"));
}
#[test]
fn test_generate_footer_html() {
let theme = ThemeConfig {
footer: Some(ThemeFooter {
message: Some("Footer message".to_string()),
copyright: Some("Copyright info".to_string()),
}),
..Default::default()
};
let html = generate_footer_html(&theme);
assert!(html.contains("site-footer"));
assert!(html.contains("Footer message"));
assert!(html.contains("Copyright info"));
}
}