use std::io::Cursor;
use chrono::NaiveDate;
use quick_xml::Writer;
use quick_xml::events::{BytesDecl, BytesText};
use super::RootArtifact;
use crate::content::Site;
use crate::content::frontmatter::BlogFrontmatter;
use crate::content::page::{Page, PageKind};
use crate::markdown::{Rendered, RenderedSite};
const ATOM_NS: &str = "http://www.w3.org/2005/Atom";
const DC_NS: &str = "http://purl.org/dc/elements/1.1/";
fn rfc3339(date: NaiveDate) -> String {
date.and_hms_opt(0, 0, 0).unwrap().and_utc().to_rfc3339()
}
fn rfc2822(date: NaiveDate) -> String {
date.and_hms_opt(0, 0, 0).unwrap().and_utc().to_rfc2822()
}
fn feed_entries<'a>(
rendered: &'a RenderedSite<'_>,
) -> Vec<(&'a Page<BlogFrontmatter>, &'a Rendered)> {
let limit = rendered.site().config.feed_limit;
let iter = rendered.blog();
if limit == 0 {
iter.collect()
} else {
iter.take(limit).collect()
}
}
pub(super) struct AtomFeed;
impl RootArtifact for AtomFeed {
fn filename(&self) -> &'static str {
"feed.xml"
}
fn render(&self, rendered: &RenderedSite<'_>) -> Vec<u8> {
tracing::debug!("generating feed.xml (Atom)");
let entries = feed_entries(rendered);
write_atom(rendered.site(), &entries)
}
}
pub(super) struct RssFeed;
impl RootArtifact for RssFeed {
fn filename(&self) -> &'static str {
"rss.xml"
}
fn render(&self, rendered: &RenderedSite<'_>) -> Vec<u8> {
tracing::debug!("generating rss.xml (RSS 2.0)");
let entries = feed_entries(rendered);
write_rss(rendered.site(), &entries)
}
}
fn write_atom(site: &Site, entries: &[(&Page<BlogFrontmatter>, &Rendered)]) -> Vec<u8> {
let feed_url: String = site.config.absolute_url("/feed.xml").into();
let site_url: String = site.config.absolute_url("/").into();
let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);
writer
.write_event(quick_xml::events::Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
None,
)))
.expect("write XML decl");
writer
.create_element("feed")
.with_attribute(("xmlns", ATOM_NS))
.write_inner_content(|w| {
w.create_element("title")
.write_text_content(BytesText::new(&site.config.title))?;
w.create_element("link")
.with_attribute(("href", feed_url.as_str()))
.with_attribute(("rel", "self"))
.with_attribute(("type", "application/atom+xml"))
.write_empty()?;
w.create_element("link")
.with_attribute(("href", site_url.as_str()))
.with_attribute(("rel", "alternate"))
.with_attribute(("type", "text/html"))
.write_empty()?;
w.create_element("id")
.write_text_content(BytesText::new(&site_url))?;
if let Some((post, _)) = entries.first() {
let date = post.frontmatter.updated.unwrap_or(post.frontmatter.created);
w.create_element("updated")
.write_text_content(BytesText::new(&rfc3339(date)))?;
}
if let Some(ref desc) = site.config.description {
w.create_element("subtitle")
.write_text_content(BytesText::new(desc))?;
}
for (post, rendered) in entries {
let url: String = site
.config
.absolute_url(&PageKind::Blog.url_path(&post.slug))
.into();
let updated = post.frontmatter.updated.unwrap_or(post.frontmatter.created);
let content = rendered.html_with_absolute_urls(&site.config);
w.create_element("entry").write_inner_content(|w| {
w.create_element("title")
.write_text_content(BytesText::new(&post.frontmatter.title))?;
w.create_element("link")
.with_attribute(("href", url.as_str()))
.with_attribute(("rel", "alternate"))
.with_attribute(("type", "text/html"))
.write_empty()?;
w.create_element("id")
.write_text_content(BytesText::new(&url))?;
w.create_element("published")
.write_text_content(BytesText::new(&rfc3339(post.frontmatter.created)))?;
w.create_element("updated")
.write_text_content(BytesText::new(&rfc3339(updated)))?;
w.create_element("author").write_inner_content(|w| {
w.create_element("name")
.write_text_content(BytesText::new(&post.frontmatter.author))?;
Ok(())
})?;
if let Some(ref desc) = post.frontmatter.description {
w.create_element("summary")
.with_attribute(("type", "text"))
.write_text_content(BytesText::new(desc))?;
}
w.create_element("content")
.with_attribute(("type", "html"))
.write_text_content(BytesText::new(&content))?;
for tag in &post.frontmatter.tags {
w.create_element("category")
.with_attribute(("term", tag.as_str()))
.write_empty()?;
}
Ok(())
})?;
}
Ok(())
})
.expect("write Atom feed");
let mut bytes = writer.into_inner().into_inner();
bytes.push(b'\n');
bytes
}
fn write_rss(site: &Site, entries: &[(&Page<BlogFrontmatter>, &Rendered)]) -> Vec<u8> {
let feed_url: String = site.config.absolute_url("/rss.xml").into();
let site_url: String = site.config.absolute_url("/").into();
let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);
writer
.write_event(quick_xml::events::Event::Decl(BytesDecl::new(
"1.0",
Some("UTF-8"),
None,
)))
.expect("write XML decl");
writer
.create_element("rss")
.with_attribute(("version", "2.0"))
.with_attribute(("xmlns:atom", ATOM_NS))
.with_attribute(("xmlns:dc", DC_NS))
.write_inner_content(|w| {
w.create_element("channel").write_inner_content(|w| {
w.create_element("title")
.write_text_content(BytesText::new(&site.config.title))?;
w.create_element("link")
.write_text_content(BytesText::new(&site_url))?;
let desc = site
.config
.description
.as_deref()
.unwrap_or(&site.config.title);
w.create_element("description")
.write_text_content(BytesText::new(desc))?;
w.create_element("atom:link")
.with_attribute(("href", feed_url.as_str()))
.with_attribute(("rel", "self"))
.with_attribute(("type", "application/rss+xml"))
.write_empty()?;
if let Some((post, _)) = entries.first() {
let date = post.frontmatter.updated.unwrap_or(post.frontmatter.created);
w.create_element("lastBuildDate")
.write_text_content(BytesText::new(&rfc2822(date)))?;
}
for (post, rendered) in entries {
let url: String = site
.config
.absolute_url(&PageKind::Blog.url_path(&post.slug))
.into();
let content = rendered.html_with_absolute_urls(&site.config);
w.create_element("item").write_inner_content(|w| {
w.create_element("title")
.write_text_content(BytesText::new(&post.frontmatter.title))?;
w.create_element("link")
.write_text_content(BytesText::new(&url))?;
w.create_element("guid")
.with_attribute(("isPermaLink", "true"))
.write_text_content(BytesText::new(&url))?;
w.create_element("pubDate")
.write_text_content(BytesText::new(&rfc2822(
post.frontmatter.created,
)))?;
w.create_element("dc:creator")
.write_text_content(BytesText::new(&post.frontmatter.author))?;
w.create_element("description")
.write_text_content(BytesText::new(&content))?;
for tag in &post.frontmatter.tags {
w.create_element("category")
.write_text_content(BytesText::new(tag))?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})
.expect("write RSS feed");
let mut bytes = writer.into_inner().into_inner();
bytes.push(b'\n');
bytes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::Slug;
use chrono::NaiveDate;
use std::collections::HashMap;
use std::path::PathBuf;
fn rendered_map(pairs: Vec<(&str, Rendered)>) -> HashMap<Slug, Rendered> {
pairs
.into_iter()
.map(|(slug, r)| (Slug::from(slug), r))
.collect()
}
fn rendered_site<'a>(
site: &'a Site,
mut rendered: HashMap<Slug, Rendered>,
) -> RenderedSite<'a> {
let blog: Vec<_> = site
.blog
.iter()
.map(|p| {
let r = rendered
.remove(&p.slug)
.unwrap_or_else(|| rendered_html(""));
(p, r)
})
.collect();
RenderedSite::from_parts(site, blog, vec![], vec![], None, None, None)
}
fn test_config() -> crate::config::Config {
"title = \"Test Blog\"\nbase_url = \"https://example.com\"\ndescription = \"A test blog\""
.parse()
.unwrap()
}
fn blog_post(
slug: &str,
title: &str,
created: NaiveDate,
tags: Vec<&str>,
) -> Page<BlogFrontmatter> {
Page {
slug: slug.into(),
body: String::new(),
path: PathBuf::from(format!("content/blog/{slug}.md")),
frontmatter: BlogFrontmatter {
title: title.into(),
slug: slug.into(),
author: "Alice".into(),
created,
updated: None,
image: None,
description: Some(format!("About {title}")),
tags: tags.into_iter().map(Into::into).collect(),
draft: false,
},
}
}
fn rendered_html(content: &str) -> Rendered {
Rendered {
html: content.into(),
toc: vec![],
broken_wiki_links: vec![],
contains_mermaid: false,
}
}
#[test]
fn atom_well_formed() {
let site = Site::from_parts(test_config(), vec![], vec![], vec![]).unwrap();
let rendered = rendered_site(&site, HashMap::new());
let xml = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\">"));
assert!(xml.contains("<title>Test Blog</title>"));
assert!(xml.contains("<subtitle>A test blog</subtitle>"));
assert!(xml.contains("</feed>"));
}
#[test]
fn rss_well_formed() {
let site = Site::from_parts(test_config(), vec![], vec![], vec![]).unwrap();
let rendered = rendered_site(&site, HashMap::new());
let xml = String::from_utf8(RssFeed.render(&rendered)).unwrap();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<rss version=\"2.0\""));
assert!(xml.contains("<title>Test Blog</title>"));
assert!(xml.contains("<description>A test blog</description>"));
assert!(xml.contains("</channel>"));
assert!(xml.contains("</rss>"));
}
#[test]
fn atom_entries_contain_content() {
let blog = vec![blog_post(
"hello",
"Hello World",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec!["rust"],
)];
let site = Site::from_parts(test_config(), blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![("hello", rendered_html("<p>Hello, world!</p>"))]),
);
let xml = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(xml.contains("<title>Hello World</title>"));
assert!(xml.contains("https://example.com/blog/hello/"));
assert!(xml.contains("<published>2026-04-20T00:00:00+00:00</published>"));
assert!(xml.contains("<name>Alice</name>"));
assert!(xml.contains("<p>Hello, world!</p>"));
assert!(xml.contains("<category term=\"rust\""));
assert!(xml.contains("<summary type=\"text\">About Hello World</summary>"));
}
#[test]
fn rss_entries_contain_content() {
let blog = vec![blog_post(
"hello",
"Hello World",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec!["rust"],
)];
let site = Site::from_parts(test_config(), blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![("hello", rendered_html("<p>Hello, world!</p>"))]),
);
let xml = String::from_utf8(RssFeed.render(&rendered)).unwrap();
assert!(xml.contains("<title>Hello World</title>"));
assert!(xml.contains("<link>https://example.com/blog/hello/</link>"));
assert!(xml.contains("<guid isPermaLink=\"true\">https://example.com/blog/hello/</guid>"));
assert!(xml.contains("<p>Hello, world!</p>"));
assert!(xml.contains("<dc:creator>Alice</dc:creator>"));
assert!(xml.contains("<category>rust</category>"));
}
#[test]
fn feed_limit_respected() {
let mut config = test_config();
config.feed_limit = 1;
let blog = vec![
blog_post(
"a",
"First",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec![],
),
blog_post(
"b",
"Second",
NaiveDate::from_ymd_opt(2026, 4, 19).unwrap(),
vec![],
),
];
let site = Site::from_parts(config, blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![
("a", rendered_html("<p>A</p>")),
("b", rendered_html("<p>B</p>")),
]),
);
let atom = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(atom.contains("First"));
assert!(!atom.contains("Second"));
let rss = String::from_utf8(RssFeed.render(&rendered)).unwrap();
assert!(rss.contains("First"));
assert!(!rss.contains("Second"));
}
#[test]
fn feed_limit_zero_includes_all() {
let mut config = test_config();
config.feed_limit = 0;
let blog = vec![
blog_post(
"a",
"First",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec![],
),
blog_post(
"b",
"Second",
NaiveDate::from_ymd_opt(2026, 4, 19).unwrap(),
vec![],
),
];
let site = Site::from_parts(config, blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![
("a", rendered_html("<p>A</p>")),
("b", rendered_html("<p>B</p>")),
]),
);
let atom = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(atom.contains("First"));
assert!(atom.contains("Second"));
}
#[test]
fn escapes_special_characters() {
let blog = vec![blog_post(
"test",
"A & B <C>",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec![],
)];
let site = Site::from_parts(test_config(), blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![("test", rendered_html("<p>x & y</p>"))]),
);
let atom = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(atom.contains("A & B <C>"));
let rss = String::from_utf8(RssFeed.render(&rendered)).unwrap();
assert!(rss.contains("A & B <C>"));
}
#[test]
fn absolutizes_relative_urls_in_content() {
let blog = vec![blog_post(
"links",
"Links Post",
NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
vec![],
)];
let site = Site::from_parts(test_config(), blog, vec![], vec![]).unwrap();
let rendered = rendered_site(
&site,
rendered_map(vec![(
"links",
rendered_html(
r#"<p><a href="/wiki/foo/">foo</a> and <img src="/static/img.png"/></p>"#,
),
)]),
);
let atom = String::from_utf8(AtomFeed.render(&rendered)).unwrap();
assert!(atom.contains("https://example.com/wiki/foo/"));
assert!(atom.contains("https://example.com/static/img.png"));
assert!(!atom.contains("href="/wiki/foo/"));
assert!(!atom.contains("src="/static/img.png"));
let rss = String::from_utf8(RssFeed.render(&rendered)).unwrap();
assert!(rss.contains("https://example.com/wiki/foo/"));
assert!(rss.contains("https://example.com/static/img.png"));
}
}