code wiki / (root) / rss.nx

rss.nx source

↩ module page · 181 lines · 6537 B

1// rss.nx -- RSS 2.0 feed builder. 2// 3// Emits valid RSS 2.0 XML (specification: Harvard Berkman Center, 4// 2009). Every feed reader, podcast app, news aggregator, and 5// many static site generators consume RSS. nishi-pages without 6// RSS is half a blog engine. 7// 8// Feed skeleton: 9// <?xml version=\"1.0\" encoding=\"utf-8\"?> 10// <rss version=\"2.0\"> 11// <channel> 12// <title>...</title> 13// <link>...</link> 14// <description>...</description> 15// <language>en</language> 16// <pubDate>Wed, 22 Apr 2026 12:34:56 GMT</pubDate> 17// <item> 18// <title>...</title> 19// <link>...</link> 20// <description>...</description> 21// <pubDate>...</pubDate> 22// <guid>...</guid> 23// </item> 24// ... 25// </channel> 26// </rss> 27// 28// All dates use IMF-fixdate (http_date.nx). Text content is 29// HTML-escaped (html_escape.nx) so the feed can contain '<', 30// '>', '&' safely. 31// 32// This is a stream-builder: caller walks their posts and calls 33// rss_item for each; we don't own the post list. All output 34// appends to a caller-owned buffer with bounds checking. 35// 36// Invariants: 37// R1 Order matters: rss_begin -> rss_item* -> rss_end. 38// R2 Caller supplies HTML-unsafe text; we escape internally. 39// R3 All dates are unix seconds converted to GMT fixdate. 40// R4 Description can contain HTML; we wrap it in CDATA rather 41// than escaping, matching WordPress / Ghost convention. 42// (strict RSS says escape, but CDATA is universally 43// accepted and preserves readability.) 44 45import "syscalls.nx" 46import "html_escape.nx" 47import "http_date.nx" 48 49const RSS_ERR_SHORT: i64 = -1 50 51// Write literal bytes from src into out at off. Returns new off 52// or RSS_ERR_SHORT. 53func rss_put(out: *u8, cap: i64, off: i64, 54 src: *u8, n: i64) -> i64 { 55 if off + n > cap { return RSS_ERR_SHORT } 56 var i: i64 = 0 57 while i < n { 58 out[off + i] = src[i] 59 i = i + 1 60 } 61 return off + n 62} 63 64// Write an HTML-escaped text range. 65func rss_put_escaped(out: *u8, cap: i64, off: i64, 66 src: *u8, n: i64) -> i64 { 67 let written: i64 = html_escape(out + off, cap - off, src, n) 68 if written < 0 { return RSS_ERR_SHORT } 69 return off + written 70} 71 72// Emit the channel opening + channel-level metadata. Caller 73// supplies title/link/description + channel pubDate (unix seconds). 74func rss_begin(out: *u8, cap: i64, off: i64, 75 title: *u8, title_len: i64, 76 link: *u8, link_len: i64, 77 desc: *u8, desc_len: i64, 78 channel_pub_unix: i64) -> i64 { 79 var cur: i64 = off 80 cur = rss_put(out, cap, cur, 81 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n", 38) 82 if cur < 0 { return cur } 83 cur = rss_put(out, cap, cur, "<rss version=\"2.0\">\n", 20) 84 if cur < 0 { return cur } 85 cur = rss_put(out, cap, cur, "<channel>\n", 10) 86 if cur < 0 { return cur } 87 88 cur = rss_put(out, cap, cur, "<title>", 7) 89 if cur < 0 { return cur } 90 cur = rss_put_escaped(out, cap, cur, title, title_len) 91 if cur < 0 { return cur } 92 cur = rss_put(out, cap, cur, "</title>\n<link>", 15) 93 if cur < 0 { return cur } 94 cur = rss_put_escaped(out, cap, cur, link, link_len) 95 if cur < 0 { return cur } 96 cur = rss_put(out, cap, cur, "</link>\n<description>", 21) 97 if cur < 0 { return cur } 98 cur = rss_put_escaped(out, cap, cur, desc, desc_len) 99 if cur < 0 { return cur } 100 cur = rss_put(out, cap, cur, "</description>\n<pubDate>", 24) 101 if cur < 0 { return cur } 102 // http-date is 29 bytes. 103 if cur + 29 > cap { return RSS_ERR_SHORT } 104 http_date_format_unix(out + cur, cap - cur, channel_pub_unix) 105 cur = cur + 29 106 cur = rss_put(out, cap, cur, "</pubDate>\n", 11) 107 if cur < 0 { return cur } 108 return cur 109} 110 111// Emit one <item>. guid is usually the post URL or a stable ID. 112func rss_item(out: *u8, cap: i64, off: i64, 113 title: *u8, title_len: i64, 114 link: *u8, link_len: i64, 115 desc: *u8, desc_len: i64, 116 guid: *u8, guid_len: i64, 117 pub_unix: i64) -> i64 { 118 var cur: i64 = off 119 cur = rss_put(out, cap, cur, "<item>\n<title>", 14) 120 if cur < 0 { return cur } 121 cur = rss_put_escaped(out, cap, cur, title, title_len) 122 if cur < 0 { return cur } 123 124 cur = rss_put(out, cap, cur, "</title>\n<link>", 15) 125 if cur < 0 { return cur } 126 cur = rss_put_escaped(out, cap, cur, link, link_len) 127 if cur < 0 { return cur } 128 129 // description in CDATA -- preserves HTML markup in the body. 130 cur = rss_put(out, cap, cur, 131 "</link>\n<description><![CDATA[", 30) 132 if cur < 0 { return cur } 133 cur = rss_put(out, cap, cur, desc, desc_len) 134 if cur < 0 { return cur } 135 cur = rss_put(out, cap, cur, "]]></description>\n<guid>", 24) 136 if cur < 0 { return cur } 137 cur = rss_put_escaped(out, cap, cur, guid, guid_len) 138 if cur < 0 { return cur } 139 cur = rss_put(out, cap, cur, "</guid>\n<pubDate>", 17) 140 if cur < 0 { return cur } 141 if cur + 29 > cap { return RSS_ERR_SHORT } 142 http_date_format_unix(out + cur, cap - cur, pub_unix) 143 cur = cur + 29 144 cur = rss_put(out, cap, cur, "</pubDate>\n</item>\n", 19) 145 if cur < 0 { return cur } 146 return cur 147} 148 149// Close channel + rss. 150func rss_end(out: *u8, cap: i64, off: i64) -> i64 { 151 return rss_put(out, cap, off, "</channel>\n</rss>\n", 18) 152} 153 154// Compile-only smoke: 1-item feed. 155func main() -> i64 { 156 let out: *u8 = sys_mmap(4096) 157 var off: i64 = 0 158 off = rss_begin(out, 4096, off, 159 "Nishi Blog", 10, 160 "https://nishifamily.com/blog", 28, 161 "Greenfield sovereign computing notes", 36, 162 1777000000) 163 if off < 0 { return 1 } 164 165 off = rss_item(out, 4096, off, 166 "First post", 10, 167 "https://nishifamily.com/blog/1", 30, 168 "<p>Hello & welcome</p>", 22, 169 "https://nishifamily.com/blog/1", 30, 170 1777000000) 171 if off < 0 { return 2 } 172 173 off = rss_end(out, 4096, off) 174 if off < 0 { return 3 } 175 176 // Sanity: first 5 bytes are \"<?xml\". 177 if out[0] != 0x3C { return 4 } // '<' 178 if out[1] != 0x3F { return 5 } // '?' 179 if out[2] != 0x78 { return 6 } // 'x' 180 return 0 181}