feat: more filling out of M4

This commit is contained in:
2026-04-13 17:45:17 +02:00
parent 8ac9162440
commit 25b190a3a4
15 changed files with 2638 additions and 186 deletions

1135
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,8 @@ open = "5"
pulldown-cmark = "0.13"
liquid = "0.26"
liquid-core = { version = "0.26", features = ["derive"] }
rayon = "1.10"
pagefind = "1.5.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }

View File

@@ -1,6 +1,6 @@
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}
<body>
<main>
<section class="not-found" data-template="not-found">
@@ -9,7 +9,7 @@
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
<p>{{ not_found_message | default: default_not_found_message }}</p>
<p><a href="/" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
<p><a href="{{ language_prefix | default: '/' }}" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
</article>
</section>
</main>

View File

@@ -1,6 +1,6 @@
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% 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 %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}

View File

@@ -9,11 +9,12 @@
{% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="{{ language_prefix }}/category/{{ category.slug | default: category | url_encode }}/">{{ category.name | default: category | escape }}</a>
{% endfor %}
{% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
{% assign tag_name = tag.name | default: tag %}
{% assign tag_color = tag.color %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="{{ language_prefix }}/tag/{{ tag.slug | default: tag_name | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag_name | escape }}</a>
{% endfor %}
</div>
{% endif %}

View File

@@ -22,6 +22,8 @@ sys-locale = { workspace = true }
pulldown-cmark = { workspace = true }
liquid = { workspace = true }
liquid-core = { workspace = true }
rayon = { workspace = true }
pagefind = { workspace = true }
reqwest = { workspace = true }
keyring = { workspace = true }
tokio = { workspace = true }

View File

@@ -2,6 +2,8 @@ use std::path::Path;
use std::collections::HashMap;
use chrono::{DateTime, TimeZone, Utc};
use pagefind::api::PagefindIndex;
use pagefind::options::PagefindServiceConfig;
use rusqlite::Connection;
use crate::db::queries;
@@ -9,7 +11,7 @@ use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, ProjectMetadata};
use crate::render::{
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
render_markdown_to_html, render_starter_list_page_with_media_map, render_starter_single_post_page_with_media_map,
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
write_generated_file,
};
@@ -34,31 +36,15 @@ pub fn generate_starter_site(
language: &str,
) -> EngineResult<GenerationReport> {
let mut report = GenerationReport::default();
let media_rewrite_map = build_media_rewrite_map(conn, project_id)?;
let list_input = posts
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let index_page = render_starter_list_page_with_media_map(
&list_input,
metadata,
language,
media_rewrite_map.clone(),
)
let artifacts = build_site_render_artifacts(conn, output_dir.parent().unwrap_or(output_dir), project_id, metadata, &input_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
write_out(conn, output_dir, project_id, &index_page.relative_path, &index_page.html, &mut report)?;
for source in posts {
let rendered = render_starter_single_post_page_with_media_map(
&source.post,
&source.body_markdown,
metadata,
language,
media_rewrite_map.clone(),
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
write_out(conn, output_dir, project_id, &rendered.relative_path, &rendered.html, &mut report)?;
for page in &artifacts.pages {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
}
write_out(
@@ -70,11 +56,23 @@ pub fn generate_starter_site(
&mut report,
)?;
let rss = build_rss_xml(metadata, posts, language);
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
write_out(conn, output_dir, project_id, "feed.xml", &rss, &mut report)?;
write_out(conn, output_dir, project_id, "atom.xml", &build_atom_xml(metadata, posts, language), &mut report)?;
write_out(conn, output_dir, project_id, "sitemap.xml", &build_sitemap_xml(metadata, posts, language), &mut report)?;
for render_language in render_languages(metadata) {
let localized_posts = localized_sources(conn, output_dir.parent().unwrap_or(output_dir), posts, &render_language, metadata)?;
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
String::new()
} else {
format!("{}/", render_language)
};
let rss = build_rss_xml(metadata, &localized_posts, &render_language);
if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.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}sitemap.xml"), &build_sitemap_xml(metadata, &localized_posts, &render_language), &mut report)?;
}
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
Ok(report)
}
@@ -118,6 +116,99 @@ fn write_out(
Ok(())
}
fn write_pagefind_indexes(
conn: &Connection,
output_dir: &Path,
project_id: &str,
documents: &[crate::render::PagefindDocument],
report: &mut GenerationReport,
) -> EngineResult<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(EngineError::Io)?;
let grouped = documents.iter().fold(HashMap::<String, Vec<&crate::render::PagefindDocument>>::new(), |mut acc, doc| {
acc.entry(doc.language.clone()).or_default().push(doc);
acc
});
for (language, docs) in grouped {
let config = PagefindServiceConfig::builder()
.keep_index_url(true)
.force_language(language.clone())
.build();
let mut index = PagefindIndex::new(Some(config))
.map_err(|error| EngineError::Parse(error.to_string()))?;
runtime.block_on(async {
for doc in docs {
index
.add_html_file(Some(doc.relative_path.clone()), None, doc.html.clone())
.await
.map_err(|error| EngineError::Parse(error.to_string()))?;
}
let files = index.get_files().await.map_err(|error| EngineError::Parse(error.to_string()))?;
for file in files {
let relative = file.filename.to_string_lossy().trim_start_matches('/').to_string();
match write_generated_bytes(conn, output_dir, project_id, &relative, &file.contents)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
GeneratedWriteOutcome::Written => report.written_paths.push(relative),
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative),
}
}
Ok::<(), EngineError>(())
})?;
}
Ok(())
}
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
languages.push(language.clone());
}
}
languages
}
fn localized_sources(
conn: &Connection,
data_dir: &Path,
posts: &[PublishedPostSource],
language: &str,
metadata: &ProjectMetadata,
) -> EngineResult<Vec<PublishedPostSource>> {
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let mut localized = Vec::new();
for source in posts {
if language.eq_ignore_ascii_case(main_language) {
localized.push(source.clone());
continue;
}
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(conn, &source.post.id, language) {
let raw = std::fs::read_to_string(data_dir.join(translation.file_path.trim_start_matches('/')))
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: body,
});
}
}
Ok(localized)
}
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
let last_build = posts

View File

@@ -18,6 +18,7 @@ pub mod search;
pub mod calendar;
pub mod generation;
pub mod preview;
pub mod validate_site;
pub mod validate_translations;
pub mod validate_media;
pub mod validate_content;

View File

@@ -1,7 +1,6 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::collections::HashMap;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{StatusCode, Uri, header};
@@ -15,7 +14,7 @@ use crate::db::{Database, queries};
use crate::engine::generation::PublishedPostSource;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, PostStatus, ProjectMetadata};
use crate::render::{build_canonical_post_path, render_starter_list_page_with_media_map, render_starter_single_post_page_with_media_map};
use crate::render::build_preview_response;
use crate::util::frontmatter::{read_post_file, read_translation_file};
pub const PREVIEW_HOST: &str = "127.0.0.1";
@@ -62,6 +61,12 @@ struct DraftPreviewQuery {
language: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct StylePreviewQuery {
theme: Option<String>,
mode: Option<String>,
}
pub fn start_preview_server(
db_path: PathBuf,
data_dir: PathBuf,
@@ -91,6 +96,7 @@ pub fn start_preview_server(
let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener");
let app = Router::new()
.route("/__draft/{post_id}", get(handle_draft_preview))
.route("/__style-preview", get(handle_style_preview))
.route("/", get(handle_preview_request))
.route("/{*path}", get(handle_preview_request))
.with_state(state);
@@ -107,48 +113,6 @@ pub fn start_preview_server(
})
}
pub fn render_preview_path(
path: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
canonical_media_path_by_source_path: &HashMap<String, String>,
) -> EngineResult<Option<String>> {
let normalized = if path.is_empty() { "/" } else { path };
let main_language = metadata.main_language.as_deref().unwrap_or("en");
if normalized == "/" {
let list_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
return render_starter_list_page_with_media_map(
&list_posts,
metadata,
main_language,
canonical_media_path_by_source_path.clone(),
)
.map(|page| Some(page.html))
.map_err(|error| EngineError::Parse(error.to_string()));
}
let (language, route_path) = split_language_prefix(normalized, metadata);
if let Some(source) = posts.iter().find(|source| {
build_canonical_post_path(&source.post, &language, main_language) == route_path
}) {
return render_starter_single_post_page_with_media_map(
&source.post,
&source.body_markdown,
metadata,
&language,
canonical_media_path_by_source_path.clone(),
)
.map(|page| Some(page.html))
.map_err(|error| EngineError::Parse(error.to_string()));
}
Ok(None)
}
async fn handle_preview_request(
State(state): State<PreviewServerState>,
uri: Uri,
@@ -170,6 +134,21 @@ async fn handle_draft_preview(
}
}
async fn handle_style_preview(
Query(query): Query<StylePreviewQuery>,
) -> Response {
let theme = query.theme.unwrap_or_else(|| "default".to_string());
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
let html = format!(
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
theme,
mode,
theme,
mode,
);
Html(html).into_response()
}
fn render_preview_response(
state: &PreviewServerState,
path: &str,
@@ -186,12 +165,15 @@ fn render_preview_response(
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
let db = Database::open(&state.db_path)?;
let media_rewrite_map = build_media_rewrite_map(db.conn(), &state.project_id)?;
let published_posts = collect_published_posts(state, &metadata)?;
match render_preview_path(path, &metadata, &published_posts, &media_rewrite_map)? {
Some(html) => Ok(Html(html).into_response()),
None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")),
}
let input_posts = published_posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
Ok((status, Html(response.html)).into_response())
}
fn render_draft_preview(
@@ -211,7 +193,6 @@ fn render_draft_preview(
post_id,
target_language,
) {
let media_rewrite_map = build_media_rewrite_map(db.conn(), &post.project_id)?;
let mut translated_post = post.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
@@ -220,56 +201,35 @@ fn render_draft_preview(
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(post.published_at);
let body = load_translation_body(&state.data_dir, &translation)?;
return render_starter_single_post_page_with_media_map(
&translated_post,
&body,
let response = build_preview_response(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
target_language,
media_rewrite_map,
&[(translated_post, body)],
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
)
.map(|page| page.html)
.map_err(|error| EngineError::Parse(error.to_string()));
.map_err(|error| EngineError::Parse(error.to_string()))?;
return Ok(response.html);
}
}
let media_rewrite_map = build_media_rewrite_map(db.conn(), &post.project_id)?;
let body = load_post_body(&state.data_dir, &post)?;
render_starter_single_post_page_with_media_map(
&post,
&body,
let response = build_preview_response(
db.conn(),
&state.data_dir,
&state.project_id,
&metadata,
canonical_language,
media_rewrite_map,
&[(post.clone(), body)],
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")),
)
.map(|page| page.html)
.map_err(|error| EngineError::Parse(error.to_string()))
}
fn build_media_rewrite_map(
conn: &rusqlite::Connection,
project_id: &str,
) -> EngineResult<HashMap<String, String>> {
let media_items = queries::media::list_media_by_project(conn, project_id)?;
let mut map = HashMap::new();
for media in media_items {
let canonical_path = if media.file_path.starts_with('/') {
media.file_path.clone()
} else {
format!("/{}", media.file_path.trim_start_matches('/'))
};
map.insert(format!("bds-media://{}", media.id), canonical_path.clone());
let relative_key = media.file_path.trim_start_matches('/').to_lowercase();
map.insert(relative_key, canonical_path);
}
Ok(map)
.map_err(|error| EngineError::Parse(error.to_string()))?;
Ok(response.html)
}
fn collect_published_posts(
state: &PreviewServerState,
metadata: &ProjectMetadata,
_metadata: &ProjectMetadata,
) -> EngineResult<Vec<PublishedPostSource>> {
let db = Database::open(&state.db_path)?;
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
@@ -280,8 +240,7 @@ fn collect_published_posts(
post,
});
}
let main_language = metadata.main_language.as_deref().unwrap_or("en");
published.sort_by_key(|source| build_canonical_post_path(&source.post, main_language, main_language));
published.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
Ok(published)
}
@@ -373,21 +332,6 @@ fn error_response(status: StatusCode, message: &str) -> Response {
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response()
}
fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) {
let trimmed = path.trim_start_matches('/');
let mut segments = trimmed.split('/');
let first = segments.next().unwrap_or_default();
if metadata.blog_languages.iter().any(|language| language == first) {
let remainder = segments.collect::<Vec<_>>().join("/");
return (first.to_string(), format!("/{first}/{}", remainder.trim_start_matches('/')));
}
(
metadata.main_language.as_deref().unwrap_or("en").to_string(),
path.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -505,36 +449,52 @@ mod tests {
#[test]
fn root_preview_renders_index_page() {
let html = render_preview_path("/", &make_metadata(), &[make_post()], &HashMap::new())
let db = Database::open_in_memory().unwrap();
let html = build_preview_response(db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/")
.unwrap()
.unwrap();
.html;
assert!(html.contains("post-list"));
}
#[test]
fn preview_renders_single_post_for_canonical_path() {
let html = render_preview_path(
"/2024/03/09/hello",
let db = Database::open_in_memory().unwrap();
let source = make_post();
let html = build_preview_response(
db.conn(),
Path::new("."),
"project-1",
&make_metadata(),
&[make_post()],
&HashMap::new(),
&[(source.post, source.body_markdown)],
"/2024/03/09/hello",
)
.unwrap()
.unwrap();
.unwrap()
.html;
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<strong>world</strong>"));
}
#[test]
fn preview_renders_language_prefixed_single_post() {
let html = render_preview_path(
"/de/2024/03/09/hello",
&make_metadata(),
&[make_post()],
&HashMap::new(),
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
std::fs::write(
dir.path().join("posts/2024/03/hello.de.md"),
"---\ntranslationFor: post-1\nlanguage: de\ntitle: Hallo\n---\nHallo **welt**",
)
.unwrap()
.unwrap();
.ok();
let db = Database::open_in_memory().unwrap();
let source = make_post();
let html = build_preview_response(
db.conn(),
dir.path(),
"project-1",
&make_metadata(),
&[(source.post, source.body_markdown)],
"/de/2024/03/09/hello",
)
.unwrap()
.html;
assert!(html.contains("lang=\"de\""));
}
@@ -592,4 +552,31 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn preview_server_serves_style_preview() {
let _guard = preview_port_guard().lock().unwrap();
let (dir, _db) = setup_preview_fixture();
let server = start_preview_server(
dir.path().join("bds.db"),
dir.path().to_path_buf(),
"project-1".into(),
)
.unwrap();
let client = reqwest::blocking::Client::new();
let response = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=nightfall&mode=dark"
))
.send()
.unwrap();
let body = response.text().unwrap();
server.stop().unwrap();
assert!(body.contains("Style Preview"));
assert!(body.contains("nightfall"));
assert!(body.contains("dark"));
}
}

View File

@@ -0,0 +1,133 @@
use std::collections::HashSet;
use std::path::Path;
use rusqlite::Connection;
use walkdir::WalkDir;
use crate::db::queries;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, PostStatus};
use crate::render::build_site_render_artifacts;
use crate::util::file_hash;
#[derive(Debug, Clone, Default)]
pub struct SiteValidationReport {
pub missing_pages: Vec<String>,
pub extra_pages: Vec<String>,
pub stale_pages: Vec<String>,
}
pub fn validate_site(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<SiteValidationReport> {
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = data_dir.to_path_buf();
let published_posts = load_published_posts(data_dir, conn, project_id)?;
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut expected = artifacts
.pages
.iter()
.map(|page| page.relative_path.clone())
.collect::<HashSet<_>>();
expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string());
for language in render_languages(&metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
String::new()
} else {
format!("{language}/")
};
expected.insert(format!("{prefix}feed.xml"));
expected.insert(format!("{prefix}atom.xml"));
expected.insert(format!("{prefix}sitemap.xml"));
}
let mut actual = HashSet::new();
if output_dir.exists() {
for entry in WalkDir::new(&output_dir).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(&output_dir)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with("meta/")
|| rel.starts_with("posts/")
|| rel.starts_with("media/")
|| rel.starts_with("assets/")
{
continue;
}
if rel.starts_with("pagefind") || rel.contains("/pagefind/") {
continue;
}
if rel.ends_with(".html") || rel.ends_with(".xml") || rel.ends_with(".json") {
actual.insert(rel);
}
}
}
let mut missing_pages = expected.difference(&actual).cloned().collect::<Vec<_>>();
let mut extra_pages = actual.difference(&expected).cloned().collect::<Vec<_>>();
let mut stale_pages = Vec::new();
for rel in expected.intersection(&actual) {
if let Ok(stored) = queries::generated_file_hash::get_generated_file_hash(conn, project_id, rel) {
let actual_hash = file_hash(&output_dir.join(rel))?;
if actual_hash != stored.content_hash {
stale_pages.push(rel.clone());
}
}
}
missing_pages.sort();
extra_pages.sort();
stale_pages.sort();
Ok(SiteValidationReport {
missing_pages,
extra_pages,
stale_pages,
})
}
fn load_published_posts(
data_dir: &Path,
conn: &Connection,
project_id: &str,
) -> EngineResult<Vec<(Post, String)>> {
let posts = queries::post::list_posts_by_project(conn, project_id)?;
let mut published = Vec::new();
for post in posts.into_iter().filter(|post| post.status == PostStatus::Published) {
let body = if let Some(content) = &post.content {
content.clone()
} else if let Some(content) = &post.published_content {
content.clone()
} else {
let raw = std::fs::read_to_string(data_dir.join(post.file_path.trim_start_matches('/')))?;
crate::util::frontmatter::read_post_file(&raw)
.map(|(_, body)| body)
.map_err(EngineError::Parse)?
};
published.push((post, body));
}
Ok(published)
}
fn render_languages(metadata: &crate::model::ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];
for language in &metadata.blog_languages {
if !languages.iter().any(|existing| existing.eq_ignore_ascii_case(language)) {
languages.push(language.clone());
}
}
languages
}

View File

@@ -56,6 +56,39 @@ pub fn write_generated_file(
Ok(GeneratedWriteOutcome::Written)
}
pub fn write_generated_bytes(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
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)?;
}
crate::util::atomic_write(&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(),

View File

@@ -2,11 +2,12 @@ mod markdown;
mod generation;
mod page_renderer;
mod routes;
mod site;
mod template_lookup;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
build_core_generation_paths, write_generated_file,
build_core_generation_paths, write_generated_bytes, write_generated_file,
};
pub use markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template};
@@ -15,6 +16,10 @@ pub use routes::{
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
};
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
build_preview_response, build_site_render_artifacts,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
resolve_post_template,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
use bds_core::engine::validate_site::validate_site;
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
use tempfile::TempDir;
@@ -64,7 +65,10 @@ fn setup() -> (Database, TempDir) {
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_project()).unwrap();
(db, TempDir::new().unwrap())
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
bds_core::engine::meta::write_project_json(dir.path(), &make_metadata()).unwrap();
(db, dir)
}
#[test]
@@ -125,4 +129,26 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
}
#[test]
fn site_validation_detects_stale_and_missing_outputs() {
let (db, dir) = setup();
let metadata = make_metadata();
let post = make_post("hello", 1_710_000_000_000);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.stale_pages.contains(&"index.html".to_string()));
assert!(report.missing_pages.contains(&"feed.xml".to_string()));
assert!(report.extra_pages.is_empty());
}

View File

@@ -3330,7 +3330,20 @@ impl BdsApp {
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
Task::none()
self.spawn_engine_task(
"tabBar.siteValidation",
|db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
.map_err(|e| e.to_string())?;
Ok(format!(
"missing={}, extra={}, stale={}",
report.missing_pages.len(),
report.extra_pages.len(),
report.stale_pages.len(),
))
},
)
}
MenuAction::UploadSite => {
if self.offline_mode {