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"
|
pulldown-cmark = "0.13"
|
||||||
liquid = "0.26"
|
liquid = "0.26"
|
||||||
liquid-core = { version = "0.26", features = ["derive"] }
|
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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
<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>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<section class="not-found" data-template="not-found">
|
<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_message = 'render.notFound.message' | i18n: language %}
|
||||||
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
||||||
<p>{{ not_found_message | default: default_not_found_message }}</p>
|
<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>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
<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>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||||
{% for category in post_categories %}
|
{% 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 %}
|
{% endfor %}
|
||||||
{% for tag in post_tags %}
|
{% for tag in post_tags %}
|
||||||
{% assign tag_color = tag_color_by_name[tag] %}
|
{% assign tag_name = tag.name | default: 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_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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ sys-locale = { workspace = true }
|
|||||||
pulldown-cmark = { workspace = true }
|
pulldown-cmark = { workspace = true }
|
||||||
liquid = { workspace = true }
|
liquid = { workspace = true }
|
||||||
liquid-core = { workspace = true }
|
liquid-core = { workspace = true }
|
||||||
|
rayon = { workspace = true }
|
||||||
|
pagefind = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use std::path::Path;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
|
use pagefind::api::PagefindIndex;
|
||||||
|
use pagefind::options::PagefindServiceConfig;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
@@ -9,7 +11,7 @@ use crate::engine::{EngineError, EngineResult};
|
|||||||
use crate::model::{Post, ProjectMetadata};
|
use crate::model::{Post, ProjectMetadata};
|
||||||
use crate::render::{
|
use crate::render::{
|
||||||
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
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,
|
write_generated_file,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,31 +36,15 @@ pub fn generate_starter_site(
|
|||||||
language: &str,
|
language: &str,
|
||||||
) -> EngineResult<GenerationReport> {
|
) -> EngineResult<GenerationReport> {
|
||||||
let mut report = GenerationReport::default();
|
let mut report = GenerationReport::default();
|
||||||
let media_rewrite_map = build_media_rewrite_map(conn, project_id)?;
|
let input_posts = posts
|
||||||
|
|
||||||
let list_input = posts
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let index_page = render_starter_list_page_with_media_map(
|
let artifacts = build_site_render_artifacts(conn, output_dir.parent().unwrap_or(output_dir), project_id, metadata, &input_posts)
|
||||||
&list_input,
|
|
||||||
metadata,
|
|
||||||
language,
|
|
||||||
media_rewrite_map.clone(),
|
|
||||||
)
|
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.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 {
|
for page in &artifacts.pages {
|
||||||
let rendered = render_starter_single_post_page_with_media_map(
|
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
||||||
&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)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
write_out(
|
write_out(
|
||||||
@@ -70,11 +56,23 @@ pub fn generate_starter_site(
|
|||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let rss = build_rss_xml(metadata, posts, language);
|
for render_language in render_languages(metadata) {
|
||||||
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
|
let localized_posts = localized_sources(conn, output_dir.parent().unwrap_or(output_dir), posts, &render_language, metadata)?;
|
||||||
write_out(conn, output_dir, project_id, "feed.xml", &rss, &mut report)?;
|
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
||||||
write_out(conn, output_dir, project_id, "atom.xml", &build_atom_xml(metadata, posts, language), &mut report)?;
|
String::new()
|
||||||
write_out(conn, output_dir, project_id, "sitemap.xml", &build_sitemap_xml(metadata, posts, language), &mut report)?;
|
} 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)
|
Ok(report)
|
||||||
}
|
}
|
||||||
@@ -118,6 +116,99 @@ fn write_out(
|
|||||||
Ok(())
|
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 {
|
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 base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||||
let last_build = posts
|
let last_build = posts
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod search;
|
|||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod preview;
|
pub mod preview;
|
||||||
|
pub mod validate_site;
|
||||||
pub mod validate_translations;
|
pub mod validate_translations;
|
||||||
pub mod validate_media;
|
pub mod validate_media;
|
||||||
pub mod validate_content;
|
pub mod validate_content;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::{StatusCode, Uri, header};
|
use axum::http::{StatusCode, Uri, header};
|
||||||
@@ -15,7 +14,7 @@ use crate::db::{Database, queries};
|
|||||||
use crate::engine::generation::PublishedPostSource;
|
use crate::engine::generation::PublishedPostSource;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Post, PostStatus, ProjectMetadata};
|
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};
|
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||||
|
|
||||||
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
||||||
@@ -62,6 +61,12 @@ struct DraftPreviewQuery {
|
|||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct StylePreviewQuery {
|
||||||
|
theme: Option<String>,
|
||||||
|
mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_preview_server(
|
pub fn start_preview_server(
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
data_dir: 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 listener = tokio::net::TcpListener::from_std(listener).expect("preview listener");
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/__draft/{post_id}", get(handle_draft_preview))
|
.route("/__draft/{post_id}", get(handle_draft_preview))
|
||||||
|
.route("/__style-preview", get(handle_style_preview))
|
||||||
.route("/", get(handle_preview_request))
|
.route("/", get(handle_preview_request))
|
||||||
.route("/{*path}", get(handle_preview_request))
|
.route("/{*path}", get(handle_preview_request))
|
||||||
.with_state(state);
|
.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(
|
async fn handle_preview_request(
|
||||||
State(state): State<PreviewServerState>,
|
State(state): State<PreviewServerState>,
|
||||||
uri: Uri,
|
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(
|
fn render_preview_response(
|
||||||
state: &PreviewServerState,
|
state: &PreviewServerState,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -186,12 +165,15 @@ fn render_preview_response(
|
|||||||
|
|
||||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||||
let db = Database::open(&state.db_path)?;
|
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)?;
|
let published_posts = collect_published_posts(state, &metadata)?;
|
||||||
match render_preview_path(path, &metadata, &published_posts, &media_rewrite_map)? {
|
let input_posts = published_posts
|
||||||
Some(html) => Ok(Html(html).into_response()),
|
.iter()
|
||||||
None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")),
|
.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(
|
fn render_draft_preview(
|
||||||
@@ -211,7 +193,6 @@ fn render_draft_preview(
|
|||||||
post_id,
|
post_id,
|
||||||
target_language,
|
target_language,
|
||||||
) {
|
) {
|
||||||
let media_rewrite_map = build_media_rewrite_map(db.conn(), &post.project_id)?;
|
|
||||||
let mut translated_post = post.clone();
|
let mut translated_post = post.clone();
|
||||||
translated_post.title = translation.title.clone();
|
translated_post.title = translation.title.clone();
|
||||||
translated_post.excerpt = translation.excerpt.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.file_path = translation.file_path.clone();
|
||||||
translated_post.published_at = translation.published_at.or(post.published_at);
|
translated_post.published_at = translation.published_at.or(post.published_at);
|
||||||
let body = load_translation_body(&state.data_dir, &translation)?;
|
let body = load_translation_body(&state.data_dir, &translation)?;
|
||||||
return render_starter_single_post_page_with_media_map(
|
let response = build_preview_response(
|
||||||
&translated_post,
|
db.conn(),
|
||||||
&body,
|
&state.data_dir,
|
||||||
|
&state.project_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
target_language,
|
&[(translated_post, body)],
|
||||||
media_rewrite_map,
|
&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)?;
|
let body = load_post_body(&state.data_dir, &post)?;
|
||||||
render_starter_single_post_page_with_media_map(
|
let response = build_preview_response(
|
||||||
&post,
|
db.conn(),
|
||||||
&body,
|
&state.data_dir,
|
||||||
|
&state.project_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
canonical_language,
|
&[(post.clone(), body)],
|
||||||
media_rewrite_map,
|
&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()))?;
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))
|
Ok(response.html)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_published_posts(
|
fn collect_published_posts(
|
||||||
state: &PreviewServerState,
|
state: &PreviewServerState,
|
||||||
metadata: &ProjectMetadata,
|
_metadata: &ProjectMetadata,
|
||||||
) -> EngineResult<Vec<PublishedPostSource>> {
|
) -> EngineResult<Vec<PublishedPostSource>> {
|
||||||
let db = Database::open(&state.db_path)?;
|
let db = Database::open(&state.db_path)?;
|
||||||
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
||||||
@@ -280,8 +240,7 @@ fn collect_published_posts(
|
|||||||
post,
|
post,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
published.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
|
||||||
published.sort_by_key(|source| build_canonical_post_path(&source.post, main_language, main_language));
|
|
||||||
Ok(published)
|
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()
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -505,36 +449,52 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn root_preview_renders_index_page() {
|
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()
|
||||||
.unwrap();
|
.html;
|
||||||
assert!(html.contains("post-list"));
|
assert!(html.contains("post-list"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preview_renders_single_post_for_canonical_path() {
|
fn preview_renders_single_post_for_canonical_path() {
|
||||||
let html = render_preview_path(
|
let db = Database::open_in_memory().unwrap();
|
||||||
"/2024/03/09/hello",
|
let source = make_post();
|
||||||
|
let html = build_preview_response(
|
||||||
|
db.conn(),
|
||||||
|
Path::new("."),
|
||||||
|
"project-1",
|
||||||
&make_metadata(),
|
&make_metadata(),
|
||||||
&[make_post()],
|
&[(source.post, source.body_markdown)],
|
||||||
&HashMap::new(),
|
"/2024/03/09/hello",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.html;
|
||||||
assert!(html.contains("<h1>Hello</h1>"));
|
assert!(html.contains("<h1>Hello</h1>"));
|
||||||
assert!(html.contains("<strong>world</strong>"));
|
assert!(html.contains("<strong>world</strong>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preview_renders_language_prefixed_single_post() {
|
fn preview_renders_language_prefixed_single_post() {
|
||||||
let html = render_preview_path(
|
let dir = tempfile::tempdir().unwrap();
|
||||||
"/de/2024/03/09/hello",
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||||
&make_metadata(),
|
std::fs::write(
|
||||||
&[make_post()],
|
dir.path().join("posts/2024/03/hello.de.md"),
|
||||||
&HashMap::new(),
|
"---\ntranslationFor: post-1\nlanguage: de\ntitle: Hallo\n---\nHallo **welt**",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.ok();
|
||||||
.unwrap();
|
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\""));
|
assert!(html.contains("lang=\"de\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,4 +552,31 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
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)
|
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> {
|
pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String]) -> Vec<String> {
|
||||||
let mut paths = vec![
|
let mut paths = vec![
|
||||||
"index.html".to_string(),
|
"index.html".to_string(),
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ mod markdown;
|
|||||||
mod generation;
|
mod generation;
|
||||||
mod page_renderer;
|
mod page_renderer;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod site;
|
||||||
mod template_lookup;
|
mod template_lookup;
|
||||||
|
|
||||||
pub use generation::{
|
pub use generation::{
|
||||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json,
|
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 markdown::render_markdown_to_html;
|
||||||
pub use page_renderer::{RenderError, render_liquid_template};
|
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_list_page_with_media_map, render_starter_single_post_page,
|
||||||
render_starter_single_post_page_with_media_map,
|
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::{
|
pub use template_lookup::{
|
||||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||||
resolve_post_template,
|
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::queries::project::insert_project;
|
||||||
use bds_core::db::Database;
|
use bds_core::db::Database;
|
||||||
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
|
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 bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -64,7 +65,10 @@ fn setup() -> (Database, TempDir) {
|
|||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
insert_project(db.conn(), &make_project()).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]
|
#[test]
|
||||||
@@ -126,3 +130,25 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
|||||||
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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::GenerateSitemap => Task::done(Message::GenerateSite),
|
||||||
MenuAction::ValidateSite => {
|
MenuAction::ValidateSite => {
|
||||||
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
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 => {
|
MenuAction::UploadSite => {
|
||||||
if self.offline_mode {
|
if self.offline_mode {
|
||||||
|
|||||||
Reference in New Issue
Block a user