///|
/// Atom 1.0 feed rendering (RFC 4287).
///
/// Atom fields are derived from the shared `FeedConfig` / `FeedItem` model:
///
/// **Feed level:**
/// * `title` → ``
/// * `description` → ``
/// * `feed_url` → `` and ``
/// * `site_url` → ``
/// * `hub` → ``
/// * `last_build_date` → ``
/// * `author` → `…`
/// * `copyright` → ``
/// * `generator` → ``
/// * `categories` → ``
/// * `image.url` → ``
///
/// **Entry level:**
/// * `title` → ``
/// * `url` → `` and `` (fallback)
/// * `guid` → `` (preferred)
/// * `description` → ``
/// * `content` → ``
/// * `author` → `` (falls back to channel author)
/// * `pub_date` → `` and ``
/// * `categories` → ``
/// * `enclosure` → ``
///|
fn atom_link(
w : XmlWriter,
href : String,
rel : String?,
type_ : String?,
length : String?,
) -> Unit {
let attrs : Array[(String, String)] = [("href", href)]
match rel {
Some(r) => attrs.push(("rel", r))
None => ()
}
match type_ {
Some(t) => attrs.push(("type", t))
None => ()
}
match length {
Some(l) => attrs.push(("length", l))
None => ()
}
w.self_closing("link", attrs)
}
///|
fn atom_author_xml(w : XmlWriter, author : Author) -> Unit {
w.open("author", [])
w.leaf("name", author.name)
w.opt_leaf("email", author.email)
w.close("author")
}
///|
fn atom_category_xml(w : XmlWriter, category : FeedCategory) -> Unit {
let attrs : Array[(String, String)] = [("term", category.value)]
match category.domain {
Some(d) => attrs.push(("scheme", d))
None => ()
}
w.self_closing("category", attrs)
}
///|
fn is_reserved_atom_namespace_prefix(prefix : String) -> Bool {
prefix == "atom" || prefix == "geo"
}
///|
fn atom_entry_xml(
errors : Array[FeedError],
w : XmlWriter,
item : FeedItem,
channel_author : Author?,
feed_date : FeedDate?,
) -> Unit {
w.open("entry", [])
w.leaf("title", item.title)
match item.url {
Some(url) => atom_link(w, url, Some("alternate"), Some("text/html"), None)
None => ()
}
let id = match (item.guid, item.url) {
(Some(g), _) => g
(None, Some(u)) => u
(None, None) => ""
}
w.leaf("id", id)
match item.pub_date {
Some(date) => w.opt_leaf("published", optional_date_to_rfc3339(Some(date)))
None => ()
}
match item.pub_date {
Some(date) => w.opt_leaf("updated", optional_date_to_rfc3339(Some(date)))
None =>
match feed_date {
Some(date) =>
w.opt_leaf("updated", optional_date_to_rfc3339(Some(date)))
None => ()
}
}
match item.description {
Some(d) => w.leaf_cdata_with_attrs("summary", [("type", "html")], d)
None => ()
}
match item.content {
Some(c) => w.leaf_cdata_with_attrs("content", [("type", "html")], c)
None => ()
}
match (item.author, channel_author) {
(Some(a), _) => atom_author_xml(w, a)
(None, Some(a)) => atom_author_xml(w, a)
(None, None) => ()
}
for cat in item.categories {
atom_category_xml(w, cat)
}
match item.enclosure {
Some(enclosure) => {
enclosure.validate(errors)
let mime = enclosure.effective_mime()
atom_link(
w,
enclosure.url,
Some("enclosure"),
Some(mime),
Some(enclosure.size.to_string()),
)
}
None => ()
}
match item.lat {
Some(v) =>
if validate_geo_value(v, -90.0, 90.0, "latitude", errors) {
w.leaf("geo:lat", format_geo(v))
}
None => ()
}
match item.long {
Some(v) =>
if validate_geo_value(v, -180.0, 180.0, "longitude", errors) {
w.leaf("geo:long", format_geo(v))
}
None => ()
}
for elem in item.custom_elements {
custom_elem_xml(errors, w, elem)
}
w.close("entry")
}
///|
/// Render the feed as an Atom 1.0 XML string. Returns `Err` if `title` or
/// `description` is empty, or if any custom element has an invalid tag
/// name; all accumulated errors are reported without halting rendering.
///
/// When `indent` is `true`, produces indented output; otherwise minified.
pub fn FeedConfig::to_atom(
self : FeedConfig,
indent? : Bool = false,
) -> Result[String, Array[FeedError]] {
let errors : Array[FeedError] = []
let feed_date : FeedDate? = match self.last_build_date {
Some(d) => Some(d)
None => self.pub_date
}
validate_common_fields(self, errors, is_reserved_atom_namespace_prefix)
if feed_date is None {
errors.push(MissingRequiredField("updated"))
}
if self.author is None &&
self.items.iter().any(fn(item) { item.author is None }) {
errors.push(MissingRequiredField("author"))
}
for item in self.items {
if item.guid is None && item.url is None {
errors.push(MissingRequiredField("item.id (guid or url)"))
}
}
// Atom uses 2 spaces per level: feed children at depth 1, entry children at 2.
let w = XmlWriter::new(indent)
let geo_rss = self.items
.iter()
.any(fn(item) { item.lat is Some(_) || item.long is Some(_) })
w.write_raw("")
w.newline()
w.write_raw("")
w.newline()
// depth is 0 here; increment to 1 so feed children are indented.
w.indent()
w.leaf("title", self.title)
w.leaf("subtitle", self.description)
match self.feed_url {
Some(url) => {
atom_link(w, url, Some("self"), Some("application/atom+xml"), None)
w.leaf("id", url)
}
None => w.leaf("id", self.site_url)
}
atom_link(w, self.site_url, Some("alternate"), Some("text/html"), None)
match self.hub {
Some(h) => atom_link(w, h, Some("hub"), None, None)
None => ()
}
match feed_date {
Some(date) => w.opt_leaf("updated", optional_date_to_rfc3339(Some(date)))
None => ()
}
match self.author {
Some(a) => atom_author_xml(w, a)
None => ()
}
match self.copyright {
Some(c) => w.leaf("rights", c)
None => ()
}
w.leaf("generator", self.generator)
match self.image {
Some(img) => w.leaf("logo", img.url)
None => ()
}
for cat in self.categories {
atom_category_xml(w, cat)
}
for elem in self.custom_elements {
custom_elem_xml(errors, w, elem)
}
for item in self.items {
atom_entry_xml(errors, w, item, self.author, feed_date)
}
if !errors.is_empty() {
return Err(errors)
}
// depth is still 1; back to 0 before closing tag.
w.dedent()
w.write_raw("")
w.newline()
Ok(w.to_string())
}