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:
@@ -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 {
|
||||
|
||||
@@ -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("<link>https://example.com/wiki/</link>"));
|
||||
// Channel link points at the diary index page (upstream fidelity); item
|
||||
// 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("<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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn write_page_invokes_custom_wiki2html_converter() {
|
||||
let root = temp_root("custom_w2h");
|
||||
|
||||
Reference in New Issue
Block a user