feat: first take at M4
This commit is contained in:
104
crates/bds-core/src/render/generation.rs
Normal file
104
crates/bds-core/src/render/generation.rs
Normal 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))
|
||||
}
|
||||
25
crates/bds-core/src/render/markdown.rs
Normal file
25
crates/bds-core/src/render/markdown.rs
Normal 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>"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
335
crates/bds-core/src/render/page_renderer.rs
Normal file
335
crates/bds-core/src/render/page_renderer.rs
Normal 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()
|
||||
}
|
||||
474
crates/bds-core/src/render/routes.rs
Normal file
474
crates/bds-core/src/render/routes.rs
Normal 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\""));
|
||||
}
|
||||
}
|
||||
75
crates/bds-core/src/render/template_lookup.rs
Normal file
75
crates/bds-core/src/render/template_lookup.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user