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 /// Write `<html_path>/<rss_name>` listing the wiki's diary entries
/// newest-first. The feed is intentionally minimal — just enough to /// newest-first, capped by `rss_max_items`. Emits an RSS 2.0 feed with
/// validate against RSS 2.0 parsers — since the diary URLs are file: /// upstream's structure: `xmlns:atom`, channel `<link>` → the diary index
/// scheme and won't survive remote consumption anyway. /// 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> { pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result<PathBuf> {
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
sorted.sort_by(|a, b| b.date.cmp(&a.date)); 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( let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?> "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<rss version="2.0"><channel> <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))); xml.push_str(&format!("<title>{}</title>\n", xml_escape(&cfg.name)));
// `base_url` (per-wiki) prefixes the feed + item links so a hosted // Channel link points at the diary index page (upstream), not bare base.
// feed points at public URLs rather than local `file://` paths.
let base_url = cfg.html.base_url.trim();
if !base_url.is_empty() { 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"); xml.push_str("<description>nuwiki diary</description>\n");
for e in sorted { if let Some(first) = sorted.first() {
// With base_url set, build a public URL from the diary path + date; xml.push_str(&format!("<pubDate>{}</pubDate>\n", rfc822(&first.date)));
// otherwise fall back to the entry's `file://` URI. }
for e in sorted.into_iter().take(cap) {
let link = if base_url.is_empty() { let link = if base_url.is_empty() {
e.uri.as_str().to_string() e.uri.as_str().to_string()
} else { } else {
format!( format!("{base_url}{diary_rel}/{}.html", e.date.format())
"{base_url}{}/{}.html",
cfg.diary_rel_path.trim_matches('/'),
e.date.format()
)
}; };
xml.push_str("<item>"); xml.push_str("<item>");
xml.push_str(&format!("<title>{}</title>", e.date.format())); xml.push_str(&format!("<title>{}</title>", e.date.format()));
xml.push_str(&format!("<link>{}</link>", xml_escape(&link))); 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("</item>\n");
} }
xml.push_str("</channel></rss>\n"); xml.push_str("</channel></rss>\n");
@@ -3719,8 +3740,61 @@ pub mod export_ops {
Ok(path) 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 { 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 { fn xml_escape(s: &str) -> String {
+38 -3
View File
@@ -613,13 +613,48 @@ fn write_rss_uses_base_url_for_public_links() {
}]; }];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap(); let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap(); let xml = std::fs::read_to_string(&path).unwrap();
// Channel + item links use the public base URL, not file://. // Channel link points at the diary index page (upstream fidelity); item
assert!(xml.contains("<link>https://example.com/wiki/</link>")); // link is the public per-entry URL.
assert!(
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
"channel link: {xml}"
);
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>")); assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
assert!(xml.contains("<guid>https://example.com/wiki/diary/2026-05-11.html</guid>")); // guid is the bare date, isPermaLink=false.
assert!(
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
"guid: {xml}"
);
// atom:link self + a pubDate are present.
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
assert!(!xml.contains("file:///tmp/diary")); assert!(!xml.contains("file:///tmp/diary"));
} }
#[test]
fn write_rss_honours_rss_name_and_max_items() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_cap");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.rss_name = "feed.xml".into();
c.html.rss_max_items = 2;
let entries: Vec<DiaryEntry> = (1..=5)
.map(|d| DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
})
.collect();
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
let xml = std::fs::read_to_string(&path).unwrap();
// Capped at 2 items (newest first: 05-05, 05-04).
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
}
#[test] #[test]
fn write_page_invokes_custom_wiki2html_converter() { fn write_page_invokes_custom_wiki2html_converter() {
let root = temp_root("custom_w2h"); let root = temp_root("custom_w2h");