From 7b8e53934066537a76e8b4d0cc13de1e5a281d78 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 22 Jul 2026 20:19:21 +0200 Subject: [PATCH] Match bDS2 canonical file serialization --- README.md | 2 +- crates/bds-core/src/engine/menu.rs | 93 ++++- crates/bds-core/src/engine/meta.rs | 205 ++++++++++- crates/bds-core/src/engine/project.rs | 19 +- crates/bds-core/src/model/generation.rs | 8 +- crates/bds-core/src/model/metadata.rs | 43 ++- crates/bds-core/src/render/site.rs | 2 + crates/bds-core/src/util/frontmatter.rs | 448 +++++++++++++----------- crates/bds-core/src/util/sidecar.rs | 119 ++++--- crates/bds-core/tests/m1_validation.rs | 50 ++- crates/bds-ui/src/app.rs | 13 +- specs/media_processing.allium | 5 +- specs/menu.allium | 4 + specs/metadata.allium | 11 + specs/post.allium | 14 + specs/script.allium | 5 +- specs/template.allium | 3 + 17 files changed, 706 insertions(+), 338 deletions(-) diff --git a/README.md b/README.md index 101cf19..1154104 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The project is under active development. Core blogging workflows are broadly ava - Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. -- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, bidirectional metadata diff/repair including project categories and publishing preferences, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. +- SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search. - Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. - A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. diff --git a/crates/bds-core/src/engine/menu.rs b/crates/bds-core/src/engine/menu.rs index 042934d..ec73904 100644 --- a/crates/bds-core/src/engine/menu.rs +++ b/crates/bds-core/src/engine/menu.rs @@ -6,7 +6,9 @@ use quick_xml::{Reader, Writer, XmlVersion, escape::escape}; use crate::engine::EngineError; use crate::engine::EngineResult; +use crate::model::Project; use crate::util::atomic_write_str; +use crate::util::timestamp::unix_ms_to_iso; /// A navigation menu item per menu.allium. #[derive(Debug, Clone, PartialEq)] @@ -62,23 +64,28 @@ pub fn read_menu(data_dir: &Path) -> EngineResult> { /// Write the navigation menu to meta/menu.opml. /// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended. -pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> { +pub fn write_menu(data_dir: &Path, project: &Project, items: &[MenuItem]) -> EngineResult<()> { let normalized = normalize_menu(items); - let opml = serialize_opml(&normalized)?; + let timestamp = if project.updated_at > 0 { + project.updated_at + } else { + project.created_at + }; + let opml = serialize_opml(&project.name, timestamp, &normalized)?; let path = data_dir.join("meta").join("menu.opml"); atomic_write_str(&path, &opml)?; Ok(()) } /// Return the default menu OPML for new projects. -pub fn default_menu_opml() -> String { +pub fn default_menu_opml(project_name: &str, timestamp: i64) -> String { let items = vec![MenuItem { kind: MenuItemKind::Home, label: "Home".to_string(), slug: None, children: Vec::new(), }]; - serialize_opml(&items).expect("writing menu XML to memory cannot fail") + serialize_opml(project_name, timestamp, &items).expect("writing menu XML to memory cannot fail") } /// Per menu.allium HomeAlwaysPresent: ensure Home is always first. @@ -233,8 +240,9 @@ fn attach_outline(item: MenuItem, parents: &mut [Option], items: &mut } /// Serialize menu items to OPML 2.0 format. -fn serialize_opml(items: &[MenuItem]) -> EngineResult { +fn serialize_opml(project_name: &str, timestamp: i64, items: &[MenuItem]) -> EngineResult { let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); + writer.config_mut().add_space_before_slash_in_empty_elements = true; writer .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None))) .map_err(|error| EngineError::Parse(error.to_string()))?; @@ -246,14 +254,12 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult { writer .write_event(Event::Start(BytesStart::new("head"))) .map_err(|error| EngineError::Parse(error.to_string()))?; - writer - .write_event(Event::Start(BytesStart::new("title"))) + write_text_element(&mut writer, "title", project_name) .map_err(|error| EngineError::Parse(error.to_string()))?; - writer - .write_event(Event::Text(BytesText::new("Blog Menu"))) + let timestamp = unix_ms_to_iso(timestamp); + write_text_element(&mut writer, "dateCreated", ×tamp) .map_err(|error| EngineError::Parse(error.to_string()))?; - writer - .write_event(Event::End(BytesEnd::new("title"))) + write_text_element(&mut writer, "dateModified", ×tamp) .map_err(|error| EngineError::Parse(error.to_string()))?; writer .write_event(Event::End(BytesEnd::new("head"))) @@ -270,7 +276,21 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult { writer .write_event(Event::End(BytesEnd::new("opml"))) .map_err(|error| EngineError::Parse(error.to_string()))?; - String::from_utf8(writer.into_inner()).map_err(|error| EngineError::Parse(error.to_string())) + let mut rendered = String::from_utf8(writer.into_inner()) + .map_err(|error| EngineError::Parse(error.to_string()))?; + rendered.push('\n'); + Ok(rendered) +} + +fn write_text_element( + writer: &mut Writer>, + name: &str, + value: &str, +) -> quick_xml::Result<()> { + writer.write_event(Event::Start(BytesStart::new(name)))?; + writer.write_event(Event::Text(BytesText::new(value)))?; + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) } fn write_outline(writer: &mut Writer>, item: &MenuItem) -> quick_xml::Result<()> { @@ -310,7 +330,7 @@ mod tests { #[test] fn default_opml_has_home() { - let opml = default_menu_opml(); + let opml = default_menu_opml("Test Blog", 1_711_833_600_000); assert!(opml.contains("type=\"home\"")); assert!(opml.contains("text=\"Home\"")); } @@ -342,7 +362,7 @@ mod tests { }], }, ]; - let opml = serialize_opml(&items).unwrap(); + let opml = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap(); let parsed = parse_opml(&opml).unwrap(); assert_eq!(parsed.len(), 3); assert_eq!(parsed[0].kind, MenuItemKind::Home); @@ -385,7 +405,21 @@ mod tests { slug: Some("/blog".into()), children: Vec::new(), }]; - write_menu(dir.path(), &items).unwrap(); + write_menu( + dir.path(), + &Project { + id: "project-1".into(), + name: "Test Blog".into(), + slug: "test-blog".into(), + description: None, + data_path: None, + is_active: true, + created_at: 1_711_833_600_000, + updated_at: 1_711_833_600_000, + }, + &items, + ) + .unwrap(); let read = read_menu(dir.path()).unwrap(); // Home is always prepended assert_eq!(read.len(), 2); @@ -415,7 +449,7 @@ mod tests { assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive); assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes")); - let serialized = serialize_opml(&parsed).unwrap(); + let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &parsed).unwrap(); assert!(serialized.contains("type=\"home\" pageSlug=\"home\"")); assert!(serialized.contains("type=\"page\" pageSlug=\"about\"")); assert!(serialized.contains("type=\"category-archive\" categoryName=\"notes\"")); @@ -423,6 +457,33 @@ mod tests { assert!(!serialized.contains("category_archive")); } + #[test] + fn serializer_matches_bds2_head_spacing_and_trailing_newline() { + let items = vec![MenuItem { + kind: MenuItemKind::Home, + label: "Home".into(), + slug: None, + children: Vec::new(), + }]; + let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap(); + assert_eq!( + serialized, + concat!( + "\n", + "\n", + " \n", + " Test Blog\n", + " 2024-03-30T21:20:00.000Z\n", + " 2024-03-30T21:20:00.000Z\n", + " \n", + " \n", + " \n", + " \n", + "\n", + ) + ); + } + #[test] fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() { let parsed = parse_opml( diff --git a/crates/bds-core/src/engine/meta.rs b/crates/bds-core/src/engine/meta.rs index 09d5941..c55c6e9 100644 --- a/crates/bds-core/src/engine/meta.rs +++ b/crates/bds-core/src/engine/meta.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::Path; @@ -274,7 +274,7 @@ pub fn read_categories_json(data_dir: &Path) -> EngineResult> { /// Sort categories, then atomic write to meta/categories.json. pub fn write_categories_json(data_dir: &Path, categories: &[String]) -> EngineResult<()> { let mut sorted = categories.to_vec(); - sorted.sort_by_key(|a| a.to_lowercase()); + sorted.sort(); let path = data_dir.join("meta").join("categories.json"); let json = serde_json::to_string_pretty(&sorted)?; atomic_write_str(&path, &json)?; @@ -288,7 +288,7 @@ pub fn set_categories( categories: &[String], ) -> EngineResult<()> { let mut sorted = categories.to_vec(); - sorted.sort_by_key(|category| category.to_lowercase()); + sorted.sort(); persist_snapshot( conn, project_id, @@ -316,7 +316,8 @@ pub fn write_category_meta_json( meta: &HashMap, ) -> EngineResult<()> { let path = data_dir.join("meta").join("category-meta.json"); - let json = serde_json::to_string_pretty(meta)?; + let sorted = meta.iter().collect::>(); + let json = serde_json::to_string_pretty(&sorted)?; atomic_write_str(&path, &json)?; Ok(()) } @@ -346,7 +347,7 @@ pub fn set_categories_and_meta( meta: &HashMap, ) -> EngineResult<()> { let mut sorted = categories.to_vec(); - sorted.sort_by_key(|category| category.to_lowercase()); + sorted.sort(); conn.begin_savepoint()?; let result = (|| { persist_snapshot( @@ -514,7 +515,7 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> { // Ensure publishing.json exists if !meta_dir.join("publishing.json").exists() { - atomic_write_str(&meta_dir.join("publishing.json"), "{}")?; + write_publishing_json(data_dir, &PublishingPreferences::default())?; } // Ensure tags.json exists @@ -524,7 +525,9 @@ pub fn startup_sync(data_dir: &Path) -> EngineResult<()> { // Ensure menu.opml exists if !meta_dir.join("menu.opml").exists() { - let opml = crate::engine::menu::default_menu_opml(); + let project = read_project_json(data_dir)?; + let opml = + crate::engine::menu::default_menu_opml(&project.name, crate::util::now_unix_ms()); atomic_write_str(&meta_dir.join("menu.opml"), &opml)?; } @@ -568,6 +571,45 @@ mod tests { assert_eq!(read.description.as_deref(), Some("A blog")); } + #[test] + fn project_json_matches_bds2_canonical_key_order() { + let dir = setup(); + let meta = ProjectMetadata { + name: "Test".into(), + description: Some("A blog".into()), + public_url: Some("https://example.com".into()), + main_language: Some("en".into()), + default_author: Some("Writer".into()), + max_posts_per_page: 25, + image_import_concurrency: 3, + blogmark_category: Some("links".into()), + pico_theme: Some("amber".into()), + semantic_similarity_enabled: true, + blog_languages: vec!["en".into(), "de".into()], + }; + write_project_json(dir.path(), &meta).unwrap(); + + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/project.json")).unwrap(), + r#"{ + "blogLanguages": [ + "en", + "de" + ], + "blogmarkCategory": "links", + "defaultAuthor": "Writer", + "description": "A blog", + "imageImportConcurrency": 3, + "mainLanguage": "en", + "maxPostsPerPage": 25, + "name": "Test", + "picoTheme": "amber", + "publicUrl": "https://example.com", + "semanticSimilarityEnabled": true +}"# + ); + } + // ── categories.json ───────────────────────────────────────────── #[test] @@ -579,6 +621,17 @@ mod tests { assert_eq!(read, vec!["article", "aside", "picture"]); } + #[test] + fn categories_json_uses_bds2_case_sensitive_sort() { + let dir = setup(); + write_categories_json(dir.path(), &["alpha".into(), "Zebra".into(), "Beta".into()]) + .unwrap(); + assert_eq!( + read_categories_json(dir.path()).unwrap(), + vec!["Beta", "Zebra", "alpha"] + ); + } + // ── category-meta.json ────────────────────────────────────────── #[test] @@ -628,6 +681,50 @@ mod tests { assert_eq!(read["news"].title.as_deref(), Some("News Archive")); } + #[test] + fn category_meta_json_matches_bds2_deterministic_key_order() { + let dir = setup(); + let mut meta = HashMap::new(); + meta.insert( + "zebra".to_string(), + CategorySettings { + title: Some("Zebra".into()), + render_in_lists: true, + show_title: false, + post_template_slug: Some("post".into()), + list_template_slug: Some("list".into()), + }, + ); + meta.insert( + "alpha".to_string(), + CategorySettings { + title: None, + render_in_lists: false, + show_title: true, + post_template_slug: None, + list_template_slug: None, + }, + ); + write_category_meta_json(dir.path(), &meta).unwrap(); + + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(), + r#"{ + "alpha": { + "renderInLists": false, + "showTitle": true + }, + "zebra": { + "listTemplateSlug": "list", + "postTemplateSlug": "post", + "renderInLists": true, + "showTitle": false, + "title": "Zebra" + } +}"# + ); + } + // ── publishing.json ───────────────────────────────────────────── #[test] @@ -645,6 +742,77 @@ mod tests { assert_eq!(read.ssh_mode, SshMode::Rsync); } + #[test] + fn publishing_json_matches_bds2_canonical_key_order() { + let dir = setup(); + let prefs = PublishingPreferences { + ssh_host: Some("example.com".into()), + ssh_user: Some("deploy".into()), + ssh_remote_path: Some("/var/www".into()), + ssh_mode: SshMode::Rsync, + }; + write_publishing_json(dir.path(), &prefs).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/publishing.json")).unwrap(), + r#"{ + "sshHost": "example.com", + "sshMode": "rsync", + "sshRemotePath": "/var/www", + "sshUser": "deploy" +}"# + ); + } + + #[test] + fn canonical_empty_metadata_collections_and_default_publishing_are_compact() { + let dir = setup(); + write_project_json( + dir.path(), + &ProjectMetadata { + name: "Minimal".into(), + description: None, + public_url: None, + main_language: None, + default_author: None, + max_posts_per_page: 50, + image_import_concurrency: 4, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: Vec::new(), + }, + ) + .unwrap(); + write_category_meta_json(dir.path(), &HashMap::new()).unwrap(); + write_tags_json(dir.path(), &[]).unwrap(); + write_publishing_json(dir.path(), &PublishingPreferences::default()).unwrap(); + + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/project.json")).unwrap(), + concat!( + "{\n", + " \"blogLanguages\": [],\n", + " \"imageImportConcurrency\": 4,\n", + " \"maxPostsPerPage\": 50,\n", + " \"name\": \"Minimal\",\n", + " \"semanticSimilarityEnabled\": false\n", + "}" + ) + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(), + "{}" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/tags.json")).unwrap(), + "[]" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/publishing.json")).unwrap(), + "{\n \"sshMode\": \"scp\"\n}" + ); + } + // ── tags.json ─────────────────────────────────────────────────── #[test] @@ -668,6 +836,29 @@ mod tests { assert_eq!(read[1].name, "Zebra"); } + #[test] + fn tags_json_matches_bds2_entry_order_and_omits_blank_optional_values() { + let dir = setup(); + write_tags_json( + dir.path(), + &[TagEntry { + name: "rust".into(), + color: Some("".into()), + post_template_slug: Some("article".into()), + }], + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("meta/tags.json")).unwrap(), + r#"[ + { + "name": "rust", + "postTemplateSlug": "article" + } +]"# + ); + } + // ── add / remove category ─────────────────────────────────────── #[test] diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index 51d4064..b557d61 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -56,7 +56,7 @@ pub fn create_project( } // Write default meta files - write_default_meta_files(&data_dir, name)?; + write_default_meta_files(&data_dir, &project)?; crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?; emit_project(&project, NotificationAction::Created); @@ -184,7 +184,7 @@ pub fn ensure_default_project( None => std::path::PathBuf::from("projects").join(DEFAULT_PROJECT_ID), }; create_directory_structure(&data_dir)?; - write_default_meta_files(&data_dir, "My Blog")?; + write_default_meta_files(&data_dir, &project)?; crate::engine::meta::sync_metadata_from_filesystem(conn, &data_dir, &project.id)?; emit_project(&project, NotificationAction::Created); Ok(project) @@ -302,12 +302,12 @@ fn create_directory_structure(data_dir: &Path) -> EngineResult<()> { Ok(()) } -fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult<()> { +fn write_default_meta_files(data_dir: &Path, project: &Project) -> EngineResult<()> { let meta_dir = data_dir.join("meta"); // project.json let project_meta = ProjectMetadata { - name: project_name.to_string(), + name: project.name.clone(), description: None, public_url: None, main_language: None, @@ -332,14 +332,19 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult let json = serde_json::to_string_pretty(&empty_map)?; atomic_write_str(&meta_dir.join("category-meta.json"), &json)?; - // publishing.json — empty object - atomic_write_str(&meta_dir.join("publishing.json"), "{}")?; + // publishing.json + crate::engine::meta::write_publishing_json(data_dir, &Default::default())?; // tags.json — empty array atomic_write_str(&meta_dir.join("tags.json"), "[]")?; // menu.opml — default empty menu per menu.allium HomeAlwaysPresent - let default_opml = crate::engine::menu::default_menu_opml(); + let timestamp = if project.updated_at > 0 { + project.updated_at + } else { + project.created_at + }; + let default_opml = crate::engine::menu::default_menu_opml(&project.name, timestamp); atomic_write_str(&meta_dir.join("menu.opml"), &default_opml)?; copy_bundled_site_assets(data_dir)?; diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index 487cc5a..5dacfa6 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -138,12 +138,12 @@ pub enum SshMode { pub struct PublishingPreferences { #[serde(skip_serializing_if = "Option::is_none")] pub ssh_host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_remote_path: Option, #[serde(default = "default_ssh_mode")] pub ssh_mode: SshMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_remote_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_user: Option, } impl Default for PublishingPreferences { diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index 794e651..ea8874d 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -44,30 +44,30 @@ fn default_true() -> bool { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectMetadata { - pub name: String, + #[serde(default)] + pub blog_languages: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub main_language: Option, + pub blogmark_category: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_author: Option, - #[serde(default = "default_max_posts")] - pub max_posts_per_page: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde( default = "default_image_import_concurrency", deserialize_with = "deserialize_image_import_concurrency" )] pub image_import_concurrency: i32, #[serde(skip_serializing_if = "Option::is_none")] - pub blogmark_category: Option, + pub main_language: Option, + #[serde(default = "default_max_posts")] + pub max_posts_per_page: i32, + pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub pico_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_url: Option, #[serde(default)] pub semantic_similarity_enabled: bool, - #[serde(default)] - pub blog_languages: Vec, } impl ProjectMetadata { @@ -98,26 +98,33 @@ impl ProjectMetadata { #[serde(rename_all = "camelCase")] pub struct CategorySettings { #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, + pub list_template_slug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub post_template_slug: Option, #[serde(default = "default_true")] pub render_in_lists: bool, #[serde(default = "default_true")] pub show_title: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub post_template_slug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub list_template_slug: Option, + pub title: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TagEntry { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "option_is_none_or_blank")] pub color: Option, - #[serde(skip_serializing_if = "Option::is_none", rename = "postTemplateSlug")] + pub name: String, + #[serde( + skip_serializing_if = "option_is_none_or_blank", + rename = "postTemplateSlug" + )] pub post_template_slug: Option, } +fn option_is_none_or_blank(value: &Option) -> bool { + value.as_deref().is_none_or(str::is_empty) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 398c354..d7efb91 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -1862,8 +1862,10 @@ mod menu_tests { fn renderer_consumes_the_saved_opml_tree() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); + let project = crate::db::queries::project::make_test_project("p1", "Test Blog"); crate::engine::menu::write_menu( dir.path(), + &project, &[ MenuItem { kind: MenuItemKind::Page, diff --git a/crates/bds-core/src/util/frontmatter.rs b/crates/bds-core/src/util/frontmatter.rs index 59f8921..4b8a602 100644 --- a/crates/bds-core/src/util/frontmatter.rs +++ b/crates/bds-core/src/util/frontmatter.rs @@ -1,6 +1,12 @@ use crate::model::{Post, PostTranslation}; use crate::util::timestamp::{iso_to_unix_ms, unix_ms_to_iso}; +use regex::Regex; use serde::{Deserialize, Deserializer}; +use std::sync::LazyLock; + +static SIMPLE_YAML_STRING: LazyLock = LazyLock::new(|| { + Regex::new(r"^[\p{L}\p{N} ._/-]+$").expect("canonical frontmatter regex is valid") +}); fn scalar_string(value: serde_yaml::Value) -> Option { match value { @@ -35,31 +41,6 @@ where Ok(deserialize_optional_scalar_string(deserializer)?.filter(|value| !value.is_empty())) } -fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok(Option::::deserialize(deserializer)? - .and_then(|value| value.as_str().map(str::to_owned))) -} - -fn deserialize_string<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - serde_yaml::Value::deserialize(deserializer)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| serde::de::Error::custom("expected a string")) -} - -fn deserialize_optional_nonempty_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok(deserialize_optional_string(deserializer)?.filter(|value| !value.is_empty())) -} - fn deserialize_string_list<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -89,13 +70,6 @@ where .and_then(|value| iso_to_unix_ms(&value).ok())) } -fn deserialize_optional_string_timestamp<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok(deserialize_optional_string(deserializer)?.and_then(|value| iso_to_unix_ms(&value).ok())) -} - fn deserialize_bool<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -167,7 +141,7 @@ pub fn split_frontmatter(input: &str) -> Option<(&str, &str)> { /// Format frontmatter + body into a complete file string. pub fn format_frontmatter(yaml: &str, body: &str) -> String { - format!("---\n{yaml}\n---\n{body}") + format!("---\n{yaml}\n---\n{}\n", body.trim_end_matches('\n')) } // --- Post Frontmatter --- @@ -239,51 +213,27 @@ impl PostFrontmatter { } } - /// Serialize to YAML string (matching TypeScript gray-matter output). + /// Serialize using the canonical bDS2 frontmatter field and scalar rules. pub fn to_yaml(&self) -> String { let mut lines = Vec::new(); - lines.push(format!("id: {}", self.id)); - lines.push(format!("title: {}", yaml_string_value(&self.title))); - lines.push(format!("slug: {}", self.slug)); - lines.push(format!("status: {}", self.status)); - lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at))); - lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at))); - - // Tags as YAML list - if self.tags.is_empty() { - lines.push("tags: []".to_string()); - } else { - lines.push("tags:".to_string()); - for tag in &self.tags { - lines.push(format!(" - {}", yaml_string_value(tag))); - } - } - - // Categories as YAML list - if self.categories.is_empty() { - lines.push("categories: []".to_string()); - } else { - lines.push("categories:".to_string()); - for cat in &self.categories { - lines.push(format!(" - {}", yaml_string_value(cat))); - } - } - - // Conditional fields (only when truthy) + push_yaml_string(&mut lines, "id", &self.id); + push_yaml_string(&mut lines, "title", &self.title); + push_yaml_string(&mut lines, "slug", &self.slug); if let Some(ref excerpt) = self.excerpt && !excerpt.is_empty() { - lines.push(format!("excerpt: {}", yaml_string_value(excerpt))); + push_yaml_string(&mut lines, "excerpt", excerpt); } + push_yaml_string(&mut lines, "status", &self.status); if let Some(ref author) = self.author && !author.is_empty() { - lines.push(format!("author: {}", yaml_string_value(author))); + push_yaml_string(&mut lines, "author", author); } if let Some(ref language) = self.language && !language.is_empty() { - lines.push(format!("language: {language}")); + push_yaml_string(&mut lines, "language", language); } if self.do_not_translate { lines.push("doNotTranslate: true".to_string()); @@ -291,11 +241,15 @@ impl PostFrontmatter { if let Some(ref template_slug) = self.template_slug && !template_slug.is_empty() { - lines.push(format!("templateSlug: {template_slug}")); + push_yaml_string(&mut lines, "templateSlug", template_slug); } + lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at))); + lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at))); if let Some(published_at) = self.published_at { lines.push(format!("publishedAt: '{}'", unix_ms_to_iso(published_at))); } + serialize_yaml_list(&mut lines, "tags", &self.tags); + serialize_yaml_list(&mut lines, "categories", &self.categories); lines.join("\n") } @@ -316,7 +270,7 @@ pub fn write_post_file(post: &Post, body: &str) -> String { pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String> { let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?; let fm = PostFrontmatter::from_yaml(yaml)?; - Ok((fm, body.to_string())) + Ok((fm, body.trim_end_matches('\n').to_string())) } // --- Translation Frontmatter --- @@ -325,23 +279,26 @@ pub fn read_post_file(content: &str) -> Result<(PostFrontmatter, String), String #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TranslationFrontmatter { - #[serde(default, deserialize_with = "deserialize_optional_string")] + #[serde(default, deserialize_with = "deserialize_optional_scalar_string")] pub id: Option, - #[serde(deserialize_with = "deserialize_string")] + #[serde(deserialize_with = "deserialize_scalar_string")] pub translation_for: String, - #[serde(deserialize_with = "deserialize_string")] + #[serde(deserialize_with = "deserialize_scalar_string")] pub language: String, - #[serde(deserialize_with = "deserialize_string")] + #[serde(deserialize_with = "deserialize_scalar_string")] pub title: String, - #[serde(default, deserialize_with = "deserialize_optional_nonempty_string")] + #[serde( + default, + deserialize_with = "deserialize_optional_nonempty_scalar_string" + )] pub excerpt: Option, - #[serde(default, deserialize_with = "deserialize_optional_string")] + #[serde(default, deserialize_with = "deserialize_optional_scalar_string")] pub status: Option, - #[serde(default, deserialize_with = "deserialize_optional_string_timestamp")] + #[serde(default, deserialize_with = "deserialize_optional_timestamp")] pub created_at: Option, - #[serde(default, deserialize_with = "deserialize_optional_string_timestamp")] + #[serde(default, deserialize_with = "deserialize_optional_timestamp")] pub updated_at: Option, - #[serde(default, deserialize_with = "deserialize_optional_string_timestamp")] + #[serde(default, deserialize_with = "deserialize_optional_timestamp")] pub published_at: Option, } @@ -363,18 +320,18 @@ impl TranslationFrontmatter { pub fn to_yaml(&self) -> String { let mut lines = Vec::new(); if let Some(id) = &self.id { - lines.push(format!("id: {id}")); + push_yaml_string(&mut lines, "id", id); } - lines.push(format!("translationFor: {}", self.translation_for)); - lines.push(format!("language: {}", self.language)); - lines.push(format!("title: {}", yaml_string_value(&self.title))); + push_yaml_string(&mut lines, "translationFor", &self.translation_for); + push_yaml_string(&mut lines, "language", &self.language); + push_yaml_string(&mut lines, "title", &self.title); if let Some(ref excerpt) = self.excerpt && !excerpt.is_empty() { - lines.push(format!("excerpt: {}", yaml_string_value(excerpt))); + push_yaml_string(&mut lines, "excerpt", excerpt); } if let Some(status) = &self.status { - lines.push(format!("status: {status}")); + push_yaml_string(&mut lines, "status", status); } if let Some(created_at) = self.created_at { lines.push(format!("createdAt: '{}'", unix_ms_to_iso(created_at))); @@ -403,12 +360,12 @@ pub fn write_translation_file(translation: &PostTranslation, body: &str) -> Stri pub fn read_translation_file(content: &str) -> Result<(TranslationFrontmatter, String), String> { let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?; let fm = TranslationFrontmatter::from_yaml(yaml)?; - Ok((fm, body.to_string())) + Ok((fm, body.trim_end_matches('\n').to_string())) } // --- Template Frontmatter --- -/// Parsed template frontmatter (double-quoted strings, matching TypeScript output). +/// Parsed template frontmatter. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TemplateFrontmatter { @@ -436,26 +393,20 @@ pub struct TemplateFrontmatter { } impl TemplateFrontmatter { - /// Serialize to YAML with double-quoted strings (matching TypeScript). + /// Serialize using the canonical bDS2 frontmatter scalar rules. pub fn to_yaml(&self) -> String { let mut lines = Vec::new(); - lines.push(format!("id: \"{}\"", self.id)); + push_yaml_string(&mut lines, "id", &self.id); if let Some(ref pid) = self.project_id { - lines.push(format!("projectId: \"{pid}\"")); + push_yaml_string(&mut lines, "projectId", pid); } - lines.push(format!("slug: \"{}\"", self.slug)); - lines.push(format!("title: \"{}\"", self.title)); - lines.push(format!("kind: \"{}\"", self.kind)); + push_yaml_string(&mut lines, "slug", &self.slug); + push_yaml_string(&mut lines, "title", &self.title); + push_yaml_string(&mut lines, "kind", &self.kind); lines.push(format!("enabled: {}", self.enabled)); lines.push(format!("version: {}", self.version)); - lines.push(format!( - "createdAt: \"{}\"", - unix_ms_to_iso(self.created_at) - )); - lines.push(format!( - "updatedAt: \"{}\"", - unix_ms_to_iso(self.updated_at) - )); + lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at))); + lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at))); lines.join("\n") } @@ -468,7 +419,7 @@ impl TemplateFrontmatter { pub fn read_template_file(content: &str) -> Result<(TemplateFrontmatter, String), String> { let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?; let fm = TemplateFrontmatter::from_yaml(yaml)?; - Ok((fm, body.to_string())) + Ok((fm, body.trim_end_matches('\n').to_string())) } /// Write a template file (frontmatter + body). @@ -478,7 +429,7 @@ pub fn write_template_file(fm: &TemplateFrontmatter, body: &str) -> String { // --- Script Frontmatter --- -/// Parsed script frontmatter (double-quoted strings like templates, plus entrypoint). +/// Parsed script frontmatter (template fields plus entrypoint). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScriptFrontmatter { @@ -511,27 +462,21 @@ pub struct ScriptFrontmatter { } impl ScriptFrontmatter { - /// Serialize to YAML with double-quoted strings. + /// Serialize using the canonical bDS2 frontmatter scalar rules. pub fn to_yaml(&self) -> String { let mut lines = Vec::new(); - lines.push(format!("id: \"{}\"", self.id)); + push_yaml_string(&mut lines, "id", &self.id); if let Some(ref pid) = self.project_id { - lines.push(format!("projectId: \"{pid}\"")); + push_yaml_string(&mut lines, "projectId", pid); } - lines.push(format!("slug: \"{}\"", self.slug)); - lines.push(format!("title: \"{}\"", self.title)); - lines.push(format!("kind: \"{}\"", self.kind)); - lines.push(format!("entrypoint: \"{}\"", self.entrypoint)); + push_yaml_string(&mut lines, "slug", &self.slug); + push_yaml_string(&mut lines, "title", &self.title); + push_yaml_string(&mut lines, "kind", &self.kind); + push_yaml_string(&mut lines, "entrypoint", &self.entrypoint); lines.push(format!("enabled: {}", self.enabled)); lines.push(format!("version: {}", self.version)); - lines.push(format!( - "createdAt: \"{}\"", - unix_ms_to_iso(self.created_at) - )); - lines.push(format!( - "updatedAt: \"{}\"", - unix_ms_to_iso(self.updated_at) - )); + lines.push(format!("createdAt: '{}'", unix_ms_to_iso(self.created_at))); + lines.push(format!("updatedAt: '{}'", unix_ms_to_iso(self.updated_at))); lines.join("\n") } @@ -555,7 +500,7 @@ impl ScriptFrontmatter { pub fn read_script_file(content: &str) -> Result<(ScriptFrontmatter, String), String> { let (yaml, body) = split_frontmatter(content).ok_or("no frontmatter delimiters found")?; let fm = ScriptFrontmatter::from_yaml(yaml)?; - Ok((fm, body.to_string())) + Ok((fm, body.trim_end_matches('\n').to_string())) } /// Write a script file (always Lua format with --- delimiters). @@ -565,50 +510,31 @@ pub fn write_script_file(fm: &ScriptFrontmatter, body: &str) -> String { // --- Helpers --- -/// Quote a YAML string value if it contains special characters. -/// Simple values (alphanumeric, hyphens, dots) are left unquoted. -/// Values with colons, quotes, special chars are single-quoted. fn yaml_string_value(s: &str) -> String { - if s.is_empty() { - return "''".to_string(); - } - // Check if the value needs quoting - let needs_quoting = s.contains(':') - || s.contains('#') - || s.contains('\'') - || s.contains('"') - || s.contains('\n') - || s.contains('{') - || s.contains('}') - || s.contains('[') - || s.contains(']') - || s.contains(',') - || s.contains('&') - || s.contains('*') - || s.contains('!') - || s.contains('|') - || s.contains('>') - || s.contains('%') - || s.contains('@') - || s.contains('`') - || s.starts_with(' ') - || s.ends_with(' ') - || s.starts_with('-') - || s.starts_with('?') - || s == "true" - || s == "false" - || s == "null" - || s == "yes" - || s == "no" - || s == "on" - || s == "off"; - - if needs_quoting { - // Use single quotes, escaping internal single quotes by doubling them - let escaped = s.replace('\'', "''"); - format!("'{escaped}'") - } else { + if SIMPLE_YAML_STRING.is_match(s) { s.to_string() + } else { + format!( + "\"{}\"", + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + ) + } +} + +fn serialize_yaml_list(lines: &mut Vec, key: &str, values: &[String]) { + lines.push(format!("{key}:")); + lines.extend( + values + .iter() + .map(|value| format!(" - {}", yaml_string_value(value))), + ); +} + +fn push_yaml_string(lines: &mut Vec, key: &str, value: &str) { + if !value.is_empty() { + lines.push(format!("{key}: {}", yaml_string_value(value))); } } @@ -653,9 +579,12 @@ mod tests { } #[test] - fn translation_frontmatter_remains_string_only() { - let yaml = "translationFor: 42\nlanguage: en\ntitle: Title"; - assert!(TranslationFrontmatter::from_yaml(yaml).is_err()); + fn translation_frontmatter_accepts_canonical_unquoted_scalars() { + let yaml = "translationFor: 42\nlanguage: en\ntitle: true"; + let parsed = TranslationFrontmatter::from_yaml(yaml).unwrap(); + assert_eq!(parsed.translation_for, "42"); + assert_eq!(parsed.language, "en"); + assert_eq!(parsed.title, "true"); } #[test] @@ -744,42 +673,6 @@ mod tests { assert_eq!(body, body2); } - #[test] - fn golden_output_esmeralda() { - let path = fixture_dir().join("posts/2005/11/esmeralda.md"); - let expected = fs::read_to_string(&path).unwrap(); - let (fm, body) = read_post_file(&expected).unwrap(); - - let yaml = fm.to_yaml(); - let actual = format_frontmatter(&yaml, &body); - assert_eq!(actual, expected, "golden output mismatch for esmeralda.md"); - } - - #[test] - fn golden_output_ghostty() { - let path = fixture_dir().join("posts/2026/03/ghostty.md"); - let expected = fs::read_to_string(&path).unwrap(); - let (fm, body) = read_post_file(&expected).unwrap(); - - let yaml = fm.to_yaml(); - let actual = format_frontmatter(&yaml, &body); - assert_eq!(actual, expected, "golden output mismatch for ghostty.md"); - } - - #[test] - fn golden_output_translation() { - let path = fixture_dir().join("posts/2005/11/esmeralda.en.md"); - let expected = fs::read_to_string(&path).unwrap(); - let (fm, body) = read_translation_file(&expected).unwrap(); - - let yaml = fm.to_yaml(); - let actual = format_frontmatter(&yaml, &body); - assert_eq!( - actual, expected, - "golden output mismatch for esmeralda.en.md" - ); - } - #[test] fn current_translation_output_carries_full_metadata() { let translation = PostTranslation { @@ -806,12 +699,155 @@ mod tests { assert!(output.contains("publishedAt:")); } + #[test] + fn translation_output_matches_bds2_quoting_and_trailing_newline() { + let fm = TranslationFrontmatter { + id: Some("translation-1".into()), + translation_for: "post-1".into(), + language: "de".into(), + title: "true".into(), + excerpt: Some("Summary: translated".into()), + status: Some("published".into()), + created_at: Some(1_711_833_600_000), + updated_at: Some(1_711_920_000_000), + published_at: Some(1_712_006_400_000), + }; + assert_eq!( + format_frontmatter(&fm.to_yaml(), "body\n\n"), + concat!( + "---\n", + "id: translation-1\n", + "translationFor: post-1\n", + "language: de\n", + "title: true\n", + "excerpt: \"Summary: translated\"\n", + "status: published\n", + "createdAt: '2024-03-30T21:20:00.000Z'\n", + "updatedAt: '2024-03-31T21:20:00.000Z'\n", + "publishedAt: '2024-04-01T21:20:00.000Z'\n", + "---\n", + "body\n", + ) + ); + let parsed = TranslationFrontmatter::from_yaml(&fm.to_yaml()).unwrap(); + assert_eq!(parsed.title, "true"); + } + #[test] fn yaml_quoting() { assert_eq!(yaml_string_value("simple"), "simple"); - assert_eq!(yaml_string_value("has: colon"), "'has: colon'"); - assert_eq!(yaml_string_value("true"), "'true'"); - assert_eq!(yaml_string_value(""), "''"); + assert_eq!(yaml_string_value("Über Öl/123"), "Über Öl/123"); + assert_eq!(yaml_string_value("has: colon"), "\"has: colon\""); + assert_eq!(yaml_string_value("true"), "true"); + assert_eq!( + yaml_string_value("say \"hi\"\nnext"), + "\"say \\\"hi\\\"\\nnext\"" + ); + assert_eq!(yaml_string_value(""), "\"\""); + } + + #[test] + fn post_output_matches_bds2_canonical_bytes() { + let fm = PostFrontmatter { + id: "post-1".into(), + title: "Published: \"Post\"".into(), + slug: "published-post".into(), + excerpt: Some("Summary".into()), + status: "published".into(), + author: Some("Writer".into()), + language: Some("en".into()), + do_not_translate: true, + template_slug: Some("article".into()), + created_at: 1_711_833_600_000, + updated_at: 1_711_920_000_000, + published_at: Some(1_712_006_400_000), + tags: Vec::new(), + categories: vec!["notes".into()], + }; + + assert_eq!( + format_frontmatter(&fm.to_yaml(), "Hello from markdown\n\n"), + concat!( + "---\n", + "id: post-1\n", + "title: \"Published: \\\"Post\\\"\"\n", + "slug: published-post\n", + "excerpt: Summary\n", + "status: published\n", + "author: Writer\n", + "language: en\n", + "doNotTranslate: true\n", + "templateSlug: article\n", + "createdAt: '2024-03-30T21:20:00.000Z'\n", + "updatedAt: '2024-03-31T21:20:00.000Z'\n", + "publishedAt: '2024-04-01T21:20:00.000Z'\n", + "tags:\n", + "categories:\n", + " - notes\n", + "---\n", + "Hello from markdown\n", + ) + ); + } + + #[test] + fn template_and_script_output_match_bds2_scalar_and_timestamp_rules() { + let template = TemplateFrontmatter { + id: "template-1".into(), + project_id: Some("project-1".into()), + slug: "article".into(), + title: "Article: Wide".into(), + kind: "post".into(), + enabled: true, + version: 2, + created_at: 1_711_833_600_000, + updated_at: 1_711_920_000_000, + }; + assert_eq!( + write_template_file(&template, "body\n\n"), + concat!( + "---\n", + "id: template-1\n", + "projectId: project-1\n", + "slug: article\n", + "title: \"Article: Wide\"\n", + "kind: post\n", + "enabled: true\n", + "version: 2\n", + "createdAt: '2024-03-30T21:20:00.000Z'\n", + "updatedAt: '2024-03-31T21:20:00.000Z'\n", + "---\n", + "body\n", + ) + ); + + let script = ScriptFrontmatter { + id: "script-1".into(), + project_id: Some("project-1".into()), + slug: "render-card".into(), + title: "Render Card".into(), + kind: "macro".into(), + entrypoint: "render".into(), + enabled: true, + version: 3, + created_at: 1_711_833_600_000, + updated_at: 1_711_920_000_000, + }; + assert_eq!( + script.to_yaml(), + concat!( + "id: script-1\n", + "projectId: project-1\n", + "slug: render-card\n", + "title: Render Card\n", + "kind: macro\n", + "entrypoint: render\n", + "enabled: true\n", + "version: 3\n", + "createdAt: '2024-03-30T21:20:00.000Z'\n", + "updatedAt: '2024-03-31T21:20:00.000Z'", + ) + ); } #[test] @@ -883,18 +919,6 @@ mod tests { assert!(body.contains("
")); } - #[test] - fn golden_output_template() { - let path = fixture_dir().join("templates/testvorlage.liquid"); - let expected = fs::read_to_string(&path).unwrap(); - let (fm, body) = read_template_file(&expected).unwrap(); - let actual = write_template_file(&fm, &body); - assert_eq!( - actual, expected, - "golden output mismatch for testvorlage.liquid" - ); - } - #[test] fn template_frontmatter_roundtrip() { let fm = TemplateFrontmatter { diff --git a/crates/bds-core/src/util/sidecar.rs b/crates/bds-core/src/util/sidecar.rs index 8486b87..02070f3 100644 --- a/crates/bds-core/src/util/sidecar.rs +++ b/crates/bds-core/src/util/sidecar.rs @@ -54,7 +54,7 @@ impl MediaSidecar { } } - /// Serialize to the hand-built YAML-like format matching TypeScript output. + /// Serialize to the canonical bDS2 hand-built YAML-like format. fn serialize(&self) -> String { let mut lines: Vec = Vec::new(); lines.push("---".into()); @@ -99,27 +99,14 @@ impl MediaSidecar { lines.push(format!("createdAt: {}", unix_ms_to_iso(self.created_at))); lines.push(format!("updatedAt: {}", unix_ms_to_iso(self.updated_at))); - // Tags: inline JSON array - if self.tags.is_empty() { - lines.push("tags: []".into()); - } else { - let quoted: Vec = self - .tags - .iter() - .map(|t| format!("\"{}\"", escape_double_quotes(t))) - .collect(); - lines.push(format!("tags: [{}]", quoted.join(", "))); - } - - // linkedPostIds: only if non-empty - if !self.linked_post_ids.is_empty() { - let quoted: Vec = self - .linked_post_ids - .iter() - .map(|id| format!("\"{id}\"")) - .collect(); - lines.push(format!("linkedPostIds: [{}]", quoted.join(", "))); - } + lines.push(format!( + "linkedPostIds: {}", + serialize_inline_string_array(&self.linked_post_ids) + )); + lines.push(format!( + "tags: {}", + serialize_inline_string_array(&self.tags) + )); lines.push("---".into()); lines.join("\n") @@ -281,19 +268,32 @@ pub fn read_translation_sidecar(content: &str) -> Result String { - s.replace('\\', "\\\\").replace('"', "\\\"") + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") } fn unquote_double(s: &str) -> String { let s = s.trim(); if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { let inner = &s[1..s.len() - 1]; - inner.replace("\\\"", "\"").replace("\\\\", "\\") + inner + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\\\", "\\") } else { s.to_string() } } +fn serialize_inline_string_array(values: &[String]) -> String { + let quoted = values + .iter() + .map(|value| format!("\"{}\"", escape_double_quotes(value))) + .collect::>(); + format!("[{}]", quoted.join(", ")) +} + /// Parse inline JSON-like array: `["a", "b"]` or `[]`. fn parse_inline_json_array(s: &str) -> Vec { let s = s.trim(); @@ -346,21 +346,6 @@ mod tests { ); } - #[test] - fn golden_output_sidecar() { - let path = - fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta"); - let expected = fs::read_to_string(&path).unwrap(); - let sc = read_sidecar(&expected).unwrap(); - let actual = sc.to_string(); - // Compare trimmed (fixture may or may not have trailing newline) - assert_eq!( - actual.trim(), - expected.trim(), - "golden output mismatch for media sidecar" - ); - } - #[test] fn roundtrip_sidecar() { let sc = MediaSidecar { @@ -420,5 +405,61 @@ mod tests { assert_eq!(unquote_double("\"hello\""), "hello"); assert_eq!(unquote_double("plain"), "plain"); assert_eq!(escape_double_quotes("say \"hi\""), "say \\\"hi\\\""); + assert_eq!( + escape_double_quotes("line one\nline two"), + "line one\\nline two" + ); + } + + #[test] + fn output_matches_bds2_order_empty_links_and_escaping() { + let sidecar = MediaSidecar { + id: "media-1".into(), + original_name: "photo.jpg".into(), + mime_type: "image/jpeg".into(), + size: 123, + width: None, + height: None, + title: Some("Photo".into()), + alt: None, + caption: Some("First line\nSecond line".into()), + author: None, + language: Some("en".into()), + created_at: 1_711_833_600_000, + updated_at: 1_711_920_000_000, + tags: vec!["alpha".into()], + linked_post_ids: Vec::new(), + }; + assert_eq!( + sidecar.to_string(), + concat!( + "---\n", + "id: media-1\n", + "originalName: \"photo.jpg\"\n", + "mimeType: image/jpeg\n", + "size: 123\n", + "title: \"Photo\"\n", + "caption: \"First line\\nSecond line\"\n", + "language: en\n", + "createdAt: 2024-03-30T21:20:00.000Z\n", + "updatedAt: 2024-03-31T21:20:00.000Z\n", + "linkedPostIds: []\n", + "tags: [\"alpha\"]\n", + "---", + ) + ); + + let translation = MediaTranslationSidecar { + translation_for: "media-1".into(), + language: "de".into(), + title: None, + alt: None, + caption: Some("Erste Zeile\nZweite Zeile".into()), + }; + assert!( + translation + .to_string() + .contains("caption: \"Erste Zeile\\nZweite Zeile\"") + ); } } diff --git a/crates/bds-core/tests/m1_validation.rs b/crates/bds-core/tests/m1_validation.rs index 701030b..514039b 100644 --- a/crates/bds-core/tests/m1_validation.rs +++ b/crates/bds-core/tests/m1_validation.rs @@ -296,11 +296,11 @@ fn test_tag_sync_roundtrip() { } // ════════════════════════════════════════════════════════════════════ -// Step 20: Golden-file Comparisons +// Step 20: Legacy Fixture Compatibility and Canonical Reserialization // ════════════════════════════════════════════════════════════════════ #[test] -fn test_golden_post_esmeralda() { +fn test_legacy_post_fixture_reserializes_as_bds2_canonical_output() { let path = fixture_dir().join("posts/2005/11/esmeralda.md"); let expected = fs::read_to_string(&path).unwrap(); let (fm, body) = read_post_file(&expected).unwrap(); @@ -323,12 +323,13 @@ fn test_golden_post_esmeralda() { let yaml = fm.to_yaml(); let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body); - // Compare byte-for-byte with fixture - assert_eq!(actual, expected, "golden output mismatch for esmeralda.md"); + assert!(actual.find("language: es").unwrap() < actual.find("createdAt:").unwrap()); + assert!(actual.find("publishedAt:").unwrap() < actual.find("tags:").unwrap()); + assert!(actual.ends_with('\n')); } #[test] -fn test_golden_translation_esmeralda_en() { +fn test_legacy_translation_fixture_matches_bds2_canonical_output() { let path = fixture_dir().join("posts/2005/11/esmeralda.en.md"); let expected = fs::read_to_string(&path).unwrap(); let (fm, body) = read_translation_file(&expected).unwrap(); @@ -348,7 +349,7 @@ fn test_golden_translation_esmeralda_en() { } #[test] -fn test_golden_sidecar() { +fn test_legacy_sidecar_fixture_reserializes_as_bds2_canonical_output() { let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta"); let expected = fs::read_to_string(&path).unwrap(); let sc = read_sidecar(&expected).unwrap(); @@ -362,13 +363,10 @@ fn test_golden_sidecar() { assert_eq!(sc.height, Some(1200)); assert_eq!(sc.title.as_deref(), Some("Esmeralda")); - // Write back via to_string() and compare + // Write back via to_string() in canonical bDS2 order. let actual = sc.to_string(); - assert_eq!( - actual.trim(), - expected.trim(), - "golden output mismatch for media sidecar" - ); + assert!(actual.find("linkedPostIds:").unwrap() < actual.find("tags:").unwrap()); + assert!(actual.ends_with("---")); } #[test] @@ -426,7 +424,7 @@ fn test_golden_meta_files() { } #[test] -fn test_golden_template() { +fn test_legacy_template_fixture_reserializes_as_bds2_canonical_output() { let path = fixture_dir().join("templates/testvorlage.liquid"); let expected = fs::read_to_string(&path).unwrap(); let (fm, body) = read_template_file(&expected).unwrap(); @@ -443,12 +441,11 @@ fn test_golden_template() { assert!(fm.enabled); assert_eq!(fm.version, 3); - // Write back and compare byte-for-byte + // Write back with bDS2 scalar and timestamp quoting. let actual = write_template_file(&fm, &body); - assert_eq!( - actual, expected, - "golden output mismatch for testvorlage.liquid" - ); + assert!(actual.contains("id: 38704737-b7e7-4dd4-b010-9208bcf80ef6\n")); + assert!(actual.contains("createdAt: '2026-02-27T20:33:00.000Z'")); + assert!(actual.ends_with('\n')); } // ════════════════════════════════════════════════════════════════════ @@ -841,7 +838,7 @@ fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec String) -> Vec #[test] fn test_diff_detects_script_title() { let fields = script_diff_for_field(|content| { - content.replace("title: \"Diff Script\"", "title: \"Changed Script\"") + content.replace("title: Diff Script", "title: Changed Script") }); assert!( fields.contains(&"title".to_string()), @@ -948,9 +944,8 @@ fn test_diff_detects_script_title() { #[test] fn test_diff_detects_script_kind() { - let fields = script_diff_for_field(|content| { - content.replace("kind: \"utility\"", "kind: \"transform\"") - }); + let fields = + script_diff_for_field(|content| content.replace("kind: utility", "kind: transform")); assert!( fields.contains(&"kind".to_string()), "expected 'kind' in script diff fields, got: {fields:?}" @@ -959,9 +954,8 @@ fn test_diff_detects_script_kind() { #[test] fn test_diff_detects_script_entrypoint() { - let fields = script_diff_for_field(|content| { - content.replace("entrypoint: \"main\"", "entrypoint: \"run\"") - }); + let fields = + script_diff_for_field(|content| content.replace("entrypoint: main", "entrypoint: run")); assert!( fields.contains(&"entrypoint".to_string()), "expected 'entrypoint' in script diff fields, got: {fields:?}" diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 281060a..f90fba3 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -4932,13 +4932,18 @@ impl BdsApp { ); return Task::none(); } - let Some(data_dir) = self.data_dir.clone() else { + let (Some(data_dir), Some(project)) = + (self.data_dir.clone(), self.active_project.clone()) + else { return Task::none(); }; self.menu_editor_state.status = MenuEditorStatus::Saving; - let result = - engine::menu::write_menu(&data_dir, &self.menu_editor_state.persisted_items()) - .and_then(|()| engine::menu::read_menu(&data_dir)); + let result = engine::menu::write_menu( + &data_dir, + &project, + &self.menu_editor_state.persisted_items(), + ) + .and_then(|()| engine::menu::read_menu(&data_dir)); match result { Ok(items) => { let project_id = self diff --git a/specs/media_processing.allium b/specs/media_processing.allium index 3ddfa52..790790a 100644 --- a/specs/media_processing.allium +++ b/specs/media_processing.allium @@ -364,7 +364,10 @@ invariant MediaSidecarFormat { -- Sidecar files use YAML-like key-value format (hand-built, not gray-matter) -- Path: {binary_path}.meta -- Only truthy fields are written (except required fields) - -- Fields: title, alt, caption, author, tags, language, linkedPostIds + -- linkedPostIds is always written (including []), immediately before tags. + -- Always-quoted values and inline-list strings escape backslash, quote, and + -- newline; the reader reverses those escapes. + -- Fields include: title, alt, caption, author, language, linkedPostIds, tags. -- Note: 'filename' is NOT written to sidecar (it is the binary filename itself) } diff --git a/specs/menu.allium b/specs/menu.allium index e370548..368aed5 100644 --- a/specs/menu.allium +++ b/specs/menu.allium @@ -81,6 +81,10 @@ invariant MenuPersistedAsOpml { -- OPML outline attributes: text (label), type (kind), -- pageSlug (slug for page/home), categoryName (slug for category_archive). -- Nested elements represent submenu children. + -- The head title is the project name. dateCreated and dateModified both use + -- the project's updated timestamp, falling back to created timestamp. + -- Empty outlines have one space before '/>' and the document ends in one + -- newline, matching bDS2's canonical serializer. parse_opml(read_file("meta/menu.opml")) = menu.items } diff --git a/specs/metadata.allium b/specs/metadata.allium index 3585598..d052f34 100644 --- a/specs/metadata.allium +++ b/specs/metadata.allium @@ -70,6 +70,17 @@ invariant MetadataPersistedAsFiles { -- meta/category-meta.json — per-category render settings -- meta/publishing.json — SSH connection details (non-secret) -- All writes are atomic (temp file + rename) + -- JSON is pretty-printed without a trailing newline and uses bDS2/Jason + -- byte-order key ordering. Project keys are blogLanguages, + -- blogmarkCategory, defaultAuthor, description, imageImportConcurrency, + -- mainLanguage, maxPostsPerPage, name, picoTheme, publicUrl, + -- semanticSimilarityEnabled (nil keys absent). Publishing keys are sshHost, + -- sshMode, sshRemotePath, sshUser (nil keys absent; sshMode defaults to scp). + -- Category names and per-category keys are byte-order sorted; per-category + -- keys are listTemplateSlug, postTemplateSlug, renderInLists, showTitle, + -- title. categories.json uses case-sensitive byte-order sorting. tags.json + -- remains case-insensitively name-sorted; entry keys are color, name, + -- postTemplateSlug and blank optional values are absent. } config { diff --git a/specs/post.allium b/specs/post.allium index 6f568cb..c1263d7 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -297,6 +297,20 @@ invariant FrontmatterRoundtrip { parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post) } +invariant CanonicalFrontmatterSerialization { + -- Post field order is id, title, slug, excerpt, status, author, language, + -- doNotTranslate, templateSlug, createdAt, updatedAt, publishedAt, tags, + -- categories; nil/empty optional fields and false doNotTranslate are absent. + -- Translation field order is id, translationFor, language, title, excerpt, + -- status, createdAt, updatedAt, publishedAt. + -- Strings containing only Unicode letters/numbers, space, dot, underscore, + -- slash, or hyphen are bare. Other strings are double quoted with backslash, + -- quote, and newline escaping. Timestamp values are single-quoted ISO values. + -- Empty lists use a bare key line. Written bodies have all trailing newlines + -- removed and the complete file ends in exactly one newline; reads remove + -- that terminal body newline before comparing or storing content. +} + invariant DateBasedFileLayout { for post in Posts where file_path != "": post.file_path = format("posts/{yyyy}/{mm}/{slug}.md", diff --git a/specs/script.allium b/specs/script.allium index bb78b06..67e8787 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -199,7 +199,10 @@ invariant ScriptFileLayout { for s in Scripts where file_path != "": s.file_path = format("scripts/{slug}.lua", slug: s.slug) } --- Script files use standard --- YAML frontmatter +-- Script files use standard --- YAML frontmatter in field order id, projectId, +-- slug, title, kind, entrypoint, enabled, version, createdAt, updatedAt. Scalars +-- use the canonical post quoting rules, timestamps are single-quoted ISO values, +-- and the complete file ends in one newline. rule CreateScript { when: CreateScriptRequested(project, title, kind, content, entrypoint) diff --git a/specs/template.allium b/specs/template.allium index 9ff0e26..0439601 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -56,6 +56,9 @@ invariant UniqueTemplateSlug { invariant TemplateFrontmatter { -- .liquid files use standard --- YAML frontmatter -- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt + -- Field order also includes projectId after id when present. Scalars use + -- the canonical post frontmatter quoting rules, timestamps are single-quoted + -- ISO values, and the complete file ends in one newline. for t in Templates where status = published: parse_frontmatter(read_file(t.file_path)).slug = t.slug }