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 {
|
||||
|
||||
Reference in New Issue
Block a user