///|
/// Metadata editing helpers make MoonFolio usable inside automation. A CI job
/// can inspect a content folio, normalize its field order, and apply safe
/// metadata changes without touching the Markdown body.
///|
pub(all) enum EditAction {
SetMeta
RemoveMeta
RenameMeta
AppendListItem
RemoveListItem
NormalizeOrder
EnsureSlug
EnsurePermalink
EnsureFingerprint
} derive(Eq, Debug)
///|
pub(all) struct MetadataEdit {
action : EditAction
key : String
new_key : String
value : MetaValue
list_item : String
preferred_order : Array[String]
base_path : String
} derive(Eq, Debug)
///|
pub(all) struct EditPreview {
changed : Bool
before_fingerprint : String
after_fingerprint : String
before_yaml : String
after_yaml : String
document : Document
} derive(Eq, Debug)
///|
pub fn metadata_set_text(key : String, value : String) -> MetadataEdit {
{
action: SetMeta,
key,
new_key: "",
value: Text(value),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_set_bool(key : String, value : Bool) -> MetadataEdit {
{
action: SetMeta,
key,
new_key: "",
value: Bool(value),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_set_int(key : String, value : Int) -> MetadataEdit {
{
action: SetMeta,
key,
new_key: "",
value: Int(value),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_set_list(key : String, values : Array[String]) -> MetadataEdit {
{
action: SetMeta,
key,
new_key: "",
value: List(copy_strings(values)),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_remove(key : String) -> MetadataEdit {
{
action: RemoveMeta,
key,
new_key: "",
value: Text(""),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_rename(key : String, new_key : String) -> MetadataEdit {
{
action: RenameMeta,
key,
new_key,
value: Text(""),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_append_list_item(key : String, item : String) -> MetadataEdit {
{
action: AppendListItem,
key,
new_key: "",
value: Text(""),
list_item: item,
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_remove_list_item(key : String, item : String) -> MetadataEdit {
{
action: RemoveListItem,
key,
new_key: "",
value: Text(""),
list_item: item,
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_normalize_order(order : Array[String]) -> MetadataEdit {
{
action: NormalizeOrder,
key: "",
new_key: "",
value: Text(""),
list_item: "",
preferred_order: copy_strings(order),
base_path: "",
}
}
///|
pub fn metadata_ensure_slug() -> MetadataEdit {
{
action: EnsureSlug,
key: "slug",
new_key: "",
value: Text(""),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn metadata_ensure_permalink(base_path : String) -> MetadataEdit {
{
action: EnsurePermalink,
key: "permalink",
new_key: "",
value: Text(""),
list_item: "",
preferred_order: [],
base_path,
}
}
///|
pub fn metadata_ensure_fingerprint() -> MetadataEdit {
{
action: EnsureFingerprint,
key: "fingerprint",
new_key: "",
value: Text(""),
list_item: "",
preferred_order: [],
base_path: "",
}
}
///|
pub fn document_set_field(
doc : Document,
key : String,
value : MetaValue,
) -> Document {
let fields = copy_field_map(doc)
let order = copy_strings(doc.order)
if !fields.contains(key) {
order.push(key)
}
fields.set(key, value)
rebuild_document(doc, fields, order)
}
///|
pub fn document_set_text(
doc : Document,
key : String,
value : String,
) -> Document {
document_set_field(doc, key, Text(value))
}
///|
pub fn document_set_bool(
doc : Document,
key : String,
value : Bool,
) -> Document {
document_set_field(doc, key, Bool(value))
}
///|
pub fn document_set_int(doc : Document, key : String, value : Int) -> Document {
document_set_field(doc, key, Int(value))
}
///|
pub fn document_set_list(
doc : Document,
key : String,
values : Array[String],
) -> Document {
document_set_field(doc, key, List(copy_strings(values)))
}
///|
pub fn document_remove_field(doc : Document, key : String) -> Document {
let fields : Map[String, MetaValue] = Map([])
let order : Array[String] = []
for existing in doc.order {
if existing != key {
match doc.fields.get(existing) {
Some(value) => {
fields.set(existing, value)
order.push(existing)
}
None => ()
}
}
}
rebuild_document(doc, fields, order)
}
///|
pub fn document_rename_field(
doc : Document,
key : String,
new_key : String,
) -> Document {
if key == new_key {
return doc
}
match doc.fields.get(key) {
None => doc
Some(value) => {
let fields : Map[String, MetaValue] = Map([])
let order : Array[String] = []
let mut inserted = false
for existing in doc.order {
if existing == key {
if !fields.contains(new_key) {
fields.set(new_key, value)
order.push(new_key)
inserted = true
}
} else if existing != new_key {
match doc.fields.get(existing) {
Some(existing_value) => {
fields.set(existing, existing_value)
order.push(existing)
}
None => ()
}
}
}
if !inserted {
fields.set(new_key, value)
order.push(new_key)
}
rebuild_document(doc, fields, order)
}
}
}
///|
pub fn document_append_list_item(
doc : Document,
key : String,
item : String,
) -> Document {
let values = match get_list(doc, key) {
Some(existing) => copy_strings(existing)
None => []
}
if !contains_string(values, item) {
values.push(item)
}
document_set_list(doc, key, values)
}
///|
pub fn document_remove_list_item(
doc : Document,
key : String,
item : String,
) -> Document {
let values : Array[String] = []
match get_list(doc, key) {
Some(existing) =>
for value in existing {
if value != item {
values.push(value)
}
}
None => ()
}
document_set_list(doc, key, values)
}
///|
pub fn document_normalize_order(
doc : Document,
preferred_order : Array[String],
) -> Document {
let fields = copy_field_map(doc)
let order : Array[String] = []
for key in preferred_order {
if fields.contains(key) && !contains_string(order, key) {
order.push(key)
}
}
for key in doc.order {
if fields.contains(key) && !contains_string(order, key) {
order.push(key)
}
}
rebuild_document(doc, fields, order)
}
///|
pub fn document_sort_fields(doc : Document) -> Document {
document_normalize_order(doc, sorted_strings(doc.order))
}
///|
pub fn document_ensure_slug(doc : Document) -> Document {
match get_text(doc, "slug") {
Some(value) =>
if value.trim().is_empty() {
document_set_text(doc, "slug", slugify(document_title_for_slug(doc)))
} else {
document_set_text(doc, "slug", slugify(value))
}
None =>
document_set_text(doc, "slug", slugify(document_title_for_slug(doc)))
}
}
///|
pub fn document_ensure_permalink(
doc : Document,
base_path : String,
) -> Document {
let with_slug = document_ensure_slug(doc)
let slug = get_text(with_slug, "slug").unwrap_or("untitled")
let base = base_path.trim_end(chars="/").to_owned()
let permalink = if base.is_empty() { "/" + slug } else { base + "/" + slug }
document_set_text(with_slug, "permalink", permalink)
}
///|
pub fn document_ensure_fingerprint(doc : Document) -> Document {
document_set_text(doc, "fingerprint", fingerprint_document(doc))
}
///|
pub fn apply_metadata_edit(doc : Document, edit : MetadataEdit) -> Document {
match edit.action {
SetMeta => document_set_field(doc, edit.key, edit.value)
RemoveMeta => document_remove_field(doc, edit.key)
RenameMeta => document_rename_field(doc, edit.key, edit.new_key)
AppendListItem => document_append_list_item(doc, edit.key, edit.list_item)
RemoveListItem => document_remove_list_item(doc, edit.key, edit.list_item)
NormalizeOrder => document_normalize_order(doc, edit.preferred_order)
EnsureSlug => document_ensure_slug(doc)
EnsurePermalink => document_ensure_permalink(doc, edit.base_path)
EnsureFingerprint => document_ensure_fingerprint(doc)
}
}
///|
pub fn apply_metadata_edits(
doc : Document,
edits : Array[MetadataEdit],
) -> Document {
let mut current = doc
for edit in edits {
current = apply_metadata_edit(current, edit)
}
current
}
///|
pub fn preview_metadata_edit(
doc : Document,
edit : MetadataEdit,
) -> EditPreview {
preview_metadata_edits(doc, [edit])
}
///|
pub fn preview_metadata_edits(
doc : Document,
edits : Array[MetadataEdit],
) -> EditPreview {
let before_yaml = to_yaml(doc)
let before_fingerprint = fingerprint_document(doc)
let edited = apply_metadata_edits(doc, edits)
let after_yaml = to_yaml(edited)
let after_fingerprint = fingerprint_document(edited)
{
changed: before_fingerprint != after_fingerprint,
before_fingerprint,
after_fingerprint,
before_yaml,
after_yaml,
document: edited,
}
}
///|
pub fn render_document(doc : Document, format : MatterFormat) -> String {
match format {
None => doc.body
Yaml => to_yaml(document_with_format(doc, Yaml))
Toml => to_toml(document_with_format(doc, Toml))
Json => to_json_front_matter(document_with_format(doc, Json))
}
}
///|
pub fn document_with_format(doc : Document, format : MatterFormat) -> Document {
{
format,
fields: copy_field_map(doc),
order: copy_strings(doc.order),
raw: doc.raw,
body: doc.body,
start_line: doc.start_line,
end_line: doc.end_line,
}
}
///|
pub fn to_json_front_matter(doc : Document) -> String {
if doc.format == None {
doc.body
} else {
let buf = StringBuilder(size_hint=doc.raw.length() + doc.body.length() + 16)
buf.write_string(";;;\n{")
for i in 0.. {
if i > 0 {
buf.write_string(",")
}
buf.write_string("\n \"")
buf.write_string(escape_string(key))
buf.write_string("\": ")
buf.write_string(format_json_meta_value(value))
}
None => ()
}
}
if doc.order.length() > 0 {
buf.write_string("\n")
}
buf.write_string("}\n;;;")
if !doc.body.is_empty() {
buf.write_string("\n")
buf.write_string(doc.body)
}
buf.to_string()
}
}
///|
pub fn canonicalize_document(doc : Document) -> Document {
let ordered = document_normalize_order(doc, [
"title", "slug", "summary", "date", "draft", "tags", "permalink", "fingerprint",
])
let with_slug = document_ensure_slug(ordered)
document_ensure_fingerprint(with_slug)
}
///|
pub fn canonical_yaml(doc : Document) -> String {
to_yaml(canonicalize_document(document_with_format(doc, Yaml)))
}
///|
pub fn edit_action_name(action : EditAction) -> String {
match action {
SetMeta => "set"
RemoveMeta => "remove"
RenameMeta => "rename"
AppendListItem => "append-list-item"
RemoveListItem => "remove-list-item"
NormalizeOrder => "normalize-order"
EnsureSlug => "ensure-slug"
EnsurePermalink => "ensure-permalink"
EnsureFingerprint => "ensure-fingerprint"
}
}
///|
pub fn edit_plan_summary(edits : Array[MetadataEdit]) -> String {
let parts : Array[String] = []
for edit in edits {
let detail = if edit.key.is_empty() {
edit_action_name(edit.action)
} else if edit.new_key.is_empty() {
edit_action_name(edit.action) + ":" + edit.key
} else {
edit_action_name(edit.action) + ":" + edit.key + "->" + edit.new_key
}
parts.push(detail)
}
parts.join(", ")
}
///|
pub fn document_field_count(doc : Document) -> Int {
doc.order.length()
}
///|
pub fn document_missing_fields(
doc : Document,
keys : Array[String],
) -> Array[String] {
let missing : Array[String] = []
for key in keys {
if !doc.fields.contains(key) {
missing.push(key)
}
}
missing
}
///|
pub fn document_present_fields(
doc : Document,
keys : Array[String],
) -> Array[String] {
let present : Array[String] = []
for key in keys {
if doc.fields.contains(key) {
present.push(key)
}
}
present
}
///|
pub fn document_has_blank_text(doc : Document, key : String) -> Bool {
match get_text(doc, key) {
Some(value) => value.trim().is_empty()
None => false
}
}
///|
pub fn document_metadata_line_count(doc : Document) -> Int {
if doc.raw.is_empty() {
0
} else {
doc.raw.split("\n").to_array().length()
}
}
///|
fn rebuild_document(
doc : Document,
fields : Map[String, MetaValue],
order : Array[String],
) -> Document {
{
format: doc.format,
fields,
order,
raw: doc.raw,
body: doc.body,
start_line: doc.start_line,
end_line: doc.end_line,
}
}
///|
fn copy_field_map(doc : Document) -> Map[String, MetaValue] {
let fields : Map[String, MetaValue] = Map([])
for key in doc.order {
match doc.fields.get(key) {
Some(value) => fields.set(key, value)
None => ()
}
}
fields
}
///|
fn document_title_for_slug(doc : Document) -> String {
get_text(doc, "title").unwrap_or("untitled")
}
///|
fn format_json_meta_value(value : MetaValue) -> String {
match value {
Text(text) => "\"" + escape_string(text) + "\""
Bool(flag) => flag.to_string()
Int(number) => number.to_string()
List(items) => {
let parts : Array[String] = []
for item in items {
parts.push("\"" + escape_string(item) + "\"")
}
"[" + parts.join(", ") + "]"
}
}
}