Compare commits
10 Commits
39ee0dc64e
...
7880e37c34
| Author | SHA1 | Date | |
|---|---|---|---|
| 7880e37c34 | |||
| a96a6ee31c | |||
| eca5fafc1c | |||
| 316a218685 | |||
| ffcf3d9878 | |||
| 25b190a3a4 | |||
| 8ac9162440 | |||
| 9095912b48 | |||
| c44354e33f | |||
| cce4b96efa |
@@ -3,7 +3,7 @@
|
||||
This is the Rust rewrite of an existing project bDS written in Typescript and living in ../bDS - if
|
||||
in doubt about behaviour, look at the original code to verify.
|
||||
|
||||
This project has an allium spec in the folder spec/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation. The command line utility is installed.
|
||||
This project has an allium spec in the folder specs/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation. The command line utility is installed.
|
||||
|
||||
Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed.
|
||||
|
||||
|
||||
1943
Cargo.lock
generated
1943
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Foundation
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -36,6 +37,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 }
|
||||
|
||||
@@ -302,7 +302,7 @@ pub fn run_one_shot(
|
||||
let settings = load_ai_settings(conn, offline_mode)?;
|
||||
let endpoint = active_endpoint(conn, offline_mode)?;
|
||||
let model = select_model(&settings, &endpoint, &request.operation)?;
|
||||
let prompt = build_one_shot_prompt(request)?;
|
||||
let user_content = build_one_shot_user_content(request)?;
|
||||
let schema = response_schema(&request.operation);
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
@@ -313,7 +313,7 @@ pub fn run_one_shot(
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content": user_content,
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
@@ -412,37 +412,63 @@ fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn build_one_shot_prompt(request: &OneShotRequest) -> EngineResult<String> {
|
||||
fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value> {
|
||||
match &request.operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||
"Suggest tags and categories for this post: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::AnalyzePost => Ok(format!(
|
||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::DetectLanguage => Ok(format!(
|
||||
"Detect the language of this text: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||
"Translate this post to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::AnalyzeImage => Ok(format!(
|
||||
"Analyze this image metadata and return title, alt, and caption suggestions: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||
"Translate this media metadata to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_analysis_user_content(content: &Value) -> EngineResult<Value> {
|
||||
let image_data_url = content
|
||||
.get("image_data_url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| EngineError::Validation("image analysis requires image data".to_string()))?;
|
||||
|
||||
let mut metadata = content.clone();
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.remove("image_data_url");
|
||||
}
|
||||
|
||||
Ok(json!([
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Analyze this image and return title, alt, and caption suggestions. Metadata: {}",
|
||||
serde_json::to_string(&metadata)?
|
||||
)
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image_data_url
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
||||
match operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => (
|
||||
@@ -614,7 +640,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn loads_empty_defaults() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let db = setup();
|
||||
let settings = load_ai_settings(db.conn(), false).unwrap();
|
||||
assert!(!settings.offline_mode);
|
||||
@@ -624,6 +649,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
||||
fn saves_online_endpoint_with_keychain_secret() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let db = setup();
|
||||
@@ -670,7 +696,7 @@ mod tests {
|
||||
http_ok(
|
||||
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||
)
|
||||
});
|
||||
}, 1);
|
||||
let models = refresh_model_catalog(&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: server,
|
||||
@@ -684,6 +710,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||
clear_keyring(AiEndpointKind::Online);
|
||||
let server = spawn_test_server(|request| {
|
||||
@@ -695,7 +722,7 @@ mod tests {
|
||||
http_ok(
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
||||
)
|
||||
});
|
||||
}, 1);
|
||||
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
@@ -730,11 +757,194 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
|
||||
#[test]
|
||||
fn image_analysis_user_content_is_multimodal() {
|
||||
let content = build_image_analysis_user_content(&json!({
|
||||
"title": "Existing title",
|
||||
"alt": "Existing alt",
|
||||
"image_data_url": "data:image/jpeg;base64,abc123"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let parts = content.as_array().unwrap();
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0]["type"], "text");
|
||||
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title"));
|
||||
assert_eq!(parts[1]["type"], "image_url");
|
||||
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_taxonomy_analysis_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::AnalyzeTaxonomy,
|
||||
content: json!({
|
||||
"title": "Rust preview parity",
|
||||
"excerpt": "Closing M4",
|
||||
"content": "Rendering routes and previews",
|
||||
"tags": ["rendering"],
|
||||
"categories": ["engineering"]
|
||||
}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"tags\":[\"rust\",\"preview\"],\"categories\":[\"engineering\"]}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::Taxonomy(TaxonomySuggestion {
|
||||
tags: vec!["rust".to_string(), "preview".to_string()],
|
||||
categories: vec!["engineering".to_string()],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_post_analysis_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::AnalyzePost,
|
||||
content: json!({"title":"Draft title","excerpt":"","content":"Body"}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::PostAnalysis(PostAnalysisResult {
|
||||
title: "Better title".to_string(),
|
||||
excerpt: "Short summary".to_string(),
|
||||
slug: "better-title".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_language_detection_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::DetectLanguage,
|
||||
content: json!({"text": "Bonjour tout le monde"}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"language_code\":\"fr\"}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::LanguageDetection(LanguageDetectionResult {
|
||||
language_code: "fr".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_post_translation_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::TranslatePost {
|
||||
target_language: "de".to_string(),
|
||||
},
|
||||
content: json!({
|
||||
"title": "Hello",
|
||||
"excerpt": "Short summary",
|
||||
"content": "Body"
|
||||
}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Hallo\",\"excerpt\":\"Kurzfassung\",\"content\":\"Inhalt\"}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::Translation(TranslationResult {
|
||||
title: "Hallo".to_string(),
|
||||
excerpt: "Kurzfassung".to_string(),
|
||||
content: "Inhalt".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_image_analysis_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::AnalyzeImage,
|
||||
content: json!({
|
||||
"title": "Existing title",
|
||||
"alt": "",
|
||||
"caption": "",
|
||||
"filename": "hero.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"image_data_url": "data:image/jpeg;base64,abc123"
|
||||
}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Hero image\",\"alt\":\"A scenic mountain\",\"caption\":\"Sunrise over the ridge\"}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::ImageAnalysis(ImageAnalysisResult {
|
||||
title: "Hero image".to_string(),
|
||||
alt: "A scenic mountain".to_string(),
|
||||
caption: "Sunrise over the ridge".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_one_shot_supports_media_translation_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
OneShotRequest {
|
||||
operation: OneShotOperation::TranslateMedia {
|
||||
target_language: "it".to_string(),
|
||||
},
|
||||
content: json!({
|
||||
"title": "Mountain",
|
||||
"alt": "Snowy ridge",
|
||||
"caption": "Morning light"
|
||||
}),
|
||||
},
|
||||
r#"{"choices":[{"message":{"content":"{\"title\":\"Montagna\",\"alt\":\"Cresta innevata\",\"caption\":\"Luce del mattino\"}"}}]}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
OneShotResponse::MediaTranslation(MediaTranslationResult {
|
||||
title: "Montagna".to_string(),
|
||||
alt: "Cresta innevata".to_string(),
|
||||
caption: "Luce del mattino".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn run_airplane_one_shot(request: OneShotRequest, body: &'static str) -> OneShotResponse {
|
||||
let server = spawn_test_server(
|
||||
move |incoming| {
|
||||
assert!(incoming.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
||||
http_ok(body)
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: server,
|
||||
model: "llama3.2".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
run_one_shot(db.conn(), true, &request).unwrap()
|
||||
}
|
||||
|
||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static, request_count: usize) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming().take(2) {
|
||||
for stream in listener.incoming().take(request_count) {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let size = stream.read(&mut buffer).unwrap();
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use pagefind::api::PagefindIndex;
|
||||
use pagefind::options::PagefindServiceConfig;
|
||||
use rusqlite::Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::site_assets::write_bundled_site_assets;
|
||||
use crate::engine::validate_site::SiteValidationReport;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
||||
use crate::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
||||
render_markdown_to_html, render_starter_list_page, render_starter_single_post_page,
|
||||
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
|
||||
write_generated_file,
|
||||
};
|
||||
|
||||
@@ -21,6 +28,16 @@ pub struct PublishedPostSource {
|
||||
pub struct GenerationReport {
|
||||
pub written_paths: Vec<String>,
|
||||
pub skipped_paths: Vec<String>,
|
||||
pub deleted_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GenerationSection {
|
||||
Core,
|
||||
Single,
|
||||
Category,
|
||||
Tag,
|
||||
Date,
|
||||
}
|
||||
|
||||
pub fn generate_starter_site(
|
||||
@@ -29,42 +46,204 @@ pub fn generate_starter_site(
|
||||
project_id: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
_language: &str,
|
||||
) -> EngineResult<GenerationReport> {
|
||||
let mut report = GenerationReport::default();
|
||||
|
||||
let list_input = posts
|
||||
let data_dir = project_data_dir(output_dir);
|
||||
let category_settings = load_category_settings(&data_dir);
|
||||
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||
let input_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let index_page = render_starter_list_page(&list_input, metadata, language)
|
||||
let artifacts = build_site_render_artifacts(conn, &data_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(&source.post, &source.body_markdown, metadata, language)
|
||||
.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_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
||||
|
||||
write_out(
|
||||
conn,
|
||||
output_dir,
|
||||
project_id,
|
||||
"calendar.json",
|
||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&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, &data_dir, &list_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, &artifacts.pages, &localized_posts, &render_language),
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
|
||||
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec<GenerationSection> {
|
||||
let mut sections = HashSet::new();
|
||||
let mut saw_unknown = false;
|
||||
|
||||
for path in report
|
||||
.missing_pages
|
||||
.iter()
|
||||
.chain(report.extra_pages.iter())
|
||||
.chain(report.stale_pages.iter())
|
||||
{
|
||||
match classify_generated_path(path) {
|
||||
Some(section) => {
|
||||
sections.insert(section);
|
||||
}
|
||||
None => {
|
||||
saw_unknown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_unknown && !report_is_empty(report) {
|
||||
return all_sections();
|
||||
}
|
||||
|
||||
let mut ordered = sections.into_iter().collect::<Vec<_>>();
|
||||
ordered.sort_by_key(section_sort_key);
|
||||
ordered
|
||||
}
|
||||
|
||||
pub fn apply_validation_sections(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
project_id: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
sections: &[GenerationSection],
|
||||
) -> EngineResult<GenerationReport> {
|
||||
if sections.is_empty() {
|
||||
return Ok(GenerationReport::default());
|
||||
}
|
||||
|
||||
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
||||
let data_dir = project_data_dir(output_dir);
|
||||
let category_settings = load_category_settings(&data_dir);
|
||||
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||
let input_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let artifacts = build_site_render_artifacts(
|
||||
conn,
|
||||
&data_dir,
|
||||
project_id,
|
||||
metadata,
|
||||
&input_posts,
|
||||
)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let mut report = GenerationReport::default();
|
||||
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set);
|
||||
|
||||
for page in &artifacts.pages {
|
||||
if path_matches_sections(&page.relative_path, §ion_set) {
|
||||
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
|
||||
}
|
||||
}
|
||||
|
||||
if section_set.contains(&GenerationSection::Core) {
|
||||
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
|
||||
write_out(
|
||||
conn,
|
||||
output_dir,
|
||||
project_id,
|
||||
"calendar.json",
|
||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&mut report,
|
||||
)?;
|
||||
|
||||
for render_language in render_languages(metadata) {
|
||||
let localized_posts = localized_sources(
|
||||
conn,
|
||||
&data_dir,
|
||||
&list_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, &artifacts.pages, &localized_posts, &render_language),
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
remove_extra_section_paths(output_dir, &expected_paths, §ion_set, &mut report)?;
|
||||
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn build_media_rewrite_map(
|
||||
conn: &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 write_out(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
@@ -82,6 +261,282 @@ 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 project_data_dir(output_dir: &Path) -> std::path::PathBuf {
|
||||
if output_dir.join("meta").exists() {
|
||||
output_dir.to_path_buf()
|
||||
} else {
|
||||
output_dir.parent().unwrap_or(output_dir).to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_paths_for_sections(
|
||||
metadata: &ProjectMetadata,
|
||||
pages: &[crate::render::SitePage],
|
||||
sections: &HashSet<GenerationSection>,
|
||||
) -> HashSet<String> {
|
||||
let mut expected = pages
|
||||
.iter()
|
||||
.filter(|page| path_matches_sections(&page.relative_path, sections))
|
||||
.map(|page| page.relative_path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
if sections.contains(&GenerationSection::Core) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
expected
|
||||
}
|
||||
|
||||
fn remove_extra_section_paths(
|
||||
output_dir: &Path,
|
||||
expected: &HashSet<String>,
|
||||
sections: &HashSet<GenerationSection>,
|
||||
report: &mut GenerationReport,
|
||||
) -> EngineResult<()> {
|
||||
if !output_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut deleted = Vec::new();
|
||||
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/")
|
||||
|| rel.starts_with("pagefind")
|
||||
|| rel.contains("/pagefind/")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) {
|
||||
continue;
|
||||
}
|
||||
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
|
||||
deleted.push(rel);
|
||||
}
|
||||
|
||||
deleted.sort();
|
||||
report.deleted_paths.extend(deleted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_matches_sections(path: &str, sections: &HashSet<GenerationSection>) -> bool {
|
||||
classify_generated_path(path)
|
||||
.map(|section| sections.contains(§ion))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn classify_generated_path(path: &str) -> Option<GenerationSection> {
|
||||
if path.ends_with(".xml") || path.ends_with(".json") {
|
||||
return Some(GenerationSection::Core);
|
||||
}
|
||||
|
||||
let mut parts = path.split('/').collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if has_language_prefix(&parts) {
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
match parts.as_slice() {
|
||||
["index.html"] => Some(GenerationSection::Core),
|
||||
["category", ..] => Some(GenerationSection::Category),
|
||||
["tag", ..] => Some(GenerationSection::Tag),
|
||||
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
|
||||
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date),
|
||||
[year, month, day, _slug, "index.html"]
|
||||
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_language_prefix(parts: &[&str]) -> bool {
|
||||
match parts {
|
||||
[first, second, ..] => {
|
||||
!is_year_segment(first)
|
||||
&& *first != "category"
|
||||
&& *first != "tag"
|
||||
&& (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_year_segment(value: &str) -> bool {
|
||||
value.len() == 4 && value.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_month_segment(value: &str) -> bool {
|
||||
value.len() == 2 && value.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_day_segment(value: &str) -> bool {
|
||||
is_month_segment(value)
|
||||
}
|
||||
|
||||
fn matches_generated_extension(path: &str) -> bool {
|
||||
path.ends_with(".html") || path.ends_with(".xml") || path.ends_with(".json")
|
||||
}
|
||||
|
||||
fn all_sections() -> Vec<GenerationSection> {
|
||||
vec![
|
||||
GenerationSection::Core,
|
||||
GenerationSection::Single,
|
||||
GenerationSection::Category,
|
||||
GenerationSection::Tag,
|
||||
GenerationSection::Date,
|
||||
]
|
||||
}
|
||||
|
||||
fn section_sort_key(section: &GenerationSection) -> u8 {
|
||||
match section {
|
||||
GenerationSection::Core => 0,
|
||||
GenerationSection::Single => 1,
|
||||
GenerationSection::Category => 2,
|
||||
GenerationSection::Tag => 3,
|
||||
GenerationSection::Date => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn report_is_empty(report: &SiteValidationReport) -> bool {
|
||||
report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty()
|
||||
}
|
||||
|
||||
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 load_category_settings(data_dir: &Path) -> HashMap<String, CategorySettings> {
|
||||
crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn filter_posts_for_lists(
|
||||
posts: &[PublishedPostSource],
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
) -> Vec<PublishedPostSource> {
|
||||
posts.iter()
|
||||
.filter(|source| {
|
||||
!source.post.categories.iter().any(|category| {
|
||||
category_settings
|
||||
.get(category)
|
||||
.map(|settings| !settings.render_in_lists)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -132,6 +587,8 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang
|
||||
|
||||
fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let feed_prefix = language_prefix(language, main_language);
|
||||
let updated = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
@@ -143,8 +600,8 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
|
||||
format!(" <title>{}</title>", escape_xml(&metadata.name)),
|
||||
format!(" <subtitle>{}</subtitle>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
|
||||
format!(" <id>{base_url}/</id>"),
|
||||
format!(" <link href=\"{base_url}/\" rel=\"alternate\" />"),
|
||||
format!(" <link href=\"{base_url}/atom.xml\" rel=\"self\" />"),
|
||||
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
|
||||
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
|
||||
format!(" <updated>{}</updated>", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
|
||||
];
|
||||
|
||||
@@ -176,36 +633,74 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
fn build_sitemap_xml(
|
||||
metadata: &ProjectMetadata,
|
||||
pages: &[crate::render::SitePage],
|
||||
posts: &[PublishedPostSource],
|
||||
language: &str,
|
||||
) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let languages = render_languages(metadata);
|
||||
let index_lastmod = posts
|
||||
.iter()
|
||||
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
|
||||
.max()
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
let post_lastmod_by_path = posts
|
||||
.iter()
|
||||
.filter_map(|source| {
|
||||
timestamp(source.post.published_at.unwrap_or(source.post.created_at)).map(|lastmod| {
|
||||
(
|
||||
build_canonical_post_path(&source.post, language, main_language),
|
||||
lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let page_groups = group_pages_by_logical_path(pages, &languages, main_language);
|
||||
|
||||
let mut xml = vec![
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
||||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
|
||||
" <url>".to_string(),
|
||||
format!(" <loc>{base_url}/</loc>"),
|
||||
format!(" <lastmod>{index_lastmod}</lastmod>"),
|
||||
" <changefreq>daily</changefreq>".to_string(),
|
||||
" <priority>1.0</priority>".to_string(),
|
||||
" </url>".to_string(),
|
||||
];
|
||||
|
||||
for source in posts {
|
||||
let url = format!("{base_url}{}", build_canonical_post_path(&source.post, language, metadata.main_language.as_deref().unwrap_or("en")));
|
||||
let lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
|
||||
.unwrap_or_else(Utc::now)
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
for page in pages.iter().filter(|page| page.language == language) {
|
||||
let url = format!("{base_url}{}", page.url_path);
|
||||
let logical_key = logical_page_key(&page.relative_path, &languages, main_language);
|
||||
let alternates = page_groups.get(&logical_key);
|
||||
let lastmod = post_lastmod_by_path
|
||||
.get(&page.url_path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| index_lastmod.clone());
|
||||
let is_home = page.url_path == language_root_url_path(language, main_language);
|
||||
xml.push(" <url>".to_string());
|
||||
xml.push(format!(" <loc>{url}</loc>"));
|
||||
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
||||
xml.push(" <changefreq>weekly</changefreq>".to_string());
|
||||
xml.push(" <priority>0.8</priority>".to_string());
|
||||
xml.push(format!(
|
||||
" <changefreq>{}</changefreq>",
|
||||
if is_home { "daily" } else { "weekly" }
|
||||
));
|
||||
xml.push(format!(
|
||||
" <priority>{}</priority>",
|
||||
if is_home { "1.0" } else { "0.8" }
|
||||
));
|
||||
if let Some(alternates) = alternates {
|
||||
for alternate in alternates {
|
||||
xml.push(format!(
|
||||
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{base_url}{}\" />",
|
||||
escape_xml(&alternate.language),
|
||||
alternate.url_path,
|
||||
));
|
||||
}
|
||||
if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) {
|
||||
xml.push(format!(
|
||||
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{base_url}{}\" />",
|
||||
default_page.url_path,
|
||||
));
|
||||
}
|
||||
}
|
||||
xml.push(" </url>".to_string());
|
||||
}
|
||||
|
||||
@@ -213,6 +708,50 @@ fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource],
|
||||
xml.join("\n")
|
||||
}
|
||||
|
||||
fn language_prefix(language: &str, main_language: &str) -> String {
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{language}")
|
||||
}
|
||||
}
|
||||
|
||||
fn language_root_url_path(language: &str, main_language: &str) -> String {
|
||||
let prefix = language_prefix(language, main_language);
|
||||
if prefix.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("{prefix}/")
|
||||
}
|
||||
}
|
||||
|
||||
fn group_pages_by_logical_path<'a>(
|
||||
pages: &'a [crate::render::SitePage],
|
||||
languages: &[String],
|
||||
main_language: &str,
|
||||
) -> HashMap<String, Vec<&'a crate::render::SitePage>> {
|
||||
let mut grouped = HashMap::<String, Vec<&crate::render::SitePage>>::new();
|
||||
for page in pages {
|
||||
let key = logical_page_key(&page.relative_path, languages, main_language);
|
||||
grouped.entry(key).or_default().push(page);
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
fn logical_page_key(relative_path: &str, languages: &[String], main_language: &str) -> String {
|
||||
let mut parts = relative_path.split('/');
|
||||
let Some(first) = parts.next() else {
|
||||
return relative_path.to_string();
|
||||
};
|
||||
if first.eq_ignore_ascii_case(main_language) {
|
||||
return parts.collect::<Vec<_>>().join("/");
|
||||
}
|
||||
if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) {
|
||||
return parts.collect::<Vec<_>>().join("/");
|
||||
}
|
||||
relative_path.to_string()
|
||||
}
|
||||
|
||||
fn timestamp(timestamp_ms: i64) -> Option<DateTime<Utc>> {
|
||||
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ pub mod script;
|
||||
pub mod script_rebuild;
|
||||
pub mod task;
|
||||
pub mod metadata_diff;
|
||||
pub mod site_assets;
|
||||
pub mod rebuild;
|
||||
pub mod search;
|
||||
pub mod calendar;
|
||||
pub mod generation;
|
||||
pub mod preview;
|
||||
pub mod validate_site;
|
||||
pub mod validate_translations;
|
||||
pub mod validate_media;
|
||||
pub mod validate_content;
|
||||
|
||||
@@ -14,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, render_starter_single_post_page};
|
||||
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";
|
||||
@@ -59,6 +59,14 @@ struct PreviewServerState {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct DraftPreviewQuery {
|
||||
language: Option<String>,
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct StylePreviewQuery {
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
pub fn start_preview_server(
|
||||
@@ -90,6 +98,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);
|
||||
@@ -106,41 +115,12 @@ pub fn start_preview_server(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_preview_path(
|
||||
path: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
) -> 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(&list_posts, metadata, main_language)
|
||||
.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(&source.post, &source.body_markdown, metadata, &language)
|
||||
.map(|page| Some(page.html))
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_preview_request(
|
||||
State(state): State<PreviewServerState>,
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
uri: Uri,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, uri.path(), None) {
|
||||
match render_preview_response(&state, uri.path(), None, Some(&query)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
@@ -151,19 +131,39 @@ async fn handle_draft_preview(
|
||||
AxumPath(post_id): AxumPath<String>,
|
||||
Query(query): Query<DraftPreviewQuery>,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) {
|
||||
let style = StylePreviewQuery {
|
||||
theme: query.theme.clone(),
|
||||
mode: query.mode.clone(),
|
||||
};
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
requested_language: Option<&str>,
|
||||
style: Option<&StylePreviewQuery>,
|
||||
) -> EngineResult<Response> {
|
||||
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
||||
let html = render_draft_preview(state, post_id, requested_language)?;
|
||||
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
@@ -172,11 +172,62 @@ fn render_preview_response(
|
||||
}
|
||||
|
||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let published_posts = collect_published_posts(state, &metadata)?;
|
||||
match render_preview_path(path, &metadata, &published_posts)? {
|
||||
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(apply_preview_style(response.html, style))).into_response())
|
||||
}
|
||||
|
||||
fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String {
|
||||
let Some(style) = style else {
|
||||
return html;
|
||||
};
|
||||
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let Some(start) = html.find("<html") else {
|
||||
return html;
|
||||
};
|
||||
let Some(end_rel) = html[start..].find('>') else {
|
||||
return html;
|
||||
};
|
||||
let end = start + end_rel;
|
||||
|
||||
let mut attrs = String::new();
|
||||
if let Some(theme) = style.theme.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-theme=\"");
|
||||
attrs.push_str(&escape_html_attr(theme));
|
||||
attrs.push('"');
|
||||
}
|
||||
if let Some(mode) = style.mode.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-mode=\"");
|
||||
attrs.push_str(&escape_html_attr(mode));
|
||||
attrs.push('"');
|
||||
}
|
||||
if attrs.is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let mut styled = String::with_capacity(html.len() + attrs.len());
|
||||
styled.push_str(&html[..end]);
|
||||
styled.push_str(&attrs);
|
||||
styled.push_str(&html[end..]);
|
||||
styled
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
@@ -204,21 +255,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(&translated_post, &body, &metadata, target_language)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
&[(translated_post, body)],
|
||||
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
|
||||
)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
return Ok(response.html);
|
||||
}
|
||||
}
|
||||
|
||||
let body = load_post_body(&state.data_dir, &post)?;
|
||||
render_starter_single_post_page(&post, &body, &metadata, canonical_language)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
&[(post.clone(), body)],
|
||||
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")),
|
||||
)
|
||||
.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)?;
|
||||
@@ -229,8 +294,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)
|
||||
}
|
||||
|
||||
@@ -322,21 +386,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::*;
|
||||
@@ -454,26 +503,52 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn root_preview_renders_index_page() {
|
||||
let html = render_preview_path("/", &make_metadata(), &[make_post()])
|
||||
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", &make_metadata(), &[make_post()])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let source = make_post();
|
||||
let html = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(source.post, source.body_markdown)],
|
||||
"/2024/03/09/hello",
|
||||
)
|
||||
.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()])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
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**",
|
||||
)
|
||||
.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\""));
|
||||
}
|
||||
|
||||
@@ -531,4 +606,293 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_serves_media_and_asset_files() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, _db) = setup_preview_fixture();
|
||||
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||
std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
||||
std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap();
|
||||
|
||||
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 media = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/ok.txt"))
|
||||
.send()
|
||||
.unwrap();
|
||||
let asset = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"))
|
||||
.send()
|
||||
.unwrap();
|
||||
let media_body = media.text().unwrap();
|
||||
let asset_body = asset.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert_eq!(media_body, "ok");
|
||||
assert!(asset_body.contains("color: red"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_applies_theme_query_to_rendered_pages() {
|
||||
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}/?theme=nightfall&mode=dark"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("data-theme=\"nightfall\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_respects_category_list_visibility_and_show_title_rules() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
crate::engine::meta::write_category_meta_json(
|
||||
dir.path(),
|
||||
&std::collections::HashMap::from([
|
||||
(
|
||||
"hidden".to_string(),
|
||||
crate::model::CategorySettings {
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"featured".to_string(),
|
||||
crate::model::CategorySettings {
|
||||
render_in_lists: true,
|
||||
show_title: false,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let mut hidden = make_post();
|
||||
hidden.post.title = "Hidden Post".into();
|
||||
hidden.post.slug = "hidden-post".into();
|
||||
hidden.post.categories = vec!["hidden".into()];
|
||||
hidden.body_markdown = "Hidden body".into();
|
||||
|
||||
let mut featured = make_post();
|
||||
featured.post.title = "Featured Post".into();
|
||||
featured.post.slug = "featured-post".into();
|
||||
featured.post.categories = vec!["featured".into()];
|
||||
featured.body_markdown = "Featured body".into();
|
||||
|
||||
let hidden_response = build_preview_response(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone())],
|
||||
"/category/hidden",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hidden_response.status_code, 404);
|
||||
|
||||
let featured_response = build_preview_response(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)],
|
||||
"/category/featured",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(featured_response.status_code, 200);
|
||||
assert!(featured_response.html.contains("Featured body"));
|
||||
assert!(!featured_response.html.contains("<h2 class=\"post-title\""));
|
||||
assert!(!featured_response.html.contains("Featured Post"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_tag_archive_pagination_and_date_routes() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let mut metadata = make_metadata();
|
||||
metadata.max_posts_per_page = 1;
|
||||
|
||||
let mut oldest = make_post();
|
||||
oldest.post.id = "post-1".into();
|
||||
oldest.post.slug = "alpha".into();
|
||||
oldest.post.title = "Alpha".into();
|
||||
oldest.post.tags = vec!["Rust".into()];
|
||||
oldest.post.published_at = Some(1_709_568_000_000);
|
||||
oldest.post.created_at = 1_709_568_000_000;
|
||||
oldest.body_markdown = "Alpha body".into();
|
||||
|
||||
let mut newest = make_post();
|
||||
newest.post.id = "post-2".into();
|
||||
newest.post.slug = "beta".into();
|
||||
newest.post.title = "Beta".into();
|
||||
newest.post.tags = vec!["Rust".into()];
|
||||
newest.post.published_at = Some(1_710_086_400_000);
|
||||
newest.post.created_at = 1_710_086_400_000;
|
||||
newest.body_markdown = "Beta body".into();
|
||||
|
||||
let mut april = make_post();
|
||||
april.post.id = "post-3".into();
|
||||
april.post.slug = "gamma".into();
|
||||
april.post.title = "Gamma".into();
|
||||
april.post.tags = vec!["Preview".into()];
|
||||
april.post.published_at = Some(1_712_016_000_000);
|
||||
april.post.created_at = 1_712_016_000_000;
|
||||
april.body_markdown = "Gamma body".into();
|
||||
|
||||
let posts = vec![
|
||||
(oldest.post.clone(), oldest.body_markdown.clone()),
|
||||
(newest.post.clone(), newest.body_markdown.clone()),
|
||||
(april.post.clone(), april.body_markdown.clone()),
|
||||
];
|
||||
|
||||
let tag_first = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/tag/rust",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tag_first.status_code, 200);
|
||||
assert!(tag_first.html.contains("Beta body"));
|
||||
assert!(!tag_first.html.contains("Alpha body"));
|
||||
|
||||
let tag_second = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/tag/rust/page/2",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tag_second.status_code, 200);
|
||||
assert!(tag_second.html.contains("Alpha body"));
|
||||
assert!(!tag_second.html.contains("Beta body"));
|
||||
|
||||
let year_archive = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/2024",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(year_archive.status_code, 200);
|
||||
assert!(year_archive.html.contains("Gamma body"));
|
||||
assert!(!year_archive.html.contains("Beta body"));
|
||||
|
||||
let year_archive_page_two = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/2024/page/2",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(year_archive_page_two.status_code, 200);
|
||||
assert!(year_archive_page_two.html.contains("Beta body"));
|
||||
|
||||
let month_archive = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/2024/03",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(month_archive.status_code, 200);
|
||||
assert!(month_archive.html.contains("Beta body"));
|
||||
assert!(!month_archive.html.contains("Alpha body"));
|
||||
assert!(!month_archive.html.contains("Gamma body"));
|
||||
|
||||
let month_archive_page_two = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&metadata,
|
||||
&posts,
|
||||
"/2024/03/page/2",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(month_archive_page_two.status_code, 200);
|
||||
assert!(month_archive_page_two.html.contains("Alpha body"));
|
||||
assert!(!month_archive_page_two.html.contains("Beta body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_returns_not_found_template_for_unknown_routes() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(make_post().post, make_post().body_markdown)],
|
||||
"/missing/route",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status_code, 404);
|
||||
assert!(response.html.contains("No rendered page exists for /missing/route"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::project as q;
|
||||
use crate::engine::site_assets::copy_bundled_site_assets;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Project;
|
||||
use crate::model::metadata::ProjectMetadata;
|
||||
@@ -152,7 +153,7 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn create_directory_structure(data_dir: &Path) -> EngineResult<()> {
|
||||
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts"];
|
||||
let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"];
|
||||
for sub in &subdirs {
|
||||
fs::create_dir_all(data_dir.join(sub))?;
|
||||
}
|
||||
@@ -200,6 +201,7 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
|
||||
|
||||
// Starter templates — per project.allium StarterTemplatesCopied
|
||||
copy_starter_templates(data_dir)?;
|
||||
copy_bundled_site_assets(data_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -274,6 +276,9 @@ mod tests {
|
||||
assert!(data_path.join("thumbnails").is_dir());
|
||||
assert!(data_path.join("templates").is_dir());
|
||||
assert!(data_path.join("scripts").is_dir());
|
||||
assert!(data_path.join("assets").is_dir());
|
||||
assert!(data_path.join("assets/pico.min.css").is_file());
|
||||
assert!(data_path.join("assets/tag-cloud.js").is_file());
|
||||
|
||||
// Verify meta files
|
||||
let project_json: serde_json::Value =
|
||||
|
||||
83
crates/bds-core/src/engine/site_assets.rs
Normal file
83
crates/bds-core/src/engine/site_assets.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct BundledSiteAsset {
|
||||
pub relative_path: &'static str,
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
||||
BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") },
|
||||
BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") },
|
||||
BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") },
|
||||
BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") },
|
||||
BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") },
|
||||
BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") },
|
||||
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
||||
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") },
|
||||
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") },
|
||||
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") },
|
||||
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") },
|
||||
];
|
||||
|
||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||
for asset in BUNDLED_SITE_ASSETS {
|
||||
let target = project_dir.join(asset.relative_path);
|
||||
if target.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(target, asset.bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_bundled_site_assets(
|
||||
conn: &Connection,
|
||||
output_dir: &Path,
|
||||
project_id: &str,
|
||||
report: &mut crate::engine::generation::GenerationReport,
|
||||
) -> EngineResult<()> {
|
||||
for asset in BUNDLED_SITE_ASSETS {
|
||||
match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?
|
||||
{
|
||||
GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()),
|
||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
142
crates/bds-core/src/engine/validate_site.rs
Normal file
142
crates/bds-core/src/engine/validate_site.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
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 = generated_output_dir(data_dir);
|
||||
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 generated_output_dir(data_dir: &Path) -> std::path::PathBuf {
|
||||
let html_dir = data_dir.join("html");
|
||||
if html_dir.exists() {
|
||||
html_dir
|
||||
} else {
|
||||
data_dir.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use serde::Serialize;
|
||||
|
||||
use crate::db::queries::generated_file_hash as qhash;
|
||||
use crate::model::{GeneratedFileHash, Post};
|
||||
use crate::util::{atomic_write_str, content_hash, now_unix_ms};
|
||||
use crate::util::{atomic_write_str, content_hash, file_hash, now_unix_ms};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GeneratedWriteOutcome {
|
||||
@@ -31,13 +31,16 @@ pub fn write_generated_file(
|
||||
content: &str,
|
||||
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash = content_hash(content.as_bytes());
|
||||
let target_path = output_dir.join(relative_path);
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
||||
if existing.content_hash == hash {
|
||||
if existing.content_hash == hash
|
||||
&& target_path.exists()
|
||||
&& file_hash(&target_path)? == 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)?;
|
||||
}
|
||||
@@ -56,6 +59,42 @@ 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);
|
||||
let target_path = output_dir.join(relative_path);
|
||||
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
|
||||
if existing.content_hash == hash
|
||||
&& target_path.exists()
|
||||
&& file_hash(&target_path)? == hash
|
||||
{
|
||||
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
473
crates/bds-core/src/render/macros.rs
Normal file
473
crates/bds-core/src/render/macros.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct MacroRenderContext {
|
||||
pub roots: Map<String, JsonValue>,
|
||||
pub post_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String {
|
||||
let mut expanded = String::with_capacity(markdown.len());
|
||||
let mut cursor = 0;
|
||||
|
||||
while let Some(offset) = markdown[cursor..].find("[[") {
|
||||
let start = cursor + offset;
|
||||
expanded.push_str(&markdown[cursor..start]);
|
||||
|
||||
let body_start = start + 2;
|
||||
let Some(end_offset) = markdown[body_start..].find("]]") else {
|
||||
expanded.push_str(&markdown[start..]);
|
||||
return expanded;
|
||||
};
|
||||
let end = body_start + end_offset;
|
||||
let invocation = &markdown[body_start..end];
|
||||
let raw = &markdown[start..end + 2];
|
||||
|
||||
if let Some(rendered) = render_macro(invocation, context) {
|
||||
expanded.push_str(&rendered);
|
||||
} else {
|
||||
expanded.push_str(raw);
|
||||
}
|
||||
cursor = end + 2;
|
||||
}
|
||||
|
||||
expanded.push_str(&markdown[cursor..]);
|
||||
expanded
|
||||
}
|
||||
|
||||
fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String> {
|
||||
let tokens = tokenize_invocation(invocation);
|
||||
let name = tokens.first()?.as_str();
|
||||
let mut args = HashMap::new();
|
||||
for token in tokens.iter().skip(1) {
|
||||
let (key, raw_value) = token.split_once('=')?;
|
||||
args.insert(key.to_string(), resolve_token(raw_value, context));
|
||||
}
|
||||
|
||||
match name {
|
||||
"gallery" => Some(render_gallery(&args, context)),
|
||||
"youtube" => Some(render_youtube(&args)),
|
||||
"vimeo" => Some(render_vimeo(&args)),
|
||||
"photo_archive" => Some(render_photo_archive(&args, context)),
|
||||
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
||||
_ => Some(unsupported_macro(name)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_macro(name: &str) -> String {
|
||||
format!(
|
||||
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
|
||||
escape_html_attr(name),
|
||||
)
|
||||
}
|
||||
|
||||
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quote = None;
|
||||
|
||||
for ch in invocation.chars() {
|
||||
if let Some(active_quote) = quote {
|
||||
current.push(ch);
|
||||
if ch == active_quote {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' | '\'' => {
|
||||
quote = Some(ch);
|
||||
current.push(ch);
|
||||
}
|
||||
' ' | '\n' | '\t' | '\r' => {
|
||||
if !current.is_empty() {
|
||||
tokens.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.len() >= 2 {
|
||||
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
|
||||
{
|
||||
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match trimmed {
|
||||
"true" => return JsonValue::Bool(true),
|
||||
"false" => return JsonValue::Bool(false),
|
||||
"null" => return JsonValue::Null,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Ok(value) = trimmed.parse::<i64>() {
|
||||
return JsonValue::from(value);
|
||||
}
|
||||
if let Ok(value) = trimmed.parse::<f64>() {
|
||||
return JsonValue::from(value);
|
||||
}
|
||||
|
||||
lookup_path(trimmed, context).unwrap_or_else(|| JsonValue::String(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn lookup_path(path: &str, context: &MacroRenderContext) -> Option<JsonValue> {
|
||||
let mut segments = path.split('.');
|
||||
let first = segments.next()?;
|
||||
let mut current = context.roots.get(first)?.clone();
|
||||
|
||||
for segment in segments {
|
||||
current = match current {
|
||||
JsonValue::Object(map) => map.get(segment)?.clone(),
|
||||
JsonValue::Array(values) => values.get(segment.parse::<usize>().ok()?)?.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||
let columns = args
|
||||
.get("columns")
|
||||
.and_then(value_as_u64)
|
||||
.map(|value| value.clamp(1, 6) as usize)
|
||||
.unwrap_or(3);
|
||||
let images = args
|
||||
.get("images")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.or_else(|| linked_media(context));
|
||||
|
||||
let Some(images) = images else {
|
||||
return empty_block(
|
||||
&format!("macro-gallery gallery-cols-{columns}"),
|
||||
"gallery-empty",
|
||||
"No media available.",
|
||||
);
|
||||
};
|
||||
if images.is_empty() {
|
||||
return empty_block(
|
||||
&format!("macro-gallery gallery-cols-{columns}"),
|
||||
"gallery-empty",
|
||||
"No media available.",
|
||||
);
|
||||
}
|
||||
|
||||
let gallery_id = context.post_id.as_deref().unwrap_or("gallery");
|
||||
let mut html = format!(
|
||||
"<section class=\"macro-gallery gallery-cols-{columns}\"><div class=\"gallery-container\">"
|
||||
);
|
||||
for image in images {
|
||||
let Some(path) = image_path(&image) else {
|
||||
continue;
|
||||
};
|
||||
let title = image_title(&image);
|
||||
let alt = image_alt(&image, title.as_deref());
|
||||
html.push_str(&format!(
|
||||
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
|
||||
escape_html_attr(&path),
|
||||
escape_html_attr(gallery_id),
|
||||
title
|
||||
.as_deref()
|
||||
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
|
||||
.unwrap_or_default(),
|
||||
escape_html_attr(&path),
|
||||
escape_html_attr(&alt),
|
||||
));
|
||||
}
|
||||
html.push_str("</div></section>");
|
||||
html
|
||||
}
|
||||
|
||||
fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
|
||||
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
|
||||
if video_id.is_empty() {
|
||||
return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id.");
|
||||
}
|
||||
format!(
|
||||
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
|
||||
escape_html_attr(&video_id),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_vimeo(args: &HashMap<String, JsonValue>) -> String {
|
||||
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
|
||||
if video_id.is_empty() {
|
||||
return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id.");
|
||||
}
|
||||
format!(
|
||||
"<section class=\"macro-vimeo\"><iframe src=\"https://player.vimeo.com/video/{}\" title=\"Vimeo video\" loading=\"lazy\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></section>",
|
||||
escape_html_attr(&video_id),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||
let media = args
|
||||
.get("media")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.or_else(|| linked_media(context));
|
||||
|
||||
let Some(media) = media else {
|
||||
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
|
||||
};
|
||||
if media.is_empty() {
|
||||
return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available.");
|
||||
}
|
||||
|
||||
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new();
|
||||
for item in media {
|
||||
let bucket = item
|
||||
.get("file_path")
|
||||
.and_then(JsonValue::as_str)
|
||||
.and_then(month_bucket)
|
||||
.unwrap_or_else(|| "Archive".to_string());
|
||||
grouped.entry(bucket).or_default().push(item);
|
||||
}
|
||||
|
||||
let single_month = grouped.len() == 1;
|
||||
let mut html = format!(
|
||||
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
|
||||
if single_month { " photo-archive-single-month" } else { "" }
|
||||
);
|
||||
for (bucket, items) in grouped.into_iter().rev() {
|
||||
html.push_str("<section class=\"photo-archive-month\">");
|
||||
html.push_str(&format!(
|
||||
"<header class=\"photo-archive-month-label\"><span>{}</span></header>",
|
||||
escape_html(&bucket),
|
||||
));
|
||||
html.push_str("<div class=\"photo-archive-gallery\">");
|
||||
for item in items {
|
||||
let Some(path) = image_path(&item) else {
|
||||
continue;
|
||||
};
|
||||
let title = image_title(&item);
|
||||
let alt = image_alt(&item, title.as_deref());
|
||||
html.push_str(&format!(
|
||||
"<a class=\"photo-archive-item\" href=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
|
||||
escape_html_attr(&path),
|
||||
title
|
||||
.as_deref()
|
||||
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
|
||||
.unwrap_or_default(),
|
||||
escape_html_attr(&path),
|
||||
escape_html_attr(&alt),
|
||||
));
|
||||
}
|
||||
html.push_str("</div></section>");
|
||||
}
|
||||
html.push_str("</div></section>");
|
||||
html
|
||||
}
|
||||
|
||||
fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||
let tags = args
|
||||
.get("tags")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.or_else(|| tag_items(context));
|
||||
|
||||
let Some(tags) = tags else {
|
||||
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||
};
|
||||
if tags.is_empty() {
|
||||
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||
}
|
||||
|
||||
let words = build_tag_cloud_words(&tags, context);
|
||||
if words.is_empty() {
|
||||
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available.");
|
||||
}
|
||||
|
||||
let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string());
|
||||
format!(
|
||||
"<section class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-color-distribution=\"quantile\" data-color-theme=\"pico\" data-color-easing=\"0.7\" data-width=\"900\" data-height=\"420\" data-orientation=\"mixed-diagonal\" data-tag-cloud-words='{}'><svg class=\"tag-cloud-canvas\" aria-label=\"Tag cloud\"></svg></section>",
|
||||
escape_html_attr(&words_json),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Vec<JsonValue> {
|
||||
let min_count = tags
|
||||
.iter()
|
||||
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
||||
.min()
|
||||
.unwrap_or(1);
|
||||
let max_count = tags
|
||||
.iter()
|
||||
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
||||
.max()
|
||||
.unwrap_or(min_count.max(1));
|
||||
|
||||
tags.iter()
|
||||
.filter_map(|tag| {
|
||||
let name = tag.get("name").and_then(JsonValue::as_str)?;
|
||||
let slug = tag
|
||||
.get("slug")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| name.to_lowercase().replace(' ', "-"));
|
||||
let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
|
||||
let size = if max_count == min_count {
|
||||
36.0
|
||||
} else {
|
||||
18.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 38.0)
|
||||
};
|
||||
let color = tag
|
||||
.get("color")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
context
|
||||
.roots
|
||||
.get("tag_color_by_name")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|colors| colors.get(name))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
Some(serde_json::json!({
|
||||
"text": name,
|
||||
"count": count,
|
||||
"url": format!("/tag/{slug}"),
|
||||
"size": size.round(),
|
||||
"color": color,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
||||
lookup_path("post.linked_media", context).and_then(|value| value.as_array().cloned())
|
||||
}
|
||||
|
||||
fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
||||
lookup_path("post_tags", context)
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.or_else(|| lookup_path("Tags", context).and_then(|value| value.as_array().cloned()))
|
||||
}
|
||||
|
||||
fn image_path(image: &JsonValue) -> Option<String> {
|
||||
image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn image_title(image: &JsonValue) -> Option<String> {
|
||||
image
|
||||
.get("caption")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
image
|
||||
.get("title")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String {
|
||||
image
|
||||
.get("alt")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| fallback.map(ToOwned::to_owned))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn value_as_u64(value: &JsonValue) -> Option<u64> {
|
||||
value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64))
|
||||
}
|
||||
|
||||
fn stringify_scalar(value: &JsonValue) -> String {
|
||||
match value {
|
||||
JsonValue::Null => String::new(),
|
||||
JsonValue::Bool(boolean) => boolean.to_string(),
|
||||
JsonValue::Number(number) => number.to_string(),
|
||||
JsonValue::String(text) => text.clone(),
|
||||
JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn month_bucket(path: &str) -> Option<String> {
|
||||
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
||||
match segments.as_slice() {
|
||||
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> String {
|
||||
format!(
|
||||
"<section class=\"{}\"><p class=\"{}\">{}</p></section>",
|
||||
wrapper_class,
|
||||
message_class,
|
||||
escape_html(message),
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
escape_html(value).replace('"', """).replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MacroRenderContext, expand_builtin_macros};
|
||||
|
||||
#[test]
|
||||
fn expands_gallery_and_tag_cloud_macros() {
|
||||
let mut roots = serde_json::Map::new();
|
||||
roots.insert(
|
||||
"post".to_string(),
|
||||
serde_json::json!({
|
||||
"linked_media": [
|
||||
{"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Alt one"},
|
||||
{"file_path": "/media/2026/04/two.jpg", "caption": "Two"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
roots.insert(
|
||||
"post_tags".to_string(),
|
||||
serde_json::json!([
|
||||
{"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"},
|
||||
{"name": "Iced", "slug": "iced", "post_count": 2}
|
||||
]),
|
||||
);
|
||||
roots.insert(
|
||||
"tag_color_by_name".to_string(),
|
||||
serde_json::json!({"Iced": "#0088cc"}),
|
||||
);
|
||||
|
||||
let html = expand_builtin_macros(
|
||||
"[[gallery images=post.linked_media columns=2]]\n\n[[tag_cloud tags=post_tags]]",
|
||||
&MacroRenderContext {
|
||||
roots,
|
||||
post_id: Some("post-1".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(html.contains("macro-gallery gallery-cols-2"));
|
||||
assert!(html.contains("data-lightbox=\"post-1\""));
|
||||
assert!(html.contains("macro-tag-cloud"));
|
||||
assert!(html.contains("data-tag-cloud=\"true\""));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
mod markdown;
|
||||
mod macros;
|
||||
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};
|
||||
pub use routes::{
|
||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||
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,
|
||||
};
|
||||
pub use site::{
|
||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts,
|
||||
build_preview_response, build_site_render_artifacts,
|
||||
};
|
||||
pub use template_lookup::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
|
||||
@@ -6,10 +6,13 @@ use liquid_core::{
|
||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection,
|
||||
FromFilterParameters, ParseFilter, Runtime, Value, ValueView,
|
||||
};
|
||||
use liquid_core::model::ScalarCow;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::i18n::translate_render;
|
||||
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
||||
use crate::render::render_markdown_to_html;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -123,12 +126,70 @@ impl Filter for MarkdownFilter {
|
||||
.map(value_to_string_map)
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let macro_context = MacroRenderContext {
|
||||
roots: collect_macro_roots(runtime),
|
||||
post_id: runtime
|
||||
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
||||
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
|
||||
};
|
||||
|
||||
let rendered = render_markdown_to_html(markdown.as_str());
|
||||
let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context);
|
||||
let rendered = render_markdown_to_html(&expanded);
|
||||
Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context)))
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||
let mut roots = JsonMap::new();
|
||||
|
||||
for key in ["post", "post_tags", "tag_color_by_name", "project", "Tags"] {
|
||||
if let Some(value) = runtime.try_get(&[ScalarCow::new(key)]) {
|
||||
roots.insert(key.to_string(), liquid_value_to_json(value.as_view()));
|
||||
}
|
||||
}
|
||||
|
||||
if !roots.contains_key("Tags") {
|
||||
if let Some(tags) = roots.get("post_tags").cloned() {
|
||||
roots.insert("Tags".to_string(), tags);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn liquid_value_to_json(value: &dyn ValueView) -> JsonValue {
|
||||
if value.is_nil() {
|
||||
return JsonValue::Null;
|
||||
}
|
||||
|
||||
if let Some(scalar) = value.as_scalar() {
|
||||
if let Some(boolean) = scalar.to_bool() {
|
||||
return JsonValue::Bool(boolean);
|
||||
}
|
||||
if let Some(integer) = scalar.to_integer() {
|
||||
return JsonValue::from(integer);
|
||||
}
|
||||
if let Some(float) = scalar.to_float() {
|
||||
return JsonValue::from(float);
|
||||
}
|
||||
return JsonValue::String(scalar.to_kstr().to_string());
|
||||
}
|
||||
|
||||
if let Some(array) = value.as_array() {
|
||||
return JsonValue::Array(array.values().map(liquid_value_to_json).collect());
|
||||
}
|
||||
|
||||
if let Some(object) = value.as_object() {
|
||||
let mapped = object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), liquid_value_to_json(value)))
|
||||
.collect();
|
||||
return JsonValue::Object(mapped);
|
||||
}
|
||||
|
||||
JsonValue::String(value.to_kstr().to_string())
|
||||
}
|
||||
|
||||
fn value_to_string_map(value: &impl ValueView) -> HashMap<String, String> {
|
||||
value
|
||||
.as_object()
|
||||
@@ -208,11 +269,24 @@ fn rewrite_attribute_urls(
|
||||
}
|
||||
|
||||
fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
if raw_href.is_empty() || is_external_or_special_url(raw_href) {
|
||||
if raw_href.is_empty() {
|
||||
return raw_href.to_string();
|
||||
}
|
||||
|
||||
let (path_part, suffix) = split_path_suffix(raw_href.trim());
|
||||
if let Some(media_lookup_key) = extract_media_lookup_key(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_media_path_by_source_path()
|
||||
.get(&media_lookup_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("/{media_lookup_key}"));
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
if is_external_or_special_url(raw_href) {
|
||||
return raw_href.to_string();
|
||||
}
|
||||
|
||||
if let Some(normalized) = normalize_day_route(path_part) {
|
||||
return format!("{normalized}{suffix}");
|
||||
}
|
||||
@@ -226,7 +300,7 @@ fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextV
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
if let Some(media_source_key) = extract_media_source_key(path_part) {
|
||||
if let Some(media_source_key) = extract_media_lookup_key(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_media_path_by_source_path()
|
||||
.get(&media_source_key)
|
||||
@@ -239,12 +313,12 @@ fn normalize_preview_href(raw_href: &str, rewrite_context: &impl RewriteContextV
|
||||
}
|
||||
|
||||
fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextView) -> String {
|
||||
if raw_src.is_empty() || is_external_or_special_url(raw_src) {
|
||||
if raw_src.is_empty() {
|
||||
return raw_src.to_string();
|
||||
}
|
||||
|
||||
let (path_part, suffix) = split_path_suffix(raw_src.trim());
|
||||
if let Some(media_source_key) = extract_media_source_key(path_part) {
|
||||
if let Some(media_source_key) = extract_media_lookup_key(path_part) {
|
||||
let canonical = rewrite_context
|
||||
.canonical_media_path_by_source_path()
|
||||
.get(&media_source_key)
|
||||
@@ -253,6 +327,10 @@ fn normalize_preview_src(raw_src: &str, rewrite_context: &impl RewriteContextVie
|
||||
return format!("{canonical}{suffix}");
|
||||
}
|
||||
|
||||
if is_external_or_special_url(raw_src) {
|
||||
return raw_src.to_string();
|
||||
}
|
||||
|
||||
raw_src.to_string()
|
||||
}
|
||||
|
||||
@@ -316,7 +394,15 @@ fn extract_post_slug(path: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_media_source_key(path: &str) -> Option<String> {
|
||||
fn extract_media_lookup_key(path: &str) -> Option<String> {
|
||||
if let Some(media_id) = path.trim().strip_prefix("bds-media://") {
|
||||
let media_id = media_id.trim();
|
||||
if media_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(format!("bds-media://{media_id}"));
|
||||
}
|
||||
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let segments: Vec<_> = trimmed.split('/').collect();
|
||||
match segments.as_slice() {
|
||||
@@ -332,4 +418,43 @@ fn trim_html_suffix(value: &str) -> String {
|
||||
.trim_end_matches(".html")
|
||||
.trim_end_matches(".htm")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{rewrite_rendered_html_urls, RewriteContextView};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct TestRewriteContext {
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl RewriteContextView for TestRewriteContext {
|
||||
fn canonical_post_path_by_slug(&self) -> &HashMap<String, String> {
|
||||
&self.canonical_post_path_by_slug
|
||||
}
|
||||
|
||||
fn canonical_media_path_by_source_path(&self) -> &HashMap<String, String> {
|
||||
&self.canonical_media_path_by_source_path
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_bds_media_image_src_to_canonical_media_path() {
|
||||
let context = TestRewriteContext {
|
||||
canonical_post_path_by_slug: HashMap::new(),
|
||||
canonical_media_path_by_source_path: HashMap::from([(
|
||||
"bds-media://media-1".to_string(),
|
||||
"/media/2026/04/media-1.png".to_string(),
|
||||
)]),
|
||||
};
|
||||
|
||||
let html = rewrite_rendered_html_urls(
|
||||
"<p><img src=\"bds-media://media-1\" alt=\"\" /></p>",
|
||||
&context,
|
||||
);
|
||||
|
||||
assert!(html.contains("src=\"/media/2026/04/media-1.png\""));
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,22 @@ pub fn render_starter_single_post_page(
|
||||
body_markdown: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
render_starter_single_post_page_with_media_map(
|
||||
post,
|
||||
body_markdown,
|
||||
metadata,
|
||||
language,
|
||||
HashMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_starter_single_post_page_with_media_map(
|
||||
post: &Post,
|
||||
body_markdown: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/'));
|
||||
let canonical_path = build_canonical_post_path(post, language, main_language(metadata));
|
||||
@@ -151,7 +167,7 @@ pub fn render_starter_single_post_page(
|
||||
.collect(),
|
||||
backlinks: vec![],
|
||||
canonical_post_path_by_slug: HashMap::from([(post.slug.clone(), canonical_path)]),
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
canonical_media_path_by_source_path,
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -168,6 +184,15 @@ pub fn render_starter_list_page(
|
||||
posts: &[(Post, String)],
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
render_starter_list_page_with_media_map(posts, metadata, language, HashMap::new())
|
||||
}
|
||||
|
||||
pub fn render_starter_list_page_with_media_map(
|
||||
posts: &[(Post, String)],
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
) -> Result<RenderedPage, RenderError> {
|
||||
let relative_path = if language.eq_ignore_ascii_case(main_language(metadata)) {
|
||||
"index.html".to_string()
|
||||
@@ -217,7 +242,7 @@ pub fn render_starter_list_page(
|
||||
prev_page_href: None,
|
||||
next_page_href: None,
|
||||
canonical_post_path_by_slug: canonical_paths,
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
canonical_media_path_by_source_path,
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -447,6 +472,25 @@ mod tests {
|
||||
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_renderer_rewrites_bds_media_image_links() {
|
||||
let post = make_post();
|
||||
let metadata = make_metadata();
|
||||
let rendered = render_starter_single_post_page_with_media_map(
|
||||
&post,
|
||||
"",
|
||||
&metadata,
|
||||
"en",
|
||||
HashMap::from([(
|
||||
"bds-media://media-1".to_string(),
|
||||
"/media/2026/04/media-1.png".to_string(),
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.html.contains("src=\"/media/2026/04/media-1.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_list_renderer_groups_posts_and_uses_language_specific_index_path() {
|
||||
let metadata = make_metadata();
|
||||
|
||||
1250
crates/bds-core/src/render/site.rs
Normal file
1250
crates/bds-core/src/render/site.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::queries::template::insert_template;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
|
||||
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||
use bds_core::engine::generation::{
|
||||
PublishedPostSource, apply_validation_sections, generate_starter_site,
|
||||
sections_from_validation_report,
|
||||
};
|
||||
use bds_core::engine::meta::write_category_meta_json;
|
||||
use bds_core::engine::validate_site::validate_site;
|
||||
use bds_core::model::{CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind, TemplateStatus};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
@@ -60,11 +68,31 @@ fn make_post(slug: &str, published_at: i64) -> Post {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_list_template(slug: &str, content: &str) -> Template {
|
||||
Template {
|
||||
id: format!("template-{slug}"),
|
||||
project_id: "p1".into(),
|
||||
slug: slug.into(),
|
||||
title: slug.into(),
|
||||
kind: TemplateKind::List,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: String::new(),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some(content.into()),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -90,6 +118,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"assets/pico.min.css".to_string()));
|
||||
assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
||||
|
||||
@@ -98,6 +128,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
assert!(dir.path().join("feed.xml").exists());
|
||||
assert!(dir.path().join("atom.xml").exists());
|
||||
assert!(dir.path().join("sitemap.xml").exists());
|
||||
assert!(dir.path().join("assets/pico.min.css").exists());
|
||||
assert!(dir.path().join("assets/tag-cloud.js").exists());
|
||||
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
||||
|
||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||
@@ -106,6 +138,120 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
||||
assert!(sitemap.contains("https://example.com/category/article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
||||
let (db, dir) = setup();
|
||||
let mut metadata = make_metadata();
|
||||
metadata.main_language = Some("de".into());
|
||||
metadata.blog_languages = vec!["de".into(), "en".into()];
|
||||
let posts = vec![PublishedPostSource {
|
||||
post: make_post("hallo", 1_710_000_000_000),
|
||||
body_markdown: "Hallo Welt".into(),
|
||||
}];
|
||||
|
||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
|
||||
|
||||
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
||||
|
||||
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
|
||||
assert!(atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />") || atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />"));
|
||||
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("hreflang=\"de\" href=\"https://example.com/\""));
|
||||
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en\""));
|
||||
assert!(sitemap.contains("hreflang=\"x-default\" href=\"https://example.com/\""));
|
||||
assert!(sitemap.contains("https://example.com/category/article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
||||
let (db, dir) = setup();
|
||||
insert_template(
|
||||
db.conn(),
|
||||
&make_list_template(
|
||||
"featured-list",
|
||||
"FEATURED:{% for day_block in day_blocks %}{% for post in day_block.posts %}[{{ post.title }}|{{ post.show_title }}]{% endfor %}{% endfor %}",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
write_category_meta_json(
|
||||
dir.path(),
|
||||
&HashMap::from([
|
||||
(
|
||||
"hidden".to_string(),
|
||||
CategorySettings {
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"featured".to_string(),
|
||||
CategorySettings {
|
||||
render_in_lists: true,
|
||||
show_title: false,
|
||||
post_template_slug: None,
|
||||
list_template_slug: Some("featured-list".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut hidden_post = make_post("hidden-post", 1_710_000_000_000);
|
||||
hidden_post.title = "Hidden Post".into();
|
||||
hidden_post.categories = vec!["hidden".into()];
|
||||
|
||||
let mut featured_post = make_post("featured-post", 1_710_086_400_000);
|
||||
featured_post.title = "Featured Post".into();
|
||||
featured_post.categories = vec!["featured".into()];
|
||||
|
||||
let posts = vec![
|
||||
PublishedPostSource {
|
||||
post: hidden_post,
|
||||
body_markdown: "Hidden body".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: featured_post,
|
||||
body_markdown: "Featured body".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
|
||||
|
||||
for asset in [
|
||||
"images/close.png",
|
||||
"images/loading.gif",
|
||||
"images/next.png",
|
||||
"images/prev.png",
|
||||
] {
|
||||
assert!(report.written_paths.contains(&asset.to_string()));
|
||||
assert!(dir.path().join(asset).exists(), "missing bundled image {asset}");
|
||||
}
|
||||
|
||||
assert!(!report.written_paths.contains(&"category/hidden/index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"category/featured/index.html".to_string()));
|
||||
|
||||
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
|
||||
assert!(!index_html.contains("Hidden Post"));
|
||||
assert!(index_html.contains("[Featured Post|false]"));
|
||||
|
||||
let featured_html = std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
|
||||
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
|
||||
|
||||
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
|
||||
assert!(!feed.contains("hidden-post"));
|
||||
assert!(feed.contains("featured-post"));
|
||||
|
||||
let calendar = std::fs::read_to_string(dir.path().join("calendar.json")).unwrap();
|
||||
assert!(!calendar.contains("2024-03-09"));
|
||||
assert!(calendar.contains("2024-03-10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -125,4 +271,101 @@ 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()));
|
||||
assert!(second.skipped_paths.contains(&"assets/pico.min.css".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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_validation_repairs_core_section_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();
|
||||
let sections = sections_from_validation_report(&report);
|
||||
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap();
|
||||
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
|
||||
assert!(repaired.missing_pages.is_empty());
|
||||
assert!(repaired.extra_pages.is_empty());
|
||||
assert!(repaired.stale_pages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_validation_removes_extra_section_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();
|
||||
let extra_dir = dir.path().join("tag/ghost");
|
||||
std::fs::create_dir_all(&extra_dir).unwrap();
|
||||
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
|
||||
|
||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string()));
|
||||
let sections = sections_from_validation_report(&report);
|
||||
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions).unwrap();
|
||||
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string()));
|
||||
assert!(repaired.extra_pages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_validation_uses_html_output_directory_when_present() {
|
||||
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(),
|
||||
}];
|
||||
|
||||
let output_dir = dir.path().join("html");
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
generate_starter_site(db.conn(), &output_dir, "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert!(report.missing_pages.is_empty());
|
||||
assert!(report.extra_pages.is_empty());
|
||||
assert!(report.stale_pages.is_empty());
|
||||
}
|
||||
@@ -75,6 +75,32 @@ fn generated_write_skips_unchanged_content() {
|
||||
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_write_rewrites_missing_file_even_when_hash_matches() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
fs::remove_file(dir.path().join("index.html")).unwrap();
|
||||
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
|
||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(second, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
fs::write(dir.path().join("index.html"), "tampered").unwrap();
|
||||
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
|
||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(second, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_generation_paths_include_language_prefixed_variants() {
|
||||
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);
|
||||
|
||||
@@ -56,6 +56,59 @@ fn markdown_filter_rewrites_post_and_media_urls() {
|
||||
assert!(rendered.contains("src=\"/assets/pic.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_filter_expands_builtin_macros_from_runtime_context() {
|
||||
let template = "{{ body | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||
let partials = HashMap::new();
|
||||
let context = serde_json::json!({
|
||||
"body": "[[gallery images=post.linked_media columns=2]]\n\n[[youtube id=dQw4w9WgXcQ]]\n\n[[vimeo id=123456]]\n\n[[photo_archive media=post.linked_media]]\n\n[[tag_cloud tags=post_tags]]",
|
||||
"post": {
|
||||
"id": "post-1",
|
||||
"linked_media": [
|
||||
{"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Image one"},
|
||||
{"file_path": "/media/2026/04/two.jpg", "caption": "Two"}
|
||||
]
|
||||
},
|
||||
"post_data_json_by_id": {
|
||||
"post-1": {"id": "post-1", "title": "Post 1"}
|
||||
},
|
||||
"post_tags": [
|
||||
{"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"},
|
||||
{"name": "Iced", "slug": "iced", "post_count": 2}
|
||||
],
|
||||
"tag_color_by_name": {"Iced": "#0088cc"},
|
||||
"canonical_post_path_by_slug": {},
|
||||
"canonical_media_path_by_source_path": {},
|
||||
"language": "en",
|
||||
"language_prefix": ""
|
||||
});
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("macro-gallery gallery-cols-2"));
|
||||
assert!(rendered.contains("https://www.youtube.com/embed/dQw4w9WgXcQ"));
|
||||
assert!(rendered.contains("https://player.vimeo.com/video/123456"));
|
||||
assert!(rendered.contains("macro-photo-archive"));
|
||||
assert!(rendered.contains("data-tag-cloud=\"true\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_filter_marks_unsupported_macros_visibly() {
|
||||
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||
let partials = HashMap::new();
|
||||
let context = serde_json::json!({
|
||||
"body": "[[custom_block foo=bar]]",
|
||||
"canonical_post_path_by_slug": {},
|
||||
"canonical_media_path_by_source_path": {},
|
||||
"language": "en",
|
||||
"language_prefix": ""
|
||||
});
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("macro-unsupported"));
|
||||
assert!(rendered.contains("Unsupported macro"));
|
||||
assert!(rendered.contains("custom_block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_template_renders_with_partials() {
|
||||
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
||||
|
||||
@@ -8,15 +8,18 @@ license.workspace = true
|
||||
bds-core = { workspace = true }
|
||||
bds-editor = { workspace = true }
|
||||
iced = { workspace = true }
|
||||
iced_widget = { version = "0.13.4", features = ["markdown"] }
|
||||
muda = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
open = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
wry = "0.55"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod webview;
|
||||
|
||||
317
crates/bds-ui/src/components/webview.rs
Normal file
317
crates/bds-ui/src/components/webview.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use iced::event;
|
||||
use iced::advanced::layout::{self, Node};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
||||
use iced::mouse;
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, window};
|
||||
use wry::dpi::{LogicalPosition, LogicalSize};
|
||||
use wry::{Rect, WebView, WebViewBuilder};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
thread_local! {
|
||||
static STAGED: RefCell<HashMap<u64, WebView>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub enum Content {
|
||||
Url(String),
|
||||
Html(String),
|
||||
}
|
||||
|
||||
impl Default for Content {
|
||||
fn default() -> Self {
|
||||
Self::Url(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WebViewConfig {
|
||||
content: Content,
|
||||
transparent: bool,
|
||||
devtools: bool,
|
||||
}
|
||||
|
||||
impl WebViewConfig {
|
||||
pub fn url(mut self, url: impl Into<String>) -> Self {
|
||||
self.content = Content::Url(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn html(mut self, html: impl Into<String>) -> Self {
|
||||
self.content = Content::Html(html.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn transparent(mut self, transparent: bool) -> Self {
|
||||
self.transparent = transparent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn devtools(mut self, devtools: bool) -> Self {
|
||||
self.devtools = devtools;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedState {
|
||||
webview: Option<WebView>,
|
||||
last_bounds: Option<Rectangle>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BoundsSender(Rc<RefCell<SharedState>>);
|
||||
|
||||
impl BoundsSender {
|
||||
pub(crate) fn apply(&self, bounds: Rectangle) {
|
||||
let mut state = self.0.borrow_mut();
|
||||
state.last_bounds = Some(bounds);
|
||||
if let Some(webview) = &state.webview {
|
||||
let rect = Rect {
|
||||
position: LogicalPosition::new(bounds.x as f64, bounds.y as f64).into(),
|
||||
size: LogicalSize::new(bounds.width as f64, bounds.height as f64).into(),
|
||||
};
|
||||
let _ = webview.set_bounds(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refocus_parent(&self) {
|
||||
let state = self.0.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.focus_parent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebViewController {
|
||||
id: u64,
|
||||
shared: Rc<RefCell<SharedState>>,
|
||||
config: WebViewConfig,
|
||||
}
|
||||
|
||||
impl WebViewController {
|
||||
pub fn new(config: WebViewConfig) -> Self {
|
||||
Self {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
shared: Rc::new(RefCell::new(SharedState {
|
||||
webview: None,
|
||||
last_bounds: None,
|
||||
})),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bounds_sender(&self) -> BoundsSender {
|
||||
BoundsSender(Rc::clone(&self.shared))
|
||||
}
|
||||
|
||||
pub fn create_task<M: Send + 'static>(
|
||||
&mut self,
|
||||
window_id: window::Id,
|
||||
on_result: fn(Result<(), String>) -> M,
|
||||
) -> Task<M> {
|
||||
let id = self.id;
|
||||
let content = std::mem::take(&mut self.config.content);
|
||||
let transparent = self.config.transparent;
|
||||
let devtools = self.config.devtools;
|
||||
|
||||
window::run_with_handle(window_id, move |handle| {
|
||||
build_webview(id, handle, content, transparent, devtools)
|
||||
})
|
||||
.map(on_result)
|
||||
}
|
||||
|
||||
pub fn take_staged(&mut self) {
|
||||
let webview = STAGED.with(|cell| cell.borrow_mut().remove(&self.id));
|
||||
let mut state = self.shared.borrow_mut();
|
||||
state.webview = webview;
|
||||
|
||||
if let (Some(webview), Some(bounds)) = (&state.webview, state.last_bounds) {
|
||||
let rect = Rect {
|
||||
position: LogicalPosition::new(bounds.x as f64, bounds.y as f64).into(),
|
||||
size: LogicalSize::new(bounds.width as f64, bounds.height as f64).into(),
|
||||
};
|
||||
let _ = webview.set_bounds(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_visible(&self, visible: bool) {
|
||||
let state = self.shared.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.set_visible(visible);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate(&self, url: &str) {
|
||||
let state = self.shared.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.load_url(url);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(&mut self) {
|
||||
self.shared.borrow_mut().webview = None;
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.shared.borrow().webview.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_webview(
|
||||
id: u64,
|
||||
handle: window::raw_window_handle::WindowHandle<'_>,
|
||||
content: Content,
|
||||
transparent: bool,
|
||||
devtools: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut builder = WebViewBuilder::new()
|
||||
.with_transparent(transparent)
|
||||
.with_devtools(devtools)
|
||||
.with_focused(false);
|
||||
|
||||
builder = match content {
|
||||
Content::Html(html) => builder.with_html(html),
|
||||
Content::Url(url) => builder.with_url(url),
|
||||
};
|
||||
|
||||
let webview = builder
|
||||
.build_as_child(&handle)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
STAGED.with(|cell| {
|
||||
cell.borrow_mut().insert(id, webview);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PlaceholderState {
|
||||
last_bounds: Option<Rectangle>,
|
||||
}
|
||||
|
||||
pub struct WebViewPlaceholder<Message> {
|
||||
width: Length,
|
||||
height: Length,
|
||||
bounds_tx: Option<BoundsSender>,
|
||||
_message: std::marker::PhantomData<Message>,
|
||||
}
|
||||
|
||||
impl<Message> WebViewPlaceholder<Message> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
width: Length::Fill,
|
||||
height: Length::Fill,
|
||||
bounds_tx: None,
|
||||
_message: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn bounds_sender(mut self, sender: BoundsSender) -> Self {
|
||||
self.bounds_tx = Some(sender);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message> Default for WebViewPlaceholder<Message> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for WebViewPlaceholder<Message>
|
||||
where
|
||||
Renderer: renderer::Renderer,
|
||||
{
|
||||
fn tag(&self) -> iced::advanced::widget::tree::Tag {
|
||||
iced::advanced::widget::tree::Tag::of::<PlaceholderState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> iced::advanced::widget::tree::State {
|
||||
iced::advanced::widget::tree::State::new(PlaceholderState::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(self.width, self.height)
|
||||
}
|
||||
|
||||
fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &layout::Limits) -> Node {
|
||||
Node::new(limits.resolve(self.width, self.height, Size::ZERO))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
_renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
_layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
_shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) -> event::Status {
|
||||
let Some(tx) = &self.bounds_tx else {
|
||||
return event::Status::Ignored;
|
||||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<PlaceholderState>();
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if state.last_bounds.as_ref() != Some(&bounds) {
|
||||
state.last_bounds = Some(bounds);
|
||||
tx.apply(bounds);
|
||||
}
|
||||
|
||||
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event
|
||||
&& !cursor.is_over(bounds)
|
||||
{
|
||||
tx.refocus_parent();
|
||||
}
|
||||
|
||||
event::Status::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<WebViewPlaceholder<Message>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: renderer::Renderer + 'a,
|
||||
{
|
||||
fn from(placeholder: WebViewPlaceholder<Message>) -> Self {
|
||||
Self::new(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webview<Message>(controller: &WebViewController) -> WebViewPlaceholder<Message> {
|
||||
WebViewPlaceholder::new().bounds_sender(controller.bounds_sender())
|
||||
}
|
||||
@@ -222,6 +222,7 @@ pub fn view<'a>(
|
||||
state: &'a MediaEditorState,
|
||||
locale: UiLocale,
|
||||
data_dir: Option<&Path>,
|
||||
ai_enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
@@ -230,18 +231,18 @@ pub fn view<'a>(
|
||||
vec![
|
||||
if state.mime_type.starts_with("image/") {
|
||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::DetectLanguage))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.translate")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::TranslateMetadata))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
|
||||
@@ -15,3 +15,4 @@ pub mod script_editor;
|
||||
pub mod tags_view;
|
||||
pub mod settings_view;
|
||||
pub mod dashboard;
|
||||
pub mod site_validation;
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::collections::HashMap;
|
||||
use iced::widget::text::{Shaping, Wrapping};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
use iced_widget::markdown;
|
||||
|
||||
use bds_core::i18n::{self, UiLocale};
|
||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||
@@ -20,7 +19,7 @@ use crate::views::status_bar;
|
||||
pub struct TranslationFlag {
|
||||
pub language: String,
|
||||
pub flag_emoji: String,
|
||||
pub status: String, // "draft" | "published" | "missing"
|
||||
pub status: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
@@ -46,6 +45,7 @@ pub struct ResolvedPostLink {
|
||||
pub struct LinkedMediaItem {
|
||||
pub media_id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub is_image: bool,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
@@ -58,7 +58,6 @@ pub struct PostEditorState {
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub editor_buffer: RefCell<EditorBuffer>,
|
||||
pub preview_items: Vec<markdown::Item>,
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub author: String,
|
||||
@@ -77,22 +76,13 @@ pub struct PostEditorState {
|
||||
pub quick_actions_open: bool,
|
||||
pub tags_input: String,
|
||||
pub categories_input: String,
|
||||
// ── Translation flags ──
|
||||
/// Currently-displayed language (canonical or translation).
|
||||
pub active_language: String,
|
||||
/// The post's own language (canonical).
|
||||
pub canonical_language: String,
|
||||
/// All blog languages from project metadata.
|
||||
pub blog_languages: Vec<String>,
|
||||
/// Saved canonical title/excerpt/content when viewing a translation.
|
||||
pub saved_canonical: Option<TranslationDraft>,
|
||||
/// Translation drafts keyed by language code.
|
||||
pub translation_drafts: HashMap<String, TranslationDraft>,
|
||||
/// Outgoing links from this post to other posts.
|
||||
pub outlinks: Vec<ResolvedPostLink>,
|
||||
/// Incoming links from other posts to this post.
|
||||
pub backlinks: Vec<ResolvedPostLink>,
|
||||
/// Media linked to this post.
|
||||
pub linked_media: Vec<LinkedMediaItem>,
|
||||
}
|
||||
|
||||
@@ -115,7 +105,6 @@ impl Clone for PostEditorState {
|
||||
excerpt: self.excerpt.clone(),
|
||||
content: self.content.clone(),
|
||||
editor_buffer: RefCell::new(EditorBuffer::new(&self.content)),
|
||||
preview_items: self.preview_items.clone(),
|
||||
tags: self.tags.clone(),
|
||||
categories: self.categories.clone(),
|
||||
author: self.author.clone(),
|
||||
@@ -160,8 +149,6 @@ impl PostEditorState {
|
||||
let excerpt = post.excerpt.clone().unwrap_or_default();
|
||||
let content = post.content.clone().unwrap_or_default();
|
||||
let canonical_lang = post.language.clone().unwrap_or_else(|| "en".to_string());
|
||||
let preview_items = markdown::parse(&preview_markdown_document_parts(&title, &excerpt, &content))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut translation_drafts = HashMap::new();
|
||||
for tr in translations {
|
||||
@@ -183,7 +170,6 @@ impl PostEditorState {
|
||||
excerpt,
|
||||
content: content.clone(),
|
||||
editor_buffer: RefCell::new(EditorBuffer::new(&content)),
|
||||
preview_items,
|
||||
tags: post.tags.clone(),
|
||||
categories: post.categories.clone(),
|
||||
author: post.author.clone().unwrap_or_default(),
|
||||
@@ -226,7 +212,6 @@ impl PostEditorState {
|
||||
buffer.text()
|
||||
};
|
||||
self.content = new_content;
|
||||
self.refresh_preview_items();
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
@@ -234,20 +219,12 @@ impl PostEditorState {
|
||||
self.editor_mode = normalize_editor_mode(mode);
|
||||
}
|
||||
|
||||
pub fn refresh_preview_items(&mut self) {
|
||||
self.preview_items = markdown::parse(&preview_markdown_document(self)).collect();
|
||||
}
|
||||
|
||||
/// Switch the editor to display a different language.
|
||||
/// Saves current fields and loads the target language's draft.
|
||||
pub fn switch_language(&mut self, target_lang: &str) {
|
||||
if target_lang == self.active_language {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current fields
|
||||
if self.active_language == self.canonical_language {
|
||||
// Switching away from canonical — stash canonical fields
|
||||
self.saved_canonical = Some(TranslationDraft {
|
||||
title: self.title.clone(),
|
||||
excerpt: self.excerpt.clone(),
|
||||
@@ -256,7 +233,6 @@ impl PostEditorState {
|
||||
is_dirty: self.is_dirty,
|
||||
});
|
||||
} else {
|
||||
// Switching away from a translation — save to drafts
|
||||
self.translation_drafts.insert(
|
||||
self.active_language.clone(),
|
||||
TranslationDraft {
|
||||
@@ -269,9 +245,7 @@ impl PostEditorState {
|
||||
);
|
||||
}
|
||||
|
||||
// Load target fields
|
||||
if target_lang == self.canonical_language {
|
||||
// Restore canonical
|
||||
if let Some(saved) = self.saved_canonical.take() {
|
||||
self.title = saved.title;
|
||||
self.excerpt = saved.excerpt;
|
||||
@@ -281,14 +255,12 @@ impl PostEditorState {
|
||||
self.is_dirty = saved.is_dirty;
|
||||
}
|
||||
} else if let Some(draft) = self.translation_drafts.get(target_lang) {
|
||||
// Load existing translation
|
||||
self.title = draft.title.clone();
|
||||
self.excerpt = draft.excerpt.clone();
|
||||
self.content = draft.content.clone();
|
||||
self.editor_buffer = RefCell::new(EditorBuffer::new(&draft.content));
|
||||
self.is_dirty = draft.is_dirty;
|
||||
} else {
|
||||
// No translation yet — blank fields
|
||||
self.title = String::new();
|
||||
self.excerpt = String::new();
|
||||
self.content = String::new();
|
||||
@@ -297,10 +269,8 @@ impl PostEditorState {
|
||||
}
|
||||
|
||||
self.active_language = target_lang.to_string();
|
||||
self.refresh_preview_items();
|
||||
}
|
||||
|
||||
/// Build the translation flags list for the view.
|
||||
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
@@ -402,6 +372,8 @@ pub fn view<'a>(
|
||||
state: &'a PostEditorState,
|
||||
locale: UiLocale,
|
||||
word_wrap: bool,
|
||||
ai_enabled: bool,
|
||||
preview_widget: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let on_translation = state.active_language != state.canonical_language;
|
||||
|
||||
@@ -418,7 +390,7 @@ pub fn view<'a>(
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::ToggleQuickActions))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions)))
|
||||
.padding([6, 16])
|
||||
.style(status_bar::dropdown_trigger)
|
||||
.into();
|
||||
@@ -508,10 +480,10 @@ pub fn view<'a>(
|
||||
Space::with_width(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi),
|
||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy),
|
||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate),
|
||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage),
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
|
||||
]
|
||||
.spacing(4)
|
||||
)
|
||||
@@ -603,7 +575,7 @@ pub fn view<'a>(
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::DetectLanguage))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||
.padding([6, 12]);
|
||||
let template_input = inputs::labeled_input(
|
||||
&t(locale, "editor.templateSlug"),
|
||||
@@ -859,19 +831,17 @@ pub fn view<'a>(
|
||||
],
|
||||
);
|
||||
let editor_widget: Element<'a, Message> = if state.editor_mode == "preview" {
|
||||
scrollable(
|
||||
preview_widget.unwrap_or_else(|| {
|
||||
container(
|
||||
markdown::view(
|
||||
&state.preview_items,
|
||||
markdown::Settings::with_text_size(15),
|
||||
markdown::Style::from_palette(Theme::TokyoNightStorm.palette()),
|
||||
)
|
||||
.map(|url: markdown::Url| Message::UrlOpenRequested(url.to_string())),
|
||||
text(t(locale, "tabBar.loading"))
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.padding(16),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
})
|
||||
} else {
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), "md")
|
||||
.word_wrap(word_wrap)
|
||||
@@ -1010,10 +980,10 @@ fn mode_button<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg) -> Element<'a, Message> {
|
||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
|
||||
let _ = locale;
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(msg))
|
||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||
.padding([6, 12])
|
||||
.style(status_bar::dropdown_item)
|
||||
.width(Length::Fixed(220.0))
|
||||
@@ -1033,24 +1003,6 @@ fn truncate_header_title(title: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_markdown_document(state: &PostEditorState) -> String {
|
||||
preview_markdown_document_parts(&state.title, &state.excerpt, &state.content)
|
||||
}
|
||||
|
||||
fn preview_markdown_document_parts(title: &str, excerpt: &str, content: &str) -> String {
|
||||
let mut sections = Vec::new();
|
||||
if !title.trim().is_empty() {
|
||||
sections.push(format!("# {}", title.trim()));
|
||||
}
|
||||
if !excerpt.trim().is_empty() {
|
||||
sections.push(format!("> {}", excerpt.trim()));
|
||||
}
|
||||
if !content.trim().is_empty() {
|
||||
sections.push(content.to_string());
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn normalize_editor_mode(mode: &str) -> String {
|
||||
match mode {
|
||||
"preview" => "preview".to_string(),
|
||||
@@ -1117,7 +1069,6 @@ fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_state() -> PostEditorState {
|
||||
let post = Post {
|
||||
id: "post-1".to_string(),
|
||||
@@ -1187,4 +1138,5 @@ mod tests {
|
||||
state.set_editor_mode("preview");
|
||||
assert_eq!(state.editor_mode, "preview");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
138
crates/bds-ui/src/views/site_validation.rs
Normal file
138
crates/bds-ui/src/views/site_validation.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SiteValidationState {
|
||||
pub has_run: bool,
|
||||
pub is_running: bool,
|
||||
pub is_applying: bool,
|
||||
pub missing_files: Vec<String>,
|
||||
pub extra_files: Vec<String>,
|
||||
pub stale_files: Vec<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let run_button = if state.is_running {
|
||||
button(text(t(locale, "siteValidation.running")).size(13).shaping(Shaping::Advanced))
|
||||
} else {
|
||||
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::RunSiteValidation)
|
||||
};
|
||||
let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() || !state.stale_files.is_empty();
|
||||
let apply_button = if state.is_applying {
|
||||
button(text(t(locale, "siteValidation.applying")).size(13).shaping(Shaping::Advanced))
|
||||
} else if !state.is_running && state.error_message.is_none() && has_issues {
|
||||
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::ApplySiteValidation)
|
||||
} else {
|
||||
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
|
||||
};
|
||||
|
||||
let mut content = column![
|
||||
row![
|
||||
text(t(locale, "tabBar.siteValidation"))
|
||||
.size(24)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.88, 0.88, 0.92)),
|
||||
row![run_button, apply_button].spacing(12),
|
||||
]
|
||||
.spacing(16),
|
||||
]
|
||||
.spacing(16);
|
||||
|
||||
if state.is_running {
|
||||
content = content.push(help_text(t(locale, "siteValidation.running")));
|
||||
} else if let Some(error) = &state.error_message {
|
||||
content = content.push(section(
|
||||
t(locale, "siteValidation.error"),
|
||||
std::slice::from_ref(error),
|
||||
Color::from_rgb(0.75, 0.28, 0.28),
|
||||
));
|
||||
} else if !state.has_run {
|
||||
content = content.push(help_text(t(locale, "siteValidation.idle")));
|
||||
} else if state.missing_files.is_empty() && state.extra_files.is_empty() && state.stale_files.is_empty() {
|
||||
content = content.push(help_text(t(locale, "siteValidation.clean")));
|
||||
} else {
|
||||
if !state.missing_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.missing"), state.missing_files.len()),
|
||||
&state.missing_files,
|
||||
Color::from_rgb(0.70, 0.25, 0.25),
|
||||
));
|
||||
}
|
||||
if !state.extra_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.extra"), state.extra_files.len()),
|
||||
&state.extra_files,
|
||||
Color::from_rgb(0.68, 0.48, 0.18),
|
||||
));
|
||||
}
|
||||
if !state.stale_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.stale"), state.stale_files.len()),
|
||||
&state.stale_files,
|
||||
Color::from_rgb(0.62, 0.32, 0.18),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
container(scrollable(content))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn help_text<'a>(value: String) -> Element<'a, Message> {
|
||||
container(
|
||||
text(value)
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.66, 0.66, 0.72)),
|
||||
)
|
||||
.padding(16)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'a, Message> {
|
||||
let items = entries.iter().fold(column!().spacing(6), |column, entry| {
|
||||
column.push(
|
||||
text(entry)
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.82, 0.82, 0.88)),
|
||||
)
|
||||
});
|
||||
|
||||
container(
|
||||
column![
|
||||
text(title.to_string())
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(accent),
|
||||
items,
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(16)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use crate::views::{
|
||||
modal, panel,
|
||||
post_editor::{self, PostEditorState},
|
||||
project_selector,
|
||||
site_validation::{self, SiteValidationState},
|
||||
script_editor::{self, ScriptEditorState},
|
||||
settings_view::{self, SettingsViewState},
|
||||
sidebar, status_bar, tab_bar,
|
||||
@@ -101,6 +102,7 @@ pub fn view<'a>(
|
||||
active_modal: Option<modal::ModalState>,
|
||||
// Data directory (for thumbnail paths)
|
||||
data_dir: Option<&'a Path>,
|
||||
post_preview_widget: Option<Element<'a, Message>>,
|
||||
// Editor states
|
||||
post_editors: &'a HashMap<String, PostEditorState>,
|
||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||
@@ -109,6 +111,7 @@ pub fn view<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
) -> Element<'a, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||
@@ -121,7 +124,9 @@ pub fn view<'a>(
|
||||
tabs,
|
||||
active_tab,
|
||||
locale,
|
||||
offline_mode,
|
||||
data_dir,
|
||||
post_preview_widget,
|
||||
post_editors,
|
||||
media_editors,
|
||||
template_editors,
|
||||
@@ -129,6 +134,7 @@ pub fn view<'a>(
|
||||
tags_view_state,
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
site_validation_state,
|
||||
);
|
||||
|
||||
// Right column: tab bar + content + panel
|
||||
@@ -342,7 +348,9 @@ fn route_content_area<'a>(
|
||||
tabs: &'a [Tab],
|
||||
active_tab: Option<&'a str>,
|
||||
locale: UiLocale,
|
||||
offline_mode: bool,
|
||||
data_dir: Option<&'a Path>,
|
||||
post_preview_widget: Option<Element<'a, Message>>,
|
||||
post_editors: &'a HashMap<String, PostEditorState>,
|
||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||
template_editors: &'a HashMap<String, TemplateEditorState>,
|
||||
@@ -350,6 +358,7 @@ fn route_content_area<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
) -> Element<'a, Message> {
|
||||
match route_kind(
|
||||
tabs,
|
||||
@@ -361,6 +370,7 @@ fn route_content_area<'a>(
|
||||
tags_view_state,
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
site_validation_state,
|
||||
) {
|
||||
ContentRoute::Dashboard(state) => crate::views::dashboard::view(state, locale),
|
||||
ContentRoute::Welcome => welcome::view(locale),
|
||||
@@ -368,14 +378,14 @@ fn route_content_area<'a>(
|
||||
ContentRoute::Post(tab_id) => {
|
||||
if let Some(state) = post_editors.get(tab_id) {
|
||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||
post_editor::view(state, locale, wrap)
|
||||
post_editor::view(state, locale, wrap, is_ai_enabled(settings_state, offline_mode), post_preview_widget)
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
}
|
||||
ContentRoute::Media(tab_id) => {
|
||||
if let Some(state) = media_editors.get(tab_id) {
|
||||
media_editor::view(state, locale, data_dir)
|
||||
media_editor::view(state, locale, data_dir, is_ai_enabled(settings_state, offline_mode))
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
@@ -408,6 +418,7 @@ fn route_content_area<'a>(
|
||||
loading_view(locale)
|
||||
}
|
||||
}
|
||||
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
||||
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
|
||||
}
|
||||
}
|
||||
@@ -423,6 +434,7 @@ enum ContentRoute<'a> {
|
||||
Scripts(&'a str),
|
||||
Tags,
|
||||
Settings,
|
||||
SiteValidation,
|
||||
Placeholder(&'a str),
|
||||
}
|
||||
|
||||
@@ -436,6 +448,7 @@ fn route_kind<'a>(
|
||||
tags_view_state: Option<&'a TagsViewState>,
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
_site_validation_state: &'a SiteValidationState,
|
||||
) -> ContentRoute<'a> {
|
||||
let Some(tab_id) = active_tab else {
|
||||
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||
@@ -463,6 +476,7 @@ fn route_kind<'a>(
|
||||
TabType::Settings => {
|
||||
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
|
||||
}
|
||||
TabType::SiteValidation => ContentRoute::SiteValidation,
|
||||
TabType::Style
|
||||
| TabType::Chat
|
||||
| TabType::Import
|
||||
@@ -471,12 +485,25 @@ fn route_kind<'a>(
|
||||
| TabType::GitDiff
|
||||
| TabType::Documentation
|
||||
| TabType::ApiDocumentation
|
||||
| TabType::SiteValidation
|
||||
| TabType::TranslationValidation
|
||||
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool) -> bool {
|
||||
let Some(state) = settings_state else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if offline_mode {
|
||||
!state.airplane_endpoint_url.trim().is_empty() && !state.airplane_endpoint_model.trim().is_empty()
|
||||
} else {
|
||||
!state.online_endpoint_url.trim().is_empty()
|
||||
&& !state.online_endpoint_model.trim().is_empty()
|
||||
&& (state.online_api_key_configured || !state.online_api_key_input.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -498,6 +525,7 @@ mod tests {
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let unsupported = [
|
||||
TabType::Style,
|
||||
TabType::Chat,
|
||||
@@ -507,7 +535,6 @@ mod tests {
|
||||
TabType::GitDiff,
|
||||
TabType::Documentation,
|
||||
TabType::ApiDocumentation,
|
||||
TabType::SiteValidation,
|
||||
TabType::TranslationValidation,
|
||||
TabType::FindDuplicates,
|
||||
];
|
||||
@@ -524,6 +551,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&site_validation_state,
|
||||
);
|
||||
match route {
|
||||
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
|
||||
@@ -539,6 +567,7 @@ mod tests {
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let route = route_kind(
|
||||
&[],
|
||||
None,
|
||||
@@ -549,12 +578,58 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(&dashboard),
|
||||
&site_validation_state,
|
||||
);
|
||||
match route {
|
||||
ContentRoute::Dashboard(state) => assert_eq!(state.subtitle, "Test Project"),
|
||||
_ => panic!("expected dashboard route"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_validation_tab_routes_to_real_view() {
|
||||
let empty_posts = HashMap::new();
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let tabs = vec![tab("site_validation", TabType::SiteValidation, "Validation")];
|
||||
|
||||
let route = route_kind(
|
||||
&tabs,
|
||||
Some("site_validation"),
|
||||
&empty_posts,
|
||||
&empty_media,
|
||||
&empty_templates,
|
||||
&empty_scripts,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&site_validation_state,
|
||||
);
|
||||
|
||||
match route {
|
||||
ContentRoute::SiteValidation => {}
|
||||
_ => panic!("expected site validation route"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_actions_require_configured_endpoint_for_current_mode() {
|
||||
let mut settings = SettingsViewState::default();
|
||||
assert!(!is_ai_enabled(Some(&settings), false));
|
||||
assert!(!is_ai_enabled(Some(&settings), true));
|
||||
|
||||
settings.online_endpoint_url = "https://api.example.com/v1".to_string();
|
||||
settings.online_endpoint_model = "gpt-4.1-mini".to_string();
|
||||
settings.online_api_key_configured = true;
|
||||
assert!(is_ai_enabled(Some(&settings), false));
|
||||
assert!(!is_ai_enabled(Some(&settings), true));
|
||||
|
||||
settings.airplane_endpoint_url = "http://localhost:11434/v1".to_string();
|
||||
settings.airplane_endpoint_model = "llama3.2".to_string();
|
||||
assert!(is_ai_enabled(Some(&settings), true));
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"tabBar.metadataDiff": "Metadaten-Diff",
|
||||
"tabBar.siteValidation": "Website-Validierung",
|
||||
"tabBar.translationValidation": "Übersetzungsvalidierung",
|
||||
"siteValidation.run": "Validierung starten",
|
||||
"siteValidation.apply": "Validierung anwenden",
|
||||
"siteValidation.applying": "Wird angewendet...",
|
||||
"siteValidation.idle": "Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.",
|
||||
"siteValidation.running": "Validierung läuft...",
|
||||
"siteValidation.clean": "Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.",
|
||||
"siteValidation.error": "Validierung fehlgeschlagen",
|
||||
"siteValidation.missing": "Fehlende Dateien",
|
||||
"siteValidation.extra": "Zusätzliche Dateien",
|
||||
"siteValidation.stale": "Veraltete Dateien",
|
||||
"tabBar.documentation": "Dokumentation",
|
||||
"tabBar.apiDocumentation": "API-Dokumentation",
|
||||
"tabBar.findDuplicates": "Duplikate finden",
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"tabBar.metadataDiff": "Metadata Diff",
|
||||
"tabBar.siteValidation": "Site Validation",
|
||||
"tabBar.translationValidation": "Translation Validation",
|
||||
"siteValidation.run": "Run Validation",
|
||||
"siteValidation.apply": "Apply Validation",
|
||||
"siteValidation.applying": "Applying...",
|
||||
"siteValidation.idle": "Run site validation to compare generated files against expected output.",
|
||||
"siteValidation.running": "Validation in progress...",
|
||||
"siteValidation.clean": "No missing, extra, or stale generated files were found.",
|
||||
"siteValidation.error": "Validation failed",
|
||||
"siteValidation.missing": "Missing Files",
|
||||
"siteValidation.extra": "Extra Files",
|
||||
"siteValidation.stale": "Stale Files",
|
||||
"tabBar.documentation": "Documentation",
|
||||
"tabBar.apiDocumentation": "API Documentation",
|
||||
"tabBar.findDuplicates": "Find Duplicates",
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"tabBar.metadataDiff": "Diff de metadatos",
|
||||
"tabBar.siteValidation": "Validación del sitio",
|
||||
"tabBar.translationValidation": "Validación de traducciones",
|
||||
"siteValidation.run": "Ejecutar validación",
|
||||
"siteValidation.apply": "Aplicar validación",
|
||||
"siteValidation.applying": "Aplicando...",
|
||||
"siteValidation.idle": "Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.",
|
||||
"siteValidation.running": "Validación en curso...",
|
||||
"siteValidation.clean": "No se encontraron archivos generados faltantes, extra o desactualizados.",
|
||||
"siteValidation.error": "La validación falló",
|
||||
"siteValidation.missing": "Archivos faltantes",
|
||||
"siteValidation.extra": "Archivos adicionales",
|
||||
"siteValidation.stale": "Archivos desactualizados",
|
||||
"tabBar.documentation": "Documentación",
|
||||
"tabBar.apiDocumentation": "Documentación API",
|
||||
"tabBar.findDuplicates": "Buscar duplicados",
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"tabBar.metadataDiff": "Diff métadonnées",
|
||||
"tabBar.siteValidation": "Validation du site",
|
||||
"tabBar.translationValidation": "Validation des traductions",
|
||||
"siteValidation.run": "Lancer la validation",
|
||||
"siteValidation.apply": "Appliquer la validation",
|
||||
"siteValidation.applying": "Application en cours...",
|
||||
"siteValidation.idle": "Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.",
|
||||
"siteValidation.running": "Validation en cours...",
|
||||
"siteValidation.clean": "Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.",
|
||||
"siteValidation.error": "La validation a échoué",
|
||||
"siteValidation.missing": "Fichiers manquants",
|
||||
"siteValidation.extra": "Fichiers supplémentaires",
|
||||
"siteValidation.stale": "Fichiers obsolètes",
|
||||
"tabBar.documentation": "Documentation",
|
||||
"tabBar.apiDocumentation": "Documentation API",
|
||||
"tabBar.findDuplicates": "Trouver les doublons",
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"tabBar.metadataDiff": "Diff metadati",
|
||||
"tabBar.siteValidation": "Validazione sito",
|
||||
"tabBar.translationValidation": "Validazione traduzioni",
|
||||
"siteValidation.run": "Esegui validazione",
|
||||
"siteValidation.apply": "Applica validazione",
|
||||
"siteValidation.applying": "Applicazione in corso...",
|
||||
"siteValidation.idle": "Esegui la validazione del sito per confrontare i file generati con l'output previsto.",
|
||||
"siteValidation.running": "Validazione in corso...",
|
||||
"siteValidation.clean": "Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.",
|
||||
"siteValidation.error": "Validazione non riuscita",
|
||||
"siteValidation.missing": "File mancanti",
|
||||
"siteValidation.extra": "File aggiuntivi",
|
||||
"siteValidation.stale": "File obsoleti",
|
||||
"tabBar.documentation": "Documentazione",
|
||||
"tabBar.apiDocumentation": "Documentazione API",
|
||||
"tabBar.findDuplicates": "Trova duplicati",
|
||||
|
||||
Reference in New Issue
Block a user