Match bDS2 canonical file serialization

This commit is contained in:
2026-07-22 20:19:21 +02:00
parent fdf47da40a
commit 7b8e539340
17 changed files with 706 additions and 338 deletions

View File

@@ -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", &timestamp)
.map_err(|error| EngineError::Parse(error.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("title")))
write_text_element(&mut writer, "dateModified", &timestamp)
.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(

View File

@@ -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]

View File

@@ -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)?;

View File

@@ -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 {

View File

@@ -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::*;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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\"")
);
}
}