|
|
|
|
@@ -767,8 +767,9 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|
|
|
|
title: title_text(item.title()),
|
|
|
|
|
link: non_empty(item.link()),
|
|
|
|
|
published: item.pub_date().and_then(parse_date),
|
|
|
|
|
// Content wins over description, as __getEntries preferred entry.content.
|
|
|
|
|
description: body(item.content(), item.description()),
|
|
|
|
|
// Content wins over description, as __getEntries preferred entry.content. The
|
|
|
|
|
// source namespace's Markdown copy only when there is neither (#284).
|
|
|
|
|
description: notes(item.content(), item.description(), source_markdown(item)),
|
|
|
|
|
categories: item
|
|
|
|
|
.categories()
|
|
|
|
|
.iter()
|
|
|
|
|
@@ -837,7 +838,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|
|
|
|
title: title_text(Some(e.title().as_str())),
|
|
|
|
|
link: alt.map(str::to_owned),
|
|
|
|
|
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
|
|
|
|
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
|
|
|
|
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
|
|
|
|
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
|
|
|
|
explicit: false,
|
|
|
|
|
image: None,
|
|
|
|
|
@@ -936,6 +937,42 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|
|
|
|
non_empty(Some(&out))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
|
|
|
|
|
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
|
|
|
|
|
fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
|
|
|
|
|
match body(content, description) {
|
|
|
|
|
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
|
|
|
|
|
Some(b) => Some(b),
|
|
|
|
|
None => non_empty(markdown).map(|m| markdown_html(&m)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
|
|
|
|
|
///
|
|
|
|
|
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
|
|
|
|
|
/// it to another prefix is not read, until one turns up.
|
|
|
|
|
fn source_markdown(item: &rss::Item) -> Option<&str> {
|
|
|
|
|
item.extensions().get("source")?.get("markdown")?.first()?.value()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
|
|
|
|
|
/// Plain text without any is left as it was, since rendering it would change nothing but
|
|
|
|
|
/// could still read a line starting "1984." as a list.
|
|
|
|
|
fn is_markdown(s: &str) -> bool {
|
|
|
|
|
let tag = s
|
|
|
|
|
.match_indices('<')
|
|
|
|
|
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
|
|
|
|
|
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
|
|
|
|
|
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn markdown_html(md: &str) -> String {
|
|
|
|
|
use pulldown_cmark::{Options, Parser, html};
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An item's show notes: its full body when that is whole, else its description.
|
|
|
|
|
///
|
|
|
|
|
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
|
|
|
|
@@ -1361,6 +1398,31 @@ mod tests {
|
|
|
|
|
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn markdown_notes_are_made_html_and_html_is_left_alone() {
|
|
|
|
|
let feed = |items: &str| {
|
|
|
|
|
let xml = format!(
|
|
|
|
|
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
|
|
|
|
|
);
|
|
|
|
|
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
let got = feed(
|
|
|
|
|
r#"<item><guid>a</guid><description># Hi
|
|
|
|
|
|
|
|
|
|
Some **bold** and [a link](https://x.example/).</description></item>
|
|
|
|
|
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
|
|
|
|
|
<item><guid>c</guid><description><p>HTML **as sent**</p></description><source:markdown>ignored</source:markdown></item>
|
|
|
|
|
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
got[0].as_deref(),
|
|
|
|
|
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
|
|
|
|
|
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
|
|
|
|
|
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
|
|
|
|
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
|
|
|
|
|