chore: source formatting and spec allignment
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
escape_html(value).replace('"', """).replace('\'', "'")
|
||||
escape_html(value)
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ mod tests {
|
||||
assert!(rendered.contains("<h1>Title</h1>"));
|
||||
assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(), ¯o_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user