feat: more closing of M4
This commit is contained in:
@@ -59,9 +59,11 @@ struct PreviewServerState {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct DraftPreviewQuery {
|
||||
language: Option<String>,
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct StylePreviewQuery {
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
@@ -115,9 +117,10 @@ pub fn start_preview_server(
|
||||
|
||||
async fn handle_preview_request(
|
||||
State(state): State<PreviewServerState>,
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
uri: Uri,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, uri.path(), None) {
|
||||
match render_preview_response(&state, uri.path(), None, Some(&query)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
@@ -128,7 +131,11 @@ async fn handle_draft_preview(
|
||||
AxumPath(post_id): AxumPath<String>,
|
||||
Query(query): Query<DraftPreviewQuery>,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) {
|
||||
let style = StylePreviewQuery {
|
||||
theme: query.theme.clone(),
|
||||
mode: query.mode.clone(),
|
||||
};
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
@@ -153,9 +160,10 @@ fn render_preview_response(
|
||||
state: &PreviewServerState,
|
||||
path: &str,
|
||||
requested_language: Option<&str>,
|
||||
style: Option<&StylePreviewQuery>,
|
||||
) -> EngineResult<Response> {
|
||||
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
||||
let html = render_draft_preview(state, post_id, requested_language)?;
|
||||
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
@@ -173,7 +181,53 @@ fn render_preview_response(
|
||||
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())
|
||||
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
|
||||
}
|
||||
|
||||
fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String {
|
||||
let Some(style) = style else {
|
||||
return html;
|
||||
};
|
||||
if style.theme.as_deref().unwrap_or("").is_empty() && style.mode.as_deref().unwrap_or("").is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let Some(start) = html.find("<html") else {
|
||||
return html;
|
||||
};
|
||||
let Some(end_rel) = html[start..].find('>') else {
|
||||
return html;
|
||||
};
|
||||
let end = start + end_rel;
|
||||
|
||||
let mut attrs = String::new();
|
||||
if let Some(theme) = style.theme.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-theme=\"");
|
||||
attrs.push_str(&escape_html_attr(theme));
|
||||
attrs.push('"');
|
||||
}
|
||||
if let Some(mode) = style.mode.as_deref().filter(|value| !value.is_empty()) {
|
||||
attrs.push_str(" data-mode=\"");
|
||||
attrs.push_str(&escape_html_attr(mode));
|
||||
attrs.push('"');
|
||||
}
|
||||
if attrs.is_empty() {
|
||||
return html;
|
||||
}
|
||||
|
||||
let mut styled = String::with_capacity(html.len() + attrs.len());
|
||||
styled.push_str(&html[..end]);
|
||||
styled.push_str(&attrs);
|
||||
styled.push_str(&html[end..]);
|
||||
styled
|
||||
}
|
||||
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
@@ -579,4 +633,30 @@ mod tests {
|
||||
assert!(body.contains("nightfall"));
|
||||
assert!(body.contains("dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_applies_theme_query_to_rendered_pages() {
|
||||
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}/?theme=nightfall&mode=dark"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("data-theme=\"nightfall\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use rusqlite::Connection;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::menu::{self, MenuItemKind};
|
||||
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
|
||||
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
|
||||
use crate::util::frontmatter::{read_template_file, read_translation_file};
|
||||
@@ -100,6 +101,7 @@ pub fn build_site_render_artifacts(
|
||||
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
||||
let routes = build_language_routes(&localized_posts, metadata, &language, &tags);
|
||||
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
||||
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
||||
let rendered_list_pages = routes
|
||||
.par_iter()
|
||||
.map(|route| {
|
||||
@@ -109,6 +111,7 @@ pub fn build_site_render_artifacts(
|
||||
&language,
|
||||
&localized_posts,
|
||||
&tags,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
)
|
||||
@@ -144,6 +147,7 @@ pub fn build_site_render_artifacts(
|
||||
&category_settings,
|
||||
&media_by_id,
|
||||
&canonical_map,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
)?;
|
||||
@@ -186,7 +190,8 @@ pub fn build_preview_response(
|
||||
|
||||
let bundle = load_template_bundle(conn, data_dir, project_id)?;
|
||||
let language = language_from_path(&normalized, metadata);
|
||||
let html = render_not_found_route(&bundle, metadata, &language, &normalized)?;
|
||||
let menu_items = build_menu_items(data_dir, &language, main_language(metadata))?;
|
||||
let html = render_not_found_route(&bundle, metadata, &language, &normalized, &menu_items)?;
|
||||
Ok(PreviewRenderResult {
|
||||
status_code: 404,
|
||||
html,
|
||||
@@ -447,6 +452,7 @@ fn render_list_route(
|
||||
language: &str,
|
||||
posts: &[RenderPostRecord],
|
||||
tags: &[Tag],
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -461,7 +467,7 @@ fn render_list_route(
|
||||
"pico_stylesheet_href": pico_stylesheet_href(metadata),
|
||||
"blog_languages": build_list_blog_languages(metadata, language, &route.url_path),
|
||||
"alternate_links": build_alternate_list_links(metadata, &route.url_path),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": route.posts.first().map(|post| calendar_initial_parts(&post.post).0).unwrap_or(1970),
|
||||
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
|
||||
"archive_context": route.archive_context,
|
||||
@@ -506,6 +512,7 @@ fn render_post_route(
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
media_by_id: &HashMap<String, Media>,
|
||||
canonical_post_path_by_slug: &HashMap<String, String>,
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
bundle: &TemplateBundle,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -553,7 +560,7 @@ fn render_post_route(
|
||||
"html_theme_attribute": serde_json::Value::Null,
|
||||
"alternate_links": build_alternate_post_links(&record.post, metadata),
|
||||
"blog_languages": build_post_blog_languages(&record.post, metadata, language),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": calendar_initial_parts(&record.post).0,
|
||||
"calendar_initial_month": calendar_initial_parts(&record.post).1,
|
||||
"post": post_context(&record.post, &record.body_markdown, linked_media, outgoing_link_context, incoming_link_context),
|
||||
@@ -588,6 +595,7 @@ fn render_not_found_route(
|
||||
metadata: &ProjectMetadata,
|
||||
language: &str,
|
||||
requested_path: &str,
|
||||
menu_items: &[Value],
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let context = json!({
|
||||
"language": language,
|
||||
@@ -597,7 +605,7 @@ fn render_not_found_route(
|
||||
"pico_stylesheet_href": pico_stylesheet_href(metadata),
|
||||
"blog_languages": build_list_blog_languages(metadata, language, requested_path),
|
||||
"alternate_links": build_alternate_list_links(metadata, requested_path),
|
||||
"menu_items": Vec::<Value>::new(),
|
||||
"menu_items": menu_items,
|
||||
"calendar_initial_year": 1970,
|
||||
"calendar_initial_month": 1,
|
||||
"post": serde_json::Value::Null,
|
||||
@@ -618,7 +626,7 @@ fn render_not_found_route(
|
||||
"prev_page_href": serde_json::Value::Null,
|
||||
"next_page_href": serde_json::Value::Null,
|
||||
"not_found_message": format!("No rendered page exists for {}", requested_path),
|
||||
"not_found_back_label": "Back to home",
|
||||
"not_found_back_label": serde_json::Value::Null,
|
||||
"canonical_post_path_by_slug": HashMap::<String, String>::new(),
|
||||
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
||||
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
||||
@@ -626,6 +634,68 @@ fn render_not_found_route(
|
||||
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
|
||||
}
|
||||
|
||||
fn build_menu_items(
|
||||
data_dir: &Path,
|
||||
language: &str,
|
||||
main_language: &str,
|
||||
) -> Result<Vec<Value>, Box<dyn Error + Send + Sync>> {
|
||||
let items = menu::read_menu(data_dir)?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.map(|item| menu_item_context(item, language, main_language))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn menu_item_context(item: &menu::MenuItem, language: &str, main_language: &str) -> Value {
|
||||
let children = item
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| menu_item_context(child, language, main_language))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"title": item.label,
|
||||
"href": menu_item_href(item, language, main_language),
|
||||
"has_children": !children.is_empty(),
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
|
||||
fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> String {
|
||||
let prefix = language_prefix(language, main_language);
|
||||
match item.kind {
|
||||
MenuItemKind::Home => {
|
||||
if prefix.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("{prefix}/")
|
||||
}
|
||||
}
|
||||
MenuItemKind::Submenu => "#".to_string(),
|
||||
MenuItemKind::Page => item
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(|slug| prefixed_slug_path(&prefix, slug))
|
||||
.unwrap_or_else(|| "#".to_string()),
|
||||
MenuItemKind::CategoryArchive => item
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(|slug| format!("{}/category/{}/", prefix_or_root(&prefix), slugify(slug)))
|
||||
.unwrap_or_else(|| "#".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fn route_href(route: &RouteSpec, page: usize) -> String {
|
||||
let base = route.url_path.trim_end_matches('/');
|
||||
if page <= 1 {
|
||||
|
||||
Reference in New Issue
Block a user