feat: more M4 closing
This commit is contained in:
@@ -7,6 +7,7 @@ use pagefind::options::PagefindServiceConfig;
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
|
use crate::engine::site_assets::write_bundled_site_assets;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Post, ProjectMetadata};
|
use crate::model::{Post, ProjectMetadata};
|
||||||
use crate::render::{
|
use crate::render::{
|
||||||
@@ -33,7 +34,7 @@ pub fn generate_starter_site(
|
|||||||
project_id: &str,
|
project_id: &str,
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
posts: &[PublishedPostSource],
|
posts: &[PublishedPostSource],
|
||||||
language: &str,
|
_language: &str,
|
||||||
) -> EngineResult<GenerationReport> {
|
) -> EngineResult<GenerationReport> {
|
||||||
let mut report = GenerationReport::default();
|
let mut report = GenerationReport::default();
|
||||||
let input_posts = posts
|
let input_posts = posts
|
||||||
@@ -47,6 +48,8 @@ pub fn generate_starter_site(
|
|||||||
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
||||||
|
|
||||||
write_out(
|
write_out(
|
||||||
conn,
|
conn,
|
||||||
output_dir,
|
output_dir,
|
||||||
@@ -69,7 +72,14 @@ pub fn generate_starter_site(
|
|||||||
}
|
}
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
|
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?;
|
write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?;
|
||||||
write_out(conn, output_dir, project_id, &format!("{prefix}sitemap.xml"), &build_sitemap_xml(metadata, &localized_posts, &render_language), &mut report)?;
|
write_out(
|
||||||
|
conn,
|
||||||
|
output_dir,
|
||||||
|
project_id,
|
||||||
|
&format!("{prefix}sitemap.xml"),
|
||||||
|
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
|
||||||
|
&mut report,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
|
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
|
||||||
@@ -259,6 +269,8 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
|||||||
|
|
||||||
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], 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 main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||||
|
let feed_prefix = language_prefix(language, main_language);
|
||||||
let updated = posts
|
let updated = posts
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||||
@@ -270,8 +282,8 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
|
|||||||
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
||||||
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
||||||
format!(" <id>{base_url}/</id>"),
|
format!(" <id>{base_url}/</id>"),
|
||||||
format!(" <link href=\"{base_url}/\" rel=\"alternate\" />"),
|
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
||||||
format!(" <link href=\"{base_url}/atom.xml\" rel=\"self\" />"),
|
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
|
||||||
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -303,36 +315,74 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
|
|||||||
xml.join("\n")
|
xml.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
fn build_sitemap_xml(
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
pages: &[crate::render::SitePage],
|
||||||
|
posts: &[PublishedPostSource],
|
||||||
|
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 main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||||
|
let languages = render_languages(metadata);
|
||||||
let index_lastmod = posts
|
let index_lastmod = posts
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or_else(Utc::now)
|
.unwrap_or_else(Utc::now)
|
||||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||||
|
let post_lastmod_by_path = posts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|source| {
|
||||||
|
timestamp(source.post.published_at.unwrap_or(source.post.created_at)).map(|lastmod| {
|
||||||
|
(
|
||||||
|
build_canonical_post_path(&source.post, language, main_language),
|
||||||
|
lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let page_groups = group_pages_by_logical_path(pages, &languages, main_language);
|
||||||
|
|
||||||
let mut xml = vec![
|
let mut xml = vec![
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
|
||||||
" <url>".to_string(),
|
|
||||||
format!(" <loc>{base_url}/</loc>"),
|
|
||||||
format!(" <lastmod>{index_lastmod}</lastmod>"),
|
|
||||||
" <changefreq>daily</changefreq>".to_string(),
|
|
||||||
" <priority>1.0</priority>".to_string(),
|
|
||||||
" </url>".to_string(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for source in posts {
|
for page in pages.iter().filter(|page| page.language == language) {
|
||||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
let url = format!("{base_url}{}", page.url_path);
|
||||||
let lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
let logical_key = logical_page_key(&page.relative_path, &languages, main_language);
|
||||||
.unwrap_or_else(Utc::now)
|
let alternates = page_groups.get(&logical_key);
|
||||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
let lastmod = post_lastmod_by_path
|
||||||
|
.get(&page.url_path)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| index_lastmod.clone());
|
||||||
|
let is_home = page.url_path == language_root_url_path(language, main_language);
|
||||||
xml.push(" <url>".to_string());
|
xml.push(" <url>".to_string());
|
||||||
xml.push(format!(" <loc>{url}</loc>"));
|
xml.push(format!(" <loc>{url}</loc>"));
|
||||||
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
||||||
xml.push(" <changefreq>weekly</changefreq>".to_string());
|
xml.push(format!(
|
||||||
xml.push(" <priority>0.8</priority>".to_string());
|
" <changefreq>{}</changefreq>",
|
||||||
|
if is_home { "daily" } else { "weekly" }
|
||||||
|
));
|
||||||
|
xml.push(format!(
|
||||||
|
" <priority>{}</priority>",
|
||||||
|
if is_home { "1.0" } else { "0.8" }
|
||||||
|
));
|
||||||
|
if let Some(alternates) = alternates {
|
||||||
|
for alternate in alternates {
|
||||||
|
xml.push(format!(
|
||||||
|
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{base_url}{}\" />",
|
||||||
|
escape_xml(&alternate.language),
|
||||||
|
alternate.url_path,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) {
|
||||||
|
xml.push(format!(
|
||||||
|
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
|
||||||
|
default_page.url_path,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
xml.push(" </url>".to_string());
|
xml.push(" </url>".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +390,50 @@ fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource],
|
|||||||
xml.join("\n")
|
xml.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn language_prefix(language: &str, main_language: &str) -> String {
|
||||||
|
if language.eq_ignore_ascii_case(main_language) {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("/{language}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn language_root_url_path(language: &str, main_language: &str) -> String {
|
||||||
|
let prefix = language_prefix(language, main_language);
|
||||||
|
if prefix.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_pages_by_logical_path<'a>(
|
||||||
|
pages: &'a [crate::render::SitePage],
|
||||||
|
languages: &[String],
|
||||||
|
main_language: &str,
|
||||||
|
) -> HashMap<String, Vec<&'a crate::render::SitePage>> {
|
||||||
|
let mut grouped = HashMap::<String, Vec<&crate::render::SitePage>>::new();
|
||||||
|
for page in pages {
|
||||||
|
let key = logical_page_key(&page.relative_path, languages, main_language);
|
||||||
|
grouped.entry(key).or_default().push(page);
|
||||||
|
}
|
||||||
|
grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logical_page_key(relative_path: &str, languages: &[String], main_language: &str) -> String {
|
||||||
|
let mut parts = relative_path.split('/');
|
||||||
|
let Some(first) = parts.next() else {
|
||||||
|
return relative_path.to_string();
|
||||||
|
};
|
||||||
|
if first.eq_ignore_ascii_case(main_language) {
|
||||||
|
return parts.collect::<Vec<_>>().join("/");
|
||||||
|
}
|
||||||
|
if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) {
|
||||||
|
return parts.collect::<Vec<_>>().join("/");
|
||||||
|
}
|
||||||
|
relative_path.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn timestamp(timestamp_ms: i64) -> Option<DateTime<Utc>> {
|
fn timestamp(timestamp_ms: i64) -> Option<DateTime<Utc>> {
|
||||||
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod script;
|
|||||||
pub mod script_rebuild;
|
pub mod script_rebuild;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod metadata_diff;
|
pub mod metadata_diff;
|
||||||
|
pub mod site_assets;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use rusqlite::Connection;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::project as q;
|
use crate::db::queries::project as q;
|
||||||
|
use crate::engine::site_assets::copy_bundled_site_assets;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
use crate::model::metadata::ProjectMetadata;
|
use crate::model::metadata::ProjectMetadata;
|
||||||
@@ -152,7 +153,7 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
|||||||
// ── helpers ──────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
||||||
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts"];
|
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"];
|
||||||
for sub in &subdirs {
|
for sub in &subdirs {
|
||||||
fs::create_dir_all(data_dir.join(sub))?;
|
fs::create_dir_all(data_dir.join(sub))?;
|
||||||
}
|
}
|
||||||
@@ -200,6 +201,7 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
|
|||||||
|
|
||||||
// Starter templates — per project.allium StarterTemplatesCopied
|
// Starter templates — per project.allium StarterTemplatesCopied
|
||||||
copy_starter_templates(data_dir)?;
|
copy_starter_templates(data_dir)?;
|
||||||
|
copy_bundled_site_assets(data_dir)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -274,6 +276,9 @@ mod tests {
|
|||||||
assert!(data_path.join("thumbnails").is_dir());
|
assert!(data_path.join("thumbnails").is_dir());
|
||||||
assert!(data_path.join("templates").is_dir());
|
assert!(data_path.join("templates").is_dir());
|
||||||
assert!(data_path.join("scripts").is_dir());
|
assert!(data_path.join("scripts").is_dir());
|
||||||
|
assert!(data_path.join("assets").is_dir());
|
||||||
|
assert!(data_path.join("assets/pico.min.css").is_file());
|
||||||
|
assert!(data_path.join("assets/tag-cloud.js").is_file());
|
||||||
|
|
||||||
// Verify meta files
|
// Verify meta files
|
||||||
let project_json: serde_json::Value =
|
let project_json: serde_json::Value =
|
||||||
|
|||||||
79
crates/bds-core/src/engine/site_assets.rs
Normal file
79
crates/bds-core/src/engine/site_assets.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use crate::engine::{EngineError, EngineResult};
|
||||||
|
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct BundledSiteAsset {
|
||||||
|
pub relative_path: &'static str,
|
||||||
|
pub bytes: &'static [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
||||||
|
BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
||||||
|
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||||
|
for asset in BUNDLED_SITE_ASSETS {
|
||||||
|
let target = project_dir.join(asset.relative_path);
|
||||||
|
if target.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(target, asset.bytes)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_bundled_site_assets(
|
||||||
|
conn: &Connection,
|
||||||
|
output_dir: &Path,
|
||||||
|
project_id: &str,
|
||||||
|
report: &mut crate::engine::generation::GenerationReport,
|
||||||
|
) -> EngineResult<()> {
|
||||||
|
for asset in BUNDLED_SITE_ASSETS {
|
||||||
|
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes)
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||||
|
{
|
||||||
|
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()),
|
||||||
|
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
466
crates/bds-core/src/render/macros.rs
Normal file
466
crates/bds-core/src/render/macros.rs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use serde_json::{Map, Value as JsonValue};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct MacroRenderContext {
|
||||||
|
pub roots: Map<String, JsonValue>,
|
||||||
|
pub post_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String {
|
||||||
|
let mut expanded = String::with_capacity(markdown.len());
|
||||||
|
let mut cursor = 0;
|
||||||
|
|
||||||
|
while let Some(offset) = markdown[cursor..].find("[[") {
|
||||||
|
let start = cursor + offset;
|
||||||
|
expanded.push_str(&markdown[cursor..start]);
|
||||||
|
|
||||||
|
let body_start = start + 2;
|
||||||
|
let Some(end_offset) = markdown[body_start..].find("]]") else {
|
||||||
|
expanded.push_str(&markdown[start..]);
|
||||||
|
return expanded;
|
||||||
|
};
|
||||||
|
let end = body_start + end_offset;
|
||||||
|
let invocation = &markdown[body_start..end];
|
||||||
|
let raw = &markdown[start..end + 2];
|
||||||
|
|
||||||
|
if let Some(rendered) = render_macro(invocation, context) {
|
||||||
|
expanded.push_str(&rendered);
|
||||||
|
} else {
|
||||||
|
expanded.push_str(raw);
|
||||||
|
}
|
||||||
|
cursor = end + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded.push_str(&markdown[cursor..]);
|
||||||
|
expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String> {
|
||||||
|
let tokens = tokenize_invocation(invocation);
|
||||||
|
let name = tokens.first()?.as_str();
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
for token in tokens.iter().skip(1) {
|
||||||
|
let (key, raw_value) = token.split_once('=')?;
|
||||||
|
args.insert(key.to_string(), resolve_token(raw_value, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
match name {
|
||||||
|
"gallery" => Some(render_gallery(&args, context)),
|
||||||
|
"youtube" => Some(render_youtube(&args)),
|
||||||
|
"vimeo" => Some(render_vimeo(&args)),
|
||||||
|
"photo_archive" => Some(render_photo_archive(&args, context)),
|
||||||
|
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut quote = None;
|
||||||
|
|
||||||
|
for ch in invocation.chars() {
|
||||||
|
if let Some(active_quote) = quote {
|
||||||
|
current.push(ch);
|
||||||
|
if ch == active_quote {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'"' | '\'' => {
|
||||||
|
quote = Some(ch);
|
||||||
|
current.push(ch);
|
||||||
|
}
|
||||||
|
' ' | '\n' | '\t' | '\r' => {
|
||||||
|
if !current.is_empty() {
|
||||||
|
tokens.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.is_empty() {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match trimmed {
|
||||||
|
"true" => return JsonValue::Bool(true),
|
||||||
|
"false" => return JsonValue::Bool(false),
|
||||||
|
"null" => return JsonValue::Null,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = trimmed.parse::<i64>() {
|
||||||
|
return JsonValue::from(value);
|
||||||
|
}
|
||||||
|
if let Ok(value) = trimmed.parse::<f64>() {
|
||||||
|
return JsonValue::from(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup_path(trimmed, context).unwrap_or_else(|| JsonValue::String(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_path(path: &str, context: &MacroRenderContext) -> Option<JsonValue> {
|
||||||
|
let mut segments = path.split('.');
|
||||||
|
let first = segments.next()?;
|
||||||
|
let mut current = context.roots.get(first)?.clone();
|
||||||
|
|
||||||
|
for segment in segments {
|
||||||
|
current = match current {
|
||||||
|
JsonValue::Object(map) => map.get(segment)?.clone(),
|
||||||
|
JsonValue::Array(values) => values.get(segment.parse::<usize>().ok()?)?.clone(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||||
|
let columns = args
|
||||||
|
.get("columns")
|
||||||
|
.and_then(value_as_u64)
|
||||||
|
.map(|value| value.clamp(1, 6) as usize)
|
||||||
|
.unwrap_or(3);
|
||||||
|
let images = args
|
||||||
|
.get("images")
|
||||||
|
.and_then(JsonValue::as_array)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| linked_media(context));
|
||||||
|
|
||||||
|
let Some(images) = images else {
|
||||||
|
return empty_block(
|
||||||
|
&format!("macro-gallery gallery-cols-{columns}"),
|
||||||
|
"gallery-empty",
|
||||||
|
"No media available.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if images.is_empty() {
|
||||||
|
return empty_block(
|
||||||
|
&format!("macro-gallery gallery-cols-{columns}"),
|
||||||
|
"gallery-empty",
|
||||||
|
"No media available.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gallery_id = context.post_id.as_deref().unwrap_or("gallery");
|
||||||
|
let mut html = format!(
|
||||||
|
"<section class=\"macro-gallery gallery-cols-{columns}\"><div class=\"gallery-container\">"
|
||||||
|
);
|
||||||
|
for image in images {
|
||||||
|
let Some(path) = image_path(&image) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let title = image_title(&image);
|
||||||
|
let alt = image_alt(&image, title.as_deref());
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
|
||||||
|
escape_html_attr(&path),
|
||||||
|
escape_html_attr(gallery_id),
|
||||||
|
title
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
escape_html_attr(&path),
|
||||||
|
escape_html_attr(&alt),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</div></section>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
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>",
|
||||||
|
escape_html_attr(&video_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_vimeo(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-vimeo", "gallery-empty", "Missing Vimeo video id.");
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"<section class=\"macro-vimeo\"><iframe src=\"https://player.vimeo.com/video/{}\" title=\"Vimeo video\" loading=\"lazy\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></section>",
|
||||||
|
escape_html_attr(&video_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||||
|
let media = args
|
||||||
|
.get("media")
|
||||||
|
.and_then(JsonValue::as_array)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| linked_media(context));
|
||||||
|
|
||||||
|
let Some(media) = media else {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
|
||||||
|
for item in media {
|
||||||
|
let bucket = item
|
||||||
|
.get("file_path")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.and_then(month_bucket)
|
||||||
|
.unwrap_or_else(|| "Archive".to_string());
|
||||||
|
grouped.entry(bucket).or_default().push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { "" }
|
||||||
|
);
|
||||||
|
for (bucket, items) in grouped.into_iter().rev() {
|
||||||
|
html.push_str("<section class=\"photo-archive-month\">");
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<header class=\"photo-archive-month-label\"><span>{}</span></header>",
|
||||||
|
escape_html(&bucket),
|
||||||
|
));
|
||||||
|
html.push_str("<div class=\"photo-archive-gallery\">");
|
||||||
|
for item in items {
|
||||||
|
let Some(path) = image_path(&item) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let title = image_title(&item);
|
||||||
|
let alt = image_alt(&item, title.as_deref());
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<a class=\"photo-archive-item\" href=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
|
||||||
|
escape_html_attr(&path),
|
||||||
|
title
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
escape_html_attr(&path),
|
||||||
|
escape_html_attr(&alt),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</div></section>");
|
||||||
|
}
|
||||||
|
html.push_str("</div></section>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||||
|
let tags = args
|
||||||
|
.get("tags")
|
||||||
|
.and_then(JsonValue::as_array)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| tag_items(context));
|
||||||
|
|
||||||
|
let Some(tags) = tags else {
|
||||||
|
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||||
|
};
|
||||||
|
if tags.is_empty() {
|
||||||
|
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let words = build_tag_cloud_words(&tags, context);
|
||||||
|
if words.is_empty() {
|
||||||
|
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
format!(
|
||||||
|
"<section class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-color-distribution=\"quantile\" data-color-theme=\"pico\" data-color-easing=\"0.7\" data-width=\"900\" data-height=\"420\" data-orientation=\"mixed-diagonal\" data-tag-cloud-words='{}'><svg class=\"tag-cloud-canvas\" aria-label=\"Tag cloud\"></svg></section>",
|
||||||
|
escape_html_attr(&words_json),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Vec<JsonValue> {
|
||||||
|
let min_count = tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
||||||
|
.min()
|
||||||
|
.unwrap_or(1);
|
||||||
|
let max_count = tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(min_count.max(1));
|
||||||
|
|
||||||
|
tags.iter()
|
||||||
|
.filter_map(|tag| {
|
||||||
|
let name = tag.get("name").and_then(JsonValue::as_str)?;
|
||||||
|
let slug = tag
|
||||||
|
.get("slug")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| name.to_lowercase().replace(' ', "-"));
|
||||||
|
let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
|
||||||
|
let size = if max_count == min_count {
|
||||||
|
36.0
|
||||||
|
} else {
|
||||||
|
18.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 38.0)
|
||||||
|
};
|
||||||
|
let color = tag
|
||||||
|
.get("color")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
context
|
||||||
|
.roots
|
||||||
|
.get("tag_color_by_name")
|
||||||
|
.and_then(JsonValue::as_object)
|
||||||
|
.and_then(|colors| colors.get(name))
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
});
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"text": name,
|
||||||
|
"count": count,
|
||||||
|
"url": format!("/tag/{slug}"),
|
||||||
|
"size": size.round(),
|
||||||
|
"color": color,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
||||||
|
lookup_path("post.linked_media", context).and_then(|value| value.as_array().cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
||||||
|
lookup_path("post_tags", context)
|
||||||
|
.and_then(|value| value.as_array().cloned())
|
||||||
|
.or_else(|| lookup_path("Tags", context).and_then(|value| value.as_array().cloned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_path(image: &JsonValue) -> Option<String> {
|
||||||
|
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_title(image: &JsonValue) -> Option<String> {
|
||||||
|
image
|
||||||
|
.get("caption")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
image
|
||||||
|
.get("title")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
|
||||||
|
image
|
||||||
|
.get("alt")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| fallback.map(ToOwned::to_owned))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_as_u64(value: &JsonValue) -> Option<u64> {
|
||||||
|
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stringify_scalar(value: &JsonValue) -> String {
|
||||||
|
match value {
|
||||||
|
JsonValue::Null => String::new(),
|
||||||
|
JsonValue::Bool(boolean) => boolean.to_string(),
|
||||||
|
JsonValue::Number(number) => number.to_string(),
|
||||||
|
JsonValue::String(text) => text.clone(),
|
||||||
|
JsonValue::Array(_) | JsonValue::Object(_) => value.to_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}")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"<section class=\"{}\"><p class=\"{}\">{}</p></section>",
|
||||||
|
wrapper_class,
|
||||||
|
message_class,
|
||||||
|
escape_html(message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html_attr(value: &str) -> String {
|
||||||
|
escape_html(value).replace('"', """).replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{MacroRenderContext, expand_builtin_macros};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_gallery_and_tag_cloud_macros() {
|
||||||
|
let mut roots = serde_json::Map::new();
|
||||||
|
roots.insert(
|
||||||
|
"post".to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"linked_media": [
|
||||||
|
{"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Alt one"},
|
||||||
|
{"file_path": "/media/2026/04/two.jpg", "caption": "Two"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
roots.insert(
|
||||||
|
"post_tags".to_string(),
|
||||||
|
serde_json::json!([
|
||||||
|
{"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"},
|
||||||
|
{"name": "Iced", "slug": "iced", "post_count": 2}
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
roots.insert(
|
||||||
|
"tag_color_by_name".to_string(),
|
||||||
|
serde_json::json!({"Iced": "#0088cc"}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let html = expand_builtin_macros(
|
||||||
|
"[[gallery images=post.linked_media columns=2]]\n\n[[tag_cloud tags=post_tags]]",
|
||||||
|
&MacroRenderContext {
|
||||||
|
roots,
|
||||||
|
post_id: Some("post-1".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(html.contains("macro-gallery gallery-cols-2"));
|
||||||
|
assert!(html.contains("data-lightbox=\"post-1\""));
|
||||||
|
assert!(html.contains("macro-tag-cloud"));
|
||||||
|
assert!(html.contains("data-tag-cloud=\"true\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod markdown;
|
mod markdown;
|
||||||
|
mod macros;
|
||||||
mod generation;
|
mod generation;
|
||||||
mod page_renderer;
|
mod page_renderer;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ use liquid_core::{
|
|||||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
||||||
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
||||||
};
|
};
|
||||||
|
use liquid_core::model::ScalarCow;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::i18n::translate_render;
|
use crate::i18n::translate_render;
|
||||||
|
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
||||||
use crate::render::render_markdown_to_html;
|
use crate::render::render_markdown_to_html;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -123,12 +126,70 @@ impl Filter for MarkdownFilter {
|
|||||||
.map(value_to_string_map)
|
.map(value_to_string_map)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
|
let macro_context = MacroRenderContext {
|
||||||
|
roots: collect_macro_roots(runtime),
|
||||||
|
post_id: runtime
|
||||||
|
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
||||||
|
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
let rendered = render_markdown_to_html(markdown.as_str());
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||||
|
let mut roots = JsonMap::new();
|
||||||
|
|
||||||
|
for key in ["post", "post_tags", "tag_color_by_name", "project", "Tags"] {
|
||||||
|
if let Some(value) = runtime.try_get(&[ScalarCow::new(key)]) {
|
||||||
|
roots.insert(key.to_string(), liquid_value_to_json(value.as_view()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !roots.contains_key("Tags") {
|
||||||
|
if let Some(tags) = roots.get("post_tags").cloned() {
|
||||||
|
roots.insert("Tags".to_string(), tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liquid_value_to_json(value: &dyn ValueView) -> JsonValue {
|
||||||
|
if value.is_nil() {
|
||||||
|
return JsonValue::Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(scalar) = value.as_scalar() {
|
||||||
|
if let Some(boolean) = scalar.to_bool() {
|
||||||
|
return JsonValue::Bool(boolean);
|
||||||
|
}
|
||||||
|
if let Some(integer) = scalar.to_integer() {
|
||||||
|
return JsonValue::from(integer);
|
||||||
|
}
|
||||||
|
if let Some(float) = scalar.to_float() {
|
||||||
|
return JsonValue::from(float);
|
||||||
|
}
|
||||||
|
return JsonValue::String(scalar.to_kstr().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(array) = value.as_array() {
|
||||||
|
return JsonValue::Array(array.values().map(liquid_value_to_json).collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(object) = value.as_object() {
|
||||||
|
let mapped = object
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.to_string(), liquid_value_to_json(value)))
|
||||||
|
.collect();
|
||||||
|
return JsonValue::Object(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonValue::String(value.to_kstr().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
||||||
value
|
value
|
||||||
.as_object()
|
.as_object()
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
|||||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||||
|
assert!(report.written_paths.contains(&"assets/pico.min.css".to_string()));
|
||||||
|
assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string()));
|
||||||
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
||||||
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
||||||
|
|
||||||
@@ -102,6 +104,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
|||||||
assert!(dir.path().join("feed.xml").exists());
|
assert!(dir.path().join("feed.xml").exists());
|
||||||
assert!(dir.path().join("atom.xml").exists());
|
assert!(dir.path().join("atom.xml").exists());
|
||||||
assert!(dir.path().join("sitemap.xml").exists());
|
assert!(dir.path().join("sitemap.xml").exists());
|
||||||
|
assert!(dir.path().join("assets/pico.min.css").exists());
|
||||||
|
assert!(dir.path().join("assets/tag-cloud.js").exists());
|
||||||
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
||||||
|
|
||||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||||
@@ -110,6 +114,34 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
|||||||
|
|
||||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||||
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
||||||
|
assert!(sitemap.contains("https://example.com/category/article"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
let mut metadata = make_metadata();
|
||||||
|
metadata.main_language = Some("de".into());
|
||||||
|
metadata.blog_languages = vec!["de".into(), "en".into()];
|
||||||
|
let posts = vec![PublishedPostSource {
|
||||||
|
post: make_post("hallo", 1_710_000_000_000),
|
||||||
|
body_markdown: "Hallo Welt".into(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
|
||||||
|
|
||||||
|
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
|
||||||
|
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
||||||
|
|
||||||
|
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
|
||||||
|
assert!(atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />") || atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />"));
|
||||||
|
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
|
||||||
|
|
||||||
|
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||||
|
assert!(sitemap.contains("hreflang=\"de\" href=\"https://example.com/\""));
|
||||||
|
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en\""));
|
||||||
|
assert!(sitemap.contains("hreflang=\"x-default\" href=\"https://example.com/\""));
|
||||||
|
assert!(sitemap.contains("https://example.com/category/article"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -129,6 +161,7 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
|||||||
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
||||||
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||||
|
assert!(second.skipped_paths.contains(&"assets/pico.min.css".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -56,6 +56,41 @@ fn markdown_filter_rewrites_post_and_media_urls() {
|
|||||||
assert!(rendered.contains("src=\"/assets/pic.png\""));
|
assert!(rendered.contains("src=\"/assets/pic.png\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_filter_expands_builtin_macros_from_runtime_context() {
|
||||||
|
let template = "{{ body | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||||
|
let partials = HashMap::new();
|
||||||
|
let context = serde_json::json!({
|
||||||
|
"body": "[[gallery images=post.linked_media columns=2]]\n\n[[youtube id=dQw4w9WgXcQ]]\n\n[[vimeo id=123456]]\n\n[[photo_archive media=post.linked_media]]\n\n[[tag_cloud tags=post_tags]]",
|
||||||
|
"post": {
|
||||||
|
"id": "post-1",
|
||||||
|
"linked_media": [
|
||||||
|
{"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Image one"},
|
||||||
|
{"file_path": "/media/2026/04/two.jpg", "caption": "Two"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post_data_json_by_id": {
|
||||||
|
"post-1": {"id": "post-1", "title": "Post 1"}
|
||||||
|
},
|
||||||
|
"post_tags": [
|
||||||
|
{"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"},
|
||||||
|
{"name": "Iced", "slug": "iced", "post_count": 2}
|
||||||
|
],
|
||||||
|
"tag_color_by_name": {"Iced": "#0088cc"},
|
||||||
|
"canonical_post_path_by_slug": {},
|
||||||
|
"canonical_media_path_by_source_path": {},
|
||||||
|
"language": "en",
|
||||||
|
"language_prefix": ""
|
||||||
|
});
|
||||||
|
|
||||||
|
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||||
|
assert!(rendered.contains("macro-gallery gallery-cols-2"));
|
||||||
|
assert!(rendered.contains("https://www.youtube.com/embed/dQw4w9WgXcQ"));
|
||||||
|
assert!(rendered.contains("https://player.vimeo.com/video/123456"));
|
||||||
|
assert!(rendered.contains("macro-photo-archive"));
|
||||||
|
assert!(rendered.contains("data-tag-cloud=\"true\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starter_single_post_template_renders_with_partials() {
|
fn starter_single_post_template_renders_with_partials() {
|
||||||
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
||||||
|
|||||||
Reference in New Issue
Block a user