nx_rss.nx source
↩ module page · 189 lines · 6754 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
45// nx_safety_envelope:
46// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
47// sil_target: SIL1
48// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
49// verdict: NOT_YET_EVALUATED
50
51import "nx_syscalls.nx"
52import "nx_html_escape.nx"
53import "nx_http_date.nx"
54const RSS_MAGIC_4096: i64 = 4096
55const RSS_MAGIC_1777000000: i64 = 1777000000
56
57const RSS_ERR_SHORT: i64 = -1
58
59// Write literal bytes from src into out at off. Returns new off
60// or RSS_ERR_SHORT.
61func rss_put(out: *u8, cap: i64, off: i64,
62 src: *u8, n: i64) -> i64 {
63 if off + n > cap { return RSS_ERR_SHORT }
64 var i: i64 = 0
65 while i < n {
66 out[off + i] = src[i]
67 i = i + 1
68 }
69 return off + n
70}
71
72// Write an HTML-escaped text range.
73func rss_put_escaped(out: *u8, cap: i64, off: i64,
74 src: *u8, n: i64) -> i64 {
75 let written: i64 = html_escape(out + off, cap - off, src, n)
76 if written < 0 { return RSS_ERR_SHORT }
77 return off + written
78}
79
80// Emit the channel opening + channel-level metadata. Caller
81// supplies title/link/description + channel pubDate (unix seconds).
82func rss_begin(out: *u8, cap: i64, off: i64,
83 title: *u8, title_len: i64,
84 link: *u8, link_len: i64,
85 desc: *u8, desc_len: i64,
86 channel_pub_unix: i64) -> i64 {
87 var cur: i64 = off
88 cur = rss_put(out, cap, cur,
89 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n", 38)
90 if cur < 0 { return cur }
91 cur = rss_put(out, cap, cur, "<rss version=\"2.0\">\n", 20)
92 if cur < 0 { return cur }
93 cur = rss_put(out, cap, cur, "<channel>\n", 10)
94 if cur < 0 { return cur }
95
96 cur = rss_put(out, cap, cur, "<title>", 7)
97 if cur < 0 { return cur }
98 cur = rss_put_escaped(out, cap, cur, title, title_len)
99 if cur < 0 { return cur }
100 cur = rss_put(out, cap, cur, "</title>\n<link>", 15)
101 if cur < 0 { return cur }
102 cur = rss_put_escaped(out, cap, cur, link, link_len)
103 if cur < 0 { return cur }
104 cur = rss_put(out, cap, cur, "</link>\n<description>", 21)
105 if cur < 0 { return cur }
106 cur = rss_put_escaped(out, cap, cur, desc, desc_len)
107 if cur < 0 { return cur }
108 cur = rss_put(out, cap, cur, "</description>\n<pubDate>", 24)
109 if cur < 0 { return cur }
110 // http-date is 29 bytes.
111 if cur + 29 > cap { return RSS_ERR_SHORT }
112 http_date_format_unix(out + cur, cap - cur, channel_pub_unix)
113 cur = cur + 29
114 cur = rss_put(out, cap, cur, "</pubDate>\n", 11)
115 if cur < 0 { return cur }
116 return cur
117}
118
119// Emit one <item>. guid is usually the post URL or a stable ID.
120func rss_item(out: *u8, cap: i64, off: i64,
121 title: *u8, title_len: i64,
122 link: *u8, link_len: i64,
123 desc: *u8, desc_len: i64,
124 guid: *u8, guid_len: i64,
125 pub_unix: i64) -> i64 {
126 var cur: i64 = off
127 cur = rss_put(out, cap, cur, "<item>\n<title>", 14)
128 if cur < 0 { return cur }
129 cur = rss_put_escaped(out, cap, cur, title, title_len)
130 if cur < 0 { return cur }
131
132 cur = rss_put(out, cap, cur, "</title>\n<link>", 15)
133 if cur < 0 { return cur }
134 cur = rss_put_escaped(out, cap, cur, link, link_len)
135 if cur < 0 { return cur }
136
137 // description in CDATA -- preserves HTML markup in the body.
138 cur = rss_put(out, cap, cur,
139 "</link>\n<description><![CDATA[", 30)
140 if cur < 0 { return cur }
141 cur = rss_put(out, cap, cur, desc, desc_len)
142 if cur < 0 { return cur }
143 cur = rss_put(out, cap, cur, "]]></description>\n<guid>", 24)
144 if cur < 0 { return cur }
145 cur = rss_put_escaped(out, cap, cur, guid, guid_len)
146 if cur < 0 { return cur }
147 cur = rss_put(out, cap, cur, "</guid>\n<pubDate>", 17)
148 if cur < 0 { return cur }
149 if cur + 29 > cap { return RSS_ERR_SHORT }
150 http_date_format_unix(out + cur, cap - cur, pub_unix)
151 cur = cur + 29
152 cur = rss_put(out, cap, cur, "</pubDate>\n</item>\n", 19)
153 if cur < 0 { return cur }
154 return cur
155}
156
157// Close channel + rss.
158func rss_end(out: *u8, cap: i64, off: i64) -> i64 {
159 return rss_put(out, cap, off, "</channel>\n</rss>\n", 18)
160}
161
162// Compile-only smoke: 1-item feed.
163func main() -> i64 {
164 let out: *u8 = sys_mmap(RSS_MAGIC_4096)
165 var off: i64 = 0
166 off = rss_begin(out, RSS_MAGIC_4096, off,
167 "Nishi Blog", 10,
168 "https://nishifamily.com/blog", 28,
169 "Greenfield sovereign computing notes", 36,
170 RSS_MAGIC_1777000000)
171 if off < 0 { return 1 }
172
173 off = rss_item(out, RSS_MAGIC_4096, off,
174 "First post", 10,
175 "https://nishifamily.com/blog/1", 30,
176 "<p>Hello & welcome</p>", 22,
177 "https://nishifamily.com/blog/1", 30,
178 RSS_MAGIC_1777000000)
179 if off < 0 { return 2 }
180
181 off = rss_end(out, RSS_MAGIC_4096, off)
182 if off < 0 { return 3 }
183
184 // Sanity: first 5 bytes are \"<?xml\".
185 if out[0] != 0x3C { return 4 } // '<'
186 if out[1] != 0x3F { return 5 } // '?'
187 if out[2] != 0x78 { return 6 } // 'x'
188 return 0
189}