///|
/// MoonFolio parses Markdown front matter blocks without pulling in a full
/// YAML/TOML/JSON parser. It targets common static-site metadata conventions.
///|
pub(all) enum MatterFormat {
None
Yaml
Toml
Json
} derive(Eq, Debug)
///|
pub(all) enum MetaValue {
Text(String)
Bool(Bool)
Int(Int)
List(Array[String])
} derive(Eq, Debug)
///|
pub(all) enum Severity {
Warning
Error
} derive(Eq, Debug)
///|
pub(all) struct Diagnostic {
severity : Severity
line : Int
code : String
message : String
} derive(Eq, Debug)
///|
pub(all) struct Document {
format : MatterFormat
fields : Map[String, MetaValue]
order : Array[String]
raw : String
body : String
start_line : Int
end_line : Int
} derive(Eq, Debug)
///|
pub(all) struct ParseOutput {
document : Document
diagnostics : Array[Diagnostic]
} derive(Eq, Debug)
///|
pub(all) struct ValidationReport {
ok : Bool
missing : Array[String]
present : Array[String]
diagnostics : Array[Diagnostic]
} derive(Eq, Debug)
///|
pub(all) enum FieldType {
Any
TextType
BoolType
IntType
ListType
} derive(Eq, Debug)
///|
pub(all) struct FieldRule {
key : String
kind : FieldType
required : Bool
} derive(Eq, Debug)
///|
pub(all) struct ContractReport {
ok : Bool
checked : Array[String]
diagnostics : Array[Diagnostic]
} derive(Eq, Debug)
///|
pub(all) struct ContentFolio {
title : String
slug : String
permalink : String
fingerprint : String
format : MatterFormat
manifest_line : String
} derive(Eq, Debug)
///|
pub fn parse(input : String) -> ParseOutput {
parse_with_required(input, [])
}
///|
pub fn parse_with_required(
input : String,
required : Array[String],
) -> ParseOutput {
let normalized = normalize_newlines(input)
let lines = normalized.split("\n").map(view => view.to_owned()).to_array()
if lines.length() == 0 {
let doc = empty_document(normalized)
{ document: doc, diagnostics: [] }
} else {
let first = lines[0].trim().to_owned()
let (format, delimiter) = detect_delimiter(first)
if format == None {
let doc = empty_document(normalized)
let report = validate_required(doc, required)
{ document: doc, diagnostics: report.diagnostics }
} else {
let end_index = find_end_delimiter(lines, delimiter)
match end_index {
None => {
let raw = join_range(lines, 1, lines.length(), "\n")
let doc = {
format,
fields: Map([]),
order: [],
raw,
body: "",
start_line: 1,
end_line: lines.length(),
}
let diagnostics = [
Diagnostic::{
severity: Error,
line: lines.length(),
code: "missing-end-delimiter",
message: "front matter block is opened but not closed",
},
]
{ document: doc, diagnostics }
}
Some(end) => {
let raw = join_range(lines, 1, end, "\n")
let body = join_range(lines, end + 1, lines.length(), "\n")
let parsed = parse_metadata(format, raw, start_line=2)
let doc = {
format,
fields: parsed.fields,
order: parsed.order,
raw,
body,
start_line: 1,
end_line: end + 1,
}
let report = validate_required(doc, required)
{
document: doc,
diagnostics: parsed.diagnostics + report.diagnostics,
}
}
}
}
}
}
///|
pub fn strip_front_matter(input : String) -> String {
parse(input).document.body
}
///|
pub fn is_valid(output : ParseOutput) -> Bool {
output.diagnostics.all(d => d.severity == Warning)
}
///|
pub fn format_name(format : MatterFormat) -> String {
match format {
None => "none"
Yaml => "yaml"
Toml => "toml"
Json => "json"
}
}
///|
pub fn has_field(doc : Document, key : String) -> Bool {
doc.fields.contains(key)
}
///|
pub fn get_value(doc : Document, key : String) -> MetaValue? {
doc.fields.get(key)
}
///|
pub fn get_text(doc : Document, key : String) -> String? {
match doc.fields.get(key) {
Some(Text(value)) => Some(value)
Some(Bool(value)) => Some(value.to_string())
Some(Int(value)) => Some(value.to_string())
_ => None
}
}
///|
pub fn get_bool(doc : Document, key : String) -> Bool? {
match doc.fields.get(key) {
Some(Bool(value)) => Some(value)
_ => None
}
}
///|
pub fn get_int(doc : Document, key : String) -> Int? {
match doc.fields.get(key) {
Some(Int(value)) => Some(value)
_ => None
}
}
///|
pub fn get_list(doc : Document, key : String) -> Array[String]? {
match doc.fields.get(key) {
Some(List(value)) => Some(value)
_ => None
}
}
///|
pub fn validate_required(
doc : Document,
required : Array[String],
) -> ValidationReport {
let missing : Array[String] = []
let present : Array[String] = []
let diagnostics : Array[Diagnostic] = []
for key in required {
if doc.fields.contains(key) {
present.push(key)
} else {
missing.push(key)
diagnostics.push(Diagnostic::{
severity: Error,
line: doc.start_line,
code: "missing-required-field",
message: "required field `" + key + "` is missing",
})
}
}
{ ok: missing.length() == 0, missing, present, diagnostics }
}
///|
pub fn validate_contract(
doc : Document,
rules : Array[FieldRule],
) -> ContractReport {
let checked : Array[String] = []
let diagnostics : Array[Diagnostic] = []
for rule in rules {
checked.push(rule.key)
match doc.fields.get(rule.key) {
None =>
if rule.required {
diagnostics.push(Diagnostic::{
severity: Error,
line: doc.start_line,
code: "contract-missing-field",
message: "contract requires `" + rule.key + "`",
})
}
Some(value) =>
if !value_matches(value, rule.kind) {
diagnostics.push(Diagnostic::{
severity: Error,
line: doc.start_line,
code: "contract-type-mismatch",
message: "field `" +
rule.key +
"` must be " +
field_type_name(rule.kind) +
", got " +
field_type_name(infer_value_type(value)),
})
}
}
}
{ ok: diagnostics.length() == 0, checked, diagnostics }
}
///|
pub fn build_folio(doc : Document, base_path : String) -> ContentFolio {
let title = get_text(doc, "title").unwrap_or("untitled")
let slug = match get_text(doc, "slug") {
Some(value) => slugify(value)
None => slugify(title)
}
let permalink = join_permalink(base_path, slug)
let fingerprint = fingerprint_document(doc)
let manifest_line = slug +
"|" +
fingerprint +
"|" +
format_name(doc.format) +
"|" +
title
{ title, slug, permalink, fingerprint, format: doc.format, manifest_line }
}
///|
pub fn slugify(text : String) -> String {
let buf = StringBuilder(size_hint=text.length())
let mut last_dash = false
for ch in text.to_lower().iter() {
if is_slug_char(ch) {
buf.write_char(ch)
last_dash = false
} else if !last_dash && !buf.is_empty() {
buf.write_char('-')
last_dash = true
}
}
let slug = buf.to_string().trim(chars="-").to_owned()
if slug.is_empty() {
"untitled"
} else {
slug
}
}
///|
pub fn fingerprint_document(doc : Document) -> String {
fingerprint_text(to_yaml(doc))
}
///|
pub fn folio_manifest(docs : Array[Document], base_path : String) -> String {
let lines : Array[String] = []
for doc in docs {
lines.push(build_folio(doc, base_path).manifest_line)
}
lines.join("\n")
}
///|
pub fn to_yaml(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 key in doc.order {
match doc.fields.get(key) {
Some(value) => {
buf.write_string(key)
buf.write_string(": ")
buf.write_string(format_yaml_value(value))
buf.write_string("\n")
}
None => ()
}
}
buf.write_string("---")
if !doc.body.is_empty() {
buf.write_string("\n")
buf.write_string(doc.body)
}
buf.to_string()
}
}
///|
pub fn to_toml(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 key in doc.order {
match doc.fields.get(key) {
Some(value) => {
buf.write_string(key)
buf.write_string(" = ")
buf.write_string(format_toml_value(value))
buf.write_string("\n")
}
None => ()
}
}
buf.write_string("+++")
if !doc.body.is_empty() {
buf.write_string("\n")
buf.write_string(doc.body)
}
buf.to_string()
}
}
///|
pub fn summary(output : ParseOutput) -> String {
let doc = output.document
let buf = StringBuilder(size_hint=128)
buf.write_string("format=")
buf.write_string(format_name(doc.format))
buf.write_string(", fields=")
buf.write_string(doc.fields.length().to_string())
buf.write_string(", diagnostics=")
buf.write_string(output.diagnostics.length().to_string())
if doc.order.length() > 0 {
buf.write_string(", keys=")
buf.write_string(doc.order.join(","))
}
buf.to_string()
}
///|
priv struct MetadataParse {
fields : Map[String, MetaValue]
order : Array[String]
diagnostics : Array[Diagnostic]
}
///|
fn empty_document(body : String) -> Document {
{
format: None,
fields: Map([]),
order: [],
raw: "",
body,
start_line: 1,
end_line: 0,
}
}
///|
fn detect_delimiter(line : String) -> (MatterFormat, String) {
match line {
"---" => (Yaml, "---")
"+++" => (Toml, "+++")
";;;" => (Json, ";;;")
_ => (None, "")
}
}
///|
fn find_end_delimiter(lines : Array[String], delimiter : String) -> Int? {
for i in 1.. MetadataParse {
match format {
Json => parse_json_like(raw, start_line~)
_ => parse_line_pairs(raw, format, start_line~)
}
}
///|
fn parse_line_pairs(
raw : String,
format : MatterFormat,
start_line~ : Int,
) -> MetadataParse {
let fields : Map[String, MetaValue] = Map([])
let order : Array[String] = []
let diagnostics : Array[Diagnostic] = []
let delimiter = if format == Toml { "=" } else { ":" }
let lines = raw.split("\n").map(view => view.to_owned()).to_array()
for i in 0..
diagnostics.push(Diagnostic::{
severity: Error,
line: line_number,
code: "invalid-entry",
message: "metadata entry must contain `" + delimiter + "`",
})
Some((raw_key, raw_value)) => {
let key = raw_key.trim().to_owned()
let value_text = strip_inline_comment(raw_value.trim().to_owned())
if !is_valid_key(key) {
diagnostics.push(Diagnostic::{
severity: Error,
line: line_number,
code: "invalid-key",
message: "metadata key `" +
key +
"` contains unsupported characters",
})
} else {
if fields.contains(key) {
diagnostics.push(Diagnostic::{
severity: Warning,
line: line_number,
code: "duplicate-key",
message: "metadata key `" + key + "` overrides an earlier value",
})
} else {
order.push(key)
}
fields.set(key, parse_value(value_text))
}
}
}
}
{ fields, order, diagnostics }
}
///|
fn parse_json_like(raw : String, start_line~ : Int) -> MetadataParse {
let fields : Map[String, MetaValue] = Map([])
let order : Array[String] = []
let diagnostics : Array[Diagnostic] = []
let trimmed = raw.trim().to_owned()
if !trimmed.has_prefix("{") || !trimmed.has_suffix("}") {
diagnostics.push(Diagnostic::{
severity: Error,
line: start_line,
code: "invalid-json-object",
message: "json front matter must be a single object",
})
return { fields, order, diagnostics }
}
let inner = trimmed.sub(start=1, end=trimmed.length() - 1).trim().to_owned()
if inner.is_empty() {
return { fields, order, diagnostics }
}
let entries = split_top_level(inner, ',')
for i in 0..
diagnostics.push(Diagnostic::{
severity: Error,
line: start_line,
code: "invalid-json-entry",
message: "json entry must contain `:`",
})
Some((raw_key, raw_value)) => {
let key = unquote(raw_key.trim().to_owned())
let value_text = raw_value.trim().to_owned()
if !is_valid_key(key) {
diagnostics.push(Diagnostic::{
severity: Error,
line: start_line,
code: "invalid-key",
message: "metadata key `" +
key +
"` contains unsupported characters",
})
} else {
if fields.contains(key) {
diagnostics.push(Diagnostic::{
severity: Warning,
line: start_line,
code: "duplicate-key",
message: "metadata key `" + key + "` overrides an earlier value",
})
} else {
order.push(key)
}
fields.set(key, parse_value(value_text))
}
}
}
}
{ fields, order, diagnostics }
}
///|
fn parse_value(text : String) -> MetaValue {
let value = text.trim().to_owned()
if value.has_prefix("[") && value.has_suffix("]") {
let inner = value.sub(start=1, end=value.length() - 1).to_owned()
List(parse_list(inner))
} else if value == "true" {
Bool(true)
} else if value == "false" {
Bool(false)
} else {
match parse_int_maybe(value) {
Some(n) => Int(n)
None => Text(unquote(value))
}
}
}
///|
fn parse_list(inner : String) -> Array[String] {
let values : Array[String] = []
if inner.trim().is_empty() {
return values
}
let parts = split_top_level(inner, ',')
for part in parts {
let item = unquote(part.trim().to_owned())
values.push(item)
}
values
}
///|
fn parse_int_maybe(value : String) -> Int? {
if value.is_empty() {
return None
}
let mut sign = 1
let mut start = 0
if value.has_prefix("-") {
sign = -1
start = 1
}
if start == value.length() {
return None
}
let mut result = 0
for i in start.. 57 {
return None
}
result = result * 10 + (code - 48)
}
Some(result * sign)
}
///|
fn split_first_unquoted(line : String, needle : String) -> (String, String)? {
let mut in_single = false
let mut in_double = false
let mut depth = 0
for i in 0.. Array[String] {
let values : Array[String] = []
let mut in_single = false
let mut in_double = false
let mut depth = 0
let mut start = 0
for i in 0.. String {
let mut in_single = false
let mut in_double = false
for i in 0.. String {
if value.length() >= 2 {
if (value.has_prefix("\"") && value.has_suffix("\"")) ||
(value.has_prefix("'") && value.has_suffix("'")) {
return value.sub(start=1, end=value.length() - 1).to_owned()
}
}
value
}
///|
fn is_valid_key(key : String) -> Bool {
if key.is_empty() {
return false
}
key.all(ch => is_key_char(ch))
}
///|
fn is_key_char(ch : Char) -> Bool {
let code = ch.to_int()
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
ch == '_' ||
ch == '-' ||
ch == '.'
}
///|
fn normalize_newlines(input : String) -> String {
input.replace_all(old="\r\n", new="\n").replace_all(old="\r", new="\n")
}
///|
fn join_range(
lines : Array[String],
start : Int,
end : Int,
separator : String,
) -> String {
let selected : Array[String] = []
if start >= end {
return ""
}
for i in start.. String {
match value {
Text(text) => quote_if_needed(text)
Bool(flag) => flag.to_string()
Int(number) => number.to_string()
List(items) =>
"[" + items.map(item => quote_if_needed(item)).join(", ") + "]"
}
}
///|
fn format_toml_value(value : MetaValue) -> String {
match value {
Text(text) => "\"" + escape_string(text) + "\""
Bool(flag) => flag.to_string()
Int(number) => number.to_string()
List(items) =>
"[" +
items.map(item => "\"" + escape_string(item) + "\"").join(", ") +
"]"
}
}
///|
fn quote_if_needed(text : String) -> String {
if text.is_empty() ||
text.contains(":") ||
text.contains("#") ||
text.contains(",") ||
text.contains("[") ||
text.contains("]") ||
text.contains("\"") ||
text.contains("'") ||
text.trim().to_owned() != text {
"\"" + escape_string(text) + "\""
} else {
text
}
}
///|
fn escape_string(text : String) -> String {
text.replace_all(old="\\", new="\\\\").replace_all(old="\"", new="\\\"")
}
///|
fn value_matches(value : MetaValue, kind : FieldType) -> Bool {
match kind {
Any => true
TextType => value is Text(_)
BoolType => value is Bool(_)
IntType => value is Int(_)
ListType => value is List(_)
}
}
///|
fn infer_value_type(value : MetaValue) -> FieldType {
match value {
Text(_) => TextType
Bool(_) => BoolType
Int(_) => IntType
List(_) => ListType
}
}
///|
fn field_type_name(kind : FieldType) -> String {
match kind {
Any => "any"
TextType => "text"
BoolType => "bool"
IntType => "int"
ListType => "list"
}
}
///|
fn join_permalink(base_path : String, slug : String) -> String {
let base = base_path.trim_end(chars="/").to_owned()
if base.is_empty() {
"/" + slug
} else {
base + "/" + slug
}
}
///|
fn is_slug_char(ch : Char) -> Bool {
let code = ch.to_int()
(code >= 48 && code <= 57) || (code >= 97 && code <= 122)
}
///|
fn fingerprint_text(text : String) -> String {
let mut a = 1
let mut b = 0
for i in 0.. String {
value.to_string(radix=16).to_lower().pad_start(4, '0')
}