diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bf628a8..cf0a032 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -221,15 +221,17 @@ Use **Blog → Find Duplicate Posts** to review pairs of posts with very similar ## Previewing your site -Preview renders through the same pipeline as site generation, so what you see is what will be published. The preview server runs only on your machine at `127.0.0.1:4123` and is never exposed to the network. +Preview renders through the same templates, routes, and asset files as site generation, so navigation and host-absolute links behave like the generated site. The preview server runs only on your machine at `127.0.0.1:4123` and is never exposed to the network. -Open a post preview inside the app with **Blog → Preview Post** or view it in your default browser with **File → Open in Browser**. Draft posts have their own preview routes, so you can check work in progress without publishing anything. +Switch a post editor from **Markdown** to **Preview** to render that post at its normal generated-site URL inside RuDS. Use **File → Open in Browser** for the complete navigable blog: with a post editor active it starts on that post, and otherwise it starts on the home page. **Blog → Preview Post** also opens the active post in the default browser. + +The preview includes draft and published posts on normal site routes. Draft content comes from the database, including draft translations; published content comes from the Markdown files that generation will use. Media, styles, scripts, images, and Pagefind files are served from their project filesystem locations, with packaged defaults used only when a project asset is absent. ### Key takeaways -- Preview and generation share one rendering pipeline; there are no preview-only differences. +- Preview and generation share one renderer and URL structure; preview overlays draft database content. - The preview server is local-only. -- Drafts can be previewed without publishing. +- Browser and embedded previews remain navigable across normal site routes. [↑ Back to In this article](#in-this-article) diff --git a/README.md b/README.md index 5d503fd..e5a1207 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The project is under active development. Core blogging workflows are broadly ava - A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating. - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. -- Local preview in the app or system browser. +- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 0ae665f..d3060c0 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -913,7 +913,7 @@ fn filter_posts_for_lists( .collect() } -fn build_rss_xml( +pub(crate) fn build_rss_xml( metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str, @@ -993,7 +993,7 @@ fn build_rss_xml( xml.join("\n") } -fn build_atom_xml( +pub(crate) fn build_atom_xml( metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str, diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index fea3315..4dac8a9 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -13,7 +13,7 @@ use tokio::sync::oneshot; use crate::db::{Database, queries}; use crate::engine::generation::PublishedPostSource; use crate::engine::{EngineError, EngineResult}; -use crate::model::{Post, PostStatus, ProjectMetadata, pico_stylesheet_href}; +use crate::model::{Post, PostStatus, pico_stylesheet_href}; use crate::render::build_preview_response; use crate::util::frontmatter::{read_post_file, read_translation_file}; @@ -69,6 +69,12 @@ struct StylePreviewQuery { mode: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreviewFeedKind { + Rss, + Atom, +} + pub fn start_preview_server( db_path: PathBuf, data_dir: PathBuf, @@ -197,8 +203,43 @@ 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)?; - let input_posts = published_posts + let preview_posts = collect_preview_posts(state)?; + let list_posts = filter_preview_list_posts(&state.data_dir, &preview_posts); + if path == "/calendar.json" { + let posts = list_posts + .iter() + .map(|source| source.post.clone()) + .collect::>(); + let json = crate::render::build_calendar_json(&posts) + .map_err(|error| EngineError::Parse(error.to_string()))?; + return Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json; charset=utf-8")], + json, + ) + .into_response()); + } + if let Some((language, kind)) = preview_feed_request(path, &metadata) { + let localized_posts = localized_preview_posts( + db.conn(), + &state.data_dir, + &list_posts, + &language, + metadata.main_language.as_deref().unwrap_or("en"), + )?; + let (content_type, xml) = match kind { + PreviewFeedKind::Rss => ( + "application/rss+xml; charset=utf-8", + crate::engine::generation::build_rss_xml(&metadata, &localized_posts, &language), + ), + PreviewFeedKind::Atom => ( + "application/atom+xml; charset=utf-8", + crate::engine::generation::build_atom_xml(&metadata, &localized_posts, &language), + ), + }; + return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response()); + } + let input_posts = preview_posts .iter() .map(|source| (source.post.clone(), source.body_markdown.clone())) .collect::>(); @@ -360,32 +401,115 @@ fn render_draft_preview( Ok(response.html) } -fn collect_published_posts( - state: &PreviewServerState, - _metadata: &ProjectMetadata, -) -> EngineResult> { +fn collect_preview_posts(state: &PreviewServerState) -> EngineResult> { let db = Database::open(&state.db_path)?; let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?; - let mut published = Vec::new(); + let mut preview_posts = Vec::new(); for post in posts .into_iter() - .filter(|post| matches!(post.status, PostStatus::Published)) + .filter(|post| matches!(post.status, PostStatus::Draft | PostStatus::Published)) { - published.push(PublishedPostSource { + preview_posts.push(PublishedPostSource { body_markdown: load_post_body(&state.data_dir, &post)?, post, }); } - published.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at)); - Ok(published) + preview_posts.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at)); + Ok(preview_posts) +} + +fn filter_preview_list_posts( + data_dir: &Path, + posts: &[PublishedPostSource], +) -> Vec { + let settings = crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default(); + posts + .iter() + .filter(|source| { + !source.post.categories.iter().any(|category| { + settings + .get(category) + .is_some_and(|setting| !setting.render_in_lists) + }) + }) + .cloned() + .collect() +} + +fn preview_feed_request( + path: &str, + metadata: &crate::model::ProjectMetadata, +) -> Option<(String, PreviewFeedKind)> { + let segments = path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + let (language, filename) = match segments.as_slice() { + [filename] => (metadata.main_language.as_deref().unwrap_or("en"), *filename), + [language, filename] + if metadata + .blog_languages + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(language)) + && !language + .eq_ignore_ascii_case(metadata.main_language.as_deref().unwrap_or("en")) => + { + (*language, *filename) + } + _ => return None, + }; + let kind = match filename { + "rss.xml" | "feed.xml" => PreviewFeedKind::Rss, + "atom.xml" => PreviewFeedKind::Atom, + _ => return None, + }; + Some((language.to_string(), kind)) +} + +fn localized_preview_posts( + conn: &crate::db::DbConnection, + data_dir: &Path, + posts: &[PublishedPostSource], + language: &str, + main_language: &str, +) -> EngineResult> { + if language.eq_ignore_ascii_case(main_language) { + return Ok(posts.to_vec()); + } + + let mut localized = Vec::new(); + for source in posts { + let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language( + conn, + &source.post.id, + language, + ) else { + continue; + }; + 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.status = translation.status.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: load_translation_body(data_dir, &translation)?, + }); + } + Ok(localized) } fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult { - if let Some(content) = &post.content { + if post.status == PostStatus::Draft + && let Some(content) = &post.content + { return Ok(content.clone()); } - if let Some(content) = &post.published_content { - return Ok(content.clone()); + if post.file_path.trim().is_empty() { + return Ok(String::new()); } load_markdown_body(data_dir, &post.file_path, false) } @@ -394,9 +518,14 @@ fn load_translation_body( data_dir: &Path, translation: &crate::model::PostTranslation, ) -> EngineResult { - if let Some(content) = &translation.content { + if translation.status == PostStatus::Draft + && let Some(content) = &translation.content + { return Ok(content.clone()); } + if translation.file_path.trim().is_empty() { + return Ok(String::new()); + } load_markdown_body(data_dir, &translation.file_path, true) } @@ -422,6 +551,14 @@ fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult &'static Mutex<()> { @@ -532,6 +669,17 @@ mod tests { (dir, db) } + fn response_text(response: Response) -> String { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let bytes = runtime + .block_on(axum::body::to_bytes(response.into_body(), usize::MAX)) + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + fn make_metadata() -> ProjectMetadata { ProjectMetadata { name: "Blog".into(), @@ -700,6 +848,88 @@ mod tests { assert!(body.contains("body")); } + #[test] + fn canonical_preview_routes_overlay_drafts_and_use_files_for_published_posts() { + let (dir, db) = setup_preview_fixture(); + let draft = make_draft_post(); + queries::post::insert_post(db.conn(), &draft).unwrap(); + queries::post_translation::insert_post_translation( + db.conn(), + &PostTranslation { + id: "translation-1".into(), + project_id: "project-1".into(), + translation_for: draft.id.clone(), + language: "de".into(), + title: "Hallo".into(), + excerpt: None, + content: Some("Deutscher **Entwurf**".into()), + status: PostStatus::Draft, + file_path: String::new(), + checksum: None, + created_at: draft.created_at, + updated_at: draft.updated_at, + published_at: None, + }, + ) + .unwrap(); + + let mut published = make_draft_post(); + published.id = "post-2".into(); + published.title = "Published from file".into(); + published.slug = "published-from-file".into(); + published.status = PostStatus::Published; + published.content = Some("Stale database body".into()); + published.file_path = "posts/2024/03/published-from-file.md".into(); + queries::post::insert_post(db.conn(), &published).unwrap(); + std::fs::write( + dir.path().join(&published.file_path), + crate::util::frontmatter::write_post_file(&published, "Filesystem **body**"), + ) + .unwrap(); + + let state = PreviewServerState { + db_path: dir.path().join("bds.db"), + data_dir: dir.path().to_path_buf(), + project_id: "project-1".into(), + }; + let draft_path = crate::render::build_canonical_post_path(&draft, "en", "en"); + let published_path = crate::render::build_canonical_post_path(&published, "en", "en"); + let translated_draft_path = crate::render::build_canonical_post_path(&draft, "de", "en"); + + let draft_response = render_preview_response(&state, &draft_path, None, None).unwrap(); + let published_response = + render_preview_response(&state, &published_path, None, None).unwrap(); + let index_response = render_preview_response(&state, "/", None, None).unwrap(); + let translated_draft_response = + render_preview_response(&state, &translated_draft_path, None, None).unwrap(); + let calendar_response = + render_preview_response(&state, "/calendar.json", None, None).unwrap(); + let rss_response = render_preview_response(&state, "/rss.xml", None, None).unwrap(); + let translated_atom_response = + render_preview_response(&state, "/de/atom.xml", None, None).unwrap(); + let draft_html = response_text(draft_response); + let published_html = response_text(published_response); + let index_html = response_text(index_response); + let translated_draft_html = response_text(translated_draft_response); + let calendar_json = response_text(calendar_response); + let rss_xml = response_text(rss_response); + let translated_atom_xml = response_text(translated_atom_response); + + assert!(draft_html.contains("Draft body")); + assert!(published_html.contains("Filesystem body")); + assert!(!published_html.contains("Stale database body")); + assert!(index_html.contains("Hello")); + assert!(index_html.contains("Published from file")); + assert!(translated_draft_html.contains("Deutscher Entwurf")); + assert!(calendar_json.contains("2024")); + assert!(rss_xml.contains("body")); + assert!(rss_xml.contains("Filesystem body")); + assert!(!rss_xml.contains("Stale database body")); + assert!(translated_atom_xml.contains("Entwurf")); + } + #[test] fn preview_server_blocks_media_path_traversal() { let _guard = preview_port_guard().lock().unwrap(); @@ -731,8 +961,16 @@ mod tests { 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::create_dir_all(dir.path().join("images")).unwrap(); + std::fs::create_dir_all(dir.path().join("html/pagefind")).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(); + std::fs::write(dir.path().join("images/custom.svg"), "").unwrap(); + std::fs::write( + dir.path().join("html/pagefind/pagefind-ui.js"), + "window.pagefind = true;", + ) + .unwrap(); let server = start_preview_server( dir.path().join("bds.db"), @@ -754,10 +992,28 @@ mod tests { .unwrap(); let media_body = media.text().unwrap(); let asset_body = asset.text().unwrap(); + let image_body = client + .get(format!( + "http://{PREVIEW_HOST}:{PREVIEW_PORT}/images/custom.svg" + )) + .send() + .unwrap() + .text() + .unwrap(); + let pagefind_body = client + .get(format!( + "http://{PREVIEW_HOST}:{PREVIEW_PORT}/pagefind/pagefind-ui.js" + )) + .send() + .unwrap() + .text() + .unwrap(); server.stop().unwrap(); assert_eq!(media_body, "ok"); assert!(asset_body.contains("color: red")); + assert!(image_body.contains("")); + assert!(pagefind_body.contains("window.pagefind")); } #[test] diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 6c45283..3a200a9 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -13,8 +13,8 @@ use crate::db::queries; use crate::engine::generation::{GenerationSection, classify_generated_path}; use crate::engine::menu::{self, MenuItemKind}; use crate::model::{ - CategorySettings, Media, Post, ProjectMetadata, ScriptKind, Tag, Template, TemplateKind, - TemplateStatus, + CategorySettings, Media, Post, PostStatus, ProjectMetadata, ScriptKind, Tag, Template, + TemplateKind, TemplateStatus, }; use crate::render::{ RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, @@ -185,8 +185,14 @@ fn build_site_render_artifacts_with_mode( let mut artifacts = SiteRenderArtifacts::default(); for language in languages { - let localized_posts = - load_language_posts(conn, data_dir, published_posts, &language, &main_language)?; + let localized_posts = load_language_posts( + conn, + data_dir, + published_posts, + &language, + &main_language, + is_preview, + )?; let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings); let routes = build_language_routes( &localized_list_posts, @@ -327,6 +333,8 @@ pub fn build_preview_response( published_posts: &[(Post, String)], requested_path: &str, ) -> Result> { + let normalized = normalize_request_path(requested_path); + let requested_paths = HashSet::from([preview_relative_path(&normalized)]); let artifacts = build_site_render_artifacts_with_mode( conn, data_dir, @@ -335,9 +343,8 @@ pub fn build_preview_response( published_posts, true, None, - None, + Some(&requested_paths), )?; - let normalized = normalize_request_path(requested_path); if let Some(page) = artifacts .pages .iter() @@ -502,6 +509,7 @@ fn load_language_posts( published_posts: &[(Post, String)], language: &str, main_language: &str, + is_preview: bool, ) -> Result, Box> { let mut posts = Vec::new(); for (post, body) in published_posts { @@ -518,8 +526,14 @@ fn load_language_posts( conn, &post.id, language, ) { - let raw = fs::read_to_string(data_dir.join(&translation.file_path))?; - let (_, translated_body) = read_translation_file(&raw)?; + let translated_body = if is_preview && translation.status == PostStatus::Draft { + match &translation.content { + Some(content) => content.clone(), + None => read_translation_body(data_dir, &translation.file_path)?, + } + } else { + read_translation_body(data_dir, &translation.file_path)? + }; let mut translated_post = post.clone(); translated_post.title = translation.title.clone(); translated_post.excerpt = translation.excerpt.clone(); @@ -544,6 +558,18 @@ fn load_language_posts( Ok(posts) } +fn read_translation_body( + data_dir: &Path, + file_path: &str, +) -> Result> { + if file_path.trim().is_empty() { + return Ok(String::new()); + } + let raw = fs::read_to_string(data_dir.join(file_path))?; + let (_, body) = read_translation_file(&raw)?; + Ok(body) +} + fn build_language_routes( posts: &[RenderPostRecord], metadata: &ProjectMetadata, @@ -1014,11 +1040,11 @@ fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> } fn prefixed_slug_path(prefix: &str, slug: &str) -> String { - format!("{}{}/", prefix_or_root(prefix), slug.trim_matches('/')) -} - -fn prefix_or_root(prefix: &str) -> &str { - if prefix.is_empty() { "/" } else { prefix } + format!( + "{}/{}/", + prefix.trim_end_matches('/'), + slug.trim_matches('/') + ) } fn route_href(route: &RouteSpec, page: usize) -> String { @@ -1514,6 +1540,20 @@ fn normalize_request_path(path: &str) -> String { } } +fn preview_relative_path(requested_path: &str) -> String { + let normalized = normalize_request_path(requested_path); + if normalized == "/" { + return "index.html".to_string(); + } + + let trimmed = normalized.trim_start_matches('/'); + if trimmed == "404" || trimmed.ends_with("/404") { + format!("{trimmed}.html") + } else { + format!("{trimmed}/index.html") + } +} + fn relative_to_url_path(relative_path: &str) -> String { if relative_path == "index.html" { return "/".to_string(); @@ -1630,6 +1670,18 @@ mod menu_tests { use super::*; use crate::engine::menu::{MenuItem, MenuItemKind}; + #[test] + fn preview_request_paths_select_only_the_matching_generated_page() { + assert_eq!(preview_relative_path("/"), "index.html"); + assert_eq!( + preview_relative_path("/2024/03/hello"), + "2024/03/hello/index.html" + ); + assert_eq!(preview_relative_path("/de"), "de/index.html"); + assert_eq!(preview_relative_path("/404"), "404.html"); + assert_eq!(preview_relative_path("/de/404"), "de/404.html"); + } + #[test] fn renderer_consumes_the_saved_opml_tree() { let dir = tempfile::tempdir().unwrap(); @@ -1663,6 +1715,7 @@ mod menu_tests { assert_eq!(rendered[1]["href"], "/about/"); assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/"); let translated = build_menu_items(dir.path(), "de", "en").unwrap(); + assert_eq!(translated[1]["href"], "/de/about/"); assert_eq!( translated[2]["children"][0]["href"], "/de/category/long-form/" diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index a7f6ac2..ca4416c 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -756,13 +756,20 @@ fn referenced_media_ids(content: &str) -> Vec { ids } -fn draft_preview_url(post_id: &str, language: &str) -> String { +fn preview_base_url() -> String { format!( - "http://{}:{}/__draft/{}?language={}", + "http://{}:{}", engine::preview::PREVIEW_HOST, engine::preview::PREVIEW_PORT, - post_id, - language + ) +} + +fn post_preview_url(post: &Post, language: &str, main_language: &str) -> String { + let path = bds_core::render::build_canonical_post_path(post, language, main_language); + format!( + "{}{path}?draft=true&post_id={}", + preview_base_url(), + post.id ) } @@ -4089,7 +4096,7 @@ impl BdsApp { Some(TabType::MenuEditor) => Task::done(Message::MenuEditor(MenuEditorMsg::Save)), _ => Task::none(), }, - MenuAction::OpenInBrowser => self.preview_active_post(), + MenuAction::OpenInBrowser => self.open_preview_in_browser(), MenuAction::OpenDataFolder => { if let Some(ref dir) = self.data_dir { let _ = open::that(dir); @@ -8626,8 +8633,20 @@ impl BdsApp { .map(|editor| editor.active_language.clone()) .filter(|language| !language.is_empty()) .unwrap_or_else(|| self.content_language.clone()); + let db = self + .db + .as_ref() + .ok_or_else(|| t(self.ui_locale, "common.databaseUnavailable"))?; + let post = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) + .map_err(|error| error.to_string())?; + let main_language = self + .data_dir + .as_deref() + .and_then(|data_dir| engine::meta::read_project_json(data_dir).ok()) + .and_then(|metadata| metadata.main_language) + .unwrap_or_else(|| language.clone()); - Ok(draft_preview_url(post_id, &language)) + Ok(post_preview_url(&post, &language, &main_language)) } fn active_post_uses_embedded_preview(&self) -> bool { @@ -8788,6 +8807,25 @@ impl BdsApp { Task::none() } + fn open_preview_in_browser(&mut self) -> Task { + let active_post = active_post_tab_id(self.active_tab.as_deref(), &self.tabs); + let url = match active_post { + Some(post_id) => self.preview_url_for_post(&post_id), + None => self + .ensure_preview_server() + .map(|()| format!("{}/", preview_base_url())), + }; + match url { + Ok(url) => { + if let Err(error) = open::that(url) { + self.notify(ToastLevel::Error, &error.to_string()); + } + } + Err(error) => self.notify(ToastLevel::Error, &error), + } + Task::none() + } + fn run_post_ai_analysis(&mut self, post_id: &str) -> Task { let Some(db) = &self.db else { self.notify( @@ -9339,6 +9377,13 @@ fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> .map(|tab| tab.id.clone()) } +fn active_post_tab_id(active_tab: Option<&str>, tabs: &[Tab]) -> Option { + let active_tab = active_tab?; + tabs.iter() + .find(|tab| tab.id == active_tab && tab.tab_type == TabType::Post) + .map(|tab| tab.id.clone()) +} + fn split_csv_values(value: &str) -> Vec { value .split(',') @@ -9376,11 +9421,11 @@ fn remote_error_closes_connection(code: &str) -> bool { mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, - PostStatus, SettingsMsg, dropped_image_target, localize_chat_error, month_abbreviation, - persist_media_editor_state_impl, persist_post_editor_preview_state_impl, - persist_post_editor_state_impl, remote_error_closes_connection, - save_editor_settings_state_impl, save_script_editor_state_impl, - save_template_editor_state_impl, + PostStatus, SettingsMsg, active_post_tab_id, dropped_image_target, localize_chat_error, + month_abbreviation, persist_media_editor_state_impl, + persist_post_editor_preview_state_impl, persist_post_editor_state_impl, + remote_error_closes_connection, save_editor_settings_state_impl, + save_script_editor_state_impl, save_template_editor_state_impl, }; use crate::i18n::t; use crate::platform::menu::MenuAction; @@ -9853,6 +9898,33 @@ mod tests { ); } + #[test] + fn browser_preview_targets_the_active_post_or_the_site_root() { + let tabs = vec![ + Tab { + id: "post-1".to_string(), + tab_type: TabType::Post, + title: "Post".to_string(), + is_transient: false, + is_dirty: false, + }, + Tab { + id: "settings".to_string(), + tab_type: TabType::Settings, + title: "Settings".to_string(), + is_transient: false, + is_dirty: false, + }, + ]; + + assert_eq!( + active_post_tab_id(Some("post-1"), &tabs), + Some("post-1".to_string()) + ); + assert_eq!(active_post_tab_id(Some("settings"), &tabs), None); + assert_eq!(active_post_tab_id(None, &tabs), None); + } + #[test] fn multiple_file_drop_events_queue_one_managed_import_at_a_time() { let (db, project, tmp) = setup(); @@ -10790,15 +10862,28 @@ mod tests { } #[test] - fn draft_preview_url_points_at_local_preview_server() { - let url = super::draft_preview_url("post-42", "de"); + fn post_preview_url_uses_the_generated_site_route() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Preview Route", + Some("Body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let path = bds_core::render::build_canonical_post_path(&created, "de", "en"); assert_eq!( - url, + super::post_preview_url(&created, "de", "en"), format!( - "http://{}:{}/__draft/post-42?language=de", - bds_core::engine::preview::PREVIEW_HOST, - bds_core::engine::preview::PREVIEW_PORT, + "http://127.0.0.1:4123{path}?draft=true&post_id={}", + created.id ) ); } diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index 049ccdf..c0984e4 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -248,9 +248,8 @@ pub(crate) fn action_enabled( match action { MenuAction::Save => savable_tab, MenuAction::Find | MenuAction::Replace => text_editor, - MenuAction::OpenInBrowser | MenuAction::PublishSelected | MenuAction::PreviewPost => { - has_project && post_tab - } + MenuAction::OpenInBrowser => has_project, + MenuAction::PublishSelected | MenuAction::PreviewPost => has_project && post_tab, MenuAction::UploadSite => has_project && !offline, MenuAction::NewPost | MenuAction::ImportMedia @@ -711,6 +710,20 @@ mod tests { true, true )); + assert!(action_enabled( + MenuAction::OpenInBrowser, + true, + None, + false, + true + )); + assert!(!action_enabled( + MenuAction::OpenInBrowser, + false, + Some(&TabType::Post), + false, + true + )); assert!(!action_enabled( MenuAction::ReindexText, true, diff --git a/specs/editor_post.allium b/specs/editor_post.allium index b8ed58c..c024373 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -163,7 +163,9 @@ surface PostEditorSurface { @guarantee EditorModes -- Markdown: code editor with markdown-with-macros language, -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. - -- Preview: iframe showing rendered preview. + -- Preview: iframe served by the localhost preview server at the post's + -- normal generated-site URL, using the shared site renderer. Draft + -- content comes from DB; published content and assets come from files. -- Markdown and Preview are the only editor modes. @guarantee DragDropImages diff --git a/specs/preview.allium b/specs/preview.allium index 563aaf6..74a02cc 100644 --- a/specs/preview.allium +++ b/specs/preview.allium @@ -52,14 +52,17 @@ rule StopPreview { rule OpenPreviewInBrowser { when: OpenPreviewInBrowserRequested(project) -- Starts or reuses the project's localhost preview server, then opens its - -- base URL in the external system browser. This is separate from the - -- post editor's internal preview pane (editor_post.allium EditorModes). - ensures: ExternalBrowserOpened(url: preview_base_url(project)) + -- base URL in the external system browser when no post editor is active. + -- With an active post editor it opens that post's normal generated-site URL. + -- This is separate from the post editor's internal preview pane. + ensures: ExternalBrowserOpened( + url: active_post_generated_url or preview_base_url(project) + ) } -- Route resolution -- Preview renders all posts (published + draft) on-demand via Liquid templates. --- Content priority: DB content (draft edits) over published .md file content. +-- Draft content comes from the DB; published content comes from its .md file. -- See invariant PreviewDraftOverlay below. rule ServePostPreview { @@ -67,15 +70,16 @@ rule ServePostPreview { requires: is_post_path(path) -- path matches "/{yyyy}/{mm}/{dd}/{slug}" -- Finds post by slug+date regardless of status (published or draft). - -- Content resolved via editor_body: DB content if present, else .md file. + -- Draft bodies resolve from DB; published bodies resolve from .md files. -- Renders via Liquid template with full PageRenderer context. ensures: PreviewResponse(rendered_html) } rule ServeDraftPreview { when: PreviewDraftRequest(path, post_id) - -- Explicit draft preview by post_id (used by editor preview pane). - -- Renders draft content (from DB, not filesystem). + -- Editor preview uses the post's canonical generated route with draft and + -- post_id query markers, so links continue through the normal route tree. + -- Renders draft content from DB and published content from the filesystem. ensures: PreviewResponse(rendered_html) } @@ -100,6 +104,16 @@ rule ServeAssets { ensures: PreviewResponse(asset_file) } +rule ServeGeneratedRuntimeData { + when: PreviewRequest(path) + requires: path = "/calendar.json" or is_feed_path(path) + or is_pagefind_path(path) or is_image_path(path) + -- Calendar and feed data use the preview post universe and normal list + -- visibility rules. Pagefind and image files are read from the same project + -- filesystem locations as the generated site. + ensures: PreviewResponse(runtime_file) +} + rule ServeLanguagePrefixedRoute { when: PreviewRequest(path) requires: is_language_prefixed(path) @@ -115,15 +129,15 @@ invariant PreviewDraftOverlay { -- Post universe: all posts with status in {published, draft}. -- Archived posts are excluded. -- - -- Content priority (per post): - -- 1. DB content field (draft edits not yet published) → used when non-nil - -- 2. Published .md file (last-published snapshot) → used when DB content is nil - -- 3. Empty string → fallback if neither exists +-- Content source (per post or translation): +-- 1. status=draft → DB content, falling back to its last-published file +-- 2. status=published → published .md file +-- 3. No DB body or file → empty string -- -- This means: -- - A purely draft post (never published) renders from DB content. -- - A published-then-edited post renders from DB content (the draft edits). - -- - A published post with no pending edits renders from its .md file. + -- - A published post renders from its .md file. -- -- Contrast with generation (see generation.allium GenerationPublishedOnly): -- Generation uses *only* published .md file content, never DB draft content,