nx_rss_lib.nx source
↩ module page · 164 lines · 6146 B
1// nx_rss_lib.nx -- THE RSS 2.0 BUILDER AS A LIBRARY (extracted 2026-09-16 from nx_rss.nx, whose compile-only smoke main() made it
2// un-importable -- the first consumer that needed to COMPOSE it, the /compare daily feed, could not). Every builder function
3// below is byte-identical to the one it was moved from; nx_rss.nx now imports this file and keeps only its smoke main.
4// rss.nx -- RSS 2.0 feed builder.
5//
6// Emits valid RSS 2.0 XML (specification: Harvard Berkman Center,
7// 2009). Every feed reader, podcast app, news aggregator, and
8// many static site generators consume RSS. nishi-pages without
9// RSS is half a blog engine.
10//
11// Feed skeleton:
12// <?xml version=\"1.0\" encoding=\"utf-8\"?>
13// <rss version=\"2.0\">
14// <channel>
15// <title>...</title>
16// <link>...</link>
17// <description>...</description>
18// <language>en</language>
19// <pubDate>Wed, 22 Apr 2026 12:34:56 GMT</pubDate>
20// <item>
21// <title>...</title>
22// <link>...</link>
23// <description>...</description>
24// <pubDate>...</pubDate>
25// <guid>...</guid>
26// </item>
27// ...
28// </channel>
29// </rss>
30//
31// All dates use IMF-fixdate (http_date.nx). Text content is
32// HTML-escaped (html_escape.nx) so the feed can contain '<',
33// '>', '&' safely.
34//
35// This is a stream-builder: caller walks their posts and calls
36// rss_item for each; we don't own the post list. All output
37// appends to a caller-owned buffer with bounds checking.
38//
39// Invariants:
40// R1 Order matters: rss_begin -> rss_item* -> rss_end.
41// R2 Caller supplies HTML-unsafe text; we escape internally.
42// R3 All dates are unix seconds converted to GMT fixdate.
43// R4 Description can contain HTML; we wrap it in CDATA rather
44// than escaping, matching WordPress / Ghost convention.
45// (strict RSS says escape, but CDATA is universally
46// accepted and preserves readability.)
47
48// nx_safety_envelope:
49// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
50// sil_target: SIL1
51// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
52// verdict: NOT_YET_EVALUATED
53
54import "nx_syscalls.nx"
55import "nx_html_escape.nx"
56import "nx_http_date.nx"
57const RSS_MAGIC_4096: i64 = 4096
58
59const RSS_ERR_SHORT: i64 = -1
60
61// Write literal bytes from src into out at off. Returns new off
62// or RSS_ERR_SHORT.
63func rss_put(out: *u8, cap: i64, off: i64,
64 src: *u8, n: i64) -> i64 {
65 if off + n > cap { return RSS_ERR_SHORT }
66 var i: i64 = 0
67 while i < n {
68 out[off + i] = src[i]
69 i = i + 1
70 }
71 return off + n
72}
73
74// Write an HTML-escaped text range.
75func rss_put_escaped(out: *u8, cap: i64, off: i64,
76 src: *u8, n: i64) -> i64 {
77 let written: i64 = html_escape(out + off, cap - off, src, n)
78 if written < 0 { return RSS_ERR_SHORT }
79 return off + written
80}
81
82// Emit the channel opening + channel-level metadata. Caller
83// supplies title/link/description + channel pubDate (unix seconds).
84func rss_begin(out: *u8, cap: i64, off: i64,
85 title: *u8, title_len: i64,
86 link: *u8, link_len: i64,
87 desc: *u8, desc_len: i64,
88 channel_pub_unix: i64) -> i64 {
89 var cur: i64 = off
90 cur = rss_put(out, cap, cur,
91 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n", 38)
92 if cur < 0 { return cur }
93 cur = rss_put(out, cap, cur, "<rss version=\"2.0\">\n", 20)
94 if cur < 0 { return cur }
95 cur = rss_put(out, cap, cur, "<channel>\n", 10)
96 if cur < 0 { return cur }
97
98 cur = rss_put(out, cap, cur, "<title>", 7)
99 if cur < 0 { return cur }
100 cur = rss_put_escaped(out, cap, cur, title, title_len)
101 if cur < 0 { return cur }
102 cur = rss_put(out, cap, cur, "</title>\n<link>", 15)
103 if cur < 0 { return cur }
104 cur = rss_put_escaped(out, cap, cur, link, link_len)
105 if cur < 0 { return cur }
106 cur = rss_put(out, cap, cur, "</link>\n<description>", 21)
107 if cur < 0 { return cur }
108 cur = rss_put_escaped(out, cap, cur, desc, desc_len)
109 if cur < 0 { return cur }
110 cur = rss_put(out, cap, cur, "</description>\n<pubDate>", 24)
111 if cur < 0 { return cur }
112 // http-date is 29 bytes.
113 if cur + 29 > cap { return RSS_ERR_SHORT }
114 http_date_format_unix(out + cur, cap - cur, channel_pub_unix)
115 cur = cur + 29
116 cur = rss_put(out, cap, cur, "</pubDate>\n", 11)
117 if cur < 0 { return cur }
118 return cur
119}
120
121// Emit one <item>. guid is usually the post URL or a stable ID.
122func rss_item(out: *u8, cap: i64, off: i64,
123 title: *u8, title_len: i64,
124 link: *u8, link_len: i64,
125 desc: *u8, desc_len: i64,
126 guid: *u8, guid_len: i64,
127 pub_unix: i64) -> i64 {
128 var cur: i64 = off
129 cur = rss_put(out, cap, cur, "<item>\n<title>", 14)
130 if cur < 0 { return cur }
131 cur = rss_put_escaped(out, cap, cur, title, title_len)
132 if cur < 0 { return cur }
133
134 cur = rss_put(out, cap, cur, "</title>\n<link>", 15)
135 if cur < 0 { return cur }
136 cur = rss_put_escaped(out, cap, cur, link, link_len)
137 if cur < 0 { return cur }
138
139 // description in CDATA -- preserves HTML markup in the body.
140 cur = rss_put(out, cap, cur,
141 "</link>\n<description><![CDATA[", 30)
142 if cur < 0 { return cur }
143 cur = rss_put(out, cap, cur, desc, desc_len)
144 if cur < 0 { return cur }
145 cur = rss_put(out, cap, cur, "]]></description>\n<guid>", 24)
146 if cur < 0 { return cur }
147 cur = rss_put_escaped(out, cap, cur, guid, guid_len)
148 if cur < 0 { return cur }
149 cur = rss_put(out, cap, cur, "</guid>\n<pubDate>", 17)
150 if cur < 0 { return cur }
151 if cur + 29 > cap { return RSS_ERR_SHORT }
152 http_date_format_unix(out + cur, cap - cur, pub_unix)
153 cur = cur + 29
154 cur = rss_put(out, cap, cur, "</pubDate>\n</item>\n", 19)
155 if cur < 0 { return cur }
156 return cur
157}
158
159// Close channel + rss.
160func rss_end(out: *u8, cap: i64, off: i64) -> i64 {
161 return rss_put(out, cap, off, "</channel>\n</rss>\n", 18)
162}
163
164// Compile-only smoke: 1-item feed.