feat: more filling out of M4
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
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};
|
||||
@@ -15,7 +14,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_with_media_map, render_starter_single_post_page_with_media_map};
|
||||
use crate::render::build_preview_response;
|
||||
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||
|
||||
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
||||
@@ -62,6 +61,12 @@ struct DraftPreviewQuery {
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct StylePreviewQuery {
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
pub fn start_preview_server(
|
||||
db_path: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
@@ -91,6 +96,7 @@ pub fn start_preview_server(
|
||||
let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener");
|
||||
let app = Router::new()
|
||||
.route("/__draft/{post_id}", get(handle_draft_preview))
|
||||
.route("/__style-preview", get(handle_style_preview))
|
||||
.route("/", get(handle_preview_request))
|
||||
.route("/{*path}", get(handle_preview_request))
|
||||
.with_state(state);
|
||||
@@ -107,48 +113,6 @@ pub fn start_preview_server(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_preview_path(
|
||||
path: &str,
|
||||
metadata: &ProjectMetadata,
|
||||
posts: &[PublishedPostSource],
|
||||
canonical_media_path_by_source_path: &HashMap<String, String>,
|
||||
) -> EngineResult<Option<String>> {
|
||||
let normalized = if path.is_empty() { "/" } else { path };
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
|
||||
if normalized == "/" {
|
||||
let list_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
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()));
|
||||
}
|
||||
|
||||
let (language, route_path) = split_language_prefix(normalized, metadata);
|
||||
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_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()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_preview_request(
|
||||
State(state): State<PreviewServerState>,
|
||||
uri: Uri,
|
||||
@@ -170,6 +134,21 @@ async fn handle_draft_preview(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_style_preview(
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
) -> Response {
|
||||
let theme = query.theme.unwrap_or_else(|| "default".to_string());
|
||||
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
|
||||
theme,
|
||||
mode,
|
||||
theme,
|
||||
mode,
|
||||
);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
fn render_preview_response(
|
||||
state: &PreviewServerState,
|
||||
path: &str,
|
||||
@@ -186,12 +165,15 @@ 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, &media_rewrite_map)? {
|
||||
Some(html) => Ok(Html(html).into_response()),
|
||||
None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")),
|
||||
}
|
||||
let input_posts = published_posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
|
||||
Ok((status, Html(response.html)).into_response())
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
@@ -211,7 +193,6 @@ 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();
|
||||
@@ -220,56 +201,35 @@ 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_with_media_map(
|
||||
&translated_post,
|
||||
&body,
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
target_language,
|
||||
media_rewrite_map,
|
||||
&[(translated_post, body)],
|
||||
&crate::render::build_canonical_post_path(&post, target_language, metadata.main_language.as_deref().unwrap_or("en")),
|
||||
)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
return Ok(response.html);
|
||||
}
|
||||
}
|
||||
|
||||
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_with_media_map(
|
||||
&post,
|
||||
&body,
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&state.project_id,
|
||||
&metadata,
|
||||
canonical_language,
|
||||
media_rewrite_map,
|
||||
&[(post.clone(), body)],
|
||||
&crate::render::build_canonical_post_path(&post, canonical_language, metadata.main_language.as_deref().unwrap_or("en")),
|
||||
)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
fn build_media_rewrite_map(
|
||||
conn: &rusqlite::Connection,
|
||||
project_id: &str,
|
||||
) -> EngineResult<HashMap<String, String>> {
|
||||
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)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
Ok(response.html)
|
||||
}
|
||||
|
||||
fn collect_published_posts(
|
||||
state: &PreviewServerState,
|
||||
metadata: &ProjectMetadata,
|
||||
_metadata: &ProjectMetadata,
|
||||
) -> EngineResult<Vec<PublishedPostSource>> {
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
||||
@@ -280,8 +240,7 @@ fn collect_published_posts(
|
||||
post,
|
||||
});
|
||||
}
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
published.sort_by_key(|source| build_canonical_post_path(&source.post, main_language, main_language));
|
||||
published.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
|
||||
Ok(published)
|
||||
}
|
||||
|
||||
@@ -373,21 +332,6 @@ fn error_response(status: StatusCode, message: &str) -> Response {
|
||||
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response()
|
||||
}
|
||||
|
||||
fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let mut segments = trimmed.split('/');
|
||||
let first = segments.next().unwrap_or_default();
|
||||
if metadata.blog_languages.iter().any(|language| language == first) {
|
||||
let remainder = segments.collect::<Vec<_>>().join("/");
|
||||
return (first.to_string(), format!("/{first}/{}", remainder.trim_start_matches('/')));
|
||||
}
|
||||
|
||||
(
|
||||
metadata.main_language.as_deref().unwrap_or("en").to_string(),
|
||||
path.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -505,36 +449,52 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn root_preview_renders_index_page() {
|
||||
let html = render_preview_path("/", &make_metadata(), &[make_post()], &HashMap::new())
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let html = build_preview_response(db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
.html;
|
||||
assert!(html.contains("post-list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_single_post_for_canonical_path() {
|
||||
let html = render_preview_path(
|
||||
"/2024/03/09/hello",
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let source = make_post();
|
||||
let html = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[make_post()],
|
||||
&HashMap::new(),
|
||||
&[(source.post, source.body_markdown)],
|
||||
"/2024/03/09/hello",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.html;
|
||||
assert!(html.contains("<h1>Hello</h1>"));
|
||||
assert!(html.contains("<strong>world</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_language_prefixed_single_post() {
|
||||
let html = render_preview_path(
|
||||
"/de/2024/03/09/hello",
|
||||
&make_metadata(),
|
||||
&[make_post()],
|
||||
&HashMap::new(),
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("posts/2024/03/hello.de.md"),
|
||||
"---\ntranslationFor: post-1\nlanguage: de\ntitle: Hallo\n---\nHallo **welt**",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
.ok();
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let source = make_post();
|
||||
let html = build_preview_response(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(source.post, source.body_markdown)],
|
||||
"/de/2024/03/09/hello",
|
||||
)
|
||||
.unwrap()
|
||||
.html;
|
||||
assert!(html.contains("lang=\"de\""));
|
||||
}
|
||||
|
||||
@@ -592,4 +552,31 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_serves_style_preview() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, _db) = setup_preview_fixture();
|
||||
|
||||
let server = start_preview_server(
|
||||
dir.path().join("bds.db"),
|
||||
dir.path().to_path_buf(),
|
||||
"project-1".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=nightfall&mode=dark"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("Style Preview"));
|
||||
assert!(body.contains("nightfall"));
|
||||
assert!(body.contains("dark"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user