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

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

View File

@@ -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<String>,
}
#[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::<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()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
@@ -360,32 +401,115 @@ fn render_draft_preview(
Ok(response.html)
}
fn collect_published_posts(
state: &PreviewServerState,
_metadata: &ProjectMetadata,
) -> EngineResult<Vec<PublishedPostSource>> {
fn collect_preview_posts(state: &PreviewServerState) -> EngineResult<Vec<PublishedPostSource>> {
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<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> {
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<String> {
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<Option<Respon
if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? {
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)
}
@@ -496,7 +633,7 @@ mod tests {
use super::*;
use crate::db::queries;
use crate::engine::meta;
use crate::model::{Post, PostStatus, Project, ProjectMetadata};
use crate::model::{Post, PostStatus, PostTranslation, Project, ProjectMetadata};
use std::sync::{Mutex, OnceLock};
fn preview_port_guard() -> &'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("<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]
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"), "<svg></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("<svg>"));
assert!(pagefind_body.contains("window.pagefind"));
}
#[test]

View File

@@ -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<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(
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<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
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<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(
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/"