feat(config): P3 config batch — group 6 (RSS fidelity + rss_name/rss_max_items)

write_rss now emits upstream's full RSS 2.0 structure:
- xmlns:atom on <rss>; channel <link> points at the diary index page
  (base_url + diary_rel + diary_index + .html), not bare base_url.
- <atom:link rel="self"> to base_url + rss_name.
- channel + per-item <pubDate> (RFC-822, derived from the diary date;
  1970-01-01=Thu weekday math via to_days_epoch).
- per-item <guid isPermaLink="false"> holding the bare date.
- per-item <![CDATA[ rendered page body ]]> (render_entry_body reads+parses
  the entry and renders body-only with the wiki's colour/valid-tag/emoji opts;
  cdata_safe splits any literal ]]>).
- rss_name drives the output filename; rss_max_items caps the feed (0 =
  unlimited).

Tests: updated write_rss_uses_base_url_for_public_links for the new channel
link/guid/atom/pubDate; new write_rss_honours_rss_name_and_max_items. Full
rust suite + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:34:32 +00:00
parent 5d1ac09245
commit 4a19f180f2
2 changed files with 133 additions and 24 deletions
+95 -21
View File
@@ -3671,42 +3671,63 @@ pub mod export_ops {
})
}
/// Write `<html_path>/rss.xml` listing the wiki's diary entries
/// newest-first. The feed is intentionally minimal — just enough to
/// validate against RSS 2.0 parsers — since the diary URLs are file:
/// scheme and won't survive remote consumption anyway.
/// Write `<html_path>/<rss_name>` listing the wiki's diary entries
/// newest-first, capped by `rss_max_items`. Emits an RSS 2.0 feed with
/// upstream's structure: `xmlns:atom`, channel `<link>` → the diary index
/// page, an `<atom:link rel="self">`, channel + item `<pubDate>`, per-item
/// `<guid isPermaLink="false">` (bare date), and a `<![CDATA[…]]>`
/// rendered-HTML body per entry.
pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result<PathBuf> {
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
sorted.sort_by(|a, b| b.date.cmp(&a.date));
let cap = if cfg.html.rss_max_items == 0 {
usize::MAX
} else {
cfg.html.rss_max_items
};
let base_url = cfg.html.base_url.trim();
let diary_rel = cfg.diary_rel_path.trim_matches('/');
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
"#,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\"><channel>\n",
);
xml.push_str(&format!("<title>{}</title>\n", xml_escape(&cfg.name)));
// `base_url` (per-wiki) prefixes the feed + item links so a hosted
// feed points at public URLs rather than local `file://` paths.
let base_url = cfg.html.base_url.trim();
// Channel link points at the diary index page (upstream), not bare base.
if !base_url.is_empty() {
xml.push_str(&format!("<link>{}</link>\n", xml_escape(base_url)));
let index_link = format!("{base_url}{diary_rel}/{}.html", cfg.diary_index);
xml.push_str(&format!("<link>{}</link>\n", xml_escape(&index_link)));
xml.push_str(&format!(
"<atom:link href=\"{}{}\" rel=\"self\" type=\"application/rss+xml\" />\n",
xml_escape(base_url),
xml_escape(&cfg.html.rss_name)
));
}
xml.push_str("<description>nuwiki diary</description>\n");
for e in sorted {
// With base_url set, build a public URL from the diary path + date;
// otherwise fall back to the entry's `file://` URI.
if let Some(first) = sorted.first() {
xml.push_str(&format!("<pubDate>{}</pubDate>\n", rfc822(&first.date)));
}
for e in sorted.into_iter().take(cap) {
let link = if base_url.is_empty() {
e.uri.as_str().to_string()
} else {
format!(
"{base_url}{}/{}.html",
cfg.diary_rel_path.trim_matches('/'),
e.date.format()
)
format!("{base_url}{diary_rel}/{}.html", e.date.format())
};
xml.push_str("<item>");
xml.push_str(&format!("<title>{}</title>", e.date.format()));
xml.push_str(&format!("<link>{}</link>", xml_escape(&link)));
xml.push_str(&format!("<guid>{}</guid>", xml_escape(&link)));
xml.push_str(&format!(
"<guid isPermaLink=\"false\">{}</guid>",
xml_escape(&e.date.format())
));
xml.push_str(&format!("<pubDate>{}</pubDate>", rfc822(&e.date)));
let body = render_entry_body(cfg, e);
if !body.is_empty() {
xml.push_str(&format!(
"<description><![CDATA[{}]]></description>",
cdata_safe(&body)
));
}
xml.push_str("</item>\n");
}
xml.push_str("</channel></rss>\n");
@@ -3719,8 +3740,61 @@ pub mod export_ops {
Ok(path)
}
/// Render a diary entry's page body (no template) for the RSS CDATA block,
/// honouring the wiki's colour/valid-tag/emoji HTML options. Returns an
/// empty string when the file can't be read.
fn render_entry_body(cfg: &WikiConfig, e: &DiaryEntry) -> String {
use nuwiki_core::render::{HtmlRenderer, Renderer};
use nuwiki_core::syntax::{vimwiki::VimwikiSyntax, SyntaxPlugin};
let Ok(path) = e.uri.to_file_path() else {
return String::new();
};
let Ok(src) = std::fs::read_to_string(&path) else {
return String::new();
};
let ast = VimwikiSyntax::new().parse(&src);
let mut r = HtmlRenderer::new();
if !cfg.html.color_dic.is_empty() {
r = r.with_colors(cfg.html.color_dic.clone());
}
if !cfg.html.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.html.valid_html_tags.clone());
}
r = r.with_emoji(cfg.html.emoji_enable);
r.render_to_string(&ast).unwrap_or_default()
}
/// Make rendered HTML safe to embed inside a `<![CDATA[…]]>` block by
/// splitting any literal `]]>` sequence.
fn cdata_safe(s: &str) -> String {
s.replace("]]>", "]]]]><![CDATA[>")
}
/// RFC-822 date for a diary date at midnight UTC (1970-01-01 was a
/// Thursday). Used for RSS `<pubDate>`.
fn rfc822(d: &nuwiki_core::date::DiaryDate) -> String {
const WD: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MO: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let days = d.to_days_epoch();
let wd = (((days % 7) + 4) % 7 + 7) % 7; // 1970-01-01 = Thursday (4)
format!(
"{}, {:02} {} {} 00:00:00 +0000",
WD[wd as usize],
d.day,
MO[(d.month.clamp(1, 12) - 1) as usize],
d.year
)
}
fn rss_path(cfg: &WikiConfig) -> PathBuf {
cfg.html.html_path.join("rss.xml")
let name = if cfg.html.rss_name.trim().is_empty() {
"rss.xml"
} else {
cfg.html.rss_name.trim()
};
cfg.html.html_path.join(name)
}
fn xml_escape(s: &str) -> String {