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}