///|
/// Parsing is atomic: an invalid supported field rejects the entire feed.
pub(all) suberror ParseError {
InvalidJson(String)
MissingField(String)
InvalidType(String, String)
UnsupportedVersion(String)
InvalidValue(String, String)
} derive(Eq, @debug.Debug)
///|
fn object(value : Json, path : String) -> Map[String, Json] raise ParseError {
match value {
Object(fields) => fields
_ => raise InvalidType(path, "object")
}
}
///|
fn required(
fields : Map[String, Json],
key : String,
path : String,
) -> Json raise ParseError {
match fields.get(key) {
Some(value) => value
None => raise MissingField(path + "." + key)
}
}
///|
fn string(value : Json, path : String) -> String raise ParseError {
match value {
String(value) => value
_ => raise InvalidType(path, "string")
}
}
///|
fn required_string(
fields : Map[String, Json],
key : String,
path : String,
) -> String raise ParseError {
string(required(fields, key, path), path + "." + key)
}
///|
fn optional_string(
fields : Map[String, Json],
key : String,
path : String,
) -> String? raise ParseError {
match fields.get(key) {
None => None
Some(value) => Some(string(value, path + "." + key))
}
}
///|
fn array(value : Json, path : String) -> Array[Json] raise ParseError {
match value {
Array(values) => values
_ => raise InvalidType(path, "array")
}
}
///|
fn authors(
fields : Map[String, Json],
path : String,
) -> Array[@model.Author]? raise ParseError {
match fields.get("authors") {
None => None
Some(value) => {
let result : Array[@model.Author] = []
let values = array(value, path + ".authors")
for index, entry in values {
let author_path = path + ".authors[\{index}]"
let entry = object(entry, author_path)
let name = optional_string(entry, "name", author_path)
let url = optional_string(entry, "url", author_path)
let avatar = optional_string(entry, "avatar", author_path)
if name is None && url is None && avatar is None {
raise InvalidValue(author_path, "author needs name, url or avatar")
}
result.push({ name, url, avatar, })
}
Some(result)
}
}
}
///|
fn tags(
fields : Map[String, Json],
path : String,
) -> Array[String] raise ParseError {
let result = []
if fields.get("tags") is Some(value) {
for index, tag in array(value, path + ".tags") {
result.push(string(tag, path + ".tags[\{index}]"))
}
}
result
}
///|
fn item(
value : Json,
path : String,
feed_authors : Array[@model.Author],
feed_language : String?,
) -> @model.FeedItem raise ParseError {
let fields = object(value, path)
let id = required_string(fields, "id", path)
let published = optional_string(fields, "date_published", path)
let updated = optional_string(fields, "date_modified", path)
let published_at = @datetime.parse_optional_datetime(published)
let updated_at = @datetime.parse_optional_datetime(updated)
if id == "" {
raise InvalidValue(path + ".id", "id must not be empty")
}
let content_text = optional_string(fields, "content_text", path)
let content_html = optional_string(fields, "content_html", path)
if content_text is None && content_html is None {
raise MissingField(path + ".content_text|content_html")
}
let item_authors = match authors(fields, path) {
Some(value) => value
None => feed_authors.copy()
}
let language = match optional_string(fields, "language", path) {
Some(value) => Some(value)
None => feed_language
}
{
id,
title: optional_string(fields, "title", path),
url: optional_string(fields, "url", path),
external_url: optional_string(fields, "external_url", path),
authors: item_authors,
summary: optional_string(fields, "summary", path),
content_text,
content_html,
published,
published_at,
updated,
updated_at,
language,
categories: tags(fields, path),
attachments: [],
}
}
///|
/// Decode JSON Feed 1.1 into the unified model. No network or filesystem access.
/// Unsupported fields are ignored. Date strings are preserved and parsed best-effort; URLs are retained without validation.
pub fn parse(input : String) -> @model.Feed raise ParseError {
let value = @json.parse(input) catch {
error => raise InvalidJson(error.to_string())
}
let fields = object(value, "$")
let version = required_string(fields, "version", "$")
if version != "https://jsonfeed.org/version/1.1" {
raise UnsupportedVersion(version)
}
let title = required_string(fields, "title", "$")
let feed_authors = authors(fields, "$").unwrap_or([])
let language = optional_string(fields, "language", "$")
let items = []
let ids : Map[String, Bool] = Map([])
for index, value in array(required(fields, "items", "$"), "$.items") {
let path = "$.items[\{index}]"
let parsed = item(value, path, feed_authors, language)
if ids.contains(parsed.id) {
raise InvalidValue(path + ".id", "duplicate item id")
}
ids.set(parsed.id, true)
items.push(parsed)
}
{
title,
description: optional_string(fields, "description", "$"),
home_page_url: optional_string(fields, "home_page_url", "$"),
feed_url: optional_string(fields, "feed_url", "$"),
language,
authors: feed_authors,
categories: [],
updated: None,
updated_at: None,
items,
}
}