diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index ccd16c8..039089a 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -614,7 +614,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 +623,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(); @@ -684,6 +684,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| { diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 2a5cd74..3d668fc 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -1,13 +1,15 @@ use std::path::Path; +use std::collections::HashMap; use chrono::{DateTime, TimeZone, Utc}; use rusqlite::Connection; +use crate::db::queries; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, ProjectMetadata}; use crate::render::{ GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path, - render_markdown_to_html, render_starter_list_page, render_starter_single_post_page, + render_markdown_to_html, render_starter_list_page_with_media_map, render_starter_single_post_page_with_media_map, write_generated_file, }; @@ -32,17 +34,29 @@ pub fn generate_starter_site( language: &str, ) -> EngineResult { let mut report = GenerationReport::default(); + let media_rewrite_map = build_media_rewrite_map(conn, project_id)?; let list_input = posts .iter() .map(|source| (source.post.clone(), source.body_markdown.clone())) .collect::>(); - let index_page = render_starter_list_page(&list_input, metadata, language) + let index_page = render_starter_list_page_with_media_map( + &list_input, + metadata, + language, + media_rewrite_map.clone(), + ) .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) + let rendered = render_starter_single_post_page_with_media_map( + &source.post, + &source.body_markdown, + metadata, + language, + media_rewrite_map.clone(), + ) .map_err(|error| EngineError::Parse(error.to_string()))?; write_out(conn, output_dir, project_id, &rendered.relative_path, &rendered.html, &mut report)?; } @@ -65,6 +79,28 @@ pub fn generate_starter_site( Ok(report) } +fn build_media_rewrite_map( + conn: &Connection, + project_id: &str, +) -> EngineResult> { + 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, diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index 85a644b..8e60776 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::thread; +use std::collections::HashMap; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{StatusCode, Uri, header}; @@ -14,7 +15,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_canonical_post_path, render_starter_list_page_with_media_map, render_starter_single_post_page_with_media_map}; use crate::util::frontmatter::{read_post_file, read_translation_file}; pub const PREVIEW_HOST: &str = "127.0.0.1"; @@ -110,6 +111,7 @@ pub fn render_preview_path( path: &str, metadata: &ProjectMetadata, posts: &[PublishedPostSource], + canonical_media_path_by_source_path: &HashMap, ) -> EngineResult> { let normalized = if path.is_empty() { "/" } else { path }; let main_language = metadata.main_language.as_deref().unwrap_or("en"); @@ -119,7 +121,12 @@ pub fn render_preview_path( .iter() .map(|source| (source.post.clone(), source.body_markdown.clone())) .collect::>(); - return render_starter_list_page(&list_posts, metadata, main_language) + return render_starter_list_page_with_media_map( + &list_posts, + metadata, + main_language, + canonical_media_path_by_source_path.clone(), + ) .map(|page| Some(page.html)) .map_err(|error| EngineError::Parse(error.to_string())); } @@ -128,7 +135,13 @@ pub fn render_preview_path( 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) + return render_starter_single_post_page_with_media_map( + &source.post, + &source.body_markdown, + metadata, + &language, + canonical_media_path_by_source_path.clone(), + ) .map(|page| Some(page.html)) .map_err(|error| EngineError::Parse(error.to_string())); } @@ -172,8 +185,10 @@ fn render_preview_response( } let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; + let db = Database::open(&state.db_path)?; + let media_rewrite_map = build_media_rewrite_map(db.conn(), &state.project_id)?; let published_posts = collect_published_posts(state, &metadata)?; - match render_preview_path(path, &metadata, &published_posts)? { + match render_preview_path(path, &metadata, &published_posts, &media_rewrite_map)? { Some(html) => Ok(Html(html).into_response()), None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")), } @@ -196,6 +211,7 @@ fn render_draft_preview( post_id, target_language, ) { + let media_rewrite_map = build_media_rewrite_map(db.conn(), &post.project_id)?; let mut translated_post = post.clone(); translated_post.title = translation.title.clone(); translated_post.excerpt = translation.excerpt.clone(); @@ -204,18 +220,53 @@ 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) + return render_starter_single_post_page_with_media_map( + &translated_post, + &body, + &metadata, + target_language, + media_rewrite_map, + ) .map(|page| page.html) .map_err(|error| EngineError::Parse(error.to_string())); } } + let media_rewrite_map = build_media_rewrite_map(db.conn(), &post.project_id)?; let body = load_post_body(&state.data_dir, &post)?; - render_starter_single_post_page(&post, &body, &metadata, canonical_language) + render_starter_single_post_page_with_media_map( + &post, + &body, + &metadata, + canonical_language, + media_rewrite_map, + ) .map(|page| page.html) .map_err(|error| EngineError::Parse(error.to_string())) } +fn build_media_rewrite_map( + conn: &rusqlite::Connection, + project_id: &str, +) -> EngineResult> { + let media_items = queries::media::list_media_by_project(conn, project_id)?; + let mut map = HashMap::new(); + + for media in media_items { + let canonical_path = if media.file_path.starts_with('/') { + media.file_path.clone() + } else { + format!("/{}", media.file_path.trim_start_matches('/')) + }; + map.insert(format!("bds-media://{}", media.id), canonical_path.clone()); + + let relative_key = media.file_path.trim_start_matches('/').to_lowercase(); + map.insert(relative_key, canonical_path); + } + + Ok(map) +} + fn collect_published_posts( state: &PreviewServerState, metadata: &ProjectMetadata, @@ -454,7 +505,7 @@ mod tests { #[test] fn root_preview_renders_index_page() { - let html = render_preview_path("/", &make_metadata(), &[make_post()]) + let html = render_preview_path("/", &make_metadata(), &[make_post()], &HashMap::new()) .unwrap() .unwrap(); assert!(html.contains("post-list")); @@ -462,7 +513,12 @@ mod tests { #[test] fn preview_renders_single_post_for_canonical_path() { - let html = render_preview_path("/2024/03/09/hello", &make_metadata(), &[make_post()]) + let html = render_preview_path( + "/2024/03/09/hello", + &make_metadata(), + &[make_post()], + &HashMap::new(), + ) .unwrap() .unwrap(); assert!(html.contains("

Hello

")); @@ -471,7 +527,12 @@ mod tests { #[test] fn preview_renders_language_prefixed_single_post() { - let html = render_preview_path("/de/2024/03/09/hello", &make_metadata(), &[make_post()]) + let html = render_preview_path( + "/de/2024/03/09/hello", + &make_metadata(), + &[make_post()], + &HashMap::new(), + ) .unwrap() .unwrap(); assert!(html.contains("lang=\"de\"")); diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index bc26d3d..643cf1f 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -12,7 +12,8 @@ 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 template_lookup::{ RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index c6362bd..889ec2b 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -208,11 +208,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 +239,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 +252,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 +266,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 +333,15 @@ fn extract_post_slug(path: &str) -> Option { } } -fn extract_media_source_key(path: &str) -> Option { +fn extract_media_lookup_key(path: &str) -> Option { + 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 +357,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, + canonical_media_path_by_source_path: HashMap, + } + + impl RewriteContextView for TestRewriteContext { + fn canonical_post_path_by_slug(&self) -> &HashMap { + &self.canonical_post_path_by_slug + } + + fn canonical_media_path_by_source_path(&self) -> &HashMap { + &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( + "

\"\"

", + &context, + ); + + assert!(html.contains("src=\"/media/2026/04/media-1.png\"")); + } } \ No newline at end of file diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs index ce4fdb4..fcd43a4 100644 --- a/crates/bds-core/src/render/routes.rs +++ b/crates/bds-core/src/render/routes.rs @@ -119,6 +119,22 @@ pub fn render_starter_single_post_page( body_markdown: &str, metadata: &ProjectMetadata, language: &str, +) -> Result { + 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, ) -> Result { 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 { + 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, ) -> Result { 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();