use std::path::Path;
use anyhow::{anyhow, bail, Result};
use crate::html::{
SiteContextInput, SiteLanguageEntry, SiteNavEntry, SiteNavSection, SitePagefindEntry,
};
use crate::utils::html::escape as html_escape;
use super::config::{SearchEngine, WebsiteConfig};
use super::language::LanguageInfo;
use super::navigation::{MenusModel, NavSectionModel};
use super::pagefind::{PAGEFIND_CSS, PAGEFIND_DIR, PAGEFIND_JS};
use super::paths::{join_normalized_under_root, normalize_path, slash_path};
use super::url::{
absolute_site_url, absolute_site_url_without_index, is_absolute_or_special_url,
page_relative_url,
};
use super::util::clean_optional_string;
use super::{PageInfo, PageInfoMap};
#[derive(Debug, Clone, Default)]
pub(super) struct SiteMetadata {
pub(super) title: Option<String>,
pub(super) description: Option<String>,
pub(super) base_url: Option<String>,
pub(super) logo: Option<String>,
pub(super) logo_alt: Option<String>,
pub(super) favicon: Option<String>,
pub(super) image: Option<String>,
pub(super) theme_color: Option<String>,
}
impl SiteMetadata {
pub(super) fn from_config(
config: &WebsiteConfig,
src_dir: &Path,
default_favicon_path: &str,
) -> Result<Self> {
Ok(Self {
title: clean_optional_string(config.title.as_deref()),
description: clean_optional_string(config.description.as_deref()),
base_url: clean_optional_string(config.base_url.as_deref())
.map(|url| url.trim_end_matches('/').to_string()),
logo: source_asset_output_path(src_dir, config.logo.as_deref(), "logo")?,
logo_alt: clean_optional_string(config.logo_alt.as_deref())
.or_else(|| clean_optional_string(config.title.as_deref())),
favicon: source_asset_output_path(src_dir, config.favicon.as_deref(), "favicon")?
.or_else(|| Some(default_favicon_path.to_string())),
image: source_asset_output_path(src_dir, config.image.as_deref(), "image")?,
theme_color: clean_optional_string(config.theme_color.as_deref()),
})
}
}
fn source_asset_output_path(
src_dir: &Path,
value: Option<&str>,
key: &str,
) -> Result<Option<String>> {
let Some(value) = clean_optional_string(value) else {
return Ok(None);
};
if is_absolute_or_special_url(&value) {
return Ok(Some(value));
}
let path = Path::new(&value);
let root = normalize_path(src_dir);
let what = format!("website `{key}` path must stay inside the source directory: {value}");
let candidate = join_normalized_under_root(&root, path, &what)?;
let rel = candidate.strip_prefix(&root).map_err(|_| {
anyhow!("website `{key}` path must stay inside the source directory: {value}")
})?;
if rel.as_os_str().is_empty() {
bail!("website `{key}` path must stay inside the source directory: {value}");
}
Ok(Some(slash_path(rel)))
}
#[derive(Debug)]
pub(super) struct SiteModel {
sections: Vec<NavSectionModel>,
menus: MenusModel,
metadata: SiteMetadata,
sidebar_fold: bool,
}
impl SiteModel {
pub(super) fn new(
sections: Vec<NavSectionModel>,
menus: MenusModel,
metadata: SiteMetadata,
sidebar_fold: bool,
) -> Self {
Self {
sections,
menus,
metadata,
sidebar_fold,
}
}
pub(super) fn theme_context(
&self,
current_href: &str,
page_info: Option<&PageInfo>,
page_info_map: &PageInfoMap,
languages: Option<&[LanguageInfo]>,
search: Option<SearchEngine>,
publish_source: bool,
) -> SiteContextInput {
let mut sidebar = Vec::new();
let mut sidebar_sections = Vec::new();
let mut page_title = None;
let current_language = page_info.and_then(|info| info.language.as_deref());
for section in &self.sections {
if section
.language
.as_deref()
.is_some_and(|section_language| Some(section_language) != current_language)
{
continue;
}
let mut items = Vec::new();
for item in §ion.items {
if !item.href.is_empty() && item.href == current_href {
page_title = Some(html_escape(&item.label));
}
let is_current_page = !item.href.is_empty() && item.href == current_href;
let item_href = if item.href.is_empty() {
String::new()
} else if is_current_page && section.language.is_some() {
item.href.clone()
} else {
page_relative_url(current_href, &item.href)
};
let entry = SiteNavEntry {
href: html_escape(&item_href),
label: html_escape(&item.label),
label_html: item.label_html.clone(),
active: is_current_page,
};
sidebar.push(entry.clone());
items.push(entry);
}
sidebar_sections.push(SiteNavSection {
title: section.title.as_ref().map(|title| html_escape(title)),
active: items.iter().any(|item| item.active),
items,
});
}
let language_entries = languages
.map(|languages| language_entries(current_href, page_info, page_info_map, languages))
.unwrap_or_default();
let translations = page_info
.and_then(|info| {
languages.map(|languages| {
translation_entries(current_href, info, page_info_map, languages)
})
})
.unwrap_or_default();
let menus = self
.menus
.entries_for_current_page(current_href, current_language);
SiteContextInput {
sidebar,
sidebar_sections,
sidebar_fold: self.sidebar_fold,
menus,
languages: language_entries,
translations,
language: current_language.map(str::to_string),
title: self.metadata.title.as_deref().map(html_escape),
description: self.metadata.description.as_deref().map(html_escape),
base_url: self.metadata.base_url.as_deref().map(html_escape),
logo: self
.metadata
.logo
.as_deref()
.map(|logo| html_escape(&page_relative_url(current_href, logo))),
logo_alt: self.metadata.logo_alt.as_deref().map(html_escape),
home_url: Some(html_escape(&page_relative_url(current_href, "index.html"))),
favicon: self
.metadata
.favicon
.as_deref()
.map(|favicon| html_escape(&page_relative_url(current_href, favicon))),
page_url: self.metadata.base_url.as_deref().map(|base_url| {
html_escape(&absolute_site_url_without_index(base_url, current_href))
}),
page_title,
page_image: social_image_url(
self.metadata.base_url.as_deref(),
page_info
.and_then(|info| info.image.as_deref())
.or(self.metadata.image.as_deref()),
)
.as_deref()
.map(html_escape),
page_description: page_info
.and_then(|info| info.description.as_deref())
.or(self.metadata.description.as_deref())
.map(html_escape),
theme_color: self.metadata.theme_color.as_deref().map(html_escape),
page_pdf: page_info
.and_then(|info| info.pdf_href.as_deref())
.map(|pdf| html_escape(&page_relative_url(current_href, pdf))),
page_source: publish_source,
pagefind: (search == Some(SearchEngine::Pagefind)).then(|| SitePagefindEntry {
css: html_escape(&page_relative_url(current_href, PAGEFIND_CSS)),
js: html_escape(&page_relative_url(current_href, PAGEFIND_JS)),
bundle: html_escape(&page_relative_url(
current_href,
&format!("{PAGEFIND_DIR}/"),
)),
}),
store: Default::default(),
toc_depth: None,
toc_floating: None,
}
}
}
fn social_image_url(base_url: Option<&str>, image: Option<&str>) -> Option<String> {
let image = clean_optional_string(image)?;
if image.starts_with("http://") || image.starts_with("https://") || image.starts_with("//") {
return Some(image);
}
base_url.map(|base_url| absolute_site_url(base_url, &image))
}
pub(super) fn language_entries(
current_href: &str,
current: Option<&PageInfo>,
page_info: &PageInfoMap,
languages: &[LanguageInfo],
) -> Vec<SiteLanguageEntry> {
languages
.iter()
.map(|language| {
let href = current
.and_then(|current| {
page_info.values().find(|info| {
info.translation_key == current.translation_key
&& info.language.as_deref() == Some(language.code.as_str())
})
})
.map(|info| info.href.clone())
.unwrap_or_else(|| language_home_href(language));
SiteLanguageEntry {
code: language.code.clone(),
label: html_escape(&language.label),
href: html_escape(&page_relative_url(current_href, &href)),
active: current
.and_then(|info| info.language.as_deref())
.is_some_and(|code| code == language.code),
}
})
.collect()
}
fn language_home_href(language: &LanguageInfo) -> String {
if language.url_prefix.is_empty() {
"index.html".to_string()
} else {
format!("{}/index.html", language.url_prefix)
}
}
pub(super) fn translation_entries(
current_href: &str,
current: &PageInfo,
page_info: &PageInfoMap,
languages: &[LanguageInfo],
) -> Vec<SiteLanguageEntry> {
languages
.iter()
.filter_map(|language| {
page_info
.values()
.find(|info| {
info.translation_key == current.translation_key
&& info.language.as_deref() == Some(language.code.as_str())
})
.map(|info| SiteLanguageEntry {
code: language.code.clone(),
label: html_escape(&language.label),
href: html_escape(&page_relative_url(current_href, &info.href)),
active: info.language == current.language,
})
})
.collect()
}