Fix navigable blog previews.

This commit is contained in:
2026-07-20 17:10:33 +02:00
parent 29cdd016b8
commit 49adfed68f
9 changed files with 495 additions and 70 deletions

View File

@@ -221,15 +221,17 @@ Use **Blog → Find Duplicate Posts** to review pairs of posts with very similar
## Previewing your site ## 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 ### 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. - 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) [↑ Back to In this article](#in-this-article)

View File

@@ -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. - 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. - `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. - 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. - 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. - 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. - SSH-agent-based SCP or rsync publishing.

View File

@@ -913,7 +913,7 @@ fn filter_posts_for_lists(
.collect() .collect()
} }
fn build_rss_xml( pub(crate) fn build_rss_xml(
metadata: &ProjectMetadata, metadata: &ProjectMetadata,
posts: &[PublishedPostSource], posts: &[PublishedPostSource],
language: &str, language: &str,
@@ -993,7 +993,7 @@ fn build_rss_xml(
xml.join("\n") xml.join("\n")
} }
fn build_atom_xml( pub(crate) fn build_atom_xml(
metadata: &ProjectMetadata, metadata: &ProjectMetadata,
posts: &[PublishedPostSource], posts: &[PublishedPostSource],
language: &str, language: &str,

View File

@@ -13,7 +13,7 @@ use tokio::sync::oneshot;
use crate::db::{Database, queries}; use crate::db::{Database, queries};
use crate::engine::generation::PublishedPostSource; use crate::engine::generation::PublishedPostSource;
use crate::engine::{EngineError, EngineResult}; 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::render::build_preview_response;
use crate::util::frontmatter::{read_post_file, read_translation_file}; use crate::util::frontmatter::{read_post_file, read_translation_file};
@@ -69,6 +69,12 @@ struct StylePreviewQuery {
mode: Option<String>, mode: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreviewFeedKind {
Rss,
Atom,
}
pub fn start_preview_server( pub fn start_preview_server(
db_path: PathBuf, db_path: PathBuf,
data_dir: PathBuf, data_dir: PathBuf,
@@ -197,8 +203,43 @@ fn render_preview_response(
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
let db = Database::open(&state.db_path)?; let db = Database::open(&state.db_path)?;
let published_posts = collect_published_posts(state, &metadata)?; let preview_posts = collect_preview_posts(state)?;
let input_posts = published_posts 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::<Vec<_>>();
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() .iter()
.map(|source| (source.post.clone(), source.body_markdown.clone())) .map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -360,32 +401,115 @@ fn render_draft_preview(
Ok(response.html) Ok(response.html)
} }
fn collect_published_posts( fn collect_preview_posts(state: &PreviewServerState) -> EngineResult<Vec<PublishedPostSource>> {
state: &PreviewServerState,
_metadata: &ProjectMetadata,
) -> EngineResult<Vec<PublishedPostSource>> {
let db = Database::open(&state.db_path)?; let db = Database::open(&state.db_path)?;
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?; 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 for post in posts
.into_iter() .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)?, body_markdown: load_post_body(&state.data_dir, &post)?,
post, post,
}); });
} }
published.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at)); preview_posts.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
Ok(published) Ok(preview_posts)
}
fn filter_preview_list_posts(
data_dir: &Path,
posts: &[PublishedPostSource],
) -> Vec<PublishedPostSource> {
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::<Vec<_>>();
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<Vec<PublishedPostSource>> {
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<String> { fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult<String> {
if let Some(content) = &post.content { if post.status == PostStatus::Draft
&& let Some(content) = &post.content
{
return Ok(content.clone()); return Ok(content.clone());
} }
if let Some(content) = &post.published_content { if post.file_path.trim().is_empty() {
return Ok(content.clone()); return Ok(String::new());
} }
load_markdown_body(data_dir, &post.file_path, false) load_markdown_body(data_dir, &post.file_path, false)
} }
@@ -394,9 +518,14 @@ fn load_translation_body(
data_dir: &Path, data_dir: &Path,
translation: &crate::model::PostTranslation, translation: &crate::model::PostTranslation,
) -> EngineResult<String> { ) -> EngineResult<String> {
if let Some(content) = &translation.content { if translation.status == PostStatus::Draft
&& let Some(content) = &translation.content
{
return Ok(content.clone()); return Ok(content.clone());
} }
if translation.file_path.trim().is_empty() {
return Ok(String::new());
}
load_markdown_body(data_dir, &translation.file_path, true) load_markdown_body(data_dir, &translation.file_path, true)
} }
@@ -422,6 +551,14 @@ fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult<Option<Respon
if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? { if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? {
return Ok(Some(response)); return Ok(Some(response));
} }
if let Some(response) = serve_scoped_file(data_dir, path, "/images/", "images")? {
return Ok(Some(response));
}
if let Some(response) =
serve_scoped_file(&data_dir.join("html"), path, "/pagefind/", "pagefind")?
{
return Ok(Some(response));
}
Ok(None) Ok(None)
} }
@@ -496,7 +633,7 @@ mod tests {
use super::*; use super::*;
use crate::db::queries; use crate::db::queries;
use crate::engine::meta; use crate::engine::meta;
use crate::model::{Post, PostStatus, Project, ProjectMetadata}; use crate::model::{Post, PostStatus, PostTranslation, Project, ProjectMetadata};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn preview_port_guard() -> &'static Mutex<()> { fn preview_port_guard() -> &'static Mutex<()> {
@@ -532,6 +669,17 @@ mod tests {
(dir, db) (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 { fn make_metadata() -> ProjectMetadata {
ProjectMetadata { ProjectMetadata {
name: "Blog".into(), name: "Blog".into(),
@@ -700,6 +848,88 @@ mod tests {
assert!(body.contains("<strong>body</strong>")); assert!(body.contains("<strong>body</strong>"));
} }
#[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 <strong>body</strong>"));
assert!(published_html.contains("Filesystem <strong>body</strong>"));
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 <strong>Entwurf</strong>"));
assert!(calendar_json.contains("2024"));
assert!(rss_xml.contains("<rss"));
assert!(rss_xml.contains("Draft <strong>body</strong>"));
assert!(rss_xml.contains("Filesystem <strong>body</strong>"));
assert!(!rss_xml.contains("Stale database body"));
assert!(translated_atom_xml.contains("<feed"));
assert!(translated_atom_xml.contains("Deutscher <strong>Entwurf</strong>"));
}
#[test] #[test]
fn preview_server_blocks_media_path_traversal() { fn preview_server_blocks_media_path_traversal() {
let _guard = preview_port_guard().lock().unwrap(); let _guard = preview_port_guard().lock().unwrap();
@@ -731,8 +961,16 @@ mod tests {
let _guard = preview_port_guard().lock().unwrap(); let _guard = preview_port_guard().lock().unwrap();
let (dir, _db) = setup_preview_fixture(); let (dir, _db) = setup_preview_fixture();
std::fs::create_dir_all(dir.path().join("assets")).unwrap(); 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("media/ok.txt"), "ok").unwrap();
std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap(); std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap();
std::fs::write(dir.path().join("images/custom.svg"), "<svg></svg>").unwrap();
std::fs::write(
dir.path().join("html/pagefind/pagefind-ui.js"),
"window.pagefind = true;",
)
.unwrap();
let server = start_preview_server( let server = start_preview_server(
dir.path().join("bds.db"), dir.path().join("bds.db"),
@@ -754,10 +992,28 @@ mod tests {
.unwrap(); .unwrap();
let media_body = media.text().unwrap(); let media_body = media.text().unwrap();
let asset_body = asset.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(); server.stop().unwrap();
assert_eq!(media_body, "ok"); assert_eq!(media_body, "ok");
assert!(asset_body.contains("color: red")); assert!(asset_body.contains("color: red"));
assert!(image_body.contains("<svg>"));
assert!(pagefind_body.contains("window.pagefind"));
} }
#[test] #[test]

View File

@@ -13,8 +13,8 @@ use crate::db::queries;
use crate::engine::generation::{GenerationSection, classify_generated_path}; use crate::engine::generation::{GenerationSection, classify_generated_path};
use crate::engine::menu::{self, MenuItemKind}; use crate::engine::menu::{self, MenuItemKind};
use crate::model::{ use crate::model::{
CategorySettings, Media, Post, ProjectMetadata, ScriptKind, Tag, Template, TemplateKind, CategorySettings, Media, Post, PostStatus, ProjectMetadata, ScriptKind, Tag, Template,
TemplateStatus, TemplateKind, TemplateStatus,
}; };
use crate::render::{ use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
@@ -185,8 +185,14 @@ fn build_site_render_artifacts_with_mode(
let mut artifacts = SiteRenderArtifacts::default(); let mut artifacts = SiteRenderArtifacts::default();
for language in languages { for language in languages {
let localized_posts = let localized_posts = load_language_posts(
load_language_posts(conn, data_dir, published_posts, &language, &main_language)?; conn,
data_dir,
published_posts,
&language,
&main_language,
is_preview,
)?;
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings); let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
let routes = build_language_routes( let routes = build_language_routes(
&localized_list_posts, &localized_list_posts,
@@ -327,6 +333,8 @@ pub fn build_preview_response(
published_posts: &[(Post, String)], published_posts: &[(Post, String)],
requested_path: &str, requested_path: &str,
) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> { ) -> Result<PreviewRenderResult, Box<dyn Error + Send + Sync>> {
let normalized = normalize_request_path(requested_path);
let requested_paths = HashSet::from([preview_relative_path(&normalized)]);
let artifacts = build_site_render_artifacts_with_mode( let artifacts = build_site_render_artifacts_with_mode(
conn, conn,
data_dir, data_dir,
@@ -335,9 +343,8 @@ pub fn build_preview_response(
published_posts, published_posts,
true, true,
None, None,
None, Some(&requested_paths),
)?; )?;
let normalized = normalize_request_path(requested_path);
if let Some(page) = artifacts if let Some(page) = artifacts
.pages .pages
.iter() .iter()
@@ -502,6 +509,7 @@ fn load_language_posts(
published_posts: &[(Post, String)], published_posts: &[(Post, String)],
language: &str, language: &str,
main_language: &str, main_language: &str,
is_preview: bool,
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> { ) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
let mut posts = Vec::new(); let mut posts = Vec::new();
for (post, body) in published_posts { for (post, body) in published_posts {
@@ -518,8 +526,14 @@ fn load_language_posts(
conn, &post.id, language, conn, &post.id, language,
) )
{ {
let raw = fs::read_to_string(data_dir.join(&translation.file_path))?; let translated_body = if is_preview && translation.status == PostStatus::Draft {
let (_, translated_body) = read_translation_file(&raw)?; 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(); let mut translated_post = post.clone();
translated_post.title = translation.title.clone(); translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone(); translated_post.excerpt = translation.excerpt.clone();
@@ -544,6 +558,18 @@ fn load_language_posts(
Ok(posts) Ok(posts)
} }
fn read_translation_body(
data_dir: &Path,
file_path: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
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( fn build_language_routes(
posts: &[RenderPostRecord], posts: &[RenderPostRecord],
metadata: &ProjectMetadata, 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 { fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
format!("{}{}/", prefix_or_root(prefix), slug.trim_matches('/')) format!(
} "{}/{}/",
prefix.trim_end_matches('/'),
fn prefix_or_root(prefix: &str) -> &str { slug.trim_matches('/')
if prefix.is_empty() { "/" } else { prefix } )
} }
fn route_href(route: &RouteSpec, page: usize) -> String { 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 { fn relative_to_url_path(relative_path: &str) -> String {
if relative_path == "index.html" { if relative_path == "index.html" {
return "/".to_string(); return "/".to_string();
@@ -1630,6 +1670,18 @@ mod menu_tests {
use super::*; use super::*;
use crate::engine::menu::{MenuItem, MenuItemKind}; 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] #[test]
fn renderer_consumes_the_saved_opml_tree() { fn renderer_consumes_the_saved_opml_tree() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1663,6 +1715,7 @@ mod menu_tests {
assert_eq!(rendered[1]["href"], "/about/"); assert_eq!(rendered[1]["href"], "/about/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/"); assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/");
let translated = build_menu_items(dir.path(), "de", "en").unwrap(); let translated = build_menu_items(dir.path(), "de", "en").unwrap();
assert_eq!(translated[1]["href"], "/de/about/");
assert_eq!( assert_eq!(
translated[2]["children"][0]["href"], translated[2]["children"][0]["href"],
"/de/category/long-form/" "/de/category/long-form/"

View File

@@ -756,13 +756,20 @@ fn referenced_media_ids(content: &str) -> Vec<String> {
ids ids
} }
fn draft_preview_url(post_id: &str, language: &str) -> String { fn preview_base_url() -> String {
format!( format!(
"http://{}:{}/__draft/{}?language={}", "http://{}:{}",
engine::preview::PREVIEW_HOST, engine::preview::PREVIEW_HOST,
engine::preview::PREVIEW_PORT, 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)), Some(TabType::MenuEditor) => Task::done(Message::MenuEditor(MenuEditorMsg::Save)),
_ => Task::none(), _ => Task::none(),
}, },
MenuAction::OpenInBrowser => self.preview_active_post(), MenuAction::OpenInBrowser => self.open_preview_in_browser(),
MenuAction::OpenDataFolder => { MenuAction::OpenDataFolder => {
if let Some(ref dir) = self.data_dir { if let Some(ref dir) = self.data_dir {
let _ = open::that(dir); let _ = open::that(dir);
@@ -8626,8 +8633,20 @@ impl BdsApp {
.map(|editor| editor.active_language.clone()) .map(|editor| editor.active_language.clone())
.filter(|language| !language.is_empty()) .filter(|language| !language.is_empty())
.unwrap_or_else(|| self.content_language.clone()); .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 { fn active_post_uses_embedded_preview(&self) -> bool {
@@ -8788,6 +8807,25 @@ impl BdsApp {
Task::none() Task::none()
} }
fn open_preview_in_browser(&mut self) -> Task<Message> {
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<Message> { fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else { let Some(db) = &self.db else {
self.notify( self.notify(
@@ -9339,6 +9377,13 @@ fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) ->
.map(|tab| tab.id.clone()) .map(|tab| tab.id.clone())
} }
fn active_post_tab_id(active_tab: Option<&str>, tabs: &[Tab]) -> Option<String> {
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<String> { fn split_csv_values(value: &str) -> Vec<String> {
value value
.split(',') .split(',')
@@ -9376,11 +9421,11 @@ fn remote_error_closes_connection(code: &str) -> bool {
mod tests { mod tests {
use super::{ use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
PostStatus, SettingsMsg, dropped_image_target, localize_chat_error, month_abbreviation, PostStatus, SettingsMsg, active_post_tab_id, dropped_image_target, localize_chat_error,
persist_media_editor_state_impl, persist_post_editor_preview_state_impl, month_abbreviation, persist_media_editor_state_impl,
persist_post_editor_state_impl, remote_error_closes_connection, persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
save_editor_settings_state_impl, save_script_editor_state_impl, remote_error_closes_connection, save_editor_settings_state_impl,
save_template_editor_state_impl, save_script_editor_state_impl, save_template_editor_state_impl,
}; };
use crate::i18n::t; use crate::i18n::t;
use crate::platform::menu::MenuAction; 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] #[test]
fn multiple_file_drop_events_queue_one_managed_import_at_a_time() { fn multiple_file_drop_events_queue_one_managed_import_at_a_time() {
let (db, project, tmp) = setup(); let (db, project, tmp) = setup();
@@ -10790,15 +10862,28 @@ mod tests {
} }
#[test] #[test]
fn draft_preview_url_points_at_local_preview_server() { fn post_preview_url_uses_the_generated_site_route() {
let url = super::draft_preview_url("post-42", "de"); 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!( assert_eq!(
url, super::post_preview_url(&created, "de", "en"),
format!( format!(
"http://{}:{}/__draft/post-42?language=de", "http://127.0.0.1:4123{path}?draft=true&post_id={}",
bds_core::engine::preview::PREVIEW_HOST, created.id
bds_core::engine::preview::PREVIEW_PORT,
) )
); );
} }

View File

@@ -248,9 +248,8 @@ pub(crate) fn action_enabled(
match action { match action {
MenuAction::Save => savable_tab, MenuAction::Save => savable_tab,
MenuAction::Find | MenuAction::Replace => text_editor, MenuAction::Find | MenuAction::Replace => text_editor,
MenuAction::OpenInBrowser | MenuAction::PublishSelected | MenuAction::PreviewPost => { MenuAction::OpenInBrowser => has_project,
has_project && post_tab MenuAction::PublishSelected | MenuAction::PreviewPost => has_project && post_tab,
}
MenuAction::UploadSite => has_project && !offline, MenuAction::UploadSite => has_project && !offline,
MenuAction::NewPost MenuAction::NewPost
| MenuAction::ImportMedia | MenuAction::ImportMedia
@@ -711,6 +710,20 @@ mod tests {
true, true,
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( assert!(!action_enabled(
MenuAction::ReindexText, MenuAction::ReindexText,
true, true,

View File

@@ -163,7 +163,9 @@ surface PostEditorSurface {
@guarantee EditorModes @guarantee EditorModes
-- Markdown: code editor with markdown-with-macros language, -- Markdown: code editor with markdown-with-macros language,
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. -- 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. -- Markdown and Preview are the only editor modes.
@guarantee DragDropImages @guarantee DragDropImages

View File

@@ -52,14 +52,17 @@ rule StopPreview {
rule OpenPreviewInBrowser { rule OpenPreviewInBrowser {
when: OpenPreviewInBrowserRequested(project) when: OpenPreviewInBrowserRequested(project)
-- Starts or reuses the project's localhost preview server, then opens its -- Starts or reuses the project's localhost preview server, then opens its
-- base URL in the external system browser. This is separate from the -- base URL in the external system browser when no post editor is active.
-- post editor's internal preview pane (editor_post.allium EditorModes). -- With an active post editor it opens that post's normal generated-site URL.
ensures: ExternalBrowserOpened(url: preview_base_url(project)) -- 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 -- Route resolution
-- Preview renders all posts (published + draft) on-demand via Liquid templates. -- 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. -- See invariant PreviewDraftOverlay below.
rule ServePostPreview { rule ServePostPreview {
@@ -67,15 +70,16 @@ rule ServePostPreview {
requires: is_post_path(path) requires: is_post_path(path)
-- path matches "/{yyyy}/{mm}/{dd}/{slug}" -- path matches "/{yyyy}/{mm}/{dd}/{slug}"
-- Finds post by slug+date regardless of status (published or draft). -- 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. -- Renders via Liquid template with full PageRenderer context.
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
rule ServeDraftPreview { rule ServeDraftPreview {
when: PreviewDraftRequest(path, post_id) when: PreviewDraftRequest(path, post_id)
-- Explicit draft preview by post_id (used by editor preview pane). -- Editor preview uses the post's canonical generated route with draft and
-- Renders draft content (from DB, not filesystem). -- 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) ensures: PreviewResponse(rendered_html)
} }
@@ -100,6 +104,16 @@ rule ServeAssets {
ensures: PreviewResponse(asset_file) 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 { rule ServeLanguagePrefixedRoute {
when: PreviewRequest(path) when: PreviewRequest(path)
requires: is_language_prefixed(path) requires: is_language_prefixed(path)
@@ -115,15 +129,15 @@ invariant PreviewDraftOverlay {
-- Post universe: all posts with status in {published, draft}. -- Post universe: all posts with status in {published, draft}.
-- Archived posts are excluded. -- Archived posts are excluded.
-- --
-- Content priority (per post): -- Content source (per post or translation):
-- 1. DB content field (draft edits not yet published) → used when non-nil -- 1. status=draft → DB content, falling back to its last-published file
-- 2. Published .md file (last-published snapshot) → used when DB content is nil -- 2. status=published → published .md file
-- 3. Empty string → fallback if neither exists -- 3. No DB body or file → empty string
-- --
-- This means: -- This means:
-- - A purely draft post (never published) renders from DB content. -- - A purely draft post (never published) renders from DB content.
-- - A published-then-edited post renders from DB content (the draft edits). -- - 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): -- Contrast with generation (see generation.allium GenerationPublishedOnly):
-- Generation uses *only* published .md file content, never DB draft content, -- Generation uses *only* published .md file content, never DB draft content,