Items written in Markdown show formatted (#284)

A description with Markdown and no HTML tag is rendered to HTML with
pulldown-cmark, and an item with no text but RSS's <source:markdown>
(https://source.scripting.com/, as Scripting News sends beside its HTML) uses
that. HTML is left alone, and plain text without Markdown in it stays as it
was, so a line like "1984. ..." is not made a list. ammonia cleans the result
as any feed's HTML. Stored items keep what they have: the scanner does not
rewrite an item it has.

Reference: http://scripting.com/2026/10/06/005559.html

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 18:05:05 +00:00
parent f9246129f2
commit 3a8d564eb7
6 changed files with 91 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ anyhow.workspace = true
atom_syndication.workspace = true
chrono.workspace = true
opml.workspace = true
pulldown-cmark.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true

View File

@@ -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>&lt;p&gt;HTML **as sent**&lt;/p&gt;</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>