code wiki / hub / nx_nishi_page_emit.nx

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}