feat: more M4 closing
This commit is contained in:
@@ -7,6 +7,7 @@ use pagefind::options::PagefindServiceConfig;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::site_assets::write_bundled_site_assets;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::render::{
|
||||
@@ -33,7 +34,7 @@ pub fn generate_starter_site(
|
||||
project_id: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
_language: &str,
|
||||
) -> EngineResult<GenerationReport> {
|
||||
let mut report = GenerationReport::default();
|
||||
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_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
||||
|
||||
write_out(
|
||||
conn,
|
||||
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}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)?;
|
||||
@@ -259,6 +269,8 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
||||
|
||||
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 main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let feed_prefix = language_prefix(language, main_language);
|
||||
let updated = posts
|
||||
.iter()
|
||||
.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!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
||||
format!(" <id>{base_url}/</id>"),
|
||||
format!(" <link href=\"{base_url}/\" rel=\"alternate\" />"),
|
||||
format!(" <link href=\"{base_url}/atom.xml\" rel=\"self\" />"),
|
||||
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
||||
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
|
||||
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")
|
||||
}
|
||||
|
||||
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 main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let languages = render_languages(metadata);
|
||||
let index_lastmod = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now)
|
||||
.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![
|
||||
"<?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(),
|
||||
" <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 {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
for page in pages.iter().filter(|page| page.language == language) {
|
||||
let url = format!("{base_url}{}", page.url_path);
|
||||
let logical_key = logical_page_key(&page.relative_path, &languages, main_language);
|
||||
let alternates = page_groups.get(&logical_key);
|
||||
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(format!(" <loc>{url}</loc>"));
|
||||
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
||||
xml.push(" <changefreq>weekly</changefreq>".to_string());
|
||||
xml.push(" <priority>0.8</priority>".to_string());
|
||||
xml.push(format!(
|
||||
" <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());
|
||||
}
|
||||
|
||||
@@ -340,6 +390,50 @@ fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource],
|
||||
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>> {
|
||||
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod script;
|
||||
pub mod script_rebuild;
|
||||
pub mod task;
|
||||
pub mod metadata_diff;
|
||||
pub mod site_assets;
|
||||
pub mod rebuild;
|
||||
pub mod search;
|
||||
pub mod calendar;
|
||||
|
||||
@@ -6,6 +6,7 @@ use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::project as q;
|
||||
use crate::engine::site_assets::copy_bundled_site_assets;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Project;
|
||||
use crate::model::metadata::ProjectMetadata;
|
||||
@@ -152,7 +153,7 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
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
|
||||
copy_starter_templates(data_dir)?;
|
||||
copy_bundled_site_assets(data_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -274,6 +276,9 @@ mod tests {
|
||||
assert!(data_path.join("thumbnails").is_dir());
|
||||
assert!(data_path.join("templates").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
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user