///|
/// An author with display name and optional email address.
pub struct Author {
name : String
email : String?
} derive(Debug, Eq, Compare)
///|
pub fn Author::new(name : String, email? : String) -> Author {
{ name, email }
}
///|
/// Return the author display name.
pub fn Author::name(self : Author) -> String {
self.name
}
///|
/// Return the author email address.
pub fn Author::email(self : Author) -> String? {
self.email
}
///|
/// A category with an optional classification scheme URI (the `domain`
/// attribute on the RSS `` element).
pub struct FeedCategory {
value : String
domain : String?
} derive(Debug, Eq, Compare)
///|
/// Channel image. Optional `width` / `height` / `description` are omitted
/// from the output when `None`.
pub struct FeedImage {
url : String
title : String
link : String
width : Int?
height : Int?
description : String?
} derive(Debug, Eq, Compare)
///|
pub struct FeedConfig {
title : String
description : String
feed_url : String?
site_url : String
author : Author?
categories : Array[FeedCategory]
pub_date : FeedDate?
last_build_date : FeedDate?
image : FeedImage?
hub : String?
docs : String?
copyright : String?
language : String?
managing_editor : String?
web_master : String?
ttl : Int?
generator : String
custom_namespaces : Array[(String, String)]
custom_elements : Array[CustomElement]
items : Array[FeedItem]
} derive(Debug, Eq, Compare)
///|
pub struct FeedItem {
title : String
description : String?
content : String?
url : String?
guid : String?
guid_is_perma_link : Bool?
categories : Array[FeedCategory]
author : Author?
pub_date : FeedDate?
lat : Double?
long : Double?
enclosure : FeedEnclosure?
custom_elements : Array[CustomElement]
} derive(Debug, Eq, Compare)
///|
pub struct FeedEnclosure {
url : String
size : Int64
mime_type : String?
} derive(Debug, Eq, Compare)
///|
/// An arbitrary XML element to embed in the channel or an item.
/// `Text` is a leaf element with text content; `Element` is a container with
/// attributes and child elements.
pub(all) enum CustomElement {
Text(String, String)
Cdata(String, String)
Element(String, Array[(String, String)], Array[CustomElement])
} derive(Debug, Eq, Compare)
///|
/// Errors produced by feed rendering.
pub suberror FeedError {
/// A custom element has an invalid XML tag name.
InvalidTag(String)
/// A required field (e.g. `title`, `description`) is empty.
MissingRequiredField(String)
/// A custom element has an invalid XML attribute name.
InvalidAttrName(String)
/// A custom namespace prefix is declared more than once.
DuplicateNamespacePrefix(String)
/// A custom namespace prefix is reserved by the renderer.
ReservedNamespacePrefix(String)
/// A custom element attribute is declared more than once.
DuplicateAttrName(String)
/// A field value is outside the renderer's supported range.
InvalidValue(String)
/// An enclosure has invalid data (e.g. empty URL, negative size).
InvalidEnclosure(String)
/// A geo coordinate is out of range or not finite.
InvalidGeo(String)
/// A feed date has invalid date/time components.
InvalidDate(String)
} derive(Debug, Eq, Compare)
///|
impl Show for FeedError with fn output(self, logger) {
match self {
InvalidTag(tag) => logger.write_string("invalid XML tag: \{tag}")
MissingRequiredField(field) =>
logger.write_string("required field is empty: \{field}")
InvalidAttrName(name) =>
logger.write_string("invalid XML attribute name: \{name}")
DuplicateNamespacePrefix(prefix) =>
logger.write_string("duplicate namespace prefix: \{prefix}")
ReservedNamespacePrefix(prefix) =>
logger.write_string("reserved namespace prefix: \{prefix}")
DuplicateAttrName(name) =>
logger.write_string("duplicate attribute name: \{name}")
InvalidValue(msg) => logger.write_string("invalid value: \{msg}")
InvalidEnclosure(msg) => logger.write_string("invalid enclosure: \{msg}")
InvalidGeo(msg) => logger.write_string("invalid geo coordinate: \{msg}")
InvalidDate(msg) => logger.write_string("invalid date: \{msg}")
}
}
///|
let default_generator : String = "tkancf/feed_gen"
///|
fn validate_optional_date(
date : FeedDate?,
field : String,
errors : Array[FeedError],
) -> Unit {
match date {
Some(d) =>
match d.validation_error(field) {
Some(e) => errors.push(e)
None => ()
}
None => ()
}
}
///|
fn validate_geo_value(
value : Double,
min : Double,
max : Double,
field : String,
errors : Array[FeedError],
) -> Bool {
if value.is_nan() || value.is_inf() || value < min || value > max {
errors.push(InvalidGeo("\{field} out of range: \{value}"))
false
} else {
true
}
}
///|
fn FeedEnclosure::validate(
self : FeedEnclosure,
errors : Array[FeedError],
) -> Unit {
if self.url == "" {
errors.push(InvalidEnclosure("URL is empty"))
}
if self.size < 0L {
errors.push(InvalidEnclosure("size is negative"))
}
if self.mime_type is Some("") {
errors.push(InvalidEnclosure("mime type is empty"))
}
}
///|
/// Create a category. `domain` is the optional classification scheme URI.
pub fn FeedCategory::new(value : String, domain? : String) -> FeedCategory {
{ value, domain }
}
///|
/// Return the category value.
pub fn FeedCategory::value(self : FeedCategory) -> String {
self.value
}
///|
/// Return the category domain.
pub fn FeedCategory::domain(self : FeedCategory) -> String? {
self.domain
}
///|
/// Create a channel image. Optional dimensions default to `None`.
pub fn FeedImage::new(url : String, title : String, link : String) -> FeedImage {
{ url, title, link, width: None, height: None, description: None }
}
///|
/// Return the image URL.
pub fn FeedImage::url(self : FeedImage) -> String {
self.url
}
///|
/// Return the image title.
pub fn FeedImage::title(self : FeedImage) -> String {
self.title
}
///|
/// Return the image link.
pub fn FeedImage::link(self : FeedImage) -> String {
self.link
}
///|
/// Set the image width.
pub fn FeedImage::set_width(self : FeedImage, width : Int?) -> FeedImage {
{ ..self, width, }
}
///|
/// Return the image width.
pub fn FeedImage::width(self : FeedImage) -> Int? {
self.width
}
///|
/// Set the image height.
pub fn FeedImage::set_height(self : FeedImage, height : Int?) -> FeedImage {
{ ..self, height, }
}
///|
/// Return the image height.
pub fn FeedImage::height(self : FeedImage) -> Int? {
self.height
}
///|
/// Set the image description.
pub fn FeedImage::set_description(
self : FeedImage,
description : String?,
) -> FeedImage {
{ ..self, description, }
}
///|
/// Return the image description.
pub fn FeedImage::description(self : FeedImage) -> String? {
self.description
}
///|
/// Create a feed config with the required `title`, `description`, and
/// `site_url`. The default `generator` is `"tkancf/feed_gen"`.
pub fn FeedConfig::new(
title : String,
description : String,
site_url : String,
items? : Array[FeedItem] = [],
) -> FeedConfig {
{
title,
description,
feed_url: None,
site_url,
author: None,
categories: [],
pub_date: None,
last_build_date: None,
image: None,
hub: None,
docs: None,
copyright: None,
language: None,
managing_editor: None,
web_master: None,
ttl: None,
generator: default_generator,
custom_namespaces: [],
custom_elements: [],
items,
}
}
///|
/// Set the feed title.
pub fn FeedConfig::set_title(self : FeedConfig, title : String) -> FeedConfig {
{ ..self, title, }
}
///|
/// Set the feed description.
pub fn FeedConfig::set_description(
self : FeedConfig,
description : String,
) -> FeedConfig {
{ ..self, description, }
}
///|
/// Set the site URL.
pub fn FeedConfig::set_site_url(
self : FeedConfig,
site_url : String,
) -> FeedConfig {
{ ..self, site_url, }
}
///|
/// Set the feed URL used for RSS `atom:link` and Atom `self` link output.
pub fn FeedConfig::set_feed_url(
self : FeedConfig,
feed_url : String?,
) -> FeedConfig {
{ ..self, feed_url, }
}
///|
/// Set the last build date used for feed metadata and Atom `updated`.
pub fn FeedConfig::set_last_build_date(
self : FeedConfig,
last_build_date : FeedDate?,
) -> FeedConfig {
{ ..self, last_build_date, }
}
///|
/// Set the feed author.
pub fn FeedConfig::set_author(
self : FeedConfig,
author : Author?,
) -> FeedConfig {
{ ..self, author, }
}
///|
/// Append a category to the feed.
pub fn FeedConfig::add_category(
self : FeedConfig,
category : FeedCategory,
) -> FeedConfig {
let categories = self.categories.copy()
categories.push(category)
{ ..self, categories, }
}
///|
/// Set the feed publication date.
pub fn FeedConfig::set_pub_date(
self : FeedConfig,
pub_date : FeedDate?,
) -> FeedConfig {
{ ..self, pub_date, }
}
///|
/// Set the feed image.
pub fn FeedConfig::set_image(
self : FeedConfig,
image : FeedImage?,
) -> FeedConfig {
{ ..self, image, }
}
///|
/// Set the PubSubHubbub hub URL.
pub fn FeedConfig::set_hub(self : FeedConfig, hub : String?) -> FeedConfig {
{ ..self, hub, }
}
///|
/// Set the RSS documentation URL.
pub fn FeedConfig::set_docs(self : FeedConfig, docs : String?) -> FeedConfig {
{ ..self, docs, }
}
///|
/// Set the feed copyright text.
pub fn FeedConfig::set_copyright(
self : FeedConfig,
copyright : String?,
) -> FeedConfig {
{ ..self, copyright, }
}
///|
/// Set the feed language.
pub fn FeedConfig::set_language(
self : FeedConfig,
language : String?,
) -> FeedConfig {
{ ..self, language, }
}
///|
/// Set the RSS managing editor.
pub fn FeedConfig::set_managing_editor(
self : FeedConfig,
managing_editor : String?,
) -> FeedConfig {
{ ..self, managing_editor, }
}
///|
/// Set the RSS webmaster.
pub fn FeedConfig::set_web_master(
self : FeedConfig,
web_master : String?,
) -> FeedConfig {
{ ..self, web_master, }
}
///|
/// Set the RSS time-to-live value.
pub fn FeedConfig::set_ttl(self : FeedConfig, ttl : Int?) -> FeedConfig {
{ ..self, ttl, }
}
///|
/// Set the generator string.
pub fn FeedConfig::set_generator(
self : FeedConfig,
generator : String,
) -> FeedConfig {
{ ..self, generator, }
}
///|
/// Append a custom XML namespace declaration.
pub fn FeedConfig::add_custom_namespace(
self : FeedConfig,
prefix : String,
uri : String,
) -> FeedConfig {
let namespaces = self.custom_namespaces.copy()
namespaces.push((prefix, uri))
{ ..self, custom_namespaces: namespaces }
}
///|
/// Append a custom XML element to the channel.
pub fn FeedConfig::add_custom_element(
self : FeedConfig,
element : CustomElement,
) -> FeedConfig {
let elements = self.custom_elements.copy()
elements.push(element)
{ ..self, custom_elements: elements }
}
///|
/// Create a feed item with the required `title`. `description` is optional
/// and defaults to `None`.
pub fn FeedItem::new(title : String, description? : String) -> FeedItem {
{
title,
description,
content: None,
url: None,
guid: None,
guid_is_perma_link: None,
categories: [],
author: None,
pub_date: None,
lat: None,
long: None,
enclosure: None,
custom_elements: [],
}
}
///|
/// Set the item title.
pub fn FeedItem::set_title(self : FeedItem, title : String) -> FeedItem {
{ ..self, title, }
}
///|
/// Return the item title.
pub fn FeedItem::title(self : FeedItem) -> String {
self.title
}
///|
/// Set the item description.
pub fn FeedItem::set_description(
self : FeedItem,
description : String?,
) -> FeedItem {
{ ..self, description, }
}
///|
/// Return the item description.
pub fn FeedItem::description(self : FeedItem) -> String? {
self.description
}
///|
/// Set the item content.
pub fn FeedItem::set_content(self : FeedItem, content : String?) -> FeedItem {
{ ..self, content, }
}
///|
/// Return the item content.
pub fn FeedItem::content(self : FeedItem) -> String? {
self.content
}
///|
/// Set the item URL.
pub fn FeedItem::set_url(self : FeedItem, url : String?) -> FeedItem {
{ ..self, url, }
}
///|
/// Return the item URL.
pub fn FeedItem::url(self : FeedItem) -> String? {
self.url
}
///|
/// Set the item GUID.
pub fn FeedItem::set_guid(self : FeedItem, guid : String?) -> FeedItem {
{ ..self, guid, }
}
///|
/// Return the item GUID.
pub fn FeedItem::guid(self : FeedItem) -> String? {
self.guid
}
///|
/// Set whether the GUID should render as a permalink.
pub fn FeedItem::set_guid_is_perma_link(
self : FeedItem,
guid_is_perma_link : Bool?,
) -> FeedItem {
{ ..self, guid_is_perma_link, }
}
///|
/// Return whether the item GUID renders as a permalink.
pub fn FeedItem::guid_is_perma_link(self : FeedItem) -> Bool? {
self.guid_is_perma_link
}
///|
/// Append a category to the item.
pub fn FeedItem::add_category(
self : FeedItem,
category : FeedCategory,
) -> FeedItem {
let categories = self.categories.copy()
categories.push(category)
{ ..self, categories, }
}
///|
/// Return the item categories.
pub fn FeedItem::categories(self : FeedItem) -> Array[FeedCategory] {
self.categories.copy()
}
///|
/// Set the item author.
pub fn FeedItem::set_author(self : FeedItem, author : Author?) -> FeedItem {
{ ..self, author, }
}
///|
/// Return the item author.
pub fn FeedItem::author(self : FeedItem) -> Author? {
self.author
}
///|
/// Set the item publication date.
pub fn FeedItem::set_pub_date(
self : FeedItem,
pub_date : FeedDate?,
) -> FeedItem {
{ ..self, pub_date, }
}
///|
/// Return the item publication date.
pub fn FeedItem::pub_date(self : FeedItem) -> FeedDate? {
self.pub_date
}
///|
/// Set the item latitude.
pub fn FeedItem::set_lat(self : FeedItem, lat : Double?) -> FeedItem {
{ ..self, lat, }
}
///|
/// Return the item latitude.
pub fn FeedItem::lat(self : FeedItem) -> Double? {
self.lat
}
///|
/// Set the item longitude.
pub fn FeedItem::set_long(self : FeedItem, long : Double?) -> FeedItem {
{ ..self, long, }
}
///|
/// Return the item longitude.
pub fn FeedItem::long(self : FeedItem) -> Double? {
self.long
}
///|
/// Set the item enclosure.
pub fn FeedItem::set_enclosure(
self : FeedItem,
enclosure : FeedEnclosure?,
) -> FeedItem {
{ ..self, enclosure, }
}
///|
/// Return the item enclosure.
pub fn FeedItem::enclosure(self : FeedItem) -> FeedEnclosure? {
self.enclosure
}
///|
/// Append a custom XML element to the item.
pub fn FeedItem::add_custom_element(
self : FeedItem,
element : CustomElement,
) -> FeedItem {
let elements = self.custom_elements.copy()
elements.push(element)
{ ..self, custom_elements: elements }
}
///|
/// Return the item custom XML elements.
pub fn FeedItem::custom_elements(self : FeedItem) -> Array[CustomElement] {
self.custom_elements.copy()
}
///|
/// Create an enclosure. MIME type is detected lazily from the URL extension
/// during rendering. Use `set_mime_type` to override detection.
pub fn FeedEnclosure::new(url : String, size : Int64) -> FeedEnclosure {
{ url, size, mime_type: None }
}
///|
/// Return the enclosure URL.
pub fn FeedEnclosure::url(self : FeedEnclosure) -> String {
self.url
}
///|
/// Return the enclosure size.
pub fn FeedEnclosure::size(self : FeedEnclosure) -> Int64 {
self.size
}
///|
/// Override the MIME type used during rendering.
pub fn FeedEnclosure::set_mime_type(
self : FeedEnclosure,
mime_type : String?,
) -> FeedEnclosure {
{ ..self, mime_type, }
}
///|
/// Return the explicit MIME type override.
pub fn FeedEnclosure::mime_type(self : FeedEnclosure) -> String? {
self.mime_type
}
///|
/// Return the effective MIME type, resolved from explicit override or URL.
pub fn FeedEnclosure::effective_mime(self : FeedEnclosure) -> String {
match self.mime_type {
Some(m) => m
None => mime_from_url(self.url)
}
}
///|
/// Append an item to this config's items list.
/// Returns a new config with the item added, for chaining.
pub fn FeedConfig::add_item(self : FeedConfig, item : FeedItem) -> FeedConfig {
let new_items = {
let arr = self.items.copy()
arr.push(item)
arr
}
{ ..self, items: new_items }
}
///|
/// Sort items using the given comparator function.
/// Returns a new config with items sorted.
pub fn FeedConfig::sort_by(
self : FeedConfig,
cmp : (FeedItem, FeedItem) -> Int,
) -> FeedConfig {
let sorted = self.items.copy()
sorted.sort_by(cmp)
{ ..self, items: sorted }
}
///|
/// Validate fields common to both RSS and Atom rendering: required
/// non-empty title/description/site_url, date validity, and namespace prefix
/// shape. Renderers provide their own namespace reservation policy.
fn validate_common_fields(
config : FeedConfig,
errors : Array[FeedError],
is_reserved_namespace_prefix : (String) -> Bool,
) -> Unit {
if config.title == "" {
errors.push(MissingRequiredField("title"))
}
if config.description == "" {
errors.push(MissingRequiredField("description"))
}
if config.site_url == "" {
errors.push(MissingRequiredField("site_url"))
}
validate_optional_date(config.pub_date, "pub_date", errors)
validate_optional_date(config.last_build_date, "last_build_date", errors)
for item in config.items {
if item.title == "" {
errors.push(MissingRequiredField("item.title"))
}
match (item.lat, item.long) {
(Some(_), None) => errors.push(InvalidGeo("latitude without longitude"))
(None, Some(_)) => errors.push(InvalidGeo("longitude without latitude"))
_ => ()
}
validate_optional_date(item.pub_date, "item.pub_date", errors)
}
let seen_namespace_prefixes : Array[String] = []
for ns in config.custom_namespaces {
let (prefix, _) = ns
if is_reserved_namespace_prefix(prefix) {
errors.push(ReservedNamespacePrefix(prefix))
}
if seen_namespace_prefixes.contains(prefix) {
errors.push(DuplicateNamespacePrefix(prefix))
continue
}
seen_namespace_prefixes.push(prefix)
try validate_ncname(prefix, fn(n) { InvalidAttrName(n) }) catch {
e => errors.push(e)
} noraise {
_ => ()
}
}
}