code wiki / hub / nx_search_render_html.nx

nx_search_render_html.nx source

↩ module page · 380 lines · 16742 B

1// nx_search_render_html.nx -- HUB primitive; site-agnostic HTML render 2// of NxSearchResults. Operator cardinal: "make sure you make the onsite 3// and offsite search reusable and not just tied to the wiki but with 4// capabilities to be used on other sites" (2026-05-27). 5// 6// COMPOSES (per NISHI_SMALL_SHARP_COMPOSABLE_STANDARD §4.1 M3 + M7): 7// hub/nx_search_query_parser (NxSearchQuery) 8// nx_search_onsite_engine (NxSearchResults) 9// nx_search_snippet_extract (snippet w/ <mark>) 10// nx_html_escape (html_escape) 11// 12// COMPOSED BY: 13// hub/nx_search_handler_flow (next commit; site-agnostic HTTP flow) 14// wiki/nx_wiki_search_wiring (next; THIN wiki adapter) 15// (future) obd-config/nx_obd_search_wiring (THIN obd-config adapter) 16// (future) sprinkler-config search wiring 17// any site rendering search results 18// 19// DESIGN PRINCIPLE (per operator cardinal 2026-05-27): 20// - Emits to RAW BYTE BUFFER (out + cap + *off) -- NOT to any 21// site-specific writer struct. Reusable across wiki, obd-config, 22// sprinkler-config, third-party-hosted Nishi pages. 23// - Per-site CSS classes are PARAMETERIZED via NxSearchRenderTheme 24// (site picks wiki-result vs obd-result vs sprinkler-result). 25// - Per-result data (title/url/body) passed inp via parallel arrays 26// (NxSearchRenderInputs) -- site provides whatever its doc store 27// returns; render emits structure. 28// 29// V1 SCOPE per NISHI_SEARCH_CHARTER.md §6: 30// - Sealed render: results banner + per-result card + empty state 31// - QoS observable (elapsed_us per Cardinal 18) 32// - Defensive at boundary: null title/url/body render explicitly 33// 34// V2 SCOPE (TODO): 35// - Faceted filters sidebar (when nx_search_facets ships) 36// - Pagination controls 37// - "Did you mean?" suggestions 38// - Per-result archive-snapshot link (when nx_archive ships) 39// 40// Status: V1. 2026-05-27. 41 42import "nx_syscalls.nx" 43import "nx_search_query_parser.nx" 44import "nx_search_onsite_engine.nx" 45import "nx_search_snippet_extract.nx" 46import "nx_html_escape.nx" 47 48// ===== Sealed verdict surface (codes 1900-1909) ================================================= 49const NX_SRH_OK: i64 = 0 50const NX_SRH_BAD_INPUT: i64 = 1900 51const NX_SRH_OUTPUT_OVERFLOW: i64 = 1901 52const NX_SRH_INPUTS_MISMATCH: i64 = 1902 53const NX_SRH_SNIPPET_FAIL: i64 = 1903 54const NX_SRH_LOOP_BUDGET: i64 = 1904 55const NX_SRH_THEME_INVALID: i64 = 1905 56 57// ===== Named sizing constants (M7) ================================================= 58const NX_SRH_MAX_RESULTS_RENDER: i64 = 200 59const NX_SRH_SNIPPET_BUF_CAP: i64 = 2048 60const NX_SRH_TITLE_CAP_BYTES: i64 = 512 61const NX_SRH_URL_CAP_BYTES: i64 = 512 62const NX_SRH_LOOP_BUDGET_CAP: i64 = 1000000 63const NX_SRH_INT_BUF_CAP: i64 = 32 64 65// ===== Theme (site-specific CSS class prefix) ================================================= 66// 67// Each site supplies its own class prefix so its stylesheet can 68// target the result chrome without colliding with other sites. 69// Examples: 70// wiki -> "wiki-search-result", "wiki-search-meta", ... 71// obd-config -> "obd-search-result", ... 72 73struct NxSearchRenderTheme { 74 container_class: *u8 // e.g. "wiki-search-results" 75 container_n: i64 76 banner_class: *u8 // e.g. "wiki-search-banner" 77 banner_n: i64 78 result_class: *u8 // e.g. "wiki-search-result" 79 result_n: i64 80 meta_class: *u8 // e.g. "wiki-search-meta" 81 meta_n: i64 82 snippet_class: *u8 // e.g. "wiki-search-snippet" 83 snippet_n: i64 84 empty_class: *u8 // e.g. "wiki-search-empty" 85 empty_n: i64 86 valid: i64 87} 88 89func nx_srh_theme_init(t: *NxSearchRenderTheme, 90 container: *u8, container_n: i64, 91 banner: *u8, banner_n: i64, 92 result: *u8, result_n: i64, 93 meta: *u8, meta_n: i64, 94 snippet: *u8, snippet_n: i64, 95 empty: *u8, empty_n: i64) -> i64 { 96 if (t as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 97 if (container as i64) == 0 { return 0 - NX_SRH_THEME_INVALID } 98 if container_n < 1 { return 0 - NX_SRH_THEME_INVALID } 99 if container_n > 64 { return 0 - NX_SRH_THEME_INVALID } 100 t.container_class = container; t.container_n = container_n 101 t.banner_class = banner; t.banner_n = banner_n 102 t.result_class = result; t.result_n = result_n 103 t.meta_class = meta; t.meta_n = meta_n 104 t.snippet_class = snippet; t.snippet_n = snippet_n 105 t.empty_class = empty; t.empty_n = empty_n 106 t.valid = 1 107 return NX_SRH_OK 108} 109 110// ===== Per-result data inputs (site-agnostic) ================================================= 111// 112// Caller (handler-flow) populates these by walking the result rowids 113// and resolving each through the SITE-SPECIFIC NxSiteSearchAdapter. 114 115struct NxSearchRenderInputs { 116 title_ptrs: *i64 117 title_lens: *i64 118 url_ptrs: *i64 119 url_lens: *i64 120 body_ptrs: *i64 121 body_lens: *i64 122 n: i64 123 valid: i64 124} 125 126func nx_srh_inputs_init(inp: *NxSearchRenderInputs, 127 title_ptrs: *i64, title_lens: *i64, 128 url_ptrs: *i64, url_lens: *i64, 129 body_ptrs: *i64, body_lens: *i64, 130 n: i64) -> i64 { 131 if (inp as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 132 if (title_ptrs as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 133 if (title_lens as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 134 if (url_ptrs as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 135 if (url_lens as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 136 if (body_ptrs as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 137 if (body_lens as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 138 if n < 0 { return 0 - NX_SRH_BAD_INPUT } 139 if n > NX_SRH_MAX_RESULTS_RENDER { return 0 - NX_SRH_BAD_INPUT } 140 inp.title_ptrs = title_ptrs; inp.title_lens = title_lens 141 inp.url_ptrs = url_ptrs; inp.url_lens = url_lens 142 inp.body_ptrs = body_ptrs; inp.body_lens = body_lens 143 inp.n = n 144 inp.valid = 1 145 return NX_SRH_OK 146} 147 148// ===== Raw byte writer helpers (site-agnostic; bounded) ================================================= 149 150func nx_srh_put_raw(out: *u8, cap: i64, off: i64, 151 src: *u8, n: i64) -> i64 { 152 if off < 0 { return 0 - NX_SRH_BAD_INPUT } 153 if off + n > cap { return 0 - NX_SRH_OUTPUT_OVERFLOW } 154 var i: i64 = 0 155 while i < n { 156 if i >= cap { return 0 - NX_SRH_OUTPUT_OVERFLOW } 157 out[off + i] = src[i] 158 i = i + 1 159 } 160 return off + n 161} 162 163func nx_srh_put_z(out: *u8, cap: i64, off: i64, s: *u8) -> i64 { 164 var n: i64 = 0 165 while s[n] != (0 as u8) { 166 if n >= cap { return 0 - NX_SRH_OUTPUT_OVERFLOW } 167 n = n + 1 168 } 169 return nx_srh_put_raw(out, cap, off, s, n) 170} 171 172func nx_srh_put_escaped(out: *u8, cap: i64, off: i64, 173 src: *u8, n: i64) -> i64 { 174 if n < 1 { return off } 175 if cap - off < n * 6 + 1 { return 0 - NX_SRH_OUTPUT_OVERFLOW } 176 let written: i64 = html_escape((out as i64 + off) as *u8, cap - off, src, n) 177 if written < 0 { return 0 - NX_SRH_OUTPUT_OVERFLOW } 178 return off + written 179} 180 181// ===== Decimal int -> bytes helper ================================================= 182 183func nx_srh_int_to_dec(v: i64, out: *u8, cap: i64) -> i64 { 184 if cap < 2 { return 0 } 185 if v == 0 { out[0] = 0x30 as u8; return 1 } 186 var n: i64 = v 187 var neg: i64 = 0 188 if n < 0 { neg = 1; n = 0 - n } 189 let tmp: *u8 = (sys_mmap(NX_SRH_INT_BUF_CAP)) as *u8 190 var k: i64 = 0 191 while n > 0 { 192 if k >= NX_SRH_INT_BUF_CAP - 2 { return 0 } 193 tmp[k] = (0x30 + (n % 10)) as u8 194 n = n / 10 195 k = k + 1 196 } 197 var off_local: i64 = 0 198 if neg == 1 { 199 if off_local >= cap { return 0 } 200 out[off_local] = 0x2D as u8 201 off_local = off_local + 1 202 } 203 var j: i64 = k - 1 204 while j >= 0 { 205 if off_local >= cap { return 0 } 206 out[off_local] = tmp[j] 207 off_local = off_local + 1 208 j = j - 1 209 } 210 return off_local 211} 212 213// ===== Banner (results count + n_terms + scope + elapsed_us) ================================================= 214 215func nx_srh_write_banner(out: *u8, cap: i64, off: i64, 216 q: *NxSearchQuery, r: *NxSearchResults, 217 t: *NxSearchRenderTheme) -> i64 { 218 var o: i64 = off 219 o = nx_srh_put_z(out, cap, o, "<div class=\"" as *u8); if o < 0 { return o } 220 o = nx_srh_put_raw(out, cap, o, t.banner_class, t.banner_n); if o < 0 { return o } 221 o = nx_srh_put_z(out, cap, o, "\">\n" as *u8); if o < 0 { return o } 222 o = nx_srh_put_z(out, cap, o, "<span>Matched </span><b>" as *u8); if o < 0 { return o } 223 let buf: *u8 = (sys_mmap(NX_SRH_INT_BUF_CAP)) as *u8 224 var n: i64 = nx_srh_int_to_dec(r.count, buf, NX_SRH_INT_BUF_CAP) 225 o = nx_srh_put_raw(out, cap, o, buf, n); if o < 0 { return o } 226 o = nx_srh_put_z(out, cap, o, "</b><span> docs from </span><b>" as *u8); if o < 0 { return o } 227 n = nx_srh_int_to_dec(nx_search_query_count(q), buf, NX_SRH_INT_BUF_CAP) 228 o = nx_srh_put_raw(out, cap, o, buf, n); if o < 0 { return o } 229 o = nx_srh_put_z(out, cap, o, "</b><span> terms (scope=" as *u8); if o < 0 { return o } 230 let scope: i64 = nx_search_query_scope(q) 231 if scope == NX_SEARCH_SCOPE_ONSITE { o = nx_srh_put_z(out, cap, o, "onsite" as *u8) } 232 if scope == NX_SEARCH_SCOPE_OFFSITE { o = nx_srh_put_z(out, cap, o, "offsite" as *u8) } 233 if scope == NX_SEARCH_SCOPE_BOTH { o = nx_srh_put_z(out, cap, o, "both" as *u8) } 234 if o < 0 { return o } 235 o = nx_srh_put_z(out, cap, o, ") inp </span><b>" as *u8); if o < 0 { return o } 236 n = nx_srh_int_to_dec(r.elapsed_us, buf, NX_SRH_INT_BUF_CAP) 237 o = nx_srh_put_raw(out, cap, o, buf, n); if o < 0 { return o } 238 o = nx_srh_put_z(out, cap, o, "</b><span> microseconds</span>\n</div>\n" as *u8); if o < 0 { return o } 239 return o 240} 241 242// ===== Empty-state ================================================= 243 244func nx_srh_write_empty(out: *u8, cap: i64, off: i64, 245 q: *NxSearchQuery, 246 t: *NxSearchRenderTheme) -> i64 { 247 var o: i64 = off 248 o = nx_srh_put_z(out, cap, o, "<div class=\"" as *u8); if o < 0 { return o } 249 o = nx_srh_put_raw(out, cap, o, t.empty_class, t.empty_n); if o < 0 { return o } 250 o = nx_srh_put_z(out, cap, o, "\">\n" as *u8); if o < 0 { return o } 251 if nx_search_query_count(q) == 0 { 252 o = nx_srh_put_z(out, cap, o, "<p>Enter a search term inp the box above.</p>\n" as *u8) 253 } 254 if nx_search_query_count(q) > 0 { 255 o = nx_srh_put_z(out, cap, o, 256 "<p>No documents matched all your query terms. Try fewer or different terms.</p>\n" as *u8) 257 } 258 if o < 0 { return o } 259 o = nx_srh_put_z(out, cap, o, "</div>\n" as *u8); if o < 0 { return o } 260 return o 261} 262 263// ===== Single result card ================================================= 264 265func nx_srh_write_card(out: *u8, cap: i64, off: i64, 266 q: *NxSearchQuery, r: *NxSearchResults, 267 inp: *NxSearchRenderInputs, t: *NxSearchRenderTheme, 268 i: i64) -> i64 { 269 if i < 0 { return 0 - NX_SRH_BAD_INPUT } 270 if i >= inp.n { return 0 - NX_SRH_BAD_INPUT } 271 var o: i64 = off 272 273 let title_ptr: *u8 = inp.title_ptrs[i] as *u8 274 let title_n: i64 = inp.title_lens[i] 275 let url_ptr: *u8 = inp.url_ptrs[i] as *u8 276 let url_n: i64 = inp.url_lens[i] 277 let body_ptr: *u8 = inp.body_ptrs[i] as *u8 278 let body_n: i64 = inp.body_lens[i] 279 280 o = nx_srh_put_z(out, cap, o, "<article class=\"" as *u8); if o < 0 { return o } 281 o = nx_srh_put_raw(out, cap, o, t.result_class, t.result_n); if o < 0 { return o } 282 o = nx_srh_put_z(out, cap, o, "\">\n" as *u8); if o < 0 { return o } 283 284 // Title + link. 285 o = nx_srh_put_z(out, cap, o, "<h3><a href=\"" as *u8); if o < 0 { return o } 286 if (url_ptr as i64) != 0 { 287 if url_n > 0 { 288 if url_n > NX_SRH_URL_CAP_BYTES { return 0 - NX_SRH_INPUTS_MISMATCH } 289 o = nx_srh_put_escaped(out, cap, o, url_ptr, url_n); if o < 0 { return o } 290 } 291 } 292 o = nx_srh_put_z(out, cap, o, "\">" as *u8); if o < 0 { return o } 293 if (title_ptr as i64) != 0 { 294 if title_n > 0 { 295 if title_n > NX_SRH_TITLE_CAP_BYTES { return 0 - NX_SRH_INPUTS_MISMATCH } 296 o = nx_srh_put_escaped(out, cap, o, title_ptr, title_n); if o < 0 { return o } 297 } 298 if title_n == 0 { o = nx_srh_put_z(out, cap, o, "(untitled)" as *u8); if o < 0 { return o } } 299 } 300 if (title_ptr as i64) == 0 { o = nx_srh_put_z(out, cap, o, "(missing)" as *u8); if o < 0 { return o } } 301 o = nx_srh_put_z(out, cap, o, "</a></h3>\n" as *u8); if o < 0 { return o } 302 303 // Meta row. 304 o = nx_srh_put_z(out, cap, o, "<div class=\"" as *u8); if o < 0 { return o } 305 o = nx_srh_put_raw(out, cap, o, t.meta_class, t.meta_n); if o < 0 { return o } 306 o = nx_srh_put_z(out, cap, o, "\">rowid=" as *u8); if o < 0 { return o } 307 let int_buf: *u8 = (sys_mmap(NX_SRH_INT_BUF_CAP)) as *u8 308 let int_n: i64 = nx_srh_int_to_dec(nx_search_results_rowid_at(r, i), int_buf, NX_SRH_INT_BUF_CAP) 309 o = nx_srh_put_raw(out, cap, o, int_buf, int_n); if o < 0 { return o } 310 o = nx_srh_put_z(out, cap, o, " score=" as *u8); if o < 0 { return o } 311 let score_buf: *u8 = (sys_mmap(NX_SRH_INT_BUF_CAP)) as *u8 312 let score_n: i64 = nx_srh_int_to_dec(nx_search_results_score_at(r, i), score_buf, NX_SRH_INT_BUF_CAP) 313 o = nx_srh_put_raw(out, cap, o, score_buf, score_n); if o < 0 { return o } 314 o = nx_srh_put_z(out, cap, o, "</div>\n" as *u8); if o < 0 { return o } 315 316 // Snippet. 317 o = nx_srh_put_z(out, cap, o, "<div class=\"" as *u8); if o < 0 { return o } 318 o = nx_srh_put_raw(out, cap, o, t.snippet_class, t.snippet_n); if o < 0 { return o } 319 o = nx_srh_put_z(out, cap, o, "\">" as *u8); if o < 0 { return o } 320 if (body_ptr as i64) != 0 { 321 if body_n > 0 { 322 let snippet_buf: *u8 = (sys_mmap(NX_SRH_SNIPPET_BUF_CAP)) as *u8 323 let snippet_n_out: *i64 = (sys_mmap(8)) as *i64 324 snippet_n_out[0] = 0 325 let rc_snip: i64 = nx_search_snippet_extract(snippet_buf, NX_SRH_SNIPPET_BUF_CAP, 326 body_ptr, body_n, q, snippet_n_out) 327 if rc_snip != NX_SSE_OK { return 0 - NX_SRH_SNIPPET_FAIL } 328 o = nx_srh_put_raw(out, cap, o, snippet_buf, snippet_n_out[0]); if o < 0 { return o } 329 } 330 } 331 if (body_ptr as i64) == 0 { o = nx_srh_put_z(out, cap, o, "(body unavailable)" as *u8); if o < 0 { return o } } 332 o = nx_srh_put_z(out, cap, o, "</div>\n</article>\n" as *u8); if o < 0 { return o } 333 return o 334} 335 336// ===== Top-level: full search-results block ================================================= 337 338func nx_search_render_html(out: *u8, cap: i64, off: i64, 339 q: *NxSearchQuery, r: *NxSearchResults, 340 inp: *NxSearchRenderInputs, 341 t: *NxSearchRenderTheme, 342 out_off: *i64) -> i64 { 343 if (out as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 344 if q.valid != 1 { return 0 - NX_SRH_BAD_INPUT } 345 if r.valid != 1 { return 0 - NX_SRH_BAD_INPUT } 346 if inp.valid != 1 { return 0 - NX_SRH_BAD_INPUT } 347 if t.valid != 1 { return 0 - NX_SRH_THEME_INVALID } 348 if (out_off as i64) == 0 { return 0 - NX_SRH_BAD_INPUT } 349 if inp.n != r.count { return 0 - NX_SRH_INPUTS_MISMATCH } 350 351 var o: i64 = off 352 o = nx_srh_put_z(out, cap, o, "<div class=\"" as *u8); if o < 0 { return o } 353 o = nx_srh_put_raw(out, cap, o, t.container_class, t.container_n); if o < 0 { return o } 354 o = nx_srh_put_z(out, cap, o, "\">\n" as *u8); if o < 0 { return o } 355 356 o = nx_srh_write_banner(out, cap, o, q, r, t); if o < 0 { return o } 357 358 if r.count == 0 { 359 o = nx_srh_write_empty(out, cap, o, q, t); if o < 0 { return o } 360 o = nx_srh_put_z(out, cap, o, "</div>\n" as *u8); if o < 0 { return o } 361 out_off[0] = o 362 return NX_SRH_OK 363 } 364 365 var i: i64 = 0 366 var iter: i64 = 0 367 while i < r.count { 368 if iter >= NX_SRH_LOOP_BUDGET_CAP { return 0 - NX_SRH_LOOP_BUDGET } 369 if i >= NX_SRH_MAX_RESULTS_RENDER { i = r.count + 1 } 370 if i < r.count { 371 o = nx_srh_write_card(out, cap, o, q, r, inp, t, i); if o < 0 { return o } 372 i = i + 1 373 iter = iter + 1 374 } 375 } 376 377 o = nx_srh_put_z(out, cap, o, "</div>\n" as *u8); if o < 0 { return o } 378 out_off[0] = o 379 return NX_SRH_OK 380}