nx_nishi_page_emit.nx source
↩ module page · 289 lines · 13273 B
1// nx_nishi_page_emit.nx -- HUB primitive; emits Nishi Page Format V1
2// conformant <head> block from caller-supplied metadata. Cross-checks
3// own output via nx_nishi_page_validator before returning (idempotency
4// proof per Cardinal #10).
5//
6// COMPOSES (HUB primitives only):
7// nx_html_escape (safe meta-value emission)
8// nx_search_inverted (FNV-1a 64 for content-hash)
9// hub/nx_nishi_page_validator (cross-validates output)
10//
11// COMPOSED BY:
12// wiki/nx_wiki_doc_render (retrofit; queued next commit)
13// hub/nx_html_to_nishi_page (uses emit_meta helper)
14// (future) obd-config doc emitter
15// (future) sprinkler-config doc emitter
16//
17// API surfaces:
18// - emit_head_block: stamps 9 required <meta> tags into out buffer;
19// caller wraps with <html>/<head>/<body> shell
20// - emit_full_page: emits complete conforming HTML page (shell + head + body)
21// - compute_content_hash: FNV-1a 64 of body bytes -> 16-char hex
22//
23// V1 SCOPE per NISHI_PAGE_FORMAT_V1 §6:
24// - All 9 required tags emitted inp canonical order
25// - Caller supplies values; emitter html-escapes each
26// - Auto-compute: content-hash (from body bytes)
27// - Constants: page-version="1.0.0"
28// - Cross-validate emitted output -- if validator rejects own emit,
29// return INTERNAL_ASSERT_FAIL (catches our own bugs inp development)
30//
31// Status: V1. 2026-05-27.
32
33import "nx_syscalls.nx"
34import "nx_html_escape.nx"
35import "nx_search_inverted.nx"
36import "nx_nishi_page_validator.nx"
37
38// ===== Sealed verdict surface (codes 2500-2519) =================================================
39const NX_NPE_OK: i64 = 0
40const NX_NPE_BAD_INPUT: i64 = 2500
41const NX_NPE_OUTPUT_OVERFLOW: i64 = 2501
42const NX_NPE_INVALID_FIELD_LEN: i64 = 2502
43const NX_NPE_ESCAPE_FAIL: i64 = 2503
44const NX_NPE_INTERNAL_ASSERT_FAIL: i64 = 2504 // emit produced non-conformant output
45const NX_NPE_BODY_TOO_LARGE: i64 = 2505
46
47// ===== Named constants (M7) =================================================
48const NX_NPE_MAX_BODY_BYTES: i64 = 1048576 // 1 MB
49const NX_NPE_CONTENT_HASH_LEN: i64 = 16
50const NX_NPE_PAGE_VERSION: *u8 = "1.0.0" as *u8
51const NX_NPE_PAGE_VERSION_N: i64 = 5
52const NX_NPE_HEAD_OPEN: *u8 = "<head>\n <meta charset=\"utf-8\">\n <!-- NISHI PAGE FORMAT V1 -->\n" as *u8
53const NX_NPE_HEAD_OPEN_N: i64 = 67
54const NX_NPE_HEAD_CLOSE: *u8 = "</head>\n" as *u8
55const NX_NPE_HEAD_CLOSE_N: i64 = 8
56const NX_NPE_HTML_OPEN: *u8 = "<!DOCTYPE html>\n<html lang=\"en\">\n" as *u8
57const NX_NPE_HTML_OPEN_N: i64 = 32
58const NX_NPE_HTML_CLOSE: *u8 = "</html>\n" as *u8
59const NX_NPE_HTML_CLOSE_N: i64 = 8
60const NX_NPE_BODY_OPEN: *u8 = "<body>\n" as *u8
61const NX_NPE_BODY_OPEN_N: i64 = 7
62const NX_NPE_BODY_CLOSE: *u8 = "</body>\n" as *u8
63const NX_NPE_BODY_CLOSE_N: i64 = 8
64const NX_NPE_META_OPEN: *u8 = " <meta name=\"" as *u8
65const NX_NPE_META_OPEN_N: i64 = 16 // 4 spaces + `<meta name="` (12) = 16; was 17 (off-by-one -> a stray leading byte inside name=" nishi-...")
66const NX_NPE_META_MID: *u8 = "\" content=\"" as *u8
67const NX_NPE_META_MID_N: i64 = 11
68const NX_NPE_META_CLOSE: *u8 = "\">\n" as *u8
69const NX_NPE_META_CLOSE_N: i64 = 3
70const NX_NPE_TITLE_OPEN: *u8 = " <title>" as *u8
71const NX_NPE_TITLE_OPEN_N: i64 = 11
72const NX_NPE_TITLE_CLOSE: *u8 = "</title>\n" as *u8
73const NX_NPE_TITLE_CLOSE_N: i64 = 9
74
75// ===== NxNishiPageEmitInputs (caller-supplied metadata) =================================================
76
77struct NxNishiPageEmitInputs {
78 canonical: *u8
79 canonical_n: i64
80 title: *u8
81 title_n: i64
82 summary: *u8
83 summary_n: i64
84 tags: *u8
85 tags_n: i64
86 last_modified: *u8
87 last_modified_n: i64
88 license: *u8
89 license_n: i64
90 author: *u8
91 author_n: i64
92 body: *u8 // raw body bytes (used for content-hash + emit)
93 body_n: i64
94 valid: i64
95}
96
97func nx_npe_inputs_init(inp: *NxNishiPageEmitInputs,
98 canonical: *u8, canonical_n: i64,
99 title: *u8, title_n: i64,
100 summary: *u8, summary_n: i64,
101 tags: *u8, tags_n: i64,
102 last_modified: *u8, last_modified_n: i64,
103 license: *u8, license_n: i64,
104 author: *u8, author_n: i64,
105 body: *u8, body_n: i64) -> i64 {
106 if (inp as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
107 if (canonical as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
108 if canonical_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
109 if (title as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
110 if title_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
111 if title_n > 200 { return 0 - NX_NPE_INVALID_FIELD_LEN }
112 if (summary as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
113 if summary_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
114 if summary_n > 500 { return 0 - NX_NPE_INVALID_FIELD_LEN }
115 if (tags as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
116 if tags_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
117 if (last_modified as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
118 if last_modified_n < 20 { return 0 - NX_NPE_INVALID_FIELD_LEN }
119 if last_modified_n > 32 { return 0 - NX_NPE_INVALID_FIELD_LEN }
120 if (license as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
121 if license_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
122 if license_n > 64 { return 0 - NX_NPE_INVALID_FIELD_LEN }
123 if (author as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
124 if author_n < 1 { return 0 - NX_NPE_INVALID_FIELD_LEN }
125 if author_n > 100 { return 0 - NX_NPE_INVALID_FIELD_LEN }
126 if body_n < 0 { return 0 - NX_NPE_BAD_INPUT }
127 if body_n > NX_NPE_MAX_BODY_BYTES { return 0 - NX_NPE_BODY_TOO_LARGE }
128
129 inp.canonical = canonical; inp.canonical_n = canonical_n
130 inp.title = title; inp.title_n = title_n
131 inp.summary = summary; inp.summary_n = summary_n
132 inp.tags = tags; inp.tags_n = tags_n
133 inp.last_modified = last_modified; inp.last_modified_n = last_modified_n
134 inp.license = license; inp.license_n = license_n
135 inp.author = author; inp.author_n = author_n
136 inp.body = body; inp.body_n = body_n
137 inp.valid = 1
138 return NX_NPE_OK
139}
140
141// ===== Writer helpers (bounded) =================================================
142
143func nx_npe_put_raw(out: *u8, cap: i64, off: i64,
144 src: *u8, n: i64) -> i64 {
145 if off < 0 { return 0 - NX_NPE_BAD_INPUT }
146 if off + n > cap { return 0 - NX_NPE_OUTPUT_OVERFLOW }
147 var i: i64 = 0
148 while i < n {
149 out[off + i] = src[i]
150 i = i + 1
151 }
152 return off + n
153}
154
155func nx_npe_put_escaped(out: *u8, cap: i64, off: i64,
156 src: *u8, n: i64) -> i64 {
157 if n < 1 { return off }
158 if cap - off < n * 6 + 1 { return 0 - NX_NPE_OUTPUT_OVERFLOW }
159 let w: i64 = html_escape((out as i64 + off) as *u8, cap - off, src, n)
160 if w < 0 { return 0 - NX_NPE_ESCAPE_FAIL }
161 return off + w
162}
163
164// ===== Compute content-hash hex (FNV-1a 64 -> 16 lowercase hex) =================================================
165
166func nx_npe_hex16(v: i64, out: *u8) -> i64 {
167 var i: i64 = 0
168 while i < 16 {
169 let nibble: i64 = (v >> ((15 - i) * 4)) & 0xF
170 var c: i64 = 0x30 + nibble
171 if nibble >= 10 { c = 0x61 + nibble - 10 }
172 out[i] = (c & 0xff) as u8
173 i = i + 1
174 }
175 return 16
176}
177
178func nx_nishi_compute_content_hash(body: *u8, body_n: i64, out_hex: *u8) -> i64 {
179 if (body as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
180 if (out_hex as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
181 if body_n < 0 { return 0 - NX_NPE_BAD_INPUT }
182 if body_n > NX_NPE_MAX_BODY_BYTES { return 0 - NX_NPE_BODY_TOO_LARGE }
183 let h: i64 = nx_inv_hash_bytes_lower(body, body_n)
184 nx_npe_hex16(h, out_hex)
185 return NX_NPE_OK
186}
187
188// ===== Emit one <meta name="X" content="Y"> tag =================================================
189
190func nx_nishi_emit_meta(out: *u8, cap: i64, off: i64,
191 name_z: *u8, name_n: i64,
192 value: *u8, value_n: i64) -> i64 {
193 var o: i64 = off
194 o = nx_npe_put_raw(out, cap, o, NX_NPE_META_OPEN, NX_NPE_META_OPEN_N); if o < 0 { return o }
195 o = nx_npe_put_raw(out, cap, o, name_z, name_n); if o < 0 { return o }
196 o = nx_npe_put_raw(out, cap, o, NX_NPE_META_MID, NX_NPE_META_MID_N); if o < 0 { return o }
197 if value_n > 0 {
198 o = nx_npe_put_escaped(out, cap, o, value, value_n); if o < 0 { return o }
199 }
200 o = nx_npe_put_raw(out, cap, o, NX_NPE_META_CLOSE, NX_NPE_META_CLOSE_N); if o < 0 { return o }
201 return o
202}
203
204// ===== Emit head block (the 9 required tags + <title>) =================================================
205
206func nx_nishi_emit_head_block(inp: *NxNishiPageEmitInputs,
207 out: *u8, cap: i64, off: i64,
208 out_off: *i64) -> i64 {
209 if inp.valid != 1 { return 0 - NX_NPE_BAD_INPUT }
210 if (out_off as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
211 var o: i64 = off
212
213 o = nx_npe_put_raw(out, cap, o, NX_NPE_HEAD_OPEN, NX_NPE_HEAD_OPEN_N); if o < 0 { return o }
214
215 // <title> mirrors nishi-title (per spec §3.1 SHOULD).
216 o = nx_npe_put_raw(out, cap, o, NX_NPE_TITLE_OPEN, NX_NPE_TITLE_OPEN_N); if o < 0 { return o }
217 o = nx_npe_put_escaped(out, cap, o, inp.title, inp.title_n); if o < 0 { return o }
218 o = nx_npe_put_raw(out, cap, o, NX_NPE_TITLE_CLOSE, NX_NPE_TITLE_CLOSE_N); if o < 0 { return o }
219
220 // Nine required meta tags (per NISHI_PAGE_FORMAT_V1 §2; emission order
221 // matches §6 reference page).
222 o = nx_nishi_emit_meta(out, cap, o, "nishi-canonical" as *u8, 15,
223 inp.canonical, inp.canonical_n); if o < 0 { return o }
224 o = nx_nishi_emit_meta(out, cap, o, "nishi-title" as *u8, 11,
225 inp.title, inp.title_n); if o < 0 { return o }
226 o = nx_nishi_emit_meta(out, cap, o, "nishi-summary" as *u8, 13,
227 inp.summary, inp.summary_n); if o < 0 { return o }
228 o = nx_nishi_emit_meta(out, cap, o, "nishi-tags" as *u8, 10,
229 inp.tags, inp.tags_n); if o < 0 { return o }
230 o = nx_nishi_emit_meta(out, cap, o, "nishi-last-modified" as *u8, 19,
231 inp.last_modified, inp.last_modified_n); if o < 0 { return o }
232
233 // Auto-computed content-hash.
234 let hash_hex: *u8 = (sys_mmap(NX_NPE_CONTENT_HASH_LEN + 1)) as *u8
235 let rc_h: i64 = nx_nishi_compute_content_hash(inp.body, inp.body_n, hash_hex)
236 if rc_h != NX_NPE_OK { return rc_h }
237 o = nx_nishi_emit_meta(out, cap, o, "nishi-content-hash" as *u8, 18,
238 hash_hex, NX_NPE_CONTENT_HASH_LEN); if o < 0 { return o }
239
240 o = nx_nishi_emit_meta(out, cap, o, "nishi-page-version" as *u8, 18,
241 NX_NPE_PAGE_VERSION, NX_NPE_PAGE_VERSION_N); if o < 0 { return o }
242 o = nx_nishi_emit_meta(out, cap, o, "nishi-license" as *u8, 13,
243 inp.license, inp.license_n); if o < 0 { return o }
244 o = nx_nishi_emit_meta(out, cap, o, "nishi-author" as *u8, 12,
245 inp.author, inp.author_n); if o < 0 { return o }
246
247 o = nx_npe_put_raw(out, cap, o, NX_NPE_HEAD_CLOSE, NX_NPE_HEAD_CLOSE_N); if o < 0 { return o }
248
249 out_off[0] = o
250 return NX_NPE_OK
251}
252
253// ===== Top-level: emit complete conforming page + cross-validate =================================================
254
255func nx_nishi_emit_full_page(inp: *NxNishiPageEmitInputs,
256 out: *u8, cap: i64, out_n: *i64) -> i64 {
257 if inp.valid != 1 { return 0 - NX_NPE_BAD_INPUT }
258 if (out as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
259 if (out_n as i64) == 0 { return 0 - NX_NPE_BAD_INPUT }
260 out_n[0] = 0
261
262 var o: i64 = 0
263 o = nx_npe_put_raw(out, cap, o, NX_NPE_HTML_OPEN, NX_NPE_HTML_OPEN_N); if o < 0 { return o }
264
265 let head_off: *i64 = (sys_mmap(8)) as *i64
266 head_off[0] = o
267 let rc_h: i64 = nx_nishi_emit_head_block(inp, out, cap, o, head_off)
268 if rc_h != NX_NPE_OK { return rc_h }
269 o = head_off[0]
270
271 o = nx_npe_put_raw(out, cap, o, NX_NPE_BODY_OPEN, NX_NPE_BODY_OPEN_N); if o < 0 { return o }
272 if inp.body_n > 0 {
273 o = nx_npe_put_raw(out, cap, o, inp.body, inp.body_n); if o < 0 { return o }
274 }
275 o = nx_npe_put_raw(out, cap, o, NX_NPE_BODY_CLOSE, NX_NPE_BODY_CLOSE_N); if o < 0 { return o }
276 o = nx_npe_put_raw(out, cap, o, NX_NPE_HTML_CLOSE, NX_NPE_HTML_CLOSE_N); if o < 0 { return o }
277
278 out_n[0] = o
279
280 // Cross-validate own output (Cardinal 10 idempotency proof).
281 // If our emit produces non-conformant output, that's an internal
282 // bug -- caller gets INTERNAL_ASSERT_FAIL rather than a silently
283 // bad page.
284 let report: *NxNishiPageReport = (sys_mmap(128)) as *NxNishiPageReport
285 let rc_v: i64 = nx_nishi_page_validate(out, o, report)
286 if rc_v != NX_NPV_OK { return 0 - NX_NPE_INTERNAL_ASSERT_FAIL }
287
288 return NX_NPE_OK
289}