Match bDS2 canonical file serialization
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<Vec<MenuItem>> {
|
||||
|
||||
/// 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<MenuItem>], items: &mut
|
||||
}
|
||||
|
||||
/// Serialize menu items to OPML 2.0 format.
|
||||
fn serialize_opml(items: &[MenuItem]) -> EngineResult<String> {
|
||||
fn serialize_opml(project_name: &str, timestamp: i64, items: &[MenuItem]) -> EngineResult<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<u8>>,
|
||||
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<Vec<u8>>, 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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
|
||||
"<opml version=\"2.0\">\n",
|
||||
" <head>\n",
|
||||
" <title>Test Blog</title>\n",
|
||||
" <dateCreated>2024-03-30T21:20:00.000Z</dateCreated>\n",
|
||||
" <dateModified>2024-03-30T21:20:00.000Z</dateModified>\n",
|
||||
" </head>\n",
|
||||
" <body>\n",
|
||||
" <outline text=\"Home\" type=\"home\" pageSlug=\"home\" />\n",
|
||||
" </body>\n",
|
||||
"</opml>\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() {
|
||||
let parsed = parse_opml(
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
/// 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<String, CategorySettings>,
|
||||
) -> EngineResult<()> {
|
||||
let path = data_dir.join("meta").join("category-meta.json");
|
||||
let json = serde_json::to_string_pretty(meta)?;
|
||||
let sorted = meta.iter().collect::<BTreeMap<_, _>>();
|
||||
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<String, CategorySettings>,
|
||||
) -> 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]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -138,12 +138,12 @@ pub enum SshMode {
|
||||
pub struct PublishingPreferences {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_user: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_remote_path: Option<String>,
|
||||
#[serde(default = "default_ssh_mode")]
|
||||
pub ssh_mode: SshMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_remote_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_user: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PublishingPreferences {
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub main_language: Option<String>,
|
||||
pub blogmark_category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_author: Option<String>,
|
||||
#[serde(default = "default_max_posts")]
|
||||
pub max_posts_per_page: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[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<String>,
|
||||
pub main_language: Option<String>,
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub semantic_similarity_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub blog_languages: Vec<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub list_template_slug: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub post_template_slug: Option<String>,
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_template_slug: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
fn option_is_none_or_blank(value: &Option<String>) -> bool {
|
||||
value.as_deref().is_none_or(str::is_empty)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^[\p{L}\p{N} ._/-]+$").expect("canonical frontmatter regex is valid")
|
||||
});
|
||||
|
||||
fn scalar_string(value: serde_yaml::Value) -> Option<String> {
|
||||
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<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<serde_yaml::Value>::deserialize(deserializer)?
|
||||
.and_then(|value| value.as_str().map(str::to_owned)))
|
||||
}
|
||||
|
||||
fn deserialize_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
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<Option<String>, 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<Vec<String>, 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<Option<i64>, 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<bool, D::Error>
|
||||
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<String>,
|
||||
#[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<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
|
||||
pub status: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_timestamp")]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_timestamp")]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -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<String>, 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<String>, 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("<div>"));
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -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<String> = 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<String> = 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<String> = 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<MediaTranslationSidecar
|
||||
// --- Helpers ---
|
||||
|
||||
fn escape_double_quotes(s: &str) -> 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::<Vec<_>>();
|
||||
format!("[{}]", quoted.join(", "))
|
||||
}
|
||||
|
||||
/// Parse inline JSON-like array: `["a", "b"]` or `[]`.
|
||||
fn parse_inline_json_array(s: &str) -> Vec<String> {
|
||||
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\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
#[test]
|
||||
fn test_diff_detects_template_title() {
|
||||
let fields = template_diff_for_field(|content| {
|
||||
content.replace("title: \"Diff Template\"", "title: \"Changed Template\"")
|
||||
content.replace("title: Diff Template", "title: Changed Template")
|
||||
});
|
||||
assert!(
|
||||
fields.contains(&"title".to_string()),
|
||||
@@ -851,8 +848,7 @@ fn test_diff_detects_template_title() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_detects_template_kind() {
|
||||
let fields =
|
||||
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
|
||||
let fields = template_diff_for_field(|content| content.replace("kind: post", "kind: list"));
|
||||
assert!(
|
||||
fields.contains(&"kind".to_string()),
|
||||
"expected 'kind' in template diff fields, got: {fields:?}"
|
||||
@@ -938,7 +934,7 @@ fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String>
|
||||
#[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:?}"
|
||||
|
||||
@@ -4932,12 +4932,17 @@ 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())
|
||||
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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ invariant MenuPersistedAsOpml {
|
||||
-- OPML outline attributes: text (label), type (kind),
|
||||
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
|
||||
-- Nested <outline> 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user