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}