Files
RuDS/crates/bds-core/src/render/site.rs

1901 lines
64 KiB
Rust

use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, Local, TimeZone, Utc};
use rayon::prelude::*;
use serde_json::{Value, json};
use crate::db::queries;
use crate::engine::generation::{GenerationSection, classify_generated_path};
use crate::engine::menu::{self, MenuItemKind};
use crate::model::{
CategorySettings, Media, Post, PostStatus, ProjectMetadata, ScriptKind, Tag, Template,
TemplateKind, TemplateStatus,
};
use crate::render::{
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title,
build_canonical_post_path, render_liquid_template_with_host, resolve_post_template,
select_post_language_variant,
};
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
use crate::util::{slugify, year_month_from_unix_ms};
const STARTER_SINGLE_POST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/single-post.liquid");
const STARTER_POST_LIST_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/post-list.liquid");
const STARTER_NOT_FOUND_TEMPLATE: &str =
include_str!("../../../../assets/starter-templates/not-found.liquid");
const STARTER_HEAD_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/head.liquid");
const STARTER_MENU_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
const STARTER_MENU_ITEMS_PARTIAL: &str =
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage {
pub language: String,
pub relative_path: String,
pub url_path: String,
pub html: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PagefindDocument {
pub language: String,
pub relative_path: String,
pub url_path: String,
pub html: String,
}
#[derive(Debug, Clone, Default)]
pub struct SiteRenderArtifacts {
pub pages: Vec<SitePage>,
pub pagefind_documents: Vec<PagefindDocument>,
pub route_manifest: Vec<SitePage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreviewRenderResult {
pub status_code: u16,
pub html: String,
}
#[derive(Clone)]
struct TemplateBundle {
post_templates: Vec<Template>,
template_source_by_slug: HashMap<String, String>,
list_template_sources: HashMap<String, String>,
default_list_template: String,
not_found_template: String,
partials: HashMap<String, String>,
macro_templates: HashMap<String, String>,
macro_scripts: HashMap<String, Value>,
host: Arc<dyn HostApi>,
}
#[derive(Debug, Clone)]
struct RenderPostRecord {
post: Post,
source_post_id: String,
body_markdown: String,
}
#[derive(Debug, Clone)]
struct RouteSpec {
relative_path: String,
url_path: String,
page_title: String,
archive_context: Option<Value>,
list_template_slug: Option<String>,
posts: Vec<RenderPostRecord>,
current_page: usize,
total_pages: usize,
total_items: usize,
items_per_page: usize,
}
pub fn build_site_render_artifacts(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
build_site_render_artifacts_with_mode(
conn,
data_dir,
project_id,
metadata,
published_posts,
false,
None,
None,
)
}
pub fn build_site_section_render_artifacts(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
section: GenerationSection,
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
build_site_render_artifacts_with_mode(
conn,
data_dir,
project_id,
metadata,
published_posts,
false,
Some(section),
None,
)
}
pub fn build_targeted_site_section_render_artifacts(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
section: GenerationSection,
requested_paths: &HashSet<String>,
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
build_site_render_artifacts_with_mode(
conn,
data_dir,
project_id,
metadata,
published_posts,
false,
Some(section),
Some(requested_paths),
)
}
#[expect(
clippy::too_many_arguments,
reason = "the shared renderer accepts optional section and path filters"
)]
fn build_site_render_artifacts_with_mode(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
is_preview: bool,
section: Option<GenerationSection>,
requested_paths: Option<&HashSet<String>>,
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
let bundle = load_template_bundle(conn, data_dir, project_id)?;
let main_language = main_language(metadata).to_string();
let languages = render_languages(metadata);
let tags = queries::tag::list_tags_by_project(conn, project_id).unwrap_or_default();
let category_settings = queries_category_settings(data_dir)?;
let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default();
let media_by_id = media_items
.iter()
.cloned()
.map(|media| (media.id.clone(), media))
.collect::<HashMap<_, _>>();
let project_media = media_items.iter().map(media_context).collect::<Vec<_>>();
let canonical_media_map = canonical_media_paths(&media_items);
let project_tags = build_published_tag_counts(published_posts, &tags);
let mut artifacts = SiteRenderArtifacts::default();
for language in languages {
let localized_posts = load_language_posts(
conn,
data_dir,
published_posts,
&language,
&main_language,
is_preview,
)?;
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
let routes = build_language_routes(
&localized_list_posts,
metadata,
&language,
&tags,
&category_settings,
);
artifacts
.route_manifest
.extend(routes.iter().map(|route| SitePage {
language: language.clone(),
relative_path: route.relative_path.clone(),
url_path: route.url_path.clone(),
html: String::new(),
}));
let linked_media_by_post_id =
build_linked_media_by_post_id(conn, &localized_posts, &media_by_id);
let post_data_json_by_id =
build_post_data_json_by_id(&localized_posts, &linked_media_by_post_id);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
.par_iter()
.filter(|route| {
section.is_none_or(|section| {
classify_generated_path(&route.relative_path, metadata) == Some(section)
}) && requested_paths
.is_none_or(|requested| requested.contains(&route.relative_path))
})
.map(|route| {
render_list_route(
route,
metadata,
&language,
&localized_list_posts,
&tags,
&category_settings,
&menu_items,
&post_data_json_by_id,
&canonical_media_map,
&project_media,
&project_tags,
&bundle,
is_preview,
)
.map(|html| SitePage {
language: language.clone(),
relative_path: route.relative_path.clone(),
url_path: route.url_path.clone(),
html,
})
})
.collect::<Result<Vec<_>, _>>()?;
for page in rendered_list_pages {
artifacts.pagefind_documents.push(PagefindDocument {
language: page.language.clone(),
relative_path: page.relative_path.clone(),
url_path: page.url_path.clone(),
html: page.html.clone(),
});
artifacts.pages.push(page);
}
if section.is_none_or(|section| section == GenerationSection::Core) {
let relative_path = if language == main_language {
"404.html".to_string()
} else {
format!("{language}/404.html")
};
if requested_paths.is_none_or(|requested| requested.contains(&relative_path)) {
let url_path = format!("/{}", relative_path.trim_end_matches(".html"));
artifacts.pages.push(SitePage {
language: language.clone(),
relative_path,
url_path: url_path.clone(),
html: render_not_found_route(
&bundle,
metadata,
&language,
&url_path,
&menu_items,
)?,
});
}
}
let canonical_map =
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts {
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
if record
.post
.categories
.iter()
.any(|category| category == "page")
{
post_paths.push((
if language == main_language {
format!("/{}", record.post.slug)
} else {
format!("/{language}/{}", record.post.slug)
},
GenerationSection::Core,
));
}
for (url_path, route_section) in post_paths {
let relative_path = format!("{}/index.html", url_path.trim_start_matches('/'));
artifacts.route_manifest.push(SitePage {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html: String::new(),
});
if section.is_some_and(|section| section != route_section)
|| requested_paths.is_some_and(|requested| !requested.contains(&relative_path))
{
continue;
}
let html = render_post_route(
conn,
metadata,
&language,
&main_language,
record,
&localized_posts,
&tags,
&category_settings,
&linked_media_by_post_id,
&canonical_map,
&menu_items,
&post_data_json_by_id,
&canonical_media_map,
&project_media,
&project_tags,
&bundle,
is_preview,
)?;
artifacts.pagefind_documents.push(PagefindDocument {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html: html.clone(),
});
artifacts.pages.push(SitePage {
language: language.clone(),
relative_path,
url_path,
html,
});
}
}
}
Ok(artifacts)
}
pub fn build_preview_response(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
requested_path: &str,
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
let normalized = normalize_request_path(requested_path);
let requested_paths = HashSet::from([preview_relative_path(&normalized)]);
let artifacts = build_site_render_artifacts_with_mode(
conn,
data_dir,
project_id,
metadata,
published_posts,
true,
None,
Some(&requested_paths),
)?;
if let Some(page) = artifacts
.pages
.iter()
.find(|page| page.url_path == normalized)
{
return Ok(PreviewRenderResult {
status_code: 200,
html: page.html.clone(),
});
}
let bundle = load_template_bundle(conn, data_dir, project_id)?;
let language = language_from_path(&normalized, metadata);
let menu_items = build_menu_items(data_dir, &language, main_language(metadata))?;
let html = render_not_found_route(&bundle, metadata, &language, &normalized, &menu_items)?;
Ok(PreviewRenderResult {
status_code: 404,
html,
})
}
fn load_template_bundle(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> Result<TemplateBundle, Box<dyn Error + Send + Sync>> {
let templates =
queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
let mut template_source_by_slug = HashMap::new();
let mut post_templates = Vec::new();
let mut list_template_sources = HashMap::new();
let mut partials = starter_partials();
let mut macro_templates = crate::render::macros::bundled_macro_templates();
let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
let mut macro_scripts = HashMap::new();
let host: Arc<dyn HostApi> = CoreHost::from_connection(conn, project_id, data_dir)
.map(|host| host.with_offline_mode(true))
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
.unwrap_or_else(|_| Arc::new(UnavailableHost));
for script in queries::script::list_scripts_by_project(conn, project_id).unwrap_or_default() {
if script.kind != ScriptKind::Macro
|| !script.enabled
|| script.entrypoint.trim().is_empty()
{
continue;
}
let source = if let Some(content) = script.content {
content
} else if script.file_path.is_empty() {
String::new()
} else {
fs::read_to_string(data_dir.join(&script.file_path))
.ok()
.and_then(|raw| read_script_file(&raw).ok().map(|(_, body)| body))
.unwrap_or_default()
};
macro_scripts.insert(
script.slug,
json!({ "source": source, "entrypoint": script.entrypoint }),
);
}
for template in templates {
if !template.enabled {
continue;
}
let source = load_template_source(data_dir, &template)?;
match template.kind {
TemplateKind::Post => {
template_source_by_slug.insert(template.slug.clone(), source.clone());
let mut hydrated = template.clone();
hydrated.content = Some(source);
post_templates.push(hydrated);
}
TemplateKind::List => {
list_template_sources.insert(template.slug.clone(), source.clone());
if template.slug == "list"
|| template.slug == "post-list"
|| default_list_template == STARTER_POST_LIST_TEMPLATE
{
default_list_template = source;
}
}
TemplateKind::NotFound => {
if template.slug == "not-found"
|| template.slug == "not_found"
|| not_found_template == STARTER_NOT_FOUND_TEMPLATE
{
not_found_template = source;
}
}
TemplateKind::Partial => {
if let Some(name) = normalize_macro_template_slug(&template.slug) {
macro_templates.insert(name.to_string(), source);
} else {
let key = normalize_partial_slug(&template.slug);
partials.insert(key.clone(), source.clone());
if !key.starts_with("partials/") {
partials.insert(format!("partials/{key}"), source);
}
}
}
}
}
list_template_sources
.entry("post-list".to_string())
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
list_template_sources
.entry("list".to_string())
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
if !post_templates
.iter()
.any(|template| template.slug == "post")
{
post_templates.push(Template {
id: "starter-post-template".to_string(),
project_id: project_id.to_string(),
slug: "post".to_string(),
title: "post".to_string(),
kind: TemplateKind::Post,
enabled: true,
version: 1,
file_path: String::new(),
status: TemplateStatus::Published,
content: Some(STARTER_SINGLE_POST_TEMPLATE.to_string()),
created_at: 0,
updated_at: 0,
});
template_source_by_slug
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
}
Ok(TemplateBundle {
post_templates,
template_source_by_slug,
list_template_sources,
default_list_template,
not_found_template,
partials,
macro_templates,
macro_scripts,
host,
})
}
fn load_template_source(
data_dir: &Path,
template: &Template,
) -> Result<String, Box<dyn Error + Send + Sync>> {
if let Some(content) = &template.content {
return Ok(content.clone());
}
if template.file_path.is_empty() {
return Ok(String::new());
}
let content = fs::read_to_string(data_dir.join(&template.file_path))?;
let (_, body) = read_template_file(&content)?;
Ok(body)
}
fn load_language_posts(
conn: &Connection,
data_dir: &Path,
published_posts: &[(Post, String)],
language: &str,
main_language: &str,
is_preview: bool,
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
let mut posts = Vec::new();
for (post, body) in published_posts {
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
.ok()
.filter(|translation| {
(is_preview && translation.status == PostStatus::Draft && translation.content.is_some())
|| (!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file())
});
match select_post_language_variant(post, language, main_language, translation.is_some()) {
Some(PostLanguageVariant::Base) => posts.push(RenderPostRecord {
post: post.clone(),
source_post_id: post.id.clone(),
body_markdown: body.clone(),
}),
Some(PostLanguageVariant::Translation) => {
let Some(translation) = translation else {
continue;
};
let translated_body = if is_preview && translation.status == PostStatus::Draft {
match &translation.content {
Some(content) => content.clone(),
None => read_translation_body(data_dir, &translation.file_path)?,
}
} else {
read_translation_body(data_dir, &translation.file_path)?
};
let mut translated_post = post.clone();
translated_post.id = translation.id.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.updated_at = translation.updated_at;
translated_post.published_at = translation.published_at.or(post.published_at);
posts.push(RenderPostRecord {
post: translated_post,
source_post_id: post.id.clone(),
body_markdown: translated_body,
});
}
None => {}
}
}
posts.sort_by(|left, right| {
right
.post
.created_at
.cmp(&left.post.created_at)
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
.then_with(|| left.post.slug.cmp(&right.post.slug))
});
Ok(posts)
}
fn read_translation_body(
data_dir: &Path,
file_path: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
if file_path.trim().is_empty() {
return Ok(String::new());
}
let raw = fs::read_to_string(data_dir.join(file_path))?;
let (_, body) = read_translation_file(&raw)?;
Ok(body)
}
fn build_language_routes(
posts: &[RenderPostRecord],
metadata: &ProjectMetadata,
language: &str,
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RouteSpec> {
let per_page = metadata.max_posts_per_page.max(1) as usize;
let page_title = blog_page_title(metadata).to_string();
let mut routes = Vec::new();
routes.extend(paginated_route_specs(
posts,
per_page,
language_root_prefix(language, metadata),
page_title.clone(),
None,
None,
));
let mut category_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
let mut tag_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
let mut year_posts: BTreeMap<i32, Vec<RenderPostRecord>> = BTreeMap::new();
let mut month_posts: BTreeMap<(i32, u32), Vec<RenderPostRecord>> = BTreeMap::new();
let mut day_posts: BTreeMap<(i32, u32, u32), Vec<RenderPostRecord>> = BTreeMap::new();
for record in posts {
for category in &record.post.categories {
category_posts
.entry(category.clone())
.or_default()
.push(record.clone());
}
for tag in &record.post.tags {
tag_posts
.entry(tag.clone())
.or_default()
.push(record.clone());
}
if let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() {
year_posts
.entry(timestamp.year())
.or_default()
.push(record.clone());
month_posts
.entry((timestamp.year(), timestamp.month()))
.or_default()
.push(record.clone());
day_posts
.entry((timestamp.year(), timestamp.month(), timestamp.day()))
.or_default()
.push(record.clone());
}
}
for (category, records) in category_posts {
let slug = slugify(&category);
let display_name = category_settings
.get(&category)
.and_then(|settings| settings.title.as_deref())
.map(str::trim)
.filter(|title| !title.is_empty())
.unwrap_or(&category);
routes.extend(paginated_route_specs(
&records,
per_page,
format!(
"{}/category/{slug}",
language_root_prefix(language, metadata)
),
page_title.clone(),
Some(json!({"kind": "category", "name": display_name})),
category_settings
.get(&category)
.and_then(|settings| settings.list_template_slug.clone()),
));
}
for (tag_name, records) in tag_posts {
let slug = slugify(&tag_name);
let display_name = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&tag_name))
.map(|tag| tag.name.clone())
.unwrap_or(tag_name.clone());
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/tag/{slug}", language_root_prefix(language, metadata)),
page_title.clone(),
Some(json!({"kind": "tag", "name": display_name})),
None,
));
}
for (year, records) in year_posts {
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/{year}", language_root_prefix(language, metadata)),
page_title.clone(),
Some(json!({"kind": "year", "year": year})),
None,
));
}
for ((year, month), records) in month_posts {
routes.extend(paginated_route_specs(
&records,
per_page,
format!(
"{}/{year}/{month:02}",
language_root_prefix(language, metadata)
),
page_title.clone(),
Some(json!({"kind": "month", "year": year, "month": month})),
None,
));
}
for ((year, month, day), records) in day_posts {
routes.extend(paginated_route_specs(
&records,
per_page,
format!(
"{}/{year}/{month:02}/{day:02}",
language_root_prefix(language, metadata)
),
page_title.clone(),
Some(json!({"kind": "day", "year": year, "month": month, "day": day})),
None,
));
}
routes
}
fn paginated_route_specs(
posts: &[RenderPostRecord],
per_page: usize,
base_path: String,
page_title: String,
archive_context: Option<Value>,
list_template_slug: Option<String>,
) -> Vec<RouteSpec> {
let total_items = posts.len();
let total_pages = total_items.max(1).div_ceil(per_page.max(1));
let mut pages = Vec::new();
for page_index in 0..total_pages.max(1) {
let current_page = page_index + 1;
let start = page_index * per_page;
let end = (start + per_page).min(total_items);
let slice = if start < end {
posts[start..end].to_vec()
} else {
Vec::new()
};
let relative_base = base_path.trim_matches('/');
let relative_path = if current_page == 1 {
if relative_base.is_empty() {
"index.html".to_string()
} else {
format!("{relative_base}/index.html")
}
} else if relative_base.is_empty() {
format!("page/{current_page}/index.html")
} else {
format!("{relative_base}/page/{current_page}/index.html")
};
let url_path = relative_to_url_path(&relative_path);
pages.push(RouteSpec {
relative_path,
url_path,
page_title: page_title.clone(),
archive_context: archive_context.clone(),
list_template_slug: list_template_slug.clone(),
posts: slice,
current_page,
total_pages: total_pages.max(1),
total_items,
items_per_page: per_page,
});
}
pages
}
#[expect(
clippy::too_many_arguments,
reason = "render inputs are existing domain data with distinct lifetimes"
)]
fn render_list_route(
route: &RouteSpec,
metadata: &ProjectMetadata,
language: &str,
posts: &[RenderPostRecord],
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>,
canonical_media_path_by_source_path: &HashMap<String, String>,
project_media: &[Value],
project_tags: &[Value],
bundle: &TemplateBundle,
is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let main_language = main_language(metadata);
let canonical_map = canonical_post_path_by_slug(posts, language, main_language);
let taxonomy_counts = build_taxonomy_counts(posts, tags);
let list_template = route
.list_template_slug
.as_deref()
.and_then(|slug| bundle.list_template_sources.get(slug))
.unwrap_or(&bundle.default_list_template);
let context = json!({
"language": language,
"language_prefix": language_prefix(language, main_language),
"main_language": main_language,
"is_preview": is_preview,
"macro_scripts": bundle.macro_scripts,
"macro_templates": bundle.macro_templates,
"html_theme_attribute": serde_json::Value::Null,
"page_title": route.page_title,
"pico_stylesheet_href": pico_stylesheet_href(metadata),
"blog_languages": build_list_blog_languages(metadata, language, &route.url_path),
"alternate_links": build_alternate_list_links(metadata, &route.url_path),
"menu_items": menu_items,
"calendar_initial_year": route.posts.first().map(|post| calendar_initial_parts(&post.post).0).unwrap_or(1970),
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
"archive_context": route.archive_context,
"show_archive_range_heading": false,
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.created_at)),
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.created_at)),
"day_blocks": build_day_blocks(&route.posts, category_settings),
"is_list_page": route.current_page > 1,
"is_first_page": route.current_page == 1,
"is_last_page": route.current_page >= route.total_pages,
"has_prev_page": route.current_page > 1,
"has_next_page": route.current_page < route.total_pages,
"prev_page_href": route.current_page.checked_sub(1).map(|page| route_href(route, page)),
"next_page_href": if route.current_page < route.total_pages { Some(route_href(route, route.current_page + 1)) } else { None::<String> },
"current_page": route.current_page,
"total_pages": route.total_pages,
"total_items": route.total_items,
"items_per_page": route.items_per_page,
"canonical_post_path_by_slug": canonical_map,
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
"post_data_json_by_id": post_data_json_by_id,
"project": { "media": project_media },
"Tags": project_tags,
"post_categories": taxonomy_counts.0,
"post_tags": taxonomy_counts.1,
"tag_color_by_name": taxonomy_counts.2,
"backlinks": Vec::<Value>::new(),
"not_found_message": serde_json::Value::Null,
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template_with_host(
list_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}
#[allow(clippy::too_many_arguments)]
fn render_post_route(
conn: &Connection,
metadata: &ProjectMetadata,
language: &str,
main_language: &str,
record: &RenderPostRecord,
all_posts: &[RenderPostRecord],
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
canonical_post_path_by_slug: &HashMap<String, String>,
menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>,
canonical_media_path_by_source_path: &HashMap<String, String>,
project_media: &[Value],
project_tags: &[Value],
bundle: &TemplateBundle,
is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let render_categories = category_settings
.iter()
.map(|(name, settings)| {
(
name.clone(),
RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
})
.collect::<HashMap<_, _>>();
let resolved = resolve_post_template(RenderTemplateLookup {
post: &record.post,
templates: &bundle.post_templates,
tags,
category_settings: &render_categories,
})
.map_err(|error| format!("template lookup failed: {error:?}"))?;
let template_source = bundle
.template_source_by_slug
.get(&resolved.slug)
.cloned()
.or_else(|| resolved.content.clone())
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
let linked_media = linked_media_by_post_id
.get(&record.post.id)
.cloned()
.unwrap_or_default();
let outgoing_links =
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
let incoming_links =
queries::post_link::list_links_by_target(conn, &record.source_post_id).unwrap_or_default();
let post_by_id = all_posts
.iter()
.map(|item| (item.source_post_id.clone(), item))
.collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let incoming_link_context = incoming_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let backlinks = incoming_links
.iter()
.map(|link| backlink_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
let context = json!({
"language": language,
"language_prefix": language_prefix(language, main_language),
"main_language": main_language,
"is_preview": is_preview,
"macro_scripts": bundle.macro_scripts,
"macro_templates": bundle.macro_templates,
"page_title": record.post.title,
"pico_stylesheet_href": pico_stylesheet_href(metadata),
"html_theme_attribute": serde_json::Value::Null,
"alternate_links": build_alternate_post_links(&record.post, metadata),
"blog_languages": build_post_blog_languages(&record.post, metadata, language),
"menu_items": menu_items,
"calendar_initial_year": calendar_initial_parts(&record.post).0,
"calendar_initial_month": calendar_initial_parts(&record.post).1,
"post": post_context(&record.post, &record.body_markdown, linked_media, outgoing_link_context, incoming_link_context),
"post_categories": taxonomy_items_for_categories(&record.post.categories, all_posts),
"post_tags": taxonomy_items_for_tags(&record.post.tags, all_posts, tags),
"tag_color_by_name": taxonomy_counts.2,
"backlinks": backlinks,
"canonical_post_path_by_slug": canonical_post_path_by_slug,
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
"post_data_json_by_id": post_data_json_by_id,
"project": { "media": project_media },
"Tags": project_tags,
"day_blocks": Vec::<Value>::new(),
"archive_context": serde_json::Value::Null,
"show_archive_range_heading": false,
"min_date": serde_json::Value::Null,
"max_date": serde_json::Value::Null,
"is_list_page": false,
"is_first_page": true,
"is_last_page": true,
"has_prev_page": false,
"has_next_page": false,
"prev_page_href": serde_json::Value::Null,
"next_page_href": serde_json::Value::Null,
"not_found_message": serde_json::Value::Null,
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template_with_host(
&template_source,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}
fn render_not_found_route(
bundle: &TemplateBundle,
metadata: &ProjectMetadata,
language: &str,
requested_path: &str,
menu_items: &[Value],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let context = json!({
"language": language,
"language_prefix": language_prefix(language, main_language(metadata)),
"html_theme_attribute": serde_json::Value::Null,
"page_title": format!("404 | {}", metadata.name),
"pico_stylesheet_href": pico_stylesheet_href(metadata),
"blog_languages": build_list_blog_languages(metadata, language, requested_path),
"alternate_links": build_alternate_list_links(metadata, requested_path),
"menu_items": menu_items,
"calendar_initial_year": 1970,
"calendar_initial_month": 1,
"post": serde_json::Value::Null,
"post_categories": Vec::<Value>::new(),
"post_tags": Vec::<Value>::new(),
"tag_color_by_name": HashMap::<String, String>::new(),
"backlinks": Vec::<Value>::new(),
"day_blocks": Vec::<Value>::new(),
"archive_context": serde_json::Value::Null,
"show_archive_range_heading": false,
"min_date": serde_json::Value::Null,
"max_date": serde_json::Value::Null,
"is_list_page": false,
"is_first_page": true,
"is_last_page": true,
"has_prev_page": false,
"has_next_page": false,
"prev_page_href": serde_json::Value::Null,
"next_page_href": serde_json::Value::Null,
"not_found_message": format!("No rendered page exists for {}", requested_path),
"not_found_back_label": serde_json::Value::Null,
"canonical_post_path_by_slug": HashMap::<String, String>::new(),
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
});
Ok(render_liquid_template_with_host(
&bundle.not_found_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}
fn build_menu_items(
data_dir: &Path,
language: &str,
main_language: &str,
) -> Result<Vec<Value>, Box<dyn Error + Send + Sync>> {
let items = menu::read_menu(data_dir)?;
Ok(items
.iter()
.map(|item| menu_item_context(item, language, main_language))
.collect())
}
fn menu_item_context(item: &menu::MenuItem, language: &str, main_language: &str) -> Value {
let children = item
.children
.iter()
.map(|child| menu_item_context(child, language, main_language))
.collect::<Vec<_>>();
json!({
"title": item.label,
"href": menu_item_href(item, language, main_language),
"has_children": !children.is_empty(),
"children": children,
})
}
fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> String {
let prefix = language_prefix(language, main_language);
match item.kind {
MenuItemKind::Home => {
if prefix.is_empty() {
"/".to_string()
} else {
format!("{prefix}/")
}
}
MenuItemKind::Submenu => "#".to_string(),
MenuItemKind::Page => item
.slug
.as_deref()
.map(|slug| prefixed_slug_path(&prefix, slug))
.unwrap_or_else(|| "#".to_string()),
MenuItemKind::CategoryArchive => item
.slug
.as_deref()
.map(|slug| format!("{prefix}/category/{}/", slugify(slug)))
.unwrap_or_else(|| "#".to_string()),
}
}
fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
format!(
"{}/{}/",
prefix.trim_end_matches('/'),
slug.trim_matches('/')
)
}
fn route_href(route: &RouteSpec, page: usize) -> String {
let base = route.url_path.trim_end_matches('/');
if page <= 1 {
if base.is_empty() {
"/".to_string()
} else {
base.to_string()
}
} else if base.is_empty() || base == "/" {
format!("/page/{page}")
} else {
format!("{base}/page/{page}")
}
}
fn build_day_blocks(
posts: &[RenderPostRecord],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<Value> {
let mut blocks = Vec::new();
let mut current_key = String::new();
let mut current_posts = Vec::new();
let mut current_label = String::new();
for record in posts {
let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() else {
continue;
};
let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if !current_key.is_empty() && current_key != key {
blocks.push(json!({
"show_date_marker": true,
"date_label": current_label,
"posts": current_posts,
"show_separator": true,
}));
current_posts = Vec::new();
}
current_label = key.clone();
current_key = key;
let show_title = should_show_list_title(&record.post, category_settings);
current_posts.push(json!({
"id": record.post.id,
"title": record.post.title,
"slug": record.post.slug,
"content": resolve_list_content(record, category_settings),
"show_title": show_title,
}));
}
if !current_key.is_empty() {
blocks.push(json!({
"show_date_marker": true,
"date_label": current_label,
"posts": current_posts,
"show_separator": false,
}));
}
blocks
}
fn filter_posts_for_lists(
posts: &[RenderPostRecord],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RenderPostRecord> {
posts
.iter()
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
.cloned()
.collect()
}
fn is_post_excluded_from_lists(
post: &Post,
category_settings: &HashMap<String, CategorySettings>,
) -> bool {
post.categories.iter().any(|category| {
category_settings
.get(category)
.map(|settings| !settings.render_in_lists)
.unwrap_or(false)
})
}
fn should_show_list_title(
post: &Post,
category_settings: &HashMap<String, CategorySettings>,
) -> bool {
if post.categories.is_empty() {
return true;
}
!post.categories.iter().any(|category| {
category_settings
.get(category)
.map(|settings| !settings.show_title)
.unwrap_or(false)
})
}
fn resolve_list_content(
record: &RenderPostRecord,
category_settings: &HashMap<String, CategorySettings>,
) -> String {
let show_title = should_show_list_title(&record.post, category_settings);
let excerpt = record.post.excerpt.as_deref().map(str::trim).unwrap_or("");
if show_title && !excerpt.is_empty() {
record.post.excerpt.clone().unwrap_or_default()
} else {
record.body_markdown.clone()
}
}
fn canonical_post_path_by_slug(
posts: &[RenderPostRecord],
language: &str,
main_language: &str,
) -> HashMap<String, String> {
posts
.iter()
.map(|record| {
(
record.post.slug.clone(),
build_canonical_post_path(&record.post, language, main_language),
)
})
.collect()
}
fn canonical_media_paths(media_items: &[Media]) -> HashMap<String, String> {
let mut map = HashMap::new();
for media in media_items {
let target = canonical_media_path(media);
let (year, month) = year_month_from_unix_ms(media.created_at);
for source in [
format!("media/{year}/{month}/{}", media.original_name).to_lowercase(),
format!("bds-media://{}", media.id),
media.file_path.trim_start_matches('/').to_lowercase(),
] {
map.entry(source).or_insert_with(|| target.clone());
}
}
map
}
fn build_linked_media_by_post_id(
conn: &Connection,
posts: &[RenderPostRecord],
media_by_id: &HashMap<String, Media>,
) -> HashMap<String, Vec<Value>> {
let mut result = HashMap::new();
for record in posts {
let media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
.unwrap_or_default()
.into_iter()
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect();
result.insert(record.post.id.clone(), media);
}
result
}
fn build_post_data_json_by_id(
posts: &[RenderPostRecord],
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
) -> HashMap<String, Value> {
posts.iter()
.map(|record| {
let linked_media = linked_media_by_post_id
.get(&record.post.id)
.cloned()
.unwrap_or_default();
(
record.post.id.clone(),
json!({
"id": record.post.id,
"title": record.post.title,
"slug": record.post.slug,
"excerpt": record.post.excerpt,
"author": record.post.author,
"language": record.post.language,
"published_at": timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at)),
"created_at": timestamp_parts(record.post.created_at),
"updated_at": timestamp_parts(record.post.updated_at),
"tags": record.post.tags,
"categories": record.post.categories,
"linked_media": linked_media,
}),
)
})
.collect()
}
fn build_published_tag_counts(posts: &[(Post, String)], tags: &[Tag]) -> Vec<Value> {
let mut counts = HashMap::<String, usize>::new();
for (post, _) in posts {
if post.status != PostStatus::Published {
continue;
}
for name in &post.tags {
if !name.trim().is_empty() {
*counts.entry(name.trim().to_string()).or_default() += 1;
}
}
}
let mut items = counts
.into_iter()
.map(|(name, count)| {
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&name))
.and_then(|tag| tag.color.clone())
.filter(|color| !color.is_empty());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
})
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right["post_count"]
.as_u64()
.cmp(&left["post_count"].as_u64())
.then_with(|| {
left["name"]
.as_str()
.unwrap_or_default()
.to_lowercase()
.cmp(&right["name"].as_str().unwrap_or_default().to_lowercase())
})
});
items
}
fn build_taxonomy_counts(
posts: &[RenderPostRecord],
tags: &[Tag],
) -> (Vec<Value>, Vec<Value>, HashMap<String, String>) {
let mut category_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
let mut tag_colors = HashMap::new();
for record in posts {
for category in &record.post.categories {
*category_counts.entry(category.clone()).or_default() += 1;
}
for tag_name in &record.post.tags {
*tag_counts.entry(tag_name.clone()).or_default() += 1;
}
}
for tag in tags {
if let Some(color) = &tag.color {
tag_colors.insert(tag.name.clone(), color.clone());
}
}
let categories = category_counts
.into_iter()
.map(|(name, count)| {
json!({
"name": name,
"slug": slugify(&name),
"post_count": count,
})
})
.collect::<Vec<_>>();
let tag_items = tag_counts
.into_iter()
.map(|(name, count)| {
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&name))
.and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
})
})
.collect::<Vec<_>>();
(categories, tag_items, tag_colors)
}
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
names
.iter()
.map(|name| {
let count = posts
.iter()
.filter(|record| {
record
.post
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(name))
})
.count();
json!({
"name": name,
"slug": slugify(name),
"post_count": count,
})
})
.collect()
}
fn taxonomy_items_for_tags(
names: &[String],
posts: &[RenderPostRecord],
tags: &[Tag],
) -> Vec<Value> {
names
.iter()
.map(|name| {
let count = posts
.iter()
.filter(|record| {
record
.post
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(name))
})
.count();
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(name))
.and_then(|tag| tag.color.clone());
json!({
"name": name,
"slug": slugify(name),
"color": color,
"post_count": count,
})
})
.collect()
}
fn post_context(
post: &Post,
body_markdown: &str,
linked_media: Vec<Value>,
outgoing_links: Vec<Value>,
incoming_links: Vec<Value>,
) -> Value {
json!({
"id": post.id,
"title": post.title,
"content": body_markdown,
"slug": post.slug,
"excerpt": post.excerpt,
"author": post.author,
"language": post.language,
"show_title": true,
"published_at": timestamp_parts(post.published_at.unwrap_or(post.created_at)),
"created_at": timestamp_parts(post.created_at),
"updated_at": timestamp_parts(post.updated_at),
"tags": post.tags,
"categories": post.categories,
"template_slug": post.template_slug,
"do_not_translate": post.do_not_translate,
"linked_media": linked_media,
"outgoing_links": outgoing_links,
"incoming_links": incoming_links,
})
}
fn media_context(media: &Media) -> Value {
json!({
"id": media.id,
"filename": media.filename,
"original_name": media.original_name,
"mime_type": media.mime_type,
"title": media.title,
"alt": media.alt,
"caption": media.caption,
"author": media.author,
"width": media.width,
"height": media.height,
"file_path": canonical_media_path(media),
"created_at": media.created_at,
})
}
fn link_context(
link: &crate::model::PostLink,
post_by_id: &HashMap<String, &RenderPostRecord>,
language: &str,
main_language: &str,
) -> Value {
let target = post_by_id.get(&link.target_post_id).copied();
json!({
"path": target.map(|record| build_canonical_post_path(&record.post, language, main_language)).unwrap_or_default(),
"display_slug": target.map(|record| record.post.slug.clone()).unwrap_or_default(),
"title": target.map(|record| record.post.title.clone()).unwrap_or_default(),
"link_text": link.link_text,
})
}
fn backlink_context(
link: &crate::model::PostLink,
post_by_id: &HashMap<String, &RenderPostRecord>,
language: &str,
main_language: &str,
) -> Value {
let source = post_by_id.get(&link.source_post_id).copied();
json!({
"path": source.map(|record| build_canonical_post_path(&record.post, language, main_language)).unwrap_or_default(),
"display_slug": source.map(|record| record.post.slug.clone()).unwrap_or_default(),
"title": source.map(|record| record.post.title.clone()).unwrap_or_default(),
})
}
fn timestamp_parts(timestamp_ms: i64) -> Value {
if let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() {
json!({
"year": timestamp.year(),
"month": timestamp.month(),
"day": timestamp.day(),
})
} else {
json!(null)
}
}
fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Value> {
let main_language = main_language(metadata);
let mut links = render_languages(metadata)
.into_iter()
.map(|language| json!({
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), build_canonical_post_path(post, &language, main_language)),
"hreflang": language,
}))
.collect::<Vec<_>>();
links.push(json!({
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), build_canonical_post_path(post, post.language.as_deref().unwrap_or(main_language), main_language)),
"hreflang": "x-default",
}));
links
}
fn build_post_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<Value> {
let main_language = main_language(metadata);
render_languages(metadata)
.into_iter()
.map(|language| json!({
"is_current": language.eq_ignore_ascii_case(current_language),
"code": language,
"flag": crate::i18n::normalize_language(&language).flag_emoji().to_string(),
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), build_canonical_post_path(post, &language, main_language)),
"href_prefix": language_prefix(&language, main_language),
}))
.collect()
}
fn build_list_blog_languages(
metadata: &ProjectMetadata,
current_language: &str,
current_url_path: &str,
) -> Vec<Value> {
let main_language = main_language(metadata);
render_languages(metadata)
.into_iter()
.map(|language| {
let path = relocalize_url_path(current_url_path, &language, main_language);
json!({
"is_current": language.eq_ignore_ascii_case(current_language),
"code": language,
"flag": crate::i18n::normalize_language(&language).flag_emoji().to_string(),
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), path),
"href_prefix": language_prefix(&language, main_language),
})
})
.collect()
}
fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str) -> Vec<Value> {
let main_language = main_language(metadata);
let mut links = render_languages(metadata)
.into_iter()
.map(|language| {
let path = relocalize_url_path(current_url_path, &language, main_language);
json!({
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), path),
"hreflang": language,
})
})
.collect::<Vec<_>>();
links.push(json!({
"href": format!("{}{}", metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'), relocalize_url_path(current_url_path, main_language, main_language)),
"hreflang": "x-default",
}));
links
}
fn relocalize_url_path(
current_url_path: &str,
target_language: &str,
main_language: &str,
) -> String {
let stripped = strip_language_prefix(current_url_path, main_language);
if target_language.eq_ignore_ascii_case(main_language) {
stripped
} else if stripped == "/" {
format!("/{target_language}")
} else {
format!("/{target_language}{}", stripped)
}
}
fn strip_language_prefix(path: &str, main_language: &str) -> String {
let normalized = normalize_request_path(path);
let trimmed = normalized.trim_start_matches('/');
if let Some((first, remainder)) = trimmed.split_once('/')
&& first.len() == 2
&& !first.eq_ignore_ascii_case(main_language)
{
return format!("/{}", remainder.trim_start_matches('/'));
}
normalized
}
fn normalize_request_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "/" {
"/".to_string()
} else {
format!("/{}", trimmed.trim_matches('/'))
}
}
fn preview_relative_path(requested_path: &str) -> String {
let normalized = normalize_request_path(requested_path);
if normalized == "/" {
return "index.html".to_string();
}
let trimmed = normalized.trim_start_matches('/');
if trimmed == "404" || trimmed.ends_with("/404") {
format!("{trimmed}.html")
} else {
format!("{trimmed}/index.html")
}
}
fn relative_to_url_path(relative_path: &str) -> String {
if relative_path == "index.html" {
return "/".to_string();
}
let trimmed = relative_path
.trim_end_matches("index.html")
.trim_end_matches('/');
format!("/{}", trimmed.trim_start_matches('/'))
}
fn language_from_path(path: &str, metadata: &ProjectMetadata) -> String {
let trimmed = path.trim_start_matches('/');
if let Some((candidate, _)) = trimmed.split_once('/')
&& render_languages(metadata)
.iter()
.any(|language| language == candidate)
{
return candidate.to_string();
}
main_language(metadata).to_string()
}
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = main_language(metadata).to_string();
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
languages
}
fn main_language(metadata: &ProjectMetadata) -> &str {
metadata.main_language.as_deref().unwrap_or("en")
}
fn language_prefix(language: &str, main_language: &str) -> String {
if language.eq_ignore_ascii_case(main_language) {
String::new()
} else {
format!("/{language}")
}
}
fn language_root_prefix(language: &str, metadata: &ProjectMetadata) -> String {
language_prefix(language, main_language(metadata))
}
fn normalize_partial_slug(slug: &str) -> String {
let trimmed = slug.trim().trim_matches('/');
if trimmed.starts_with("partials/") {
trimmed.to_string()
} else {
format!("partials/{trimmed}")
}
}
fn normalize_macro_template_slug(slug: &str) -> Option<&'static str> {
let normalized = slug.trim().trim_matches('/');
let normalized = normalized.strip_prefix("partials/").unwrap_or(normalized);
let normalized = normalized.strip_suffix(".liquid").unwrap_or(normalized);
match normalized {
"macros/gallery" => Some("gallery"),
"macros/youtube" => Some("youtube"),
"macros/vimeo" => Some("vimeo"),
"macros/photo-archive" | "macros/photo_archive" => Some("photo-archive"),
"macros/tag-cloud" | "macros/tag_cloud" => Some("tag-cloud"),
_ => None,
}
}
fn starter_partials() -> HashMap<String, String> {
HashMap::from([
(
"partials/head".to_string(),
STARTER_HEAD_PARTIAL.to_string(),
),
(
"partials/menu".to_string(),
STARTER_MENU_PARTIAL.to_string(),
),
(
"partials/language-switcher".to_string(),
STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(),
),
(
"partials/menu-items".to_string(),
STARTER_MENU_ITEMS_PARTIAL.to_string(),
),
])
}
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
Some(crate::model::pico_stylesheet_href(
metadata.pico_theme.as_deref(),
))
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
Local
.timestamp_millis_opt(post.created_at)
.single()
.map(|timestamp| (timestamp.year(), timestamp.month()))
.unwrap_or((1970, 1))
}
fn canonical_media_path(media: &Media) -> String {
if media.file_path.starts_with('/') {
media.file_path.clone()
} else {
format!("/{}", media.file_path.trim_start_matches('/'))
}
}
fn queries_category_settings(
data_dir: &Path,
) -> Result<HashMap<String, CategorySettings>, Box<dyn Error + Send + Sync>> {
Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default())
}
#[cfg(test)]
mod menu_tests {
use super::*;
use crate::engine::menu::{MenuItem, MenuItemKind};
#[test]
fn starter_menu_partial_keeps_nested_submenus_and_calendar_navigation() {
let partials = starter_partials();
let menu_items = partials.get("partials/menu-items").unwrap();
assert!(menu_items.contains("blog-menu-submenu"));
assert!(menu_items.contains("data-blog-calendar-toggle"));
assert!(menu_items.contains("data-blog-calendar-root"));
assert!(
partials
.get("partials/language-switcher")
.unwrap()
.contains("data-search-no-results")
);
}
#[test]
fn macro_partial_slugs_select_customizable_bundled_templates() {
let templates = crate::render::macros::bundled_macro_templates();
assert_eq!(templates.len(), 5);
assert!(templates.contains_key("photo-archive"));
assert!(templates.contains_key("tag-cloud"));
assert_eq!(
normalize_macro_template_slug("macros/gallery"),
Some("gallery")
);
assert_eq!(
normalize_macro_template_slug("partials/macros/photo-archive.liquid"),
Some("photo-archive")
);
assert_eq!(
normalize_macro_template_slug("macros/tag_cloud"),
Some("tag-cloud")
);
assert_eq!(normalize_macro_template_slug("partials/card"), None);
}
#[test]
fn preview_request_paths_select_only_the_matching_generated_page() {
assert_eq!(preview_relative_path("/"), "index.html");
assert_eq!(
preview_relative_path("/2024/03/hello"),
"2024/03/hello/index.html"
);
assert_eq!(preview_relative_path("/de"), "de/index.html");
assert_eq!(preview_relative_path("/404"), "404.html");
assert_eq!(preview_relative_path("/de/404"), "de/404.html");
}
#[test]
fn renderer_consumes_the_saved_opml_tree() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
crate::engine::menu::write_menu(
dir.path(),
&[
MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("about".into()),
children: Vec::new(),
},
MenuItem {
kind: MenuItemKind::Submenu,
label: "Topics".into(),
slug: None,
children: vec![MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Über Öl".into(),
slug: Some("Über Öl".into()),
children: Vec::new(),
}],
},
],
)
.unwrap();
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
assert_eq!(rendered[0]["href"], "/");
assert_eq!(rendered[1]["href"], "/about/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/uber-ol/");
let translated = build_menu_items(dir.path(), "de", "en").unwrap();
assert_eq!(translated[1]["href"], "/de/about/");
assert_eq!(
translated[2]["children"][0]["href"],
"/de/category/uber-ol/"
);
}
}