///|
const JSON_FEED_1_0 = "https://jsonfeed.org/version/1"
///|
const JSON_FEED_1_1 = "https://jsonfeed.org/version/1.1"
///|
fn add_json_diagnostic(
diagnostics : Array[Diagnostic],
item_index : Int32?,
field : String,
) -> Unit {
let kind = match item_index {
Some(index) => InvalidJsonFeedItem(index, field)
None => InvalidJsonFeedField(field)
}
// The core JSON DOM deliberately does not retain field spans. JSON semantic
// diagnostics therefore point at the document root; JSON syntax errors keep
// their parser-supplied location in `ParseError::InvalidJson`.
diagnostics.push({ kind, line: 1, column: 1, })
}
///|
fn json_field(object : Map[String, Json], name : String) -> Json? {
object.get(name)
}
///|
fn json_string(value : Json) -> String? {
match value {
String(text) => Some(text)
_ => None
}
}
///|
fn json_item_identifier(value : Json, version : String) -> String? {
match value {
// A blank ID cannot identify an item. Keep nonblank identifiers verbatim,
// including any whitespace surrounding their meaningful text.
String(text) => non_blank_text(text)
// `stringify` uses the parsed number's source representation when one is
// available, so converting a JSON number does not silently round it.
Number(_, ..) => non_blank_text(value.stringify())
// JSON Feed 1.0 explicitly requires readers to coerce other JSON values
// to a string. JSON serialization gives these values a deterministic
// representation without silently discarding an otherwise usable item.
_ if version == JSON_FEED_1_0 => non_blank_text(value.stringify())
_ => None
}
}
///|
fn json_optional_string(
object : Map[String, Json],
name : String,
diagnostics : Array[Diagnostic],
item_index : Int32?,
) -> String? {
match json_field(object, name) {
None => None
Some(value) =>
match json_string(value) {
Some(text) => Some(text)
None => {
add_json_diagnostic(diagnostics, item_index, name)
None
}
}
}
}
///|
// Invalid date text remains available to callers; a malformed optional date
// does not discard an otherwise usable JSON Feed item.
fn json_date_field(
item : Map[String, Json],
name : String,
diagnostics : Array[Diagnostic],
index : Int32,
) -> String? {
let raw = json_optional_string(item, name, diagnostics, Some(index))
match raw {
Some(value) =>
match normalize_date(JsonFeed1, Some(value)) {
Ok(_) => ()
Err(_) => diagnostics.push({
kind: InvalidJsonFeedDate(index, name),
line: 1,
column: 1,
})
}
None => ()
}
raw
}
///|
fn json_language(
object : Map[String, Json],
diagnostics : Array[Diagnostic],
item_index : Int32?,
) -> (Bool, String?) {
match json_field(object, "language") {
None => (false, None)
Some(String(value)) =>
match non_blank_text(value) {
Some(language) => (true, Some(language))
None => {
diagnostics.push({
kind: InvalidLanguage("json feed language blank"),
line: 1,
column: 1,
})
(true, None)
}
}
Some(_) => {
add_json_diagnostic(diagnostics, item_index, "language")
(true, None)
}
}
}
///|
fn json_feed_images(
object : Map[String, Json],
diagnostics : Array[Diagnostic],
) -> Array[FeedImage] {
let images : Array[FeedImage] = []
for field in ["icon", "favicon"] {
match json_optional_string(object, field, diagnostics, None) {
Some(value) =>
match non_blank_text(value) {
Some(url) =>
images.push({
source: JsonFeed1,
role: if field == "icon" {
JsonIcon
} else {
JsonFavicon
},
url,
title: None,
link: None,
description: None,
width: None,
height: None,
})
None =>
diagnostics.push({
kind: InvalidFeedImage("json feed " + field + " missing URL"),
line: 1,
column: 1,
})
}
None => ()
}
}
images
}
///|
fn json_hubs(
object : Map[String, Json],
diagnostics : Array[Diagnostic],
) -> Array[Hub] {
let hubs : Array[Hub] = []
match json_field(object, "hubs") {
None => ()
Some(Array(values)) =>
for value in values {
match value {
Object(hub) => {
let transport_type = match
json_optional_string(hub, "type", diagnostics, None) {
Some(raw) => non_blank_text(raw)
None => None
}
let url = match
json_optional_string(hub, "url", diagnostics, None) {
Some(raw) => non_blank_text(raw)
None => None
}
match (transport_type, url) {
(Some(transport_type), Some(url)) =>
hubs.push({ transport_type, url, })
_ => add_json_diagnostic(diagnostics, None, "hubs entry")
}
}
_ => add_json_diagnostic(diagnostics, None, "hubs entry")
}
}
Some(_) => add_json_diagnostic(diagnostics, None, "hubs")
}
hubs
}
///|
fn json_item_url(
item : Map[String, Json],
field : String,
diagnostics : Array[Diagnostic],
index : Int32,
) -> String? {
match json_optional_string(item, field, diagnostics, Some(index)) {
None => None
Some(value) =>
match non_blank_text(value) {
Some(url) => Some(url)
None => {
add_json_diagnostic(diagnostics, Some(index), field)
None
}
}
}
}
///|
fn json_entry_images(
item : Map[String, Json],
diagnostics : Array[Diagnostic],
index : Int32,
) -> Array[EntryImage] {
let images : Array[EntryImage] = []
for field in ["image", "banner_image"] {
match json_item_url(item, field, diagnostics, index) {
Some(url) =>
images.push({
source: JsonFeed1,
role: if field == "image" {
JsonFeatured
} else {
JsonBanner
},
url,
})
None => ()
}
}
images
}
///|
fn json_author(
value : Json,
diagnostics : Array[Diagnostic],
item_index : Int32?,
field : String,
) -> Author? {
match value {
Object(object) => {
let name = match
json_optional_string(object, "name", diagnostics, item_index) {
// A declared plain-text name is an opaque string, including an empty
// one. Its presence also satisfies the author's required member.
Some(value) => Some(value)
None => None
}
let uri = match
json_optional_string(object, "url", diagnostics, item_index) {
Some(value) => non_blank_text(value)
None => None
}
let avatar = match
json_optional_string(object, "avatar", diagnostics, item_index) {
Some(value) => non_blank_text(value)
None => None
}
match (name, uri, avatar) {
(None, None, None) => {
diagnostics.push({
kind: InvalidAuthor("json feed author missing name or url"),
line: 1,
column: 1,
})
None
}
_ => Some({ source: JsonFeed, name, uri, email: None, avatar, })
}
}
_ => {
add_json_diagnostic(diagnostics, item_index, field)
None
}
}
}
///|
/// Returns whether the version's author spelling was declared. JSON Feed 1.0
/// uses `author`; 1.1 prefers `authors` over its deprecated `author`. An empty
/// `authors` array is intentional and therefore suppresses legacy `author`.
fn json_authors(
object : Map[String, Json],
version : String,
diagnostics : Array[Diagnostic],
item_index : Int32?,
) -> (Bool, Array[Author]) {
let plural = if version == JSON_FEED_1_1 {
json_field(object, "authors")
} else {
None
}
match plural {
Some(Array(values)) => {
let authors : Array[Author] = []
for value in values {
match json_author(value, diagnostics, item_index, "authors") {
Some(author) => authors.push(author)
None => ()
}
}
(true, authors)
}
Some(_) => {
add_json_diagnostic(diagnostics, item_index, "authors")
(true, [])
}
None =>
match json_field(object, "author") {
Some(value) => {
let authors : Array[Author] = []
match json_author(value, diagnostics, item_index, "author") {
Some(author) => authors.push(author)
None => ()
}
(true, authors)
}
None => (false, [])
}
}
}
///|
fn json_tags(
object : Map[String, Json],
diagnostics : Array[Diagnostic],
item_index : Int32,
) -> Array[Category] {
let categories : Array[Category] = []
match json_field(object, "tags") {
None => ()
Some(Array(tags)) =>
for tag in tags {
match json_string(tag) {
Some(text) =>
categories.push({
source: JsonFeed1,
term: text,
domain: None,
scheme: None,
label: None,
})
None => add_json_diagnostic(diagnostics, Some(item_index), "tags")
}
}
Some(_other) => add_json_diagnostic(diagnostics, Some(item_index), "tags")
}
categories
}
///|
fn json_attachment_problem(
diagnostics : Array[Diagnostic],
item_index : Int32,
attachment_index : Int32,
field : String,
) -> Unit {
// Core JSON values have no semantic source span; see add_json_diagnostic.
diagnostics.push({
kind: InvalidAttachment(
"json feed item " +
item_index.to_string() +
" attachment " +
attachment_index.to_string() +
": " +
field,
),
line: 1,
column: 1,
})
}
///|
/// JSON numbers may use a fractional part and an exponent even when their
/// mathematical value is an integer. Use the source digits, not the parsed
/// Double, so values beyond 2^53-1 cannot round into the accepted range.
fn json_attachment_size(raw : String) -> Int64? {
if raw.has_prefix("-") {
return None
}
let digits : Array[Int32] = []
let mut fractional = false
let mut fraction_digits = 0
let mut exponent_part = false
let mut negative_exponent = false
let exponent_digits : Array[Int32] = []
for c in raw {
if c == 'e' || c == 'E' {
exponent_part = true
} else if exponent_part {
if c == '-' {
negative_exponent = true
} else if c != '+' {
exponent_digits.push(c.to_int() - 48)
}
} else if c == '.' {
fractional = true
} else {
digits.push(c.to_int() - 48)
if fractional {
fraction_digits += 1
}
}
}
// An exponent larger than the mantissa length plus the safe integer digit
// count can only yield zero (zero mantissa) or an unusable byte count.
let limit = digits.length() + 16
let mut exponent = 0
for digit in exponent_digits {
if exponent > (limit - digit) / 10 {
exponent = limit + 1
break
}
exponent = exponent * 10 + digit
}
if negative_exponent {
exponent = -exponent
}
let shift = exponent - fraction_digits
let mut last = digits.length()
if shift < 0 {
last += shift
if last < 0 {
last = 0
}
for i = last; i < digits.length(); i = i + 1 {
if digits[i] != 0 {
return None
}
}
}
let mut value = 0L
for i = 0; i < last; i = i + 1 {
let digit = digits[i].to_int64()
if value > (9007199254740991L - digit) / 10L {
return None
}
value = value * 10L + digit
}
if shift > 0 {
if value == 0L {
return Some(0L)
}
for zero = 0; zero < shift; zero = zero + 1 {
if value > 900719925474099L {
return None
}
value = value * 10L
}
}
Some(value)
}
///|
fn json_attachment(
value : Json,
diagnostics : Array[Diagnostic],
item_index : Int32,
attachment_index : Int32,
) -> Attachment? {
let object = match value {
Object(object) => object
_ => {
json_attachment_problem(
diagnostics, item_index, attachment_index, "object",
)
return None
}
}
let url = match json_field(object, "url") {
Some(String(value)) => non_blank_text(value)
_ => None
}
let media_type = match json_field(object, "mime_type") {
Some(String(value)) => non_blank_text(value)
_ => None
}
if url is None {
json_attachment_problem(diagnostics, item_index, attachment_index, "url")
return None
}
// mime_type is required for publishers, but a reader can still expose the
// attachment URL and metadata when it is missing or unusable.
if media_type is None {
json_attachment_problem(
diagnostics, item_index, attachment_index, "mime_type",
)
} else if atom_mime_essence(media_type.unwrap()) is None {
json_attachment_problem(
diagnostics, item_index, attachment_index, "invalid mime_type",
)
}
let title = match json_field(object, "title") {
None => None
Some(String(value)) => Some(value)
Some(_) => {
json_attachment_problem(
diagnostics, item_index, attachment_index, "title",
)
None
}
}
let size = match json_field(object, "size_in_bytes") {
None => None
Some(number) =>
match number {
Number(_, ..) =>
match json_attachment_size(number.stringify()) {
Some(value) => Some(value)
None => {
json_attachment_problem(
diagnostics, item_index, attachment_index, "size_in_bytes",
)
None
}
}
_ => {
json_attachment_problem(
diagnostics, item_index, attachment_index, "size_in_bytes",
)
None
}
}
}
let duration = match json_field(object, "duration_in_seconds") {
None => None
Some(number) =>
match number {
Number(value, ..) if value >= 0.0 && !value.is_inf() && !value.is_nan() =>
Some(value)
_ => {
json_attachment_problem(
diagnostics, item_index, attachment_index, "duration_in_seconds",
)
None
}
}
}
Some({
source: JsonFeed1,
url: url.unwrap(),
media_type,
size_in_bytes: size,
title,
duration_in_seconds: duration,
})
}
///|
fn json_attachments(
item : Map[String, Json],
diagnostics : Array[Diagnostic],
item_index : Int32,
) -> Array[Attachment] {
let attachments : Array[Attachment] = []
match json_field(item, "attachments") {
None => ()
Some(Array(values)) =>
for index = 0; index < values.length(); index = index + 1 {
match json_attachment(values[index], diagnostics, item_index, index) {
Some(attachment) => attachments.push(attachment)
None => ()
}
}
Some(_) =>
json_attachment_problem(diagnostics, item_index, 0, "attachments array")
}
attachments
}
///|
fn json_parse_error_location(error : @json.ParseError) -> (Int32, Int32) {
match error {
@json.InvalidChar({ line, column, }, _) => (line, column + 1)
@json.InvalidNumber({ line, column, }, _) => (line, column + 1)
@json.InvalidIdentEscape({ line, column, }) => (line, column + 1)
_ => (1, 1)
}
}
///|
fn parse_json_feed_value(root : Json) -> Result[Feed, ParseError] {
let object = match root {
Object(object) => object
_ => return Err(InvalidJsonFeed("JSON Feed root must be an object"))
}
let version = match json_field(object, "version") {
Some(String(version)) => version
Some(_) => return Err(InvalidJsonFeed("JSON Feed version must be a string"))
None => return Err(InvalidJsonFeed("JSON Feed version is required"))
}
if version != JSON_FEED_1_0 && version != JSON_FEED_1_1 {
return Err(UnsupportedJsonFeedVersion(Some(version)))
}
let title = match json_field(object, "title") {
Some(String(title)) => title
Some(_) => return Err(InvalidJsonFeed("JSON Feed title must be a string"))
None => return Err(InvalidJsonFeed("JSON Feed title is required"))
}
let items = match json_field(object, "items") {
Some(Array(items)) => items
Some(_) => return Err(InvalidJsonFeed("JSON Feed items must be an array"))
None => return Err(InvalidJsonFeed("JSON Feed items are required"))
}
let diagnostics : Array[Diagnostic] = []
let description = json_optional_string(
object,
"description",
diagnostics,
None,
)
let link = json_optional_string(object, "home_page_url", diagnostics, None)
let identifier = json_optional_string(object, "feed_url", diagnostics, None)
let next_url = match
json_optional_string(object, "next_url", diagnostics, None) {
Some(url) if Some(url) == identifier => {
add_json_diagnostic(diagnostics, None, "next_url equals feed_url")
None
}
value => value
}
let expired = match json_field(object, "expired") {
Some(True) => Some(true)
Some(False) => Some(false)
Some(_) => {
add_json_diagnostic(diagnostics, None, "expired")
None
}
None => None
}
let feed_language = if version == JSON_FEED_1_1 {
json_language(object, diagnostics, None).1
} else {
None
}
let (_, feed_authors) = json_authors(object, version, diagnostics, None)
let images = json_feed_images(object, diagnostics)
let hubs = json_hubs(object, diagnostics)
let entries : Array[Entry] = []
let seen_identifiers : Array[String] = []
for index = 0; index < items.length(); index = index + 1 {
let item = match items[index] {
Object(item) => item
_ => {
add_json_diagnostic(diagnostics, Some(index), "item")
continue
}
}
let identifier = match json_field(item, "id") {
Some(value) =>
match json_item_identifier(value, version) {
Some(identifier) => identifier
None => {
add_json_diagnostic(diagnostics, Some(index), "id")
continue
}
}
None => {
add_json_diagnostic(diagnostics, Some(index), "id")
continue
}
}
let html = json_optional_string(
item,
"content_html",
diagnostics,
Some(index),
)
let text = json_optional_string(
item,
"content_text",
diagnostics,
Some(index),
)
let (content, content_kind) = match html {
Some(html) => (Some(html), Html)
None =>
match text {
Some(text) => (Some(text), Text)
None => {
add_json_diagnostic(diagnostics, Some(index), "content")
continue
}
}
}
let (declared_authors, item_authors) = json_authors(
item,
version,
diagnostics,
Some(index),
)
let authors = if declared_authors {
item_authors
} else {
copy_authors(feed_authors)
}
let language = if version == JSON_FEED_1_1 {
let (declared, item_language) = json_language(
item,
diagnostics,
Some(index),
)
if declared {
item_language
} else {
feed_language
}
} else {
None
}
// Compare usable IDs exactly as exposed to callers, after the specified
// number-to-string recovery. An item discarded for invalid content does
// not occupy an identity in the resulting feed.
if seen_identifiers.any(fn(previous) { previous == identifier }) {
add_json_diagnostic(diagnostics, Some(index), "duplicate id")
} else {
seen_identifiers.push(identifier)
}
entries.push({
identifier: Some(identifier),
title: json_optional_string(item, "title", diagnostics, Some(index)),
title_kind: Text,
title_xhtml: None,
link: json_optional_string(item, "url", diagnostics, Some(index)),
external_url: json_item_url(item, "external_url", diagnostics, index),
language,
description: json_optional_string(
item,
"summary",
diagnostics,
Some(index),
),
description_kind: Text,
description_xhtml: None,
content,
content_text: text,
content_html: html,
content_kind,
content_xhtml: None,
content_source: None,
content_media_type: None,
atom_body: None,
atom_source: None,
links: [],
comments_url: None,
source_url: None,
source_title: None,
guid_is_permalink: None,
rights: None,
rights_kind: Unspecified,
rights_xhtml: None,
authors,
contributors: [],
categories: json_tags(item, diagnostics, index),
attachments: json_attachments(item, diagnostics, index),
images: json_entry_images(item, diagnostics, index),
published_at: json_date_field(item, "date_published", diagnostics, index),
updated_at: json_date_field(item, "date_modified", diagnostics, index),
})
}
Ok({
format: JsonFeed1,
identifier,
title: Some(title),
title_kind: Text,
title_xhtml: None,
link,
language: feed_language,
description,
description_kind: Text,
description_xhtml: None,
updated_at: None,
published_at: None,
ttl_minutes: None,
docs_url: None,
rating: None,
cloud: None,
text_input: None,
skip_hours: [],
skip_days: [],
rights: None,
rights_kind: Unspecified,
rights_xhtml: None,
generator: None,
generator_uri: None,
generator_version: None,
managing_editor: None,
webmaster: None,
next_url,
expired,
hubs,
links: [],
authors: feed_authors,
contributors: [],
categories: [],
images,
entries,
diagnostics,
})
}
///|
fn parse_json_feed(input : String) -> Result[Feed, ParseError] {
parse_json_feed_value(@json.parse(input)) catch {
error => {
let (line, column) = json_parse_error_location(error)
Err(InvalidJson(error.to_string(), line, column))
}
}
}