chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -32,13 +32,12 @@ pub fn write_generated_file(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
@@ -68,13 +67,12 @@ pub fn write_generated_bytes(
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
@@ -135,9 +133,13 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
*days.entry(day).or_insert(0) += 1;
}
CalendarArchiveData { years, months, days }
CalendarArchiveData {
years,
months,
days,
}
}
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
serde_json::to_string_pretty(&build_calendar_archive_data(posts))
}
}

View File

@@ -52,17 +52,10 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
"vimeo" => Some(render_vimeo(&args)),
"photo_archive" => Some(render_photo_archive(&args, context)),
"tag_cloud" => Some(render_tag_cloud(&args, context)),
_ => Some(unsupported_macro(name)),
_ => None,
}
}
fn unsupported_macro(name: &str) -> String {
format!(
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
escape_html_attr(name),
)
}
fn tokenize_invocation(invocation: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
@@ -100,12 +93,11 @@ fn tokenize_invocation(invocation: &str) -> Vec<String> {
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
let trimmed = raw.trim();
if trimmed.len() >= 2 {
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
}
if trimmed.len() >= 2
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
{
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
}
match trimmed {
@@ -197,7 +189,11 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() {
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id.");
return empty_block(
"macro-youtube",
"gallery-empty",
"Missing YouTube video id.",
);
}
format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
@@ -224,10 +220,18 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
.or_else(|| linked_media(context));
let Some(media) = media else {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
};
if media.is_empty() {
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
}
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
@@ -243,7 +247,11 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
let single_month = grouped.len() == 1;
let mut html = format!(
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
if single_month { " photo-archive-single-month" } else { "" }
if single_month {
" photo-archive-single-month"
} else {
""
}
);
for (bucket, items) in grouped.into_iter().rev() {
html.push_str("<section class=\"photo-archive-month\">");
@@ -362,7 +370,10 @@ fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
}
fn image_path(image: &JsonValue) -> Option<String> {
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned)
image
.get("file_path")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn image_title(image: &JsonValue) -> Option<String> {
@@ -391,7 +402,9 @@ fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
}
fn value_as_u64(value: &JsonValue) -> Option<u64> {
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64))
value
.as_u64()
.or_else(|| value.as_i64().map(|number| number.max(0) as u64))
}
fn stringify_scalar(value: &JsonValue) -> String {
@@ -407,7 +420,9 @@ fn stringify_scalar(value: &JsonValue) -> String {
fn month_bucket(path: &str) -> Option<String> {
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
match segments.as_slice() {
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")),
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
Some(format!("{year}-{month}"))
}
_ => None,
}
}
@@ -422,11 +437,16 @@ fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> Strin
}
fn escape_html(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn escape_html_attr(value: &str) -> String {
escape_html(value).replace('"', "&quot;").replace('\'', "&#39;")
escape_html(value)
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
@@ -470,4 +490,13 @@ mod tests {
assert!(html.contains("macro-tag-cloud"));
assert!(html.contains("data-tag-cloud=\"true\""));
}
}
#[test]
fn leaves_unknown_macros_verbatim() {
let markdown = "Before [[future_macro value='x']] after";
assert_eq!(
expand_builtin_macros(markdown, &MacroRenderContext::default()),
markdown
);
}
}

View File

@@ -22,4 +22,4 @@ mod tests {
assert!(rendered.contains("<h1>Title</h1>"));
assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
}
}
}

View File

@@ -1,27 +1,26 @@
mod markdown;
mod macros;
mod generation;
mod macros;
mod markdown;
mod page_renderer;
mod routes;
mod site;
mod template_lookup;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
build_core_generation_paths, write_generated_bytes, write_generated_file,
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
write_generated_bytes, write_generated_file,
};
pub use markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template};
pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
RenderedPage, build_canonical_post_path, render_starter_list_page,
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
};
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
build_preview_response, build_site_render_artifacts,
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
build_site_render_artifacts,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
resolve_post_template,
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
};

View File

@@ -2,11 +2,11 @@ use std::collections::HashMap;
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
};
use liquid_core::model::ScalarCow;
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
ParseFilter, Runtime, Value, ValueView,
};
use serde::Serialize;
use serde_json::{Map as JsonMap, Value as JsonValue};
use thiserror::Error;
@@ -14,6 +14,7 @@ use thiserror::Error;
use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html;
use crate::util::slugify;
#[derive(Debug, Error)]
pub enum RenderError {
@@ -40,6 +41,7 @@ pub fn render_liquid_template<T: Serialize>(
let parser = ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown)
.filter(Slugify)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
@@ -47,6 +49,28 @@ pub fn render_liquid_template<T: Serialize>(
Ok(template.render(&globals)?)
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "slugify",
description = "Convert text to the canonical post slug format.",
parsed(SlugifyFilter)
)]
struct Slugify;
#[derive(Debug, Default, Display_filter)]
#[name = "slugify"]
struct SlugifyFilter;
impl Filter for SlugifyFilter {
fn evaluate(
&self,
input: &dyn ValueView,
_runtime: &dyn Runtime,
) -> liquid_core::Result<Value> {
Ok(Value::scalar(slugify(input.to_kstr().as_str())))
}
}
#[derive(Debug, FilterParameters)]
struct I18nArgs {
#[parameter(description = "Render language", arg_type = "str")]
@@ -74,7 +98,10 @@ impl Filter for I18nFilter {
let args = self.args.evaluate(runtime)?;
let key = input.to_kstr();
let language = args.language.to_kstr();
Ok(Value::scalar(translate_render(language.as_str(), key.as_str())))
Ok(Value::scalar(translate_render(
language.as_str(),
key.as_str(),
)))
}
}
@@ -135,7 +162,10 @@ impl Filter for MarkdownFilter {
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded);
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
Ok(Value::scalar(rewrite_rendered_html_urls(
&rendered,
&rewrite_context,
)))
}
}
@@ -148,10 +178,10 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
}
}
if !roots.contains_key("Tags") {
if let Some(tags) = roots.get("post_tags").cloned() {
roots.insert("Tags".to_string(), tags);
}
if !roots.contains_key("Tags")
&& let Some(tags) = roots.get("post_tags").cloned()
{
roots.insert("Tags".to_string(), tags);
}
roots
@@ -202,9 +232,16 @@ fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
.unwrap_or_default()
}
pub(crate) fn rewrite_rendered_html_urls(html: &str, rewrite_context: &impl RewriteContextView) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| normalize_preview_href(href, rewrite_context));
rewrite_attribute_urls(&rewritten, "src", |src| normalize_preview_src(src, rewrite_context))
pub(crate) fn rewrite_rendered_html_urls(
html: &str,
rewrite_context: &impl RewriteContextView,
) -> String {
let rewritten = rewrite_attribute_urls(html, "href", |href| {
normalize_preview_href(href, rewrite_context)
});
rewrite_attribute_urls(&rewritten, "src", |src| {
normalize_preview_src(src, rewrite_context)
})
}
pub(crate) trait RewriteContextView {
@@ -387,7 +424,9 @@ fn extract_post_slug(path: &str) -> Option<String> {
let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() {
["post" | "posts", slug] => Some(trim_html_suffix(slug)),
["post" | "posts", year, month, slug] if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) => {
["post" | "posts", year, month, slug]
if year.len() == 4 && month.chars().all(|ch| ch.is_ascii_digit()) =>
{
Some(trim_html_suffix(slug))
}
_ => None,
@@ -422,7 +461,7 @@ fn trim_html_suffix(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{rewrite_rendered_html_urls, RewriteContextView};
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
use std::collections::HashMap;
struct TestRewriteContext {
@@ -457,4 +496,16 @@ mod tests {
assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
}
}
#[test]
fn exposes_slugify_liquid_filter() {
let rendered = render_liquid_template(
"{{ title | slugify }}",
&HashMap::new(),
&serde_json::json!({"title": "Über die Brücke"}),
)
.unwrap();
assert_eq!(rendered, "ueber-die-bruecke");
}
}

View File

@@ -7,11 +7,16 @@ use crate::i18n::normalize_language;
use crate::model::{Post, ProjectMetadata};
use crate::render::{RenderError, render_liquid_template};
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_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_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_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");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
@@ -136,7 +141,10 @@ pub fn render_starter_single_post_page_with_media_map(
language: &str,
canonical_media_path_by_source_path: HashMap<String, String>,
) -> Result<RenderedPage, RenderError> {
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/'));
let relative_path = format!(
"{}/index.html",
build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')
);
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post);
let context = PostTemplateContext {
@@ -171,13 +179,12 @@ pub fn render_starter_single_post_page_with_media_map(
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
STARTER_SINGLE_POST_TEMPLATE,
&starter_partials(),
&context,
)?;
let html = render_liquid_template(STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context)?;
Ok(RenderedPage { relative_path, html })
Ok(RenderedPage {
relative_path,
html,
})
}
pub fn render_starter_list_page(
@@ -246,26 +253,33 @@ pub fn render_starter_list_page_with_media_map(
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
&starter_post_list_template(),
&starter_partials(),
&context,
)?;
let html =
render_liquid_template(&starter_post_list_template(), &starter_partials(), &context)?;
Ok(RenderedPage { relative_path, html })
Ok(RenderedPage {
relative_path,
html,
})
}
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/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(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
),
])
}
@@ -305,7 +319,11 @@ fn calendar_initial_parts(post: &Post) -> (i32, u32) {
.unwrap_or((1970, 1))
}
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> {
fn build_alternate_links(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<AlternateLinkContext> {
metadata
.blog_languages
.iter()
@@ -320,7 +338,11 @@ fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_langua
.collect()
}
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
fn build_blog_languages(
post: &Post,
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
@@ -334,7 +356,10 @@ fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_languag
.collect()
}
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
fn build_blog_languages_for_index(
metadata: &ProjectMetadata,
current_language: &str,
) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
@@ -349,12 +374,23 @@ fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language:
}
fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata)))
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
format!(
"{base_url}{}",
build_canonical_post_path(post, language, main_language(metadata))
)
}
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
let base_url = metadata
.public_url
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) {
"/".to_string()
} else {
@@ -377,7 +413,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
continue;
};
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
let key = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
if current_key.as_deref() != Some(key.as_str()) {
if let Some(last) = blocks.last_mut() {
last.show_separator = true;
@@ -385,7 +426,12 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
current_key = Some(key);
blocks.push(DayBlockContext {
show_date_marker: true,
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()),
date_label: format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
),
posts: Vec::new(),
show_separator: false,
});
@@ -456,19 +502,39 @@ mod tests {
#[test]
fn canonical_post_paths_follow_language_prefix_rule() {
let post = make_post();
assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello");
assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello");
assert_eq!(
build_canonical_post_path(&post, "en", "en"),
"/2024/03/09/hello"
);
assert_eq!(
build_canonical_post_path(&post, "de", "en"),
"/de/2024/03/09/hello"
);
}
#[test]
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
let post = make_post();
let metadata = make_metadata();
let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap();
let rendered = render_starter_single_post_page(
&post,
"Body with [link](/posts/hello)",
&metadata,
"en",
)
.unwrap();
assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html");
assert!(rendered.html.contains("https://example.com/2024/03/09/hello"));
assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello"));
assert!(
rendered
.html
.contains("https://example.com/2024/03/09/hello")
);
assert!(
rendered
.html
.contains("https://example.com/de/2024/03/09/hello")
);
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
}
@@ -515,4 +581,4 @@ mod tests {
assert!(rendered.html.contains("10.03.2024"));
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
}
}
}

View File

@@ -10,17 +10,28 @@ use serde_json::{Value, json};
use crate::db::queries;
use crate::engine::menu::{self, MenuItemKind};
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
use crate::model::{
CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template, resolve_post_template,
};
use crate::util::frontmatter::{read_template_file, read_translation_file};
use crate::util::slugify;
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_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");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage {
@@ -100,9 +111,16 @@ pub fn build_site_render_artifacts(
let mut artifacts = SiteRenderArtifacts::default();
for language in languages {
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
let localized_posts =
load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
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);
let routes = build_language_routes(
&localized_list_posts,
metadata,
&language,
&tags,
&category_settings,
);
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
@@ -138,7 +156,8 @@ pub fn build_site_render_artifacts(
artifacts.pages.push(page);
}
let canonical_map = canonical_post_path_by_slug(&localized_posts, &language, &main_language);
let canonical_map =
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts {
let html = render_post_route(
conn,
@@ -183,9 +202,14 @@ pub fn build_preview_response(
published_posts: &[(Post, String)],
requested_path: &str,
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
let artifacts =
build_site_render_artifacts(conn, data_dir, project_id, metadata, published_posts)?;
let normalized = normalize_request_path(requested_path);
if let Some(page) = artifacts.pages.iter().find(|page| page.url_path == normalized) {
if let Some(page) = artifacts
.pages
.iter()
.find(|page| page.url_path == normalized)
{
return Ok(PreviewRenderResult {
status_code: 200,
html: page.html.clone(),
@@ -207,7 +231,8 @@ fn load_template_bundle(
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 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();
@@ -237,7 +262,10 @@ fn load_template_bundle(
}
}
TemplateKind::NotFound => {
if template.slug == "not-found" || template.slug == "not_found" || not_found_template == STARTER_NOT_FOUND_TEMPLATE {
if template.slug == "not-found"
|| template.slug == "not_found"
|| not_found_template == STARTER_NOT_FOUND_TEMPLATE
{
not_found_template = source;
}
}
@@ -258,7 +286,10 @@ fn load_template_bundle(
.entry("list".to_string())
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
if !post_templates.iter().any(|template| template.slug == "post") {
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(),
@@ -273,7 +304,8 @@ fn load_template_bundle(
created_at: 0,
updated_at: 0,
});
template_source_by_slug.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
template_source_by_slug
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
}
Ok(TemplateBundle {
@@ -318,7 +350,11 @@ fn load_language_posts(
continue;
}
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &post.id, language) {
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
{
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?;
let (_, translated_body) = read_translation_file(&raw)?;
let mut translated_post = post.clone();
@@ -336,7 +372,10 @@ fn load_language_posts(
}
posts.sort_by(|left, right| {
right.post.published_at.unwrap_or(right.post.created_at)
right
.post
.published_at
.unwrap_or(right.post.created_at)
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
});
Ok(posts)
@@ -367,14 +406,29 @@ fn build_language_routes(
for record in posts {
for category in &record.post.categories {
category_posts.entry(category.clone()).or_default().push(record.clone());
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());
tag_posts
.entry(tag.clone())
.or_default()
.push(record.clone());
}
if let Some(timestamp) = Utc.timestamp_millis_opt(record.post.published_at.unwrap_or(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());
if let Some(timestamp) = Utc
.timestamp_millis_opt(record.post.published_at.unwrap_or(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());
}
}
@@ -383,7 +437,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
format!(
"{}/category/{slug}",
language_root_prefix(language, metadata)
),
category.clone(),
Some(json!({"kind": "category", "name": category})),
category_settings
@@ -424,7 +481,10 @@ fn build_language_routes(
routes.extend(paginated_route_specs(
&records,
per_page,
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)),
format!(
"{}/{year}/{month:02}",
language_root_prefix(language, metadata)
),
format!("{} {year}-{month:02}", metadata.name),
Some(json!({"kind": "month", "year": year, "month": month})),
None,
@@ -449,7 +509,11 @@ fn paginated_route_specs(
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 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() {
@@ -479,6 +543,10 @@ fn paginated_route_specs(
pages
}
#[expect(
clippy::too_many_arguments,
reason = "render inputs are existing domain data with distinct lifetimes"
)]
fn render_list_route(
route: &RouteSpec,
metadata: &ProjectMetadata,
@@ -536,7 +604,11 @@ fn render_list_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(list_template, &bundle.partials, &context)?)
Ok(render_liquid_template(
list_template,
&bundle.partials,
&context,
)?)
}
#[allow(clippy::too_many_arguments)]
@@ -558,9 +630,12 @@ fn render_post_route(
let render_categories = category_settings
.iter()
.map(|(name, settings)| {
(name.clone(), RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
})
(
name.clone(),
RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
})
.collect::<HashMap<_, _>>();
let resolved = resolve_post_template(RenderTemplateLookup {
@@ -583,12 +658,26 @@ fn render_post_route(
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect::<Vec<_>>();
let outgoing_links = queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
let incoming_links = queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
let post_by_id = all_posts.iter().map(|item| (item.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 outgoing_links =
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
let incoming_links =
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
let post_by_id = all_posts
.iter()
.map(|item| (item.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!({
@@ -626,7 +715,11 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(&template_source, &bundle.partials, &context)?)
Ok(render_liquid_template(
&template_source,
&bundle.partials,
&context,
)?)
}
fn render_not_found_route(
@@ -670,7 +763,11 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
});
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
Ok(render_liquid_template(
&bundle.not_found_template,
&bundle.partials,
&context,
)?)
}
fn build_menu_items(
@@ -728,17 +825,17 @@ fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
}
fn prefix_or_root(prefix: &str) -> &str {
if prefix.is_empty() {
"/"
} else {
prefix
}
if prefix.is_empty() { "/" } else { prefix }
}
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() }
if base.is_empty() {
"/".to_string()
} else {
base.to_string()
}
} else if base.is_empty() || base == "/" {
format!("/page/{page}")
} else {
@@ -760,7 +857,12 @@ fn build_day_blocks(
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
continue;
};
let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day());
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,
@@ -771,7 +873,12 @@ fn build_day_blocks(
current_posts = Vec::new();
}
current_key = key;
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year());
current_label = format!(
"{:02}.{:02}.{:04}",
timestamp.day(),
timestamp.month(),
timestamp.year()
);
let show_title = should_show_list_title(&record.post, category_settings);
current_posts.push(json!({
"id": record.post.id,
@@ -797,7 +904,8 @@ fn filter_posts_for_lists(
posts: &[RenderPostRecord],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RenderPostRecord> {
posts.iter()
posts
.iter()
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
.cloned()
.collect()
@@ -850,11 +958,14 @@ fn canonical_post_path_by_slug(
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),
))
posts
.iter()
.map(|record| {
(
record.post.slug.clone(),
build_canonical_post_path(&record.post, language, main_language),
)
})
.collect()
}
@@ -874,7 +985,9 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
let mut remainder = markdown;
while let Some(index) = remainder.find("bds-media://") {
let rest = &remainder[index..];
let end = rest.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace()).unwrap_or(rest.len());
let end = rest
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
.unwrap_or(rest.len());
refs.push(rest[..end].to_string());
remainder = &rest[end..];
}
@@ -927,29 +1040,50 @@ fn build_taxonomy_counts(
}
}
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,
let categories = category_counts
.into_iter()
.map(|(name, count)| {
json!({
"name": name,
"slug": slugify(&name),
"post_count": count,
})
})
}).collect::<Vec<_>>();
.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()
names
.iter()
.map(|name| {
let count = posts.iter().filter(|record| record.post.categories.iter().any(|category| category.eq_ignore_ascii_case(name))).count();
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),
@@ -959,11 +1093,28 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
.collect()
}
fn taxonomy_items_for_tags(names: &[String], posts: &[RenderPostRecord], tags: &[Tag]) -> Vec<Value> {
names.iter()
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());
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),
@@ -1076,7 +1227,11 @@ fn build_alternate_post_links(post: &Post, metadata: &ProjectMetadata) -> Vec<Va
links
}
fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<Value> {
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()
@@ -1090,7 +1245,11 @@ fn build_post_blog_languages(post: &Post, metadata: &ProjectMetadata, current_la
.collect()
}
fn build_list_blog_languages(metadata: &ProjectMetadata, current_language: &str, current_url_path: &str) -> Vec<Value> {
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()
@@ -1126,7 +1285,11 @@ fn build_alternate_list_links(metadata: &ProjectMetadata, current_url_path: &str
links
}
fn relocalize_url_path(current_url_path: &str, target_language: &str, main_language: &str) -> String {
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
@@ -1140,10 +1303,11 @@ fn relocalize_url_path(current_url_path: &str, target_language: &str, main_langu
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('/') {
if first.len() == 2 && !first.eq_ignore_ascii_case(main_language) {
return format!("/{}", remainder.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
}
@@ -1161,16 +1325,20 @@ 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('/');
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('/') {
if render_languages(metadata).iter().any(|language| language == candidate) {
return candidate.to_string();
}
if let Some((candidate, _)) = trimmed.split_once('/')
&& render_languages(metadata)
.iter()
.any(|language| language == candidate)
{
return candidate.to_string();
}
main_language(metadata).to_string()
}
@@ -1179,7 +1347,10 @@ 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)) {
if !languages
.iter()
.any(|existing| existing.eq_ignore_ascii_case(language))
{
languages.push(language.clone());
}
}
@@ -1213,18 +1384,31 @@ fn normalize_partial_slug(slug: &str) -> String {
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/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(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
),
])
}
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
metadata.pico_theme.as_ref().map(|_| "/assets/pico.min.css".to_string())
metadata
.pico_theme
.as_ref()
.map(|_| "/assets/pico.min.css".to_string())
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
@@ -1247,4 +1431,4 @@ 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())
}
}

View File

@@ -21,13 +21,17 @@ pub enum TemplateLookupError {
MissingDefaultTemplate,
}
pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a Template, TemplateLookupError> {
pub fn resolve_post_template<'a>(
lookup: RenderTemplateLookup<'a>,
) -> Result<&'a Template, TemplateLookupError> {
if let Some(explicit_slug) = lookup.post.template_slug.as_deref() {
return lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, explicit_slug))
.ok_or_else(|| TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string()));
.ok_or_else(|| {
TemplateLookupError::MissingExplicitTemplate(explicit_slug.to_string())
});
}
for post_tag in &lookup.post.tags {
@@ -36,14 +40,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(post_tag))
.and_then(|tag| tag.post_template_slug.as_deref())
{
if let Some(template) = lookup
&& let Some(template) = lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
{
return Ok(template);
}
}
@@ -52,14 +54,12 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
.category_settings
.get(category_name)
.and_then(|settings| settings.post_template_slug.as_deref())
{
if let Some(template) = lookup
&& let Some(template) = lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
{
return Ok(template);
}
}
@@ -72,4 +72,4 @@ pub fn resolve_post_template<'a>(lookup: RenderTemplateLookup<'a>) -> Result<&'a
fn is_enabled_post_template(template: &Template, slug: &str) -> bool {
template.enabled && template.kind == TemplateKind::Post && template.slug == slug
}
}