Feeds out
RSS 2.0, Atom and JSON Feed written from your posts, with dates, authors, categories and full HTML content escaped correctly.
Version 1.0.0 · 3 functions · 3 crates
Functions
What use rss_write.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use rss_feed, atom_feed from rss_write (Quillo 0.6).
rss_feed(channel: any, items: list) → stror an errorAn RSS 2.0 feed as XML. channel is { title, link, description, language, self_url } (self_url the feed's own address); each item is { title, link, description, content_html, date, id, author, categories }: date as 2026-10-01T09:30:00Z, id (default the link) a stable identifier, author an email address ("ada@example.com (Ada)"), content_html the full post. Serve it as application/rss+xml.
atom_feed(feed: any, entries: list) → stror an errorAn Atom feed as XML. feed is { title, link, self_url, id, subtitle, author } (id defaults to link; author a name or { name, email, uri }); entries are as rss_feed's items, with updated (default date) and summary (default description). Serve it as application/atom+xml.
json_feed(feed: any, items: list) → anyor an errorA JSON Feed 1.1 (for readers that take JSON) as a map: the same feed and items as atom_feed. Serve it as application/feed+json.
Get it
$ quillo install rss_write # rss_write.qwer into this folder$ quillo install rss_write --example # and its example, rss_write.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/rss_write.qwer$ curl -O https://quillolang.org/plugins/files/rss_write.qweThen, in your program, all of it or only what you call:
use rss_write.qweruse rss_feed, atom_feed from rss_write // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
rss = { version = "2.1", default-features = false, features = ["atom"] }atom_syndication = { version = "0.12", default-features = false }chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }Example
// A blog's feeds, in RSS, Atom and JSON Feed, from its posts.//// quillo run rss_write.qwe then open http://localhost:8080/feed.xmluse "rss_write.qwer"let SITE = "https://blog.example.com"let posts = [ { slug: "fast-pages", title: "Fast pages & small bundles", date: "2026-10-12T08:00:00Z", summary: "What we cut, and what it bought us.", body: "<p>We removed <b>40 KB</b> of JavaScript.</p>", tags: ["performance"] }, { slug: "hello", title: "Hello, world", date: "2026-09-01", summary: "The first post.", body: "<p>It begins.</p>", tags: ["news"] },]fn items() = posts | map((p) => { title: p.title, link: `${SITE}/posts/${p.slug}`, description: p.summary, content_html: p.body, date: p.date, author: "ada@example.com (Ada)", categories: p.tags })let channel = { title: "Example blog", link: SITE, description: "Notes on building the web", language: "en", subtitle: "Notes on building the web", author: "Ada" }route GET /feed.xml = respond(200, rss_feed(merge(channel, { self_url: `${SITE}/feed.xml` }), items())?, { "content-type": "application/rss+xml; charset=utf-8" })route GET /atom.xml = respond(200, atom_feed(merge(channel, { self_url: `${SITE}/atom.xml` }), items())?, { "content-type": "application/atom+xml; charset=utf-8" })route GET /feed.json = respond(200, json.stringify(json_feed(merge(channel, { self_url: `${SITE}/feed.json` }), items())?), { "content-type": "application/feed+json" })$ quillo run rss_write.qwe$ curl 'localhost:8080/feed.xml'$ curl 'localhost:8080/atom.xml'$ curl 'localhost:8080/feed.json'Source
//! RSS 2.0, Atom and JSON Feed written from your posts: a blog's, a changelog's or a podcast-less news page's feed, with dates, authors, categories and full HTML content, escaped correctly.//! version: 1.0.0//!//! [dependencies]//! rss = { version = "2.1", default-features = false, features = ["atom"] }//! atom_syndication = { version = "0.12", default-features = false }//! chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone, Utc};use serde_json::{json, Value};const MAX_ITEMS: usize = 10_000;/// A date from `2026-10-01T09:30:00Z`, `2026-10-01T09:30:00+02:00`, `2026-10-01 09:30:00` (UTC) or `2026-10-01` (midnight UTC); "" or none is now.fn date(v: &Value) -> Result<DateTime<FixedOffset>, String> { let s = v.as_str().unwrap_or("").trim(); if s.is_empty() { return Ok(Utc::now().fixed_offset()); } if let Ok(d) = DateTime::parse_from_rfc3339(s) { return Ok(d); } if let Ok(d) = DateTime::parse_from_rfc2822(s) { return Ok(d); } if let Ok(d) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return Ok(Utc.from_utc_datetime(&d).fixed_offset()); } if let Ok(d) = NaiveDate::parse_from_str(&s[..s.len().min(10)], "%Y-%m-%d") { return Ok(Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap_or_default()).fixed_offset()); } Err(format!("`{}` is not a date like 2026-10-01T09:30:00Z", s))}fn s(v: &Value, k: &str) -> Option<String> { v.get(k).and_then(|x| match x { Value::String(s) if !s.is_empty() => Some(s.clone()), Value::Number(n) => Some(n.to_string()), _ => None, })}fn req(v: &Value, k: &str, what: &str) -> Result<String, String> { s(v, k).ok_or(format!("the {} has no `{}`", what, k))}fn cats(v: &Value) -> Vec<String> { v.get("categories").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()}fn check(items: &[Value]) -> Result<(), String> { if items.len() > MAX_ITEMS { return Err("at most 10,000 items in a feed".into()); } Ok(())}/// An RSS 2.0 feed as XML. `channel` is `{ title, link, description, language, self_url }` (`self_url` the feed's own address);/// each item is `{ title, link, description, content_html, date, id, author, categories }`: `date` as `2026-10-01T09:30:00Z`, `id` (default the link) a stable identifier,/// `author` an email address ("ada@example.com (Ada)"), `content_html` the full post. Serve it as `application/rss+xml`.pub fn rss_feed(channel: serde_json::Value, items: Vec<serde_json::Value>) -> Result<String, String> { check(&items)?; let mut out = Vec::new(); let mut newest: Option<DateTime<FixedOffset>> = None; for it in &items { let d = date(it.get("date").unwrap_or(&Value::Null))?; newest = Some(newest.map_or(d, |n| n.max(d))); let link = s(it, "link"); let id = s(it, "id").or_else(|| link.clone()); out.push(rss::Item { title: s(it, "title"), link: link.clone(), description: s(it, "description"), content: s(it, "content_html"), author: s(it, "author"), pub_date: Some(d.to_rfc2822()), guid: id.map(|v| rss::Guid { permalink: Some(&v) == link.as_ref(), value: v }), categories: cats(it).into_iter().map(|c| rss::Category { name: c, domain: None }).collect(), ..Default::default() }); } let mut ch = rss::Channel { title: req(&channel, "title", "channel")?, link: req(&channel, "link", "channel")?, description: s(&channel, "description").unwrap_or_default(), language: s(&channel, "language"), last_build_date: newest.map(|d| d.to_rfc2822()), generator: Some("Quillo".into()), items: out, ..Default::default() }; if let Some(me) = s(&channel, "self_url") { ch.namespaces.insert("atom".into(), "http://www.w3.org/2005/Atom".into()); ch.atom_ext = Some(rss::extension::atom::AtomExtension { links: vec![atom_syndication::Link { href: me, rel: "self".into(), mime_type: Some("application/rss+xml".into()), ..Default::default() }], }); } Ok(ch.to_string())}/// An Atom feed as XML. `feed` is `{ title, link, self_url, id, subtitle, author }` (`id` defaults to `link`; `author` a name or `{ name, email, uri }`);/// entries are as `rss_feed`'s items, with `updated` (default `date`) and `summary` (default `description`). Serve it as `application/atom+xml`.pub fn atom_feed(feed: serde_json::Value, entries: Vec<serde_json::Value>) -> Result<String, String> { check(&entries)?; let person = |v: Option<&Value>| -> Option<atom_syndication::Person> { match v? { // RSS's "ada@example.com (Ada)" is read as an email and a name. Value::String(n) => match n.split_once(" (") { Some((email, name)) if email.contains('@') => Some(atom_syndication::Person { name: name.trim_end_matches(')').to_string(), email: Some(email.to_string()), ..Default::default() }), _ => Some(atom_syndication::Person { name: n.clone(), ..Default::default() }), }, o @ Value::Object(_) => Some(atom_syndication::Person { name: s(o, "name")?, email: s(o, "email"), uri: s(o, "uri"), ..Default::default() }), _ => None, } }; let mut out = Vec::new(); let mut newest: Option<DateTime<FixedOffset>> = None; for e in &entries { let published = date(e.get("date").unwrap_or(&Value::Null))?; let updated = match e.get("updated") { Some(u) => date(u)?, None => published, }; newest = Some(newest.map_or(updated, |n| n.max(updated))); let link = s(e, "link"); out.push(atom_syndication::Entry { title: atom_syndication::Text::plain(s(e, "title").unwrap_or_default()), id: s(e, "id").or_else(|| link.clone()).ok_or("an entry needs an `id` or a `link`")?, updated, published: Some(published), authors: person(e.get("author")).into_iter().collect(), links: link.map(|l| atom_syndication::Link { href: l, rel: "alternate".into(), mime_type: Some("text/html".into()), ..Default::default() }).into_iter().collect(), summary: s(e, "summary").or_else(|| s(e, "description")).map(atom_syndication::Text::plain), content: s(e, "content_html").map(|h| atom_syndication::Content { value: Some(h), content_type: Some("html".into()), ..Default::default() }), categories: cats(e).into_iter().map(|c| atom_syndication::Category { term: c, scheme: None, label: None }).collect(), ..Default::default() }); } let link = req(&feed, "link", "feed")?; let mut links = vec![atom_syndication::Link { href: link.clone(), rel: "alternate".into(), mime_type: Some("text/html".into()), ..Default::default() }]; if let Some(me) = s(&feed, "self_url") { links.push(atom_syndication::Link { href: me, rel: "self".into(), mime_type: Some("application/atom+xml".into()), ..Default::default() }); } let f = atom_syndication::Feed { title: atom_syndication::Text::plain(req(&feed, "title", "feed")?), id: s(&feed, "id").unwrap_or_else(|| link.clone()), updated: newest.unwrap_or_else(|| Utc::now().fixed_offset()), authors: person(feed.get("author")).into_iter().collect(), subtitle: s(&feed, "subtitle").map(atom_syndication::Text::plain), links, entries: out, ..Default::default() }; Ok(f.to_string())}/// A JSON Feed 1.1 (for readers that take JSON) as a map: the same `feed` and `items` as `atom_feed`. Serve it as `application/feed+json`.pub fn json_feed(feed: serde_json::Value, items: Vec<serde_json::Value>) -> Result<Value, String> { check(&items)?; let mut out = Vec::new(); for it in &items { let d = date(it.get("date").unwrap_or(&Value::Null))?; let link = s(it, "link"); let mut m = json!({ "id": s(it, "id").or_else(|| link.clone()).ok_or("an item needs an `id` or a `link`")?, "url": link, "title": s(it, "title"), "summary": s(it, "summary").or_else(|| s(it, "description")), "date_published": d.to_rfc3339(), "tags": cats(it), }); match s(it, "content_html") { Some(h) => m["content_html"] = json!(h), None => m["content_text"] = json!(s(it, "description").unwrap_or_default()), } out.push(m); } Ok(json!({ "version": "https://jsonfeed.org/version/1.1", "title": req(&feed, "title", "feed")?, "home_page_url": s(&feed, "link"), "feed_url": s(&feed, "self_url"), "description": s(&feed, "subtitle").or_else(|| s(&feed, "description")), "items": out, }))}Related
opml.qwer · 1.0.0OPML outlines read and written: the subscription lists that feed readers and podcast apps import and export.8 views · 8 downloadsFeeds feed.qwer · 1.0.0RSS, Atom and JSON Feed read as one shape: news on a dashboard, a podcast's episodes, a blogroll.9 views · 7 downloadsHTML rewriting html_rewrite.qwer · 1.0.0HTML rewritten as it streams, by CSS selector: links made absolute, attributes set or removed, elements removed or unwrapped, text replaced.8 views · 6 downloadsMinify HTML minify_html.qwer · 1.0.0HTML made smaller for sending: spaces, comments and optional tags out, and the CSS and JavaScript inside minified too.5 views · 9 downloadsThread 0 posts
On Feeds out: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.