Implement the Pico CSS theme editor.
This commit is contained in:
@@ -4,8 +4,8 @@ use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::PublishingPreferences;
|
||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences};
|
||||
use crate::util::atomic_write_str;
|
||||
|
||||
// ── project.json ────────────────────────────────────────────────────
|
||||
@@ -26,6 +26,41 @@ pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the shared project row and its filesystem metadata, then publish
|
||||
/// the normal project-updated domain event.
|
||||
pub fn update_project_metadata(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project: &Project,
|
||||
metadata: &ProjectMetadata,
|
||||
) -> EngineResult<ProjectMetadata> {
|
||||
if project.id.is_empty() {
|
||||
return Err(crate::engine::EngineError::Validation(
|
||||
"project id is required".to_string(),
|
||||
));
|
||||
}
|
||||
metadata
|
||||
.validate()
|
||||
.map_err(crate::engine::EngineError::Validation)?;
|
||||
|
||||
let mut persisted_project = project.clone();
|
||||
persisted_project.name = metadata.name.clone();
|
||||
persisted_project.description = metadata.description.clone();
|
||||
persisted_project.updated_at = crate::util::now_unix_ms();
|
||||
|
||||
let category_metadata = read_category_meta_json(data_dir)?;
|
||||
write_project_json(data_dir, metadata)?;
|
||||
write_category_meta_json(data_dir, &category_metadata)?;
|
||||
crate::db::queries::project::update_project(conn, &persisted_project)?;
|
||||
crate::engine::domain_events::entity_changed(
|
||||
&project.id,
|
||||
DomainEntity::Project,
|
||||
&project.id,
|
||||
NotificationAction::Updated,
|
||||
);
|
||||
Ok(metadata.clone())
|
||||
}
|
||||
|
||||
/// Copy the project fields persisted in both representations from project.json
|
||||
/// into the machine-local project row.
|
||||
pub fn sync_project_from_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};
|
||||
use crate::model::{Post, PostStatus, ProjectMetadata, pico_stylesheet_href};
|
||||
use crate::render::build_preview_response;
|
||||
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||
|
||||
@@ -148,12 +148,31 @@ 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());
|
||||
async fn handle_style_preview(
|
||||
State(state): State<PreviewServerState>,
|
||||
Query(query): Query<StylePreviewQuery>,
|
||||
) -> Response {
|
||||
let metadata = match crate::engine::meta::read_project_json(&state.data_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string());
|
||||
}
|
||||
};
|
||||
let theme = query.theme.as_deref().unwrap_or("default");
|
||||
let stylesheet = pico_stylesheet_href(Some(theme));
|
||||
let mode = match query.mode.as_deref().map(str::trim) {
|
||||
Some("light") => Some("light"),
|
||||
Some("dark") => Some("dark"),
|
||||
_ => None,
|
||||
};
|
||||
let mode_attributes = mode
|
||||
.map(|mode| format!(" data-theme=\"{mode}\" data-mode=\"{mode}\""))
|
||||
.unwrap_or_default();
|
||||
let project_name = escape_html(&metadata.name);
|
||||
let language = escape_html(metadata.main_language.as_deref().unwrap_or("en"));
|
||||
let description = escape_html(metadata.description.as_deref().unwrap_or(&metadata.name));
|
||||
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,
|
||||
"<!doctype html><html lang=\"{language}\"{mode_attributes}><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>{project_name}</title><link rel=\"stylesheet\" href=\"{stylesheet}\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main class=\"container\"><nav><ul><li><strong>{project_name}</strong></li></ul></nav><article><header><h1>{project_name}</h1></header><p>{description}</p><progress value=\"70\" max=\"100\"></progress><input type=\"text\" value=\"{project_name}\" aria-label=\"{project_name}\" /><button type=\"button\">{project_name}</button></article></main></body></html>"
|
||||
);
|
||||
Html(html).into_response()
|
||||
}
|
||||
@@ -200,48 +219,82 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin
|
||||
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('"');
|
||||
let mut styled = html;
|
||||
if let Some(theme) = style.theme.as_deref().filter(|theme| !theme.is_empty()) {
|
||||
styled = override_pico_stylesheet(&styled, &pico_stylesheet_href(Some(theme)));
|
||||
}
|
||||
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 let Some(mode) = style.mode.as_deref() {
|
||||
let mode = match mode.trim() {
|
||||
"light" => Some("light"),
|
||||
"dark" => Some("dark"),
|
||||
_ => None,
|
||||
};
|
||||
styled = override_html_attribute(&styled, "data-theme", mode);
|
||||
styled = override_html_attribute(&styled, "data-mode", mode);
|
||||
}
|
||||
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 {
|
||||
fn override_pico_stylesheet(html: &str, href: &str) -> String {
|
||||
let Some(start) = html.find("/assets/pico") else {
|
||||
return html.to_string();
|
||||
};
|
||||
let Some(end_offset) = html[start..].find(".min.css") else {
|
||||
return html.to_string();
|
||||
};
|
||||
let end = start + end_offset + ".min.css".len();
|
||||
format!("{}{}{}", &html[..start], href, &html[end..])
|
||||
}
|
||||
|
||||
fn override_html_attribute(html: &str, attribute: &str, value: Option<&str>) -> String {
|
||||
let Some(html_start) = html.find("<html") else {
|
||||
return html.to_string();
|
||||
};
|
||||
let Some(tag_end_offset) = html[html_start..].find('>') else {
|
||||
return html.to_string();
|
||||
};
|
||||
let tag_end = html_start + tag_end_offset;
|
||||
let attribute_start_pattern = format!(" {attribute}=\"");
|
||||
let existing = html[html_start..tag_end]
|
||||
.find(&attribute_start_pattern)
|
||||
.and_then(|offset| {
|
||||
let start = html_start + offset;
|
||||
html[start + attribute_start_pattern.len()..tag_end]
|
||||
.find('"')
|
||||
.map(|end_offset| {
|
||||
(
|
||||
start,
|
||||
start + attribute_start_pattern.len() + end_offset + 1,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
match (existing, value) {
|
||||
(Some((start, end)), Some(value)) => format!(
|
||||
"{} {attribute}=\"{}\"{}",
|
||||
&html[..start],
|
||||
value,
|
||||
&html[end..]
|
||||
),
|
||||
(Some((start, end)), None) => format!("{}{}", &html[..start], &html[end..]),
|
||||
(None, Some(value)) => format!(
|
||||
"{} {attribute}=\"{}\"{}",
|
||||
&html[..tag_end],
|
||||
value,
|
||||
&html[tag_end..]
|
||||
),
|
||||
(None, None) => html.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
@@ -384,6 +437,13 @@ fn serve_scoped_file(
|
||||
let scope_root = data_dir.join(scope_dir);
|
||||
let candidate = scope_root.join(relative);
|
||||
if !candidate.exists() || !candidate.is_file() {
|
||||
let bundled_path = format!("{scope_dir}/{relative}");
|
||||
if let Some(bytes) = crate::engine::site_assets::bundled_site_asset(&bundled_path) {
|
||||
let mime = guess_content_type(Path::new(&bundled_path));
|
||||
return Ok(Some(
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
||||
));
|
||||
}
|
||||
return Ok(Some(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"preview asset not found",
|
||||
@@ -715,16 +775,28 @@ mod tests {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=nightfall&mode=dark"
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
let stylesheet = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/pico.amber.min.css"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
assert!(stylesheet.status().is_success());
|
||||
let stylesheet = stylesheet.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("Style Preview"));
|
||||
assert!(body.contains("nightfall"));
|
||||
assert!(body.contains("dark"));
|
||||
assert!(body.contains("<article>"));
|
||||
assert!(body.contains("Blog"));
|
||||
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
||||
assert!(body.contains("data-theme=\"dark\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
assert!(!body.contains("data-theme=\"amber\""));
|
||||
assert!(stylesheet.contains("--pico-primary-background:#ffbf00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -742,15 +814,34 @@ mod tests {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=nightfall&mode=dark"
|
||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light"
|
||||
))
|
||||
.send()
|
||||
.unwrap();
|
||||
let body = response.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert!(body.contains("data-theme=\"nightfall\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
||||
assert!(body.contains("data-theme=\"light\""));
|
||||
assert!(body.contains("data-mode=\"light\""));
|
||||
assert!(!body.contains("data-theme=\"amber\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_preview_mode_removes_forced_color_scheme_and_invalid_theme_falls_back() {
|
||||
let html = "<html data-theme=\"dark\" data-mode=\"dark\"><head><link rel=\"stylesheet\" href=\"/assets/pico.blue.min.css\"></head></html>".to_string();
|
||||
let styled = apply_preview_style(
|
||||
html,
|
||||
Some(&StylePreviewQuery {
|
||||
theme: Some("../../secret".into()),
|
||||
mode: Some("auto".into()),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(styled.contains("href=\"/assets/pico.min.css\""));
|
||||
assert!(!styled.contains("data-theme="));
|
||||
assert!(!styled.contains("data-mode="));
|
||||
assert!(!styled.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -231,6 +231,13 @@ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> {
|
||||
BUNDLED_SITE_ASSETS
|
||||
.iter()
|
||||
.find(|asset| asset.relative_path == relative_path)
|
||||
.map(|asset| asset.bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||
for asset in BUNDLED_SITE_ASSETS {
|
||||
let target = project_dir.join(asset.relative_path);
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SUPPORTED_PICO_THEMES: [&str; 20] = [
|
||||
"default", "amber", "blue", "cyan", "fuchsia", "green", "grey", "indigo", "jade", "lime",
|
||||
"orange", "pink", "pumpkin", "purple", "red", "sand", "slate", "violet", "yellow", "zinc",
|
||||
];
|
||||
|
||||
pub fn is_supported_pico_theme(theme: &str) -> bool {
|
||||
SUPPORTED_PICO_THEMES.contains(&theme)
|
||||
}
|
||||
|
||||
pub fn pico_stylesheet_href(theme: Option<&str>) -> String {
|
||||
match theme.filter(|theme| is_supported_pico_theme(theme)) {
|
||||
Some("default") | None => "/assets/pico.min.css".to_string(),
|
||||
Some(theme) => format!("/assets/pico.{theme}.min.css"),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_posts() -> i32 {
|
||||
50
|
||||
}
|
||||
@@ -69,6 +85,11 @@ impl ProjectMetadata {
|
||||
self.image_import_concurrency
|
||||
));
|
||||
}
|
||||
if let Some(theme) = self.pico_theme.as_deref()
|
||||
&& !is_supported_pico_theme(theme)
|
||||
{
|
||||
return Err(format!("unsupported picoTheme: {theme}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -209,4 +230,14 @@ mod tests {
|
||||
meta.max_posts_per_page = 500;
|
||||
assert!(meta.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pico_theme_validation_accepts_only_bundled_themes() {
|
||||
let mut meta: ProjectMetadata = serde_json::from_str(r#"{"name":"Test"}"#).unwrap();
|
||||
meta.pico_theme = Some("amber".into());
|
||||
assert!(meta.validate().is_ok());
|
||||
|
||||
meta.pico_theme = Some("nightfall".into());
|
||||
assert!(meta.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ pub use import::{
|
||||
};
|
||||
pub use mcp::{McpProposal, ProposalKind, ProposalStatus};
|
||||
pub use media::{Media, MediaTranslation};
|
||||
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||
pub use metadata::{
|
||||
CategorySettings, ProjectMetadata, SUPPORTED_PICO_THEMES, TagEntry, is_supported_pico_theme,
|
||||
pico_stylesheet_href,
|
||||
};
|
||||
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
||||
pub use project::{Project, Setting};
|
||||
pub use script::{Script, ScriptKind, ScriptStatus};
|
||||
|
||||
@@ -151,10 +151,9 @@ pub fn render_starter_single_post_page_with_media_map(
|
||||
language,
|
||||
language_prefix: language_prefix(language, main_language(metadata)),
|
||||
page_title: &post.title,
|
||||
pico_stylesheet_href: metadata
|
||||
.pico_theme
|
||||
.as_ref()
|
||||
.map(|_| "/assets/pico.min.css".to_string()),
|
||||
pico_stylesheet_href: Some(crate::model::pico_stylesheet_href(
|
||||
metadata.pico_theme.as_deref(),
|
||||
)),
|
||||
html_theme_attribute: None,
|
||||
alternate_links: build_alternate_links(post, metadata, language),
|
||||
blog_languages: build_blog_languages(post, metadata, language),
|
||||
@@ -226,10 +225,9 @@ pub fn render_starter_list_page_with_media_map(
|
||||
language: language.to_string(),
|
||||
language_prefix: language_prefix(language, main_language(metadata)),
|
||||
page_title: metadata.name.clone(),
|
||||
pico_stylesheet_href: metadata
|
||||
.pico_theme
|
||||
.as_ref()
|
||||
.map(|_| "/assets/pico.min.css".to_string()),
|
||||
pico_stylesheet_href: Some(crate::model::pico_stylesheet_href(
|
||||
metadata.pico_theme.as_deref(),
|
||||
)),
|
||||
html_theme_attribute: None,
|
||||
alternate_links: vec![],
|
||||
blog_languages: build_blog_languages_for_index(metadata, language),
|
||||
@@ -539,6 +537,21 @@ mod tests {
|
||||
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_renderer_uses_the_selected_pico_theme_stylesheet() {
|
||||
let post = make_post();
|
||||
let mut metadata = make_metadata();
|
||||
metadata.pico_theme = Some("amber".into());
|
||||
|
||||
let rendered = render_starter_single_post_page(&post, "Body", &metadata, "en").unwrap();
|
||||
|
||||
assert!(
|
||||
rendered
|
||||
.html
|
||||
.contains("href=\"/assets/pico.amber.min.css\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_renderer_rewrites_bds_media_image_links() {
|
||||
let post = make_post();
|
||||
|
||||
@@ -1598,10 +1598,9 @@ fn starter_partials() -> HashMap<String, String> {
|
||||
}
|
||||
|
||||
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
||||
metadata
|
||||
.pico_theme
|
||||
.as_ref()
|
||||
.map(|_| "/assets/pico.min.css".to_string())
|
||||
Some(crate::model::pico_stylesheet_href(
|
||||
metadata.pico_theme.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||
|
||||
Reference in New Issue
Block a user