use std::collections::{BTreeMap, HashSet};
use std::fmt::Write;
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use minijinja::Environment;
use serde::Serialize;
use serde_json::Value as JsonValue;
use time::OffsetDateTime;
use crate::config::Config;
use crate::content::Post;
use crate::utils::absolute_url;
use super::listing::{page_url, tag_index_url, tag_slug};
use super::posts::att_to_absolute;
use super::templates::render_template_with_scope;
use super::utils::{format_rfc2822, format_rfc3339, sanitize_cdata, xml_escape};
pub(super) fn render_feeds(
posts: &[Post],
html_root: &Path,
config: &Config,
env: &Environment<'static>,
) -> Result<()> {
render_rss(posts, html_root, config, env)?;
for tag in config_tag_feeds(config) {
let slug = tag_slug(&tag);
let tag_posts: Vec<&Post> = posts
.iter()
.rev()
.filter(|post| post.tags.iter().any(|t| t.eq(&tag)))
.collect();
let output_path = html_root.join(format!("rss-{}.xml", slug));
let title = config.title.clone().unwrap_or_else(|| "bckt".to_string());
let feed_title = format!("{} ยท {}", tag, title);
let site_path = format!("/tags/{}/", slug);
let feed_path = format!("/rss-{}.xml", slug);
render_feed(
tag_posts,
config,
env,
&site_path,
&feed_path,
&output_path,
Some(feed_title),
)?;
}
render_sitemap(posts, html_root, config)?;
Ok(())
}
fn render_rss(
posts: &[Post],
html_root: &Path,
config: &Config,
env: &Environment<'static>,
) -> Result<()> {
let output_path = html_root.join("rss.xml");
let posts_ref: Vec<&Post> = posts.iter().rev().collect();
render_feed(posts_ref, config, env, "/", "/rss.xml", &output_path, None)
}
fn render_feed(
posts: Vec<&Post>,
config: &Config,
env: &Environment<'static>,
site_path: &str,
feed_path: &str,
output_path: &Path,
title: Option<String>,
) -> Result<()> {
let template = env
.get_template("rss.xml")
.context("rss.xml template missing")?;
let site_url = absolute_url(&config.base_url, site_path);
let feed_url = absolute_url(&config.base_url, feed_path);
let resolved_title =
title.unwrap_or_else(|| config.title.clone().unwrap_or_else(|| "bckt".to_string()));
let build_date = posts
.first()
.map(|post| post.date)
.unwrap_or_else(OffsetDateTime::now_utc);
let last_build_date = format_rfc2822(&build_date)?;
let items = posts
.into_iter()
.take(50)
.map(|post| build_feed_item(config, post))
.collect::<Result<Vec<_>>>()?;
let context = FeedContext {
title: xml_escape(&resolved_title),
site_url: xml_escape(&site_url),
feed_url: xml_escape(&feed_url),
description: xml_escape(&resolved_title),
updated: xml_escape(&last_build_date),
items,
};
let scope = format!("rendering feed {}", feed_path);
let rendered =
render_template_with_scope(&template, minijinja::context! { feed => context }, &scope)?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(output_path, rendered)
.with_context(|| format!("failed to write {}", output_path.display()))?;
Ok(())
}
fn render_sitemap(posts: &[Post], html_root: &Path, config: &Config) -> Result<()> {
let per_page = std::cmp::max(1, config.homepage_posts);
let mut entries: Vec<SitemapEntry> = Vec::new();
let remainder = posts.len() % per_page;
let home_page_size = if posts.len() < per_page {
posts.len()
} else if remainder == 0 {
per_page
} else if remainder < per_page {
remainder + per_page
} else {
per_page
};
let regular_page_count = (posts.len() - home_page_size) / per_page;
let homepage_date = posts
.last()
.map(|post| format_rfc3339(&post.date))
.transpose()?;
entries.push(SitemapEntry {
loc: absolute_url(&config.base_url, "/"),
lastmod: homepage_date,
});
for page_num in 1..=regular_page_count {
let start = (page_num - 1) * per_page;
let end = start + per_page;
let path = page_url(page_num);
let page_date = format_rfc3339(&posts[end - 1].date)?;
entries.push(SitemapEntry {
loc: absolute_url(&config.base_url, &path),
lastmod: Some(page_date),
});
}
for post in posts {
entries.push(SitemapEntry {
loc: absolute_url(&config.base_url, &post.permalink),
lastmod: Some(format_rfc3339(&post.date)?),
});
}
let tag_entries = collect_tag_sitemap_entries(posts, config)?;
entries.extend(tag_entries);
let mut buffer = String::new();
writeln!(buffer, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
writeln!(
buffer,
r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#
)?;
for entry in entries {
writeln!(buffer, " <url>")?;
writeln!(buffer, " <loc>{}</loc>", xml_escape(&entry.loc))?;
if let Some(lastmod) = entry.lastmod {
writeln!(buffer, " <lastmod>{}</lastmod>", xml_escape(&lastmod))?;
}
writeln!(buffer, " </url>")?;
}
writeln!(buffer, "</urlset>")?;
let output_path = html_root.join("sitemap.xml");
fs::write(&output_path, buffer)
.with_context(|| format!("failed to write {}", output_path.display()))?;
Ok(())
}
fn collect_tag_sitemap_entries(posts: &[Post], config: &Config) -> Result<Vec<SitemapEntry>> {
let mut buckets: BTreeMap<String, TagBucket> = BTreeMap::new();
for (idx, post) in posts.iter().enumerate() {
let mut seen = HashSet::new();
for tag in &post.tags {
let tag = tag.trim();
if tag.is_empty() {
continue;
}
let slug = tag_slug(tag);
if !seen.insert(slug.clone()) {
continue;
}
let bucket = buckets.entry(slug.clone()).or_insert_with(|| TagBucket {
slug: slug.clone(),
indices: Vec::new(),
});
bucket.indices.push(idx);
}
}
if buckets.is_empty() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for bucket in buckets.values() {
let first = &posts[bucket.indices[0]];
entries.push(SitemapEntry {
loc: absolute_url(&config.base_url, &tag_index_url(&bucket.slug)),
lastmod: Some(format_rfc3339(&first.date)?),
});
}
Ok(entries)
}
fn build_feed_item(config: &Config, post: &Post) -> Result<FeedItem> {
let item_title = post
.title
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(&post.slug);
let link = absolute_url(&config.base_url, &post.permalink);
let pub_date = format_rfc2822(&post.date)?;
let description = if post.excerpt.trim().is_empty() {
item_title.to_string()
} else {
post.excerpt.clone()
};
let body = att_to_absolute(
&post.body_html,
&post.permalink,
&config.base_url,
&post.attached,
true,
);
Ok(FeedItem {
title: xml_escape(item_title),
link: xml_escape(&link),
guid: xml_escape(&link),
pub_date: xml_escape(&pub_date),
description: xml_escape(&description),
content: sanitize_cdata(&body),
})
}
fn config_tag_feeds(config: &Config) -> Vec<String> {
fn split_list(value: &str) -> Vec<String> {
value
.split(',')
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect()
}
let mut tags = Vec::new();
if let Some(value) = config.extra.get("rss_tags") {
match value {
JsonValue::String(s) => tags.extend(split_list(s)),
JsonValue::Array(items) => {
for item in items {
if let JsonValue::String(s) = item {
let trimmed = s.trim();
if !trimmed.is_empty() {
tags.push(trimmed.to_string());
}
}
}
}
_ => {}
}
}
tags.sort();
tags.dedup();
tags
}
#[derive(Serialize)]
struct FeedContext {
title: String,
site_url: String,
feed_url: String,
description: String,
updated: String,
items: Vec<FeedItem>,
}
#[derive(Serialize)]
struct FeedItem {
title: String,
link: String,
guid: String,
pub_date: String,
description: String,
content: String,
}
#[derive(Clone)]
struct TagBucket {
slug: String,
indices: Vec<usize>,
}
struct SitemapEntry {
loc: String,
lastmod: Option<String>,
}