feat: more filling out of M4
This commit is contained in:
1135
Cargo.lock
generated
1135
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
133
crates/bds-core/src/engine/validate_site.rs
Normal file
133
crates/bds-core/src/engine/validate_site.rs
Normal 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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
1085
crates/bds-core/src/render/site.rs
Normal file
1085
crates/bds-core/src/render/site.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user