From a96a6ee31ca97744ab0f5297d4db4cea3edcf43c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 13 Apr 2026 21:20:41 +0200 Subject: [PATCH] feat: more completion for M4 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/bds-core/src/engine/ai.rs | 67 ++++++++-- crates/bds-core/src/engine/generation.rs | 38 +++++- crates/bds-core/src/engine/preview.rs | 101 +++++++++++++++ crates/bds-core/src/engine/site_assets.rs | 4 + crates/bds-core/src/render/macros.rs | 9 +- crates/bds-core/src/render/site.rs | 121 ++++++++++++++++-- crates/bds-core/tests/m4_generation_engine.rs | 109 +++++++++++++++- crates/bds-core/tests/m4_page_renderer.rs | 18 +++ crates/bds-ui/Cargo.toml | 1 + crates/bds-ui/src/app.rs | 42 ++++++ crates/bds-ui/src/views/media_editor.rs | 7 +- crates/bds-ui/src/views/post_editor.rs | 17 +-- crates/bds-ui/src/views/workspace.rs | 37 +++++- 15 files changed, 527 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 661a051..4fb510a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,6 +837,7 @@ name = "bds-ui" version = "0.1.0" dependencies = [ "anyhow", + "base64", "bds-core", "bds-editor", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 512b8d5..b8636d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index 039089a..dce91b9 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -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 { +fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult { 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 { + 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 => ( @@ -731,6 +757,23 @@ mod tests { ); } + #[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"); + } + fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 4a6b187..476abe0 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -11,7 +11,7 @@ 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, build_site_render_artifacts, render_markdown_to_html, write_generated_bytes, @@ -49,11 +49,14 @@ pub fn generate_starter_site( _language: &str, ) -> EngineResult { let mut report = GenerationReport::default(); + 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::>(); - let artifacts = build_site_render_artifacts(conn, output_dir.parent().unwrap_or(output_dir), project_id, metadata, &input_posts) + let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts) .map_err(|error| EngineError::Parse(error.to_string()))?; for page in &artifacts.pages { @@ -67,12 +70,12 @@ pub fn generate_starter_site( output_dir, project_id, "calendar.json", - &build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::>())?, + &build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::>())?, &mut report, )?; for render_language in render_languages(metadata) { - let localized_posts = localized_sources(conn, output_dir.parent().unwrap_or(output_dir), posts, &render_language, metadata)?; + let 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 { @@ -142,6 +145,8 @@ pub fn apply_validation_sections( let section_set = sections.iter().copied().collect::>(); 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())) @@ -170,7 +175,7 @@ pub fn apply_validation_sections( output_dir, project_id, "calendar.json", - &build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::>())?, + &build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::>())?, &mut report, )?; @@ -178,7 +183,7 @@ pub fn apply_validation_sections( let localized_posts = localized_sources( conn, &data_dir, - posts, + &list_posts, &render_language, metadata, )?; @@ -511,6 +516,27 @@ fn localized_sources( Ok(localized) } +fn load_category_settings(data_dir: &Path) -> HashMap { + crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default() +} + +fn filter_posts_for_lists( + posts: &[PublishedPostSource], + category_settings: &HashMap, +) -> Vec { + 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 diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index b0d928e..9c01052 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -607,6 +607,38 @@ 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(); @@ -659,4 +691,73 @@ mod tests { 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("

EngineResult<()> { diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs index 8bc2927..1a46dba 100644 --- a/crates/bds-core/src/render/macros.rs +++ b/crates/bds-core/src/render/macros.rs @@ -52,10 +52,17 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option Some(render_vimeo(&args)), "photo_archive" => Some(render_photo_archive(&args, context)), "tag_cloud" => Some(render_tag_cloud(&args, context)), - _ => None, + _ => Some(unsupported_macro(name)), } } +fn unsupported_macro(name: &str) -> String { + format!( + "

Unsupported macro: {}

", + escape_html_attr(name), + ) +} + fn tokenize_invocation(invocation: &str) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index b77a8b0..fe0633b 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -54,7 +54,8 @@ pub struct PreviewRenderResult { struct TemplateBundle { post_templates: Vec