feat: first take at M4

This commit is contained in:
2026-04-10 15:32:05 +02:00
parent ee961f1b02
commit 3ca80b00d0
27 changed files with 3923 additions and 21 deletions

View File

@@ -0,0 +1,104 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use chrono::{Datelike, TimeZone, Utc};
use rusqlite::Connection;
use serde::Serialize;
use crate::db::queries::generated_file_hash as qhash;
use crate::model::{GeneratedFileHash, Post};
use crate::util::{atomic_write_str, content_hash, now_unix_ms};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeneratedWriteOutcome {
Written,
SkippedUnchanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CalendarArchiveData {
pub years: BTreeMap<String, usize>,
pub months: BTreeMap<String, usize>,
pub days: BTreeMap<String, usize>,
}
pub fn write_generated_file(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash {
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
}
let target_path = output_dir.join(relative_path);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
atomic_write_str(&target_path, content)?;
qhash::upsert_generated_file_hash(
conn,
&GeneratedFileHash {
project_id: project_id.to_string(),
relative_path: relative_path.to_string(),
content_hash: hash,
updated_at: now_unix_ms(),
},
)?;
Ok(GeneratedWriteOutcome::Written)
}
pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String]) -> Vec<String> {
let mut paths = vec![
"index.html".to_string(),
"sitemap.xml".to_string(),
"feed.xml".to_string(),
"atom.xml".to_string(),
"calendar.json".to_string(),
];
for language in blog_languages {
if language != main_language {
paths.push(format!("{language}/index.html"));
paths.push(format!("{language}/feed.xml"));
paths.push(format!("{language}/atom.xml"));
}
}
paths
}
pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
let mut years = BTreeMap::new();
let mut months = BTreeMap::new();
let mut days = BTreeMap::new();
for post in posts {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(created_at) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
continue;
};
let year = created_at.year().to_string();
let month = format!("{year}-{:02}", created_at.month());
let day = format!("{month}-{:02}", created_at.day());
*years.entry(year).or_insert(0) += 1;
*months.entry(month).or_insert(0) += 1;
*days.entry(day).or_insert(0) += 1;
}
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

@@ -0,0 +1,25 @@
use pulldown_cmark::{Options, Parser, html};
pub fn render_markdown_to_html(markdown: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut output = String::new();
html::push_html(&mut output, parser);
output
}
#[cfg(test)]
mod tests {
use super::render_markdown_to_html;
#[test]
fn renders_commonmark_to_html() {
let rendered = render_markdown_to_html("# Title\n\nSome *text*.");
assert!(rendered.contains("<h1>Title</h1>"));
assert!(rendered.contains("<p>Some <em>text</em>.</p>"));
}
}

View File

@@ -1 +1,20 @@
// Rendering pipeline — stubs for M0, implemented in M4.
mod markdown;
mod generation;
mod page_renderer;
mod routes;
mod template_lookup;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
build_core_generation_paths, 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_single_post_page,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
resolve_post_template,
};

View File

@@ -0,0 +1,335 @@
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 serde::Serialize;
use thiserror::Error;
use crate::i18n::translate_render;
use crate::render::render_markdown_to_html;
#[derive(Debug, Error)]
pub enum RenderError {
#[error("liquid error: {0}")]
Liquid(#[from] liquid::Error),
}
#[derive(Debug, Clone, Default)]
struct HtmlRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>,
canonical_media_path_by_source_path: HashMap<String, String>,
}
pub fn render_liquid_template<T: Serialize>(
template_source: &str,
partials: &HashMap<String, String>,
context: &T,
) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
let parser = ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
let globals = liquid::to_object(context)?;
Ok(template.render(&globals)?)
}
#[derive(Debug, FilterParameters)]
struct I18nArgs {
#[parameter(description = "Render language", arg_type = "str")]
language: Expression,
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "i18n",
description = "Translate a render key for a content language.",
parameters(I18nArgs),
parsed(I18nFilter)
)]
struct I18n;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "i18n"]
struct I18nFilter {
#[parameters]
args: I18nArgs,
}
impl Filter for I18nFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
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())))
}
}
#[derive(Debug, FilterParameters)]
struct MarkdownArgs {
#[parameter(description = "Post id", arg_type = "str")]
post_id: Option<Expression>,
#[parameter(description = "Post data by id", arg_type = "any")]
post_data_json_by_id: Option<Expression>,
#[parameter(description = "Canonical post path map", arg_type = "any")]
canonical_post_path_by_slug: Option<Expression>,
#[parameter(description = "Canonical media path map", arg_type = "any")]
canonical_media_path_by_source_path: Option<Expression>,
#[parameter(description = "Render language", arg_type = "str")]
language: Option<Expression>,
#[parameter(description = "Language prefix", arg_type = "str")]
language_prefix: Option<Expression>,
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "markdown",
description = "Render markdown to HTML and rewrite preview URLs.",
parameters(MarkdownArgs),
parsed(MarkdownFilter)
)]
struct Markdown;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "markdown"]
struct MarkdownFilter {
#[parameters]
args: MarkdownArgs,
}
impl Filter for MarkdownFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> liquid_core::Result<Value> {
let args = self.args.evaluate(runtime)?;
let markdown = input.to_kstr();
let rewrite_context = HtmlRewriteContext {
canonical_post_path_by_slug: args
.canonical_post_path_by_slug
.as_ref()
.map(value_to_string_map)
.unwrap_or_default(),
canonical_media_path_by_source_path: args
.canonical_media_path_by_source_path
.as_ref()
.map(value_to_string_map)
.unwrap_or_default(),
};
let rendered = render_markdown_to_html(markdown.as_str());
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
}
}
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
value
.as_object()
.map(|object| {
object
.iter()
.map(|(key, value)| (key.to_string(), value.to_kstr().into_owned().to_string()))
.collect()
})
.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) trait RewriteContextView {
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String>;
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String>;
}
impl RewriteContextView for HtmlRewriteContext {
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String> {
&self.canonical_post_path_by_slug
}
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String> {
&self.canonical_media_path_by_source_path
}
}
fn rewrite_attribute_urls(
html: &str,
attribute: &str,
normalize: impl Fn(&str) -> String,
) -> String {
let mut result = String::with_capacity(html.len());
let mut cursor = 0;
while let Some(offset) = html[cursor..].find(attribute) {
let attr_start = cursor + offset;
result.push_str(&html[cursor..attr_start]);
result.push_str(attribute);
let after_attr = attr_start + attribute.len();
if !html[after_attr..].starts_with('=') {
cursor = after_attr;
continue;
}
result.push('=');
let quote_index = after_attr + 1;
let Some(quote) = html[quote_index..].chars().next() else {
cursor = after_attr + 1;
continue;
};
if quote != '\'' && quote != '"' {
cursor = after_attr + 1;
continue;
}
result.push(quote);
let value_start = quote_index + quote.len_utf8();
let Some(value_end_rel) = html[value_start..].find(quote) else {
result.push_str(&html[value_start..]);
return result;
};
let value_end = value_start + value_end_rel;
result.push_str(&normalize(&html[value_start..value_end]));
result.push(quote);
cursor = value_end + quote.len_utf8();
}
result.push_str(&html[cursor..]);
result
}
fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextView) -> String {
if raw_href.is_empty() || is_external_or_special_url(raw_href) {
return raw_href.to_string();
}
let (path_part, suffix) = split_path_suffix(raw_href.trim());
if let Some(normalized) = normalize_day_route(path_part) {
return format!("{normalized}{suffix}");
}
if let Some(slug) = extract_post_slug(path_part) {
let canonical = rewrite_context
.canonical_post_path_by_slug()
.get(&slug)
.cloned()
.unwrap_or_else(|| format!("/posts/{slug}"));
return format!("{canonical}{suffix}");
}
if let Some(media_source_key) = extract_media_source_key(path_part) {
let canonical = rewrite_context
.canonical_media_path_by_source_path()
.get(&media_source_key)
.cloned()
.unwrap_or_else(|| format!("/{media_source_key}"));
return format!("{canonical}{suffix}");
}
raw_href.to_string()
}
fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextView) -> String {
if raw_src.is_empty() || is_external_or_special_url(raw_src) {
return raw_src.to_string();
}
let (path_part, suffix) = split_path_suffix(raw_src.trim());
if let Some(media_source_key) = extract_media_source_key(path_part) {
let canonical = rewrite_context
.canonical_media_path_by_source_path()
.get(&media_source_key)
.cloned()
.unwrap_or_else(|| format!("/{media_source_key}"));
return format!("{canonical}{suffix}");
}
raw_src.to_string()
}
fn is_external_or_special_url(value: &str) -> bool {
let normalized = value.trim();
if normalized.is_empty() {
return false;
}
if normalized.starts_with('#') || normalized.starts_with("//") {
return true;
}
let mut seen_alpha = false;
for ch in normalized.chars() {
if ch == ':' {
return seen_alpha;
}
if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-') {
seen_alpha = true;
continue;
}
break;
}
false
}
fn split_path_suffix(value: &str) -> (&str, &str) {
let split_index = value.find(['?', '#']).unwrap_or(value.len());
(&value[..split_index], &value[split_index..])
}
fn normalize_day_route(path: &str) -> Option<String> {
let segments: Vec<_> = path.trim_start_matches('/').split('/').collect();
if segments.len() != 4 {
return None;
}
let [year, month, day, slug] = segments.as_slice() else {
return None;
};
if year.len() != 4 || !year.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let month = month.parse::<u32>().ok()?;
let day = day.parse::<u32>().ok()?;
if slug.is_empty() {
return None;
}
Some(format!("/{year}/{month:02}/{day:02}/{slug}"))
}
fn extract_post_slug(path: &str) -> Option<String> {
let trimmed = path.trim_start_matches('/');
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()) => {
Some(trim_html_suffix(slug))
}
_ => None,
}
}
fn extract_media_source_key(path: &str) -> Option<String> {
let trimmed = path.trim_start_matches('/');
let segments: Vec<_> = trimmed.split('/').collect();
match segments.as_slice() {
["media", year, month, filename] if year.len() == 4 && month.len() == 2 => {
Some(format!("media/{year}/{month}/{}", filename.to_lowercase()))
}
_ => None,
}
}
fn trim_html_suffix(value: &str) -> String {
value
.trim_end_matches(".html")
.trim_end_matches(".htm")
.to_string()
}

View File

@@ -0,0 +1,474 @@
use std::collections::HashMap;
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
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");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
pub relative_path: String,
pub html: String,
}
#[derive(Debug, Clone, Serialize)]
struct AlternateLinkContext {
href: String,
hreflang: String,
}
#[derive(Debug, Clone, Serialize)]
struct BlogLanguageContext {
is_current: bool,
code: String,
flag: String,
href: String,
href_prefix: String,
}
#[derive(Debug, Clone, Serialize)]
struct DayBlockContext {
show_date_marker: bool,
date_label: String,
posts: Vec<serde_json::Value>,
show_separator: bool,
}
#[derive(Debug, Clone, Serialize)]
struct ListTemplateContext {
language: String,
language_prefix: String,
page_title: String,
pico_stylesheet_href: Option<String>,
html_theme_attribute: Option<String>,
alternate_links: Vec<AlternateLinkContext>,
blog_languages: Vec<BlogLanguageContext>,
menu_items: Vec<serde_json::Value>,
calendar_initial_year: i32,
calendar_initial_month: u32,
archive_context: Option<serde_json::Value>,
show_archive_range_heading: bool,
min_date: Option<serde_json::Value>,
max_date: Option<serde_json::Value>,
day_blocks: Vec<DayBlockContext>,
is_list_page: bool,
is_first_page: bool,
is_last_page: bool,
has_prev_page: bool,
has_next_page: bool,
prev_page_href: Option<String>,
next_page_href: Option<String>,
canonical_post_path_by_slug: HashMap<String, String>,
canonical_media_path_by_source_path: HashMap<String, String>,
post_data_json_by_id: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize)]
struct PostTemplateContext<'a> {
language: &'a str,
language_prefix: String,
page_title: &'a str,
pico_stylesheet_href: Option<String>,
html_theme_attribute: Option<String>,
alternate_links: Vec<AlternateLinkContext>,
blog_languages: Vec<BlogLanguageContext>,
menu_items: Vec<serde_json::Value>,
calendar_initial_year: i32,
calendar_initial_month: u32,
post: serde_json::Value,
post_categories: Vec<String>,
post_tags: Vec<String>,
tag_color_by_name: HashMap<String, String>,
backlinks: Vec<serde_json::Value>,
canonical_post_path_by_slug: HashMap<String, String>,
canonical_media_path_by_source_path: HashMap<String, String>,
post_data_json_by_id: HashMap<String, String>,
}
pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &str) -> String {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
return fallback_language_path(post, language, main_language);
};
let base = format!(
"/{:04}/{:02}/{:02}/{}",
timestamp.year(),
timestamp.month(),
timestamp.day(),
post.slug
);
if language.eq_ignore_ascii_case(main_language) {
base
} else {
format!("/{language}{base}")
}
}
pub fn render_starter_single_post_page(
post: &Post,
body_markdown: &str,
metadata: &ProjectMetadata,
language: &str,
) -> Result<RenderedPage, RenderError> {
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 {
language,
language_prefix: language_prefix(language, main_language(metadata)),
page_title: &post.title,
pico_stylesheet_href: metadata
.pico_theme
.as_ref()
.map(|_| "/assets/pico.min.css".to_string()),
html_theme_attribute: None,
alternate_links: build_alternate_links(post, metadata, language),
blog_languages: build_blog_languages(post, metadata, language),
menu_items: vec![],
calendar_initial_year,
calendar_initial_month,
post: serde_json::json!({
"id": post.id,
"title": post.title,
"content": body_markdown,
}),
post_categories: post.categories.clone(),
post_tags: post.tags.clone(),
tag_color_by_name: post
.tags
.iter()
.map(|tag| (tag.clone(), String::new()))
.collect(),
backlinks: vec![],
canonical_post_path_by_slug: HashMap::from([(post.slug.clone(), canonical_path)]),
canonical_media_path_by_source_path: HashMap::new(),
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
STARTER_SINGLE_POST_TEMPLATE,
&starter_partials(),
&context,
)?;
Ok(RenderedPage { relative_path, html })
}
pub fn render_starter_list_page(
posts: &[(Post, String)],
metadata: &ProjectMetadata,
language: &str,
) -> Result<RenderedPage, RenderError> {
let relative_path = if language.eq_ignore_ascii_case(main_language(metadata)) {
"index.html".to_string()
} else {
format!("{language}/index.html")
};
let canonical_paths = posts
.iter()
.map(|(post, _)| {
(
post.slug.clone(),
build_canonical_post_path(post, language, main_language(metadata)),
)
})
.collect::<HashMap<_, _>>();
let (calendar_initial_year, calendar_initial_month) = posts
.first()
.map(|(post, _)| calendar_initial_parts(post))
.unwrap_or((1970, 1));
let context = ListTemplateContext {
language: language.to_string(),
language_prefix: language_prefix(language, main_language(metadata)),
page_title: metadata.name.clone(),
pico_stylesheet_href: metadata
.pico_theme
.as_ref()
.map(|_| "/assets/pico.min.css".to_string()),
html_theme_attribute: None,
alternate_links: vec![],
blog_languages: build_blog_languages_for_index(metadata, language),
menu_items: vec![],
calendar_initial_year,
calendar_initial_month,
archive_context: None,
show_archive_range_heading: false,
min_date: None,
max_date: None,
day_blocks: build_day_blocks(posts),
is_list_page: false,
is_first_page: true,
is_last_page: true,
has_prev_page: false,
has_next_page: false,
prev_page_href: None,
next_page_href: None,
canonical_post_path_by_slug: canonical_paths,
canonical_media_path_by_source_path: HashMap::new(),
post_data_json_by_id: HashMap::new(),
};
let html = render_liquid_template(
&starter_post_list_template(),
&starter_partials(),
&context,
)?;
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/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(),
),
])
}
fn starter_post_list_template() -> String {
STARTER_POST_LIST_TEMPLATE.replace(
"{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}",
"{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}",
)
}
fn main_language(metadata: &ProjectMetadata) -> &str {
metadata.main_language.as_deref().unwrap_or("en")
}
fn language_prefix(language: &str, main_language: &str) -> String {
if language.eq_ignore_ascii_case(main_language) {
String::new()
} else {
format!("/{language}")
}
}
fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> String {
if language.eq_ignore_ascii_case(main_language) {
format!("/posts/{}", post.slug)
} else {
format!("/{language}/posts/{}", post.slug)
}
}
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
Utc.timestamp_millis_opt(timestamp_ms)
.single()
.map(|timestamp| (timestamp.year(), timestamp.month()))
.unwrap_or((1970, 1))
}
fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<AlternateLinkContext> {
metadata
.blog_languages
.iter()
.map(|language| AlternateLinkContext {
href: build_absolute_post_url(post, metadata, language),
hreflang: language.clone(),
})
.chain(std::iter::once(AlternateLinkContext {
href: build_absolute_post_url(post, metadata, current_language),
hreflang: "x-default".to_string(),
}))
.collect()
}
fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
.map(|language| BlogLanguageContext {
is_current: language.eq_ignore_ascii_case(current_language),
code: language.clone(),
flag: render_flag(language),
href: build_absolute_post_url(post, metadata, language),
href_prefix: language_prefix(language, main_language(metadata)),
})
.collect()
}
fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec<BlogLanguageContext> {
metadata
.blog_languages
.iter()
.map(|language| BlogLanguageContext {
is_current: language.eq_ignore_ascii_case(current_language),
code: language.clone(),
flag: render_flag(language),
href: build_absolute_index_url(metadata, language),
href_prefix: language_prefix(language, main_language(metadata)),
})
.collect()
}
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)))
}
fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String {
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 {
format!("/{language}/")
};
format!("{base_url}{suffix}")
}
fn render_flag(language: &str) -> String {
normalize_language(language).flag_emoji().to_string()
}
fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
let mut blocks: Vec<DayBlockContext> = Vec::new();
let mut current_key: Option<String> = None;
for (post, body) in posts {
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
continue;
};
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;
}
current_key = Some(key);
blocks.push(DayBlockContext {
show_date_marker: true,
date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()),
posts: Vec::new(),
show_separator: false,
});
}
if let Some(block) = blocks.last_mut() {
block.posts.push(serde_json::json!({
"id": post.id,
"slug": post.slug,
"title": post.title,
"content": post.excerpt.clone().unwrap_or_else(|| body.clone()),
"show_title": true,
}));
}
}
blocks
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::PostStatus;
fn make_post() -> Post {
Post {
id: "post-1".into(),
project_id: "project-1".into(),
title: "Hello".into(),
slug: "hello".into(),
excerpt: None,
content: Some("Body".into()),
status: PostStatus::Published,
author: None,
language: Some("en".into()),
do_not_translate: false,
template_slug: None,
file_path: String::new(),
checksum: None,
tags: vec![],
categories: vec![],
published_title: None,
published_content: None,
published_tags: None,
published_categories: None,
published_excerpt: None,
created_at: 1_710_000_000_000,
updated_at: 1_710_000_000_000,
published_at: Some(1_710_000_000_000),
}
}
fn make_metadata() -> ProjectMetadata {
ProjectMetadata {
name: "Blog".into(),
description: None,
public_url: Some("https://example.com".into()),
main_language: Some("en".into()),
default_author: None,
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
semantic_similarity_enabled: false,
blog_languages: vec!["en".into(), "de".into()],
}
}
#[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");
}
#[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();
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("href=\"/2024/03/09/hello\""));
}
#[test]
fn starter_list_renderer_groups_posts_and_uses_language_specific_index_path() {
let metadata = make_metadata();
let first = make_post();
let mut second = make_post();
second.id = "post-2".into();
second.slug = "next".into();
second.title = "Next".into();
second.published_at = Some(1_710_086_400_000);
second.created_at = 1_710_086_400_000;
let rendered = render_starter_list_page(
&[(first, "First body".into()), (second, "Second body".into())],
&metadata,
"de",
)
.unwrap();
assert_eq!(rendered.relative_path, "de/index.html");
assert!(rendered.html.contains("archive-day-group"));
assert!(rendered.html.contains("09.03.2024"));
assert!(rendered.html.contains("10.03.2024"));
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
}
}

View File

@@ -0,0 +1,75 @@
use std::collections::HashMap;
use crate::model::{Post, Tag, Template, TemplateKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderCategorySettings {
pub post_template_slug: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RenderTemplateLookup<'a> {
pub post: &'a Post,
pub templates: &'a [Template],
pub tags: &'a [Tag],
pub category_settings: &'a HashMap<String, RenderCategorySettings>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateLookupError {
MissingExplicitTemplate(String),
MissingDefaultTemplate,
}
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()));
}
for post_tag in &lookup.post.tags {
if let Some(template_slug) = lookup
.tags
.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
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
}
}
for category_name in &lookup.post.categories {
if let Some(template_slug) = lookup
.category_settings
.get(category_name)
.and_then(|settings| settings.post_template_slug.as_deref())
{
if let Some(template) = lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, template_slug))
{
return Ok(template);
}
}
}
lookup
.templates
.iter()
.find(|template| is_enabled_post_template(template, "post"))
.ok_or(TemplateLookupError::MissingDefaultTemplate)
}
fn is_enabled_post_template(template: &Template, slug: &str) -> bool {
template.enabled && template.kind == TemplateKind::Post && template.slug == slug
}