diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 3bd4bfe..3dbab64 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -3671,42 +3671,63 @@ pub mod export_ops { }) } - /// Write `/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 `/` 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 `` → the diary index + /// page, an ``, channel + item ``, per-item + /// `` (bare date), and a `` + /// rendered-HTML body per entry. pub fn write_rss(cfg: &WikiConfig, entries: &[DiaryEntry]) -> std::io::Result { 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#" - -"#, + "\n\ +\n", ); xml.push_str(&format!("{}\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!("{}\n", xml_escape(base_url))); + let index_link = format!("{base_url}{diary_rel}/{}.html", cfg.diary_index); + xml.push_str(&format!("{}\n", xml_escape(&index_link))); + xml.push_str(&format!( + "\n", + xml_escape(base_url), + xml_escape(&cfg.html.rss_name) + )); } xml.push_str("nuwiki diary\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!("{}\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(""); xml.push_str(&format!("{}", e.date.format())); xml.push_str(&format!("{}", xml_escape(&link))); - xml.push_str(&format!("{}", xml_escape(&link))); + xml.push_str(&format!( + "{}", + xml_escape(&e.date.format()) + )); + xml.push_str(&format!("{}", rfc822(&e.date))); + let body = render_entry_body(cfg, e); + if !body.is_empty() { + xml.push_str(&format!( + "", + cdata_safe(&body) + )); + } xml.push_str("\n"); } xml.push_str("\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 `` block by + /// splitting any literal `]]>` sequence. + fn cdata_safe(s: &str) -> String { + s.replace("]]>", "]]]]>") + } + + /// RFC-822 date for a diary date at midnight UTC (1970-01-01 was a + /// Thursday). Used for RSS ``. + 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 { diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index 65f4d97..a25f212 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -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 xml = std::fs::read_to_string(&path).unwrap(); - // Channel + item links use the public base URL, not file://. - assert!(xml.contains("https://example.com/wiki/")); + // Channel link points at the diary index page (upstream fidelity); item + // link is the public per-entry URL. + assert!( + xml.contains("https://example.com/wiki/diary/diary.html"), + "channel link: {xml}" + ); assert!(xml.contains("https://example.com/wiki/diary/2026-05-11.html")); - assert!(xml.contains("https://example.com/wiki/diary/2026-05-11.html")); + // guid is the bare date, isPermaLink=false. + assert!( + xml.contains("2026-05-11"), + "guid: {xml}" + ); + // atom:link self + a pubDate are present. + assert!(xml.contains("rel=\"self\""), "atom:link: {xml}"); + assert!(xml.contains(""), "pubDate: {xml}"); 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 = (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("").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] fn write_page_invokes_custom_wiki2html_converter() { let root = temp_root("custom_w2h");