feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
|
|
@ -0,0 +1,184 @@
|
|||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FrontmatterKeyRule {
|
||||
read_key: &'static str,
|
||||
write_key: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
canonicalize_on_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FrontmatterKey<'a>(&'a str);
|
||||
|
||||
impl<'a> FrontmatterKey<'a> {
|
||||
pub(crate) fn new(key: &'a str) -> Self {
|
||||
Self(key)
|
||||
}
|
||||
|
||||
pub(crate) fn normalized(self) -> String {
|
||||
self.0.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
pub(crate) fn is_reserved(self) -> bool {
|
||||
self.normalized().starts_with('_') || is_known_frontmatter_key(self)
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_FRONTMATTER_KEYS: &[FrontmatterKeyRule] = &[
|
||||
FrontmatterKeyRule {
|
||||
read_key: "title",
|
||||
write_key: "title",
|
||||
aliases: &["title"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "type",
|
||||
write_key: "type",
|
||||
aliases: &["type", "is_a", "Is A"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "aliases",
|
||||
write_key: "aliases",
|
||||
aliases: &["aliases"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_archived",
|
||||
write_key: "_archived",
|
||||
aliases: &["_archived", "Archived", "archived"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "Status",
|
||||
write_key: "Status",
|
||||
aliases: &["Status", "status"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_icon",
|
||||
write_key: "_icon",
|
||||
aliases: &["_icon", "icon"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "color",
|
||||
write_key: "color",
|
||||
aliases: &["color"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_order",
|
||||
write_key: "_order",
|
||||
aliases: &["_order", "order"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_sidebar_label",
|
||||
write_key: "_sidebar_label",
|
||||
aliases: &["_sidebar_label", "sidebar_label", "sidebar label"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "template",
|
||||
write_key: "template",
|
||||
aliases: &["template"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_sort",
|
||||
write_key: "_sort",
|
||||
aliases: &["_sort", "sort"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "view",
|
||||
write_key: "view",
|
||||
aliases: &["view"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_width",
|
||||
write_key: "_width",
|
||||
aliases: &["_width", "width"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_display",
|
||||
write_key: "_display",
|
||||
aliases: &["_display"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "visible",
|
||||
write_key: "visible",
|
||||
aliases: &["visible"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_organized",
|
||||
write_key: "_organized",
|
||||
aliases: &["_organized"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_favorite",
|
||||
write_key: "_favorite",
|
||||
aliases: &["_favorite"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_favorite_index",
|
||||
write_key: "_favorite_index",
|
||||
aliases: &["_favorite_index"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_list_properties_display",
|
||||
write_key: "_list_properties_display",
|
||||
aliases: &["_list_properties_display"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
];
|
||||
|
||||
impl FrontmatterKeyRule {
|
||||
pub(crate) fn read_key(self) -> &'static str {
|
||||
self.read_key
|
||||
}
|
||||
|
||||
pub(crate) fn write_key(self) -> &'static str {
|
||||
self.write_key
|
||||
}
|
||||
|
||||
pub(crate) fn canonicalizes_on_write(self) -> bool {
|
||||
self.canonicalize_on_write
|
||||
}
|
||||
|
||||
fn matches(self, key: FrontmatterKey<'_>) -> bool {
|
||||
let normalized = key.normalized();
|
||||
self.aliases
|
||||
.iter()
|
||||
.any(|alias| FrontmatterKey::new(alias).normalized() == normalized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn frontmatter_key_rule(key: FrontmatterKey<'_>) -> Option<FrontmatterKeyRule> {
|
||||
KNOWN_FRONTMATTER_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|rule| rule.matches(key))
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_known_frontmatter_key(key: FrontmatterKey<'_>) -> Option<&'static str> {
|
||||
frontmatter_key_rule(key).map(FrontmatterKeyRule::read_key)
|
||||
}
|
||||
|
||||
pub(crate) fn frontmatter_keys_match(left: FrontmatterKey<'_>, right: FrontmatterKey<'_>) -> bool {
|
||||
match (frontmatter_key_rule(left), frontmatter_key_rule(right)) {
|
||||
(Some(left_rule), Some(right_rule)) => left_rule.read_key() == right_rule.read_key(),
|
||||
_ => left.normalized() == right.normalized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_known_frontmatter_key(key: FrontmatterKey<'_>) -> bool {
|
||||
frontmatter_key_rule(key).is_some()
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
pub(crate) mod keys;
|
||||
mod ops;
|
||||
#[cfg(test)]
|
||||
mod ops_update_tests;
|
||||
mod yaml;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use ops::update_frontmatter_content;
|
||||
pub use yaml::{format_yaml_key, FrontmatterValue};
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_frontmatter_path(path: &str, file_path: &Path) -> Result<(), String> {
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !is_markdown_path(file_path) {
|
||||
return Err(format!(
|
||||
"Frontmatter can only be updated on Markdown notes: {}",
|
||||
path
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
validate_frontmatter_path(path, file_path)?;
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Update a single frontmatter property in a markdown file.
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file.
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_rejects_binary_attachment_before_utf8_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let attachment_dir = dir.path().join("attachments");
|
||||
fs::create_dir_all(&attachment_dir).unwrap();
|
||||
let attachment_path = attachment_dir.join("screenshot.png");
|
||||
fs::write(&attachment_path, [0xff, 0xfe, 0xfd]).unwrap();
|
||||
|
||||
let err = update_frontmatter(
|
||||
attachment_path.to_str().unwrap(),
|
||||
"Status",
|
||||
FrontmatterValue::String("Done".to_string()),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Frontmatter can only be updated on Markdown notes"));
|
||||
assert!(err.contains("screenshot.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("title: New Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_writes_and_rewrites() {
|
||||
let cases = [
|
||||
(
|
||||
"---\ntype: Type\n---\n# Project\n",
|
||||
"## Objective\n\n## Timeline",
|
||||
&["template: |", " ## Objective", "type: Type"][..],
|
||||
&[][..],
|
||||
),
|
||||
(
|
||||
"---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n",
|
||||
"## New\n\n## Content",
|
||||
&[" ## New", "color: green"][..],
|
||||
&["## Old"][..],
|
||||
),
|
||||
];
|
||||
|
||||
for (content, template, expected_present, expected_absent) in cases {
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
for expected in expected_present {
|
||||
assert!(updated.contains(expected));
|
||||
}
|
||||
for unexpected in expected_absent {
|
||||
assert!(!updated.contains(unexpected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
use super::keys::{frontmatter_key_rule, frontmatter_keys_match, FrontmatterKey};
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: FrontmatterLine<'_>) -> bool {
|
||||
line.0.is_empty() || line.0.starts_with(" ") || line.0.starts_with('\t')
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum KeyMatchMode {
|
||||
Exact,
|
||||
Canonical,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DocumentText<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FrontmatterLine<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PropertyKey<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FrontmatterBlock<'a> {
|
||||
body: &'a str,
|
||||
rest: &'a str,
|
||||
line_ending: &'static str,
|
||||
}
|
||||
|
||||
impl<'a> PropertyKey<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn matches(self, candidate: &str, mode: KeyMatchMode) -> bool {
|
||||
match mode {
|
||||
KeyMatchMode::Exact => candidate == self.as_str(),
|
||||
KeyMatchMode::Canonical => frontmatter_keys_match(
|
||||
FrontmatterKey::new(candidate),
|
||||
FrontmatterKey::new(self.as_str()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FrontmatterLine<'a> {
|
||||
fn key(self) -> Option<&'a str> {
|
||||
let trimmed = self.0.trim_start();
|
||||
if let Some(raw) = trimmed.strip_prefix('"') {
|
||||
return quoted_yaml_key(raw, '"');
|
||||
}
|
||||
if let Some(raw) = trimmed.strip_prefix('\'') {
|
||||
return quoted_yaml_key(raw, '\'');
|
||||
}
|
||||
trimmed
|
||||
.split_once(':')
|
||||
.map(|(key, _)| key.trim())
|
||||
.filter(|key| !key.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> {
|
||||
let (key, rest) = raw.split_once(quote)?;
|
||||
rest.trim_start().starts_with(':').then_some(key)
|
||||
}
|
||||
|
||||
fn frontmatter_open(content: &str) -> Option<(&str, &'static str)> {
|
||||
content
|
||||
.strip_prefix("---\n")
|
||||
.map(|after| (after, "\n"))
|
||||
.or_else(|| content.strip_prefix("---\r\n").map(|after| (after, "\r\n")))
|
||||
}
|
||||
|
||||
fn close_marker(line_ending: &str) -> String {
|
||||
format!("{line_ending}---")
|
||||
}
|
||||
|
||||
fn split_frontmatter_block(content: &str) -> Result<Option<FrontmatterBlock<'_>>, String> {
|
||||
let Some((after_open, line_ending)) = frontmatter_open(content) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(rest) = after_open.strip_prefix("---") {
|
||||
return Ok(Some(FrontmatterBlock {
|
||||
body: "",
|
||||
rest,
|
||||
line_ending,
|
||||
}));
|
||||
}
|
||||
|
||||
let marker = close_marker(line_ending);
|
||||
let close_start = after_open
|
||||
.find(&marker)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
let rest_start = close_start + marker.len();
|
||||
Ok(Some(FrontmatterBlock {
|
||||
body: &after_open[..close_start],
|
||||
rest: &after_open[rest_start..],
|
||||
line_ending,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FieldUpdate<'a> {
|
||||
key: PropertyKey<'a>,
|
||||
value: Option<&'a FrontmatterValue>,
|
||||
match_mode: KeyMatchMode,
|
||||
}
|
||||
|
||||
impl<'a> FieldUpdate<'a> {
|
||||
fn matches_line(self, line: FrontmatterLine<'_>) -> bool {
|
||||
line.key()
|
||||
.is_some_and(|candidate| self.key.matches(candidate, self.match_mode))
|
||||
}
|
||||
|
||||
fn prepend_to(self, content: DocumentText<'_>) -> String {
|
||||
let field_lines =
|
||||
format_yaml_field(self.key.as_str(), self.value.expect("value must exist"));
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content.0)
|
||||
}
|
||||
|
||||
fn apply_to_lines(self, lines: &[FrontmatterLine<'_>]) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !self.matches_line(lines[i]) {
|
||||
new_lines.push(lines[i].0.to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(v) = self.value {
|
||||
new_lines.extend(format_yaml_field(self.key.as_str(), v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, self.value) {
|
||||
new_lines.extend(format_yaml_field(self.key.as_str(), v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
fn apply_to_content(self, content: DocumentText<'_>) -> Result<String, String> {
|
||||
let Some(block) = split_frontmatter_block(content.0)? else {
|
||||
return match self.value {
|
||||
Some(_) => Ok(self.prepend_to(content)),
|
||||
None => Ok(content.0.to_string()),
|
||||
};
|
||||
};
|
||||
|
||||
let lines: Vec<FrontmatterLine<'_>> = block.body.lines().map(FrontmatterLine).collect();
|
||||
let new_fm = self.apply_to_lines(&lines).join(block.line_ending);
|
||||
Ok(format!(
|
||||
"---{}{}{}---{}",
|
||||
block.line_ending, new_fm, block.line_ending, block.rest
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
let update = FieldUpdate {
|
||||
key: PropertyKey(key),
|
||||
value: value.as_ref(),
|
||||
match_mode: KeyMatchMode::Exact,
|
||||
};
|
||||
let Some(rule) = frontmatter_key_rule(FrontmatterKey::new(update.key.as_str()))
|
||||
.filter(|rule| rule.canonicalizes_on_write())
|
||||
else {
|
||||
return update.apply_to_content(DocumentText(content));
|
||||
};
|
||||
|
||||
let updated = FieldUpdate {
|
||||
key: PropertyKey(rule.write_key()),
|
||||
value: None,
|
||||
match_mode: KeyMatchMode::Canonical,
|
||||
}
|
||||
.apply_to_content(DocumentText(content))?;
|
||||
|
||||
FieldUpdate {
|
||||
key: PropertyKey(rule.write_key()),
|
||||
value: update.value,
|
||||
match_mode: KeyMatchMode::Exact,
|
||||
}
|
||||
.apply_to_content(DocumentText(&updated))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bool_value(value: bool) -> FrontmatterValue {
|
||||
FrontmatterValue::Bool(value)
|
||||
}
|
||||
|
||||
fn string_value(value: &str) -> FrontmatterValue {
|
||||
FrontmatterValue::String(value.to_string())
|
||||
}
|
||||
|
||||
fn frontmatter_delimiter_lines(content: &str) -> usize {
|
||||
content.lines().filter(|line| *line == "---").count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_existing_crlf_frontmatter_without_creating_a_second_block() {
|
||||
let content = concat!(
|
||||
"---\r\n",
|
||||
"type: Note\r\n",
|
||||
"related_to:\r\n",
|
||||
" - \"[[tolaria]]\"\r\n",
|
||||
"---\r\n",
|
||||
"# Properties Panel\r\n",
|
||||
);
|
||||
|
||||
let updated = update_frontmatter_content(content, "_organized", Some(bool_value(true)))
|
||||
.expect("frontmatter update should succeed");
|
||||
|
||||
assert_eq!(frontmatter_delimiter_lines(&updated), 2);
|
||||
assert_eq!(
|
||||
updated,
|
||||
concat!(
|
||||
"---\r\n",
|
||||
"type: Note\r\n",
|
||||
"related_to:\r\n",
|
||||
" - \"[[tolaria]]\"\r\n",
|
||||
"_organized: true\r\n",
|
||||
"---\r\n",
|
||||
"# Properties Panel\r\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_crlf_updates_stay_in_the_original_frontmatter_block() {
|
||||
let content = concat!(
|
||||
"---\r\n",
|
||||
"type: Note\r\n",
|
||||
"related_to: \"[[tolaria]]\"\r\n",
|
||||
"---\r\n",
|
||||
"# Properties Panel\r\n",
|
||||
);
|
||||
|
||||
let widened = update_frontmatter_content(content, "_width", Some(string_value("wide")))
|
||||
.expect("width update should succeed");
|
||||
let organized = update_frontmatter_content(&widened, "_organized", Some(bool_value(true)))
|
||||
.expect("organized update should succeed");
|
||||
|
||||
assert_eq!(frontmatter_delimiter_lines(&organized), 2);
|
||||
assert!(organized.contains("type: Note\r\n"));
|
||||
assert!(organized.contains("_width: wide\r\n"));
|
||||
assert!(organized.contains("_organized: true\r\n"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
use super::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
struct UpdateCase<'a> {
|
||||
content: &'a str,
|
||||
key: &'a str,
|
||||
value: Option<FrontmatterValue>,
|
||||
expected_present: &'a [&'a str],
|
||||
expected_absent: &'a [&'a str],
|
||||
}
|
||||
|
||||
fn assert_updated_content(case: UpdateCase<'_>) {
|
||||
let updated = update_frontmatter_content(case.content, case.key, case.value).unwrap();
|
||||
for expected in case.expected_present {
|
||||
assert!(
|
||||
updated.contains(expected),
|
||||
"missing expected snippet: {expected}"
|
||||
);
|
||||
}
|
||||
for unexpected in case.expected_absent {
|
||||
assert!(
|
||||
!updated.contains(unexpected),
|
||||
"found unexpected snippet: {unexpected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replaces_or_adds_scalar_fields() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Status",
|
||||
value: Some(FrontmatterValue::String("Active".to_string())),
|
||||
expected_present: &["Status: Active"],
|
||||
expected_absent: &["Status: Draft"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Owner",
|
||||
value: Some(FrontmatterValue::String("Luca".to_string())),
|
||||
expected_present: &["Owner: Luca", "Status: Draft"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\n\"Is A\": Note\n---\n# Test\n",
|
||||
key: "Is A",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["\"Is A\": Note", "\"Is A\": Project"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Reviewed",
|
||||
value: Some(FrontmatterValue::Bool(true)),
|
||||
expected_present: &["Reviewed: true"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Priority",
|
||||
value: Some(FrontmatterValue::Number(5.0)),
|
||||
expected_present: &["Priority: 5"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Score",
|
||||
value: Some(FrontmatterValue::Number(9.5)),
|
||||
expected_present: &["Score: 9.5"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "ClearMe",
|
||||
value: Some(FrontmatterValue::Null),
|
||||
expected_present: &["ClearMe: null"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list_and_delete_paths() {
|
||||
let list_cases = [
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
expected_present: &["aliases:", " - \"Alias1\"", " - \"Alias2\""],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
expected_present: &[" - \"New1\"", "Status: Draft"],
|
||||
expected_absent: &["Old1", "Old2"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: None,
|
||||
expected_present: &["Status: Draft"],
|
||||
expected_absent: &["aliases", "Alias1"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n",
|
||||
key: "Owner",
|
||||
value: None,
|
||||
expected_present: &["Status: Draft"],
|
||||
expected_absent: &["Owner"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "tags",
|
||||
value: Some(FrontmatterValue::List(vec![])),
|
||||
expected_present: &["tags: []"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
];
|
||||
|
||||
for case in list_cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() {
|
||||
let inserted = update_frontmatter_content(
|
||||
"# Test\n\nSome content here.",
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inserted.starts_with("---\n"));
|
||||
assert!(inserted.contains("Status: Draft"));
|
||||
assert!(inserted.contains("# Test"));
|
||||
|
||||
let malformed = update_frontmatter_content(
|
||||
"---\nStatus: Draft\nNo closing fence here",
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(malformed.is_err());
|
||||
assert!(malformed.unwrap_err().contains("Malformed frontmatter"));
|
||||
|
||||
let unchanged =
|
||||
update_frontmatter_content("---\nStatus: Draft\n---\n# Test\n", "Missing", None).unwrap();
|
||||
assert_eq!(unchanged, "---\nStatus: Draft\n---\n# Test\n");
|
||||
|
||||
let no_frontmatter =
|
||||
update_frontmatter_content("# Test\n\nSome content.", "Missing", None).unwrap();
|
||||
assert_eq!(no_frontmatter, "# Test\n\nSome content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\narchived: false\n---\n# Test\n",
|
||||
key: "_archived",
|
||||
value: Some(FrontmatterValue::Bool(true)),
|
||||
expected_present: &["_archived: true"],
|
||||
expected_absent: &["archived: false"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nicon: rocket\n---\n# Test\n",
|
||||
key: "icon",
|
||||
value: Some(FrontmatterValue::String("star".to_string())),
|
||||
expected_present: &["_icon: star"],
|
||||
expected_absent: &["\nicon:", "rocket"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n",
|
||||
key: "_sidebar_label",
|
||||
value: Some(FrontmatterValue::String("Programs".to_string())),
|
||||
expected_present: &["_sidebar_label: Programs"],
|
||||
expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n",
|
||||
key: "_sort",
|
||||
value: None,
|
||||
expected_present: &["# Test"],
|
||||
expected_absent: &["\nsort:", "\n_sort:"],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_canonicalizes_type_key_case() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\nType: Note\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["Type: Note"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\n\"Is A\": Note\nis_a: Topic\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["\"Is A\": Note", "is_a: Topic"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nTYPE: Note\n---\n# Test\n",
|
||||
key: "Type",
|
||||
value: Some(FrontmatterValue::String("Person".to_string())),
|
||||
expected_present: &["type: Person"],
|
||||
expected_absent: &["TYPE: Note"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nType: Note\nstatus: Active\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: None,
|
||||
expected_present: &["status: Active", "# Test"],
|
||||
expected_absent: &["Type: Note", "\ntype:"],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct YamlText<'a>(&'a str);
|
||||
|
||||
impl<'a> YamlText<'a> {
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_special_chars(self) -> bool {
|
||||
self.0.contains(':') || self.0.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_collection(self) -> bool {
|
||||
self.0.starts_with('[') || self.0.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_quoting(self) -> bool {
|
||||
self.has_special_chars()
|
||||
|| self.starts_as_collection()
|
||||
|| matches!(self.0, "true" | "false" | "null")
|
||||
|| self.0.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quoted(self) -> String {
|
||||
format!("\"{}\"", self.0.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
fn as_list_item(self) -> String {
|
||||
format!(" - {}", self.quoted())
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn as_block_scalar(self) -> String {
|
||||
let indented = self
|
||||
.0
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
|
||||
fn needs_key_quoting(self) -> bool {
|
||||
self.0
|
||||
.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
let yaml_key = YamlText(key);
|
||||
if yaml_key.needs_key_quoting() {
|
||||
yaml_key.quoted()
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
let yaml_text = YamlText(s);
|
||||
if s.contains('\n') {
|
||||
yaml_text.as_block_scalar()
|
||||
} else if yaml_text.needs_quoting() {
|
||||
yaml_text.quoted()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
FrontmatterValue::Number(n) => format_yaml_number(*n),
|
||||
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
|
||||
FrontmatterValue::List(items) => items
|
||||
.iter()
|
||||
.map(|item| YamlText(item).as_list_item())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
FrontmatterValue::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if matches!(value, FrontmatterValue::List(items) if !items.is_empty()) {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_string_yaml_value(input: &str, expected: &str) {
|
||||
let value = FrontmatterValue::String(input.to_string());
|
||||
assert_eq!(value.to_yaml_value(), expected);
|
||||
}
|
||||
|
||||
fn assert_field_lines(key: &str, value: FrontmatterValue, expected: &[&str]) {
|
||||
let lines = format_yaml_field(key, &value);
|
||||
let expected_lines = expected
|
||||
.iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(lines, expected_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_cases() {
|
||||
for (input, expected) in [
|
||||
("key: value", "\"key: value\""),
|
||||
("has # comment", "\"has # comment\""),
|
||||
("[array-like]", "\"[array-like]\""),
|
||||
("{object-like}", "\"{object-like}\""),
|
||||
("true", "\"true\""),
|
||||
("false", "\"false\""),
|
||||
("null", "\"null\""),
|
||||
("42", "\"42\""),
|
||||
("3.14", "\"3.14\""),
|
||||
] {
|
||||
assert_string_yaml_value(input, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
assert_string_yaml_value("Hello World", "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.125);
|
||||
assert_eq!(v.to_yaml_value(), "3.125");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
for (input, expected) in [("Status", "Status"), ("is_a", "is_a")] {
|
||||
assert_eq!(format_yaml_key(input), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_quotes_when_needed() {
|
||||
for (input, expected) in [
|
||||
("Is A", "\"Is A\""),
|
||||
("Created at", "\"Created at\""),
|
||||
("key:value", "\"key:value\""),
|
||||
("has#tag", "\"has#tag\""),
|
||||
("key.name", "\"key.name\""),
|
||||
] {
|
||||
assert_eq!(format_yaml_key(input), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_list_layouts() {
|
||||
assert_field_lines(
|
||||
"_list_properties_display",
|
||||
FrontmatterValue::List(vec!["Belongs to".to_string()]),
|
||||
&["_list_properties_display:", " - \"Belongs to\""],
|
||||
);
|
||||
assert_field_lines("tags", FrontmatterValue::List(vec![]), &["tags: []"]);
|
||||
assert_field_lines(
|
||||
"tags",
|
||||
FrontmatterValue::List(vec!["Alpha".to_string(), "Beta".to_string()]),
|
||||
&["tags:", " - \"Alpha\"\n - \"Beta\""],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue