Web

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

#rss#atom#feeds#blog#export

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 error

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.

atom_feed(feed: any, entries: list) → stror an error

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.

json_feed(feed: any, items: list) → anyor an error

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.

Get it

$ quillo install rss_write               # rss_write.qwer into this folder$ quillo install rss_write --example     # and its example, rss_write.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/rss_write.qwer$ curl -O https://quillolang.org/plugins/files/rss_write.qwe

Then, in your program, all of it or only what you call:

main.qwe
use rss_write.qweruse rss_feed, atom_feed from rss_write      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[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"] }

Example

rss_write.qwe
// 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_write.qwer
//! 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,    }))}
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On Feeds out: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.