Compare commits

...

10 Commits

37 changed files with 7175 additions and 365 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"] }

View File

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

View File

@@ -1,6 +1,6 @@
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}

View File

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

View File

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

View File

@@ -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();

View File

@@ -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, &section_set);
for page in &artifacts.pages {
if path_matches_sections(&page.relative_path, &section_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, &section_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(&section))
.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()
}

View File

@@ -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;

View File

@@ -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('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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"));
}
}

View File

@@ -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 =

View 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(())
}

View 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
}

View File

@@ -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(),

View 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
fn escape_html_attr(value: &str) -> String {
escape_html(value).replace('"', "&quot;").replace('\'', "&#39;")
}
#[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\""));
}
}

View File

@@ -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,

View File

@@ -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(), &macro_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\""));
}
}

View File

@@ -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,
"![](bds-media://media-1)",
&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();

File diff suppressed because it is too large Load Diff

View File

@@ -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, &sections).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, &sections).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());
}

View File

@@ -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()]);

View File

@@ -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");

View File

@@ -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

View File

@@ -1 +1,2 @@
pub mod inputs;
pub mod webview;

View 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())
}

View File

@@ -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))

View File

@@ -15,3 +15,4 @@ pub mod script_editor;
pub mod tags_view;
pub mod settings_view;
pub mod dashboard;
pub mod site_validation;

View File

@@ -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");
}
}

View 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()
}

View File

@@ -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> {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",