nx_wiki_toc.nx source
↩ module page · 330 lines · 14180 B
1// nx_wiki_toc.nx -- wiki R4: auto table-of-contents + heading anchor ids.
2//
3// COMPOSES: nx_syscalls (sys_mmap scratch). No markdown library needed -- it
4// reads ATX heading LINES directly from the markdown source (the same #/##/###
5// the renderer turns into <hN>), so the TOC and the rendered anchors share one
6// definition of "what a heading is".
7//
8// TWO halves:
9// 1. nx_wiki_toc_build(src,n) -> <nav class="nx-toc"><ol>...<li><a
10// href="#<anchor>">heading text</a></li>...</ol></nav> (one <li> per
11// ATX heading, in document order). Empty <nav> elided -> "" when a doc
12// has no headings.
13// 2. nx_wiki_toc_inject_ids(html,n) -> a COPY of rendered HTML with
14// id="<anchor>" added to every <hN ...> open tag that lacks one. The
15// anchor for an <hN> is derived from that tag's INNER TEXT, computed by
16// the SAME nx_wiki_toc_slugify the TOC links use -> the "#section-a"
17// links in (1) jump to the id="section-a" stamped here.
18//
19// ANCHOR RULE: lowercase the heading text; every run of non-[a-z0-9] bytes ->
20// a single '-'; trim leading/trailing '-'. Deterministic + collision-tolerant
21// (duplicate headings get duplicate ids -- browsers jump to the first, which
22// is the documented, acceptable behavior; a -2/-3 disambiguator is a follow-on).
23//
24// Hygiene: M1 out-params; M3 capped loops; M5 bounded indexing; M6 real
25// semantics; M7 named constants; M8 propagated verdicts.
26//
27// Status: V1 (wiki R4). 2026-06-15. license_tier: ORIGINAL
28import "nx_syscalls.nx"
29
30// ===== Sealed verdict surface (codes 2660-2679) ==============================
31const NX_WTOC_OK: i64 = 0
32const NX_WTOC_BAD_INPUT: i64 = 2660
33const NX_WTOC_OVERFLOW: i64 = 2661
34const NX_WTOC_LOOP_BUDGET: i64 = 2662
35
36// ===== Named sizing constants (M7) ===========================================
37const NX_WTOC_MAX_HEADINGS: i64 = 256 // headings per document cap
38const NX_WTOC_MAX_HEAD_LEN: i64 = 512 // single heading text cap
39const NX_WTOC_SCAN_BUDGET: i64 = 8000000 // per-doc byte-scan cap (M3)
40const NX_WTOC_ANCHOR_DASH: i64 = 0x2D // '-'
41
42const NX_WTOC_HASH: i64 = 0x23 // '#'
43const NX_WTOC_SP: i64 = 0x20 // ' '
44const NX_WTOC_LF: i64 = 0x0A // '\n'
45const NX_WTOC_CR: i64 = 0x0D // '\r'
46const NX_WTOC_LT: i64 = 0x3C // '<'
47const NX_WTOC_GT: i64 = 0x3E // '>'
48
49// ===== anchor slugify ========================================================
50//
51// Writes the anchor for heading text (src,n) into out (cap), NUL-terminated.
52// Returns anchor byte length (>=0, excluding NUL) or -verdict. Rule: lowercase
53// a..z, keep 0..9, everything else collapses to a single '-', no leading or
54// trailing '-'. Empty/symbol-only heading -> "" (length 0).
55func nx_wiki_toc_slugify(src: *u8, n: i64, out: *u8, cap: i64) -> i64 {
56 if (out as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
57 if cap < 2 { return 0 - NX_WTOC_BAD_INPUT }
58 var o: i64 = 0
59 var i: i64 = 0
60 var pending_dash: i64 = 0 // emit a '-' before next kept char (if any emitted)
61 var iter: i64 = 0
62 while i < n {
63 if iter >= NX_WTOC_SCAN_BUDGET { return 0 - NX_WTOC_LOOP_BUDGET }
64 iter = iter + 1
65 let c: i64 = src[i] as i64
66 var lc: i64 = c
67 // uppercase A..Z -> lowercase
68 if c >= 0x41 { if c <= 0x5A { lc = c + 32 } }
69 var is_alnum: i64 = 0
70 if lc >= 0x61 { if lc <= 0x7A { is_alnum = 1 } } // a..z
71 if lc >= 0x30 { if lc <= 0x39 { is_alnum = 1 } } // 0..9
72 if is_alnum == 1 {
73 // flush a pending separator only if we've already emitted a char
74 if pending_dash == 1 {
75 if o > 0 {
76 if o + 1 >= cap { return 0 - NX_WTOC_OVERFLOW }
77 out[o] = NX_WTOC_ANCHOR_DASH as u8
78 o = o + 1
79 }
80 pending_dash = 0
81 }
82 if o + 1 >= cap { return 0 - NX_WTOC_OVERFLOW }
83 out[o] = lc as u8
84 o = o + 1
85 }
86 if is_alnum == 0 { pending_dash = 1 }
87 i = i + 1
88 }
89 out[o] = 0 as u8
90 return o
91}
92
93// ===== TOC build =============================================================
94//
95// Scans src line by line; a line matching ^#{1,6} ' ' is a heading. Emits a
96// nav/ol list of links to each heading's anchor. Writes NUL-terminated HTML
97// into out (cap). Returns bytes written (>=0, excluding NUL) or -verdict.
98// When the doc has no headings, writes "" (length 0).
99
100func nx_wtoc_emit(out: *u8, off: i64, cap: i64, s: *u8) -> i64 {
101 var i: i64 = 0
102 while s[i] != (0 as u8) {
103 if off + i >= cap { return 0 - NX_WTOC_OVERFLOW }
104 out[off + i] = s[i]
105 i = i + 1
106 }
107 return off + i
108}
109func nx_wtoc_emit_bytes(out: *u8, off: i64, cap: i64, src: *u8, n: i64) -> i64 {
110 var i: i64 = 0
111 while i < n {
112 if i >= NX_WTOC_MAX_HEAD_LEN { return 0 - NX_WTOC_OVERFLOW }
113 if off + i >= cap { return 0 - NX_WTOC_OVERFLOW }
114 out[off + i] = src[i]
115 i = i + 1
116 }
117 return off + i
118}
119
120func nx_wiki_toc_build(src: *u8, n: i64, out: *u8, cap: i64,
121 out_used: *i64) -> i64 {
122 if (out_used as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
123 out_used[0] = 0
124 if (out as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
125 if cap < 64 { return 0 - NX_WTOC_BAD_INPUT }
126 if (src as i64) == 0 { out[0] = 0 as u8; return NX_WTOC_OK }
127 if n < 0 { return 0 - NX_WTOC_BAD_INPUT }
128
129 let anchor: *u8 = sys_mmap(NX_WTOC_MAX_HEAD_LEN + 2)
130
131 var o: i64 = 0
132 var headings: i64 = 0
133 var ls: i64 = 0 // current line start
134 var iter: i64 = 0
135 var emitted_open: i64 = 0 // have we written <nav>...<ol> yet?
136
137 while ls < n {
138 if iter >= NX_WTOC_MAX_HEADINGS { return 0 - NX_WTOC_LOOP_BUDGET }
139 iter = iter + 1
140 // find line end (index of '\n' or n) -- clean single-exit scan
141 var le: i64 = ls
142 var ldone: i64 = 0
143 while ldone == 0 {
144 if le >= n { ldone = 1 }
145 if ldone == 0 {
146 if src[le] == (NX_WTOC_LF as u8) { ldone = 1 }
147 if src[le] != (NX_WTOC_LF as u8) { le = le + 1 }
148 }
149 }
150 // count leading '#' -- stop AT the first non-'#' (leave h there)
151 var h: i64 = ls
152 var level: i64 = 0
153 var hdone: i64 = 0
154 while hdone == 0 {
155 if h >= le { hdone = 1 }
156 if hdone == 0 {
157 if src[h] == (NX_WTOC_HASH as u8) { level = level + 1; h = h + 1 }
158 if src[h] != (NX_WTOC_HASH as u8) { hdone = 1 }
159 }
160 }
161 // a heading needs 1..6 '#' then a single space
162 var is_heading: i64 = 0
163 var text_start: i64 = h
164 if level >= 1 {
165 if level <= 6 {
166 if h < le {
167 if src[h] == (NX_WTOC_SP as u8) { is_heading = 1; text_start = h + 1 }
168 }
169 }
170 }
171 if is_heading == 1 {
172 // text end = le, minus a trailing CR
173 var text_end: i64 = le
174 if text_end > text_start {
175 if src[text_end - 1] == (NX_WTOC_CR as u8) { text_end = text_end - 1 }
176 }
177 let text_ptr: *u8 = (src as i64 + text_start) as *u8
178 let text_n: i64 = text_end - text_start
179 let an: i64 = nx_wiki_toc_slugify(text_ptr, text_n, anchor, NX_WTOC_MAX_HEAD_LEN + 2)
180 if an > 0 {
181 if emitted_open == 0 {
182 o = nx_wtoc_emit(out, o, cap, "<nav class=\"nx-toc\" aria-label=\"On this page\"><ol>" as *u8)
183 if o < 0 { return o }
184 emitted_open = 1
185 }
186 o = nx_wtoc_emit(out, o, cap, "<li><a href=\"#" as *u8)
187 if o < 0 { return o }
188 o = nx_wtoc_emit_bytes(out, o, cap, anchor, an)
189 if o < 0 { return o }
190 o = nx_wtoc_emit(out, o, cap, "\">" as *u8)
191 if o < 0 { return o }
192 o = nx_wtoc_emit_bytes(out, o, cap, text_ptr, text_n)
193 if o < 0 { return o }
194 o = nx_wtoc_emit(out, o, cap, "</a></li>" as *u8)
195 if o < 0 { return o }
196 headings = headings + 1
197 }
198 }
199 // advance to next line
200 ls = le + 1
201 }
202
203 if emitted_open == 1 {
204 o = nx_wtoc_emit(out, o, cap, "</ol></nav>" as *u8)
205 if o < 0 { return o }
206 }
207 out[o] = 0 as u8
208 out_used[0] = o
209 return NX_WTOC_OK
210}
211
212// ===== Heading anchor-id injection ===========================================
213//
214// Reads rendered HTML (html,n); writes a COPY into out (cap) with id="<anchor>"
215// inserted into every <hN ...> open tag (N in 1..6) that does not already carry
216// an id=. The anchor is slugified from the tag's INNER TEXT (bytes between the
217// '>' that closes the open tag and the next '<'), tags stripped. Returns bytes
218// written (>=0, NUL-terminated) or -verdict.
219//
220// This is what makes the TOC links live: <h2>Section A</h2> becomes
221// <h2 id="section-a">Section A</h2>, the target of the TOC's "#section-a" link.
222
223func nx_wiki_toc_inject_ids(html: *u8, n: i64, out: *u8, cap: i64,
224 out_used: *i64) -> i64 {
225 if (out_used as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
226 out_used[0] = 0
227 if (out as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
228 if (html as i64) == 0 { return 0 - NX_WTOC_BAD_INPUT }
229 if cap < n + 1 { return 0 - NX_WTOC_OVERFLOW }
230 if n < 0 { return 0 - NX_WTOC_BAD_INPUT }
231
232 let anchor: *u8 = sys_mmap(NX_WTOC_MAX_HEAD_LEN + 2)
233
234 var i: i64 = 0
235 var o: i64 = 0
236 var iter: i64 = 0
237 while i < n {
238 if iter >= NX_WTOC_SCAN_BUDGET { return 0 - NX_WTOC_LOOP_BUDGET }
239 iter = iter + 1
240 var handled: i64 = 0
241 // detect "<h" then a digit 1..6 at i
242 if i + 2 < n {
243 if html[i] == (NX_WTOC_LT as u8) {
244 let c1: i64 = html[i + 1] as i64
245 let c2: i64 = html[i + 2] as i64
246 var is_h: i64 = 0
247 if c1 == 0x68 { if c2 >= 0x31 { if c2 <= 0x36 { is_h = 1 } } } // 'h' '1'..'6'
248 if is_h == 1 {
249 // find end of this open tag '>' -- clean single-exit scan
250 var tag_end: i64 = i + 2
251 var found_gt: i64 = 0
252 var gdone: i64 = 0
253 while gdone == 0 {
254 if tag_end >= n { gdone = 1 }
255 if tag_end - i >= NX_WTOC_MAX_HEAD_LEN { gdone = 1 }
256 if gdone == 0 {
257 if html[tag_end] == (NX_WTOC_GT as u8) { found_gt = 1; gdone = 1 }
258 if found_gt == 0 { tag_end = tag_end + 1 }
259 }
260 }
261 if found_gt == 1 {
262 // does the open tag already contain id= ? (scan i..tag_end)
263 var has_id: i64 = 0
264 var p: i64 = i + 2
265 while p < tag_end - 2 {
266 if has_id == 0 {
267 if html[p] == (0x69 as u8) { // 'i'
268 if html[p + 1] == (0x64 as u8) { // 'd'
269 if html[p + 2] == (0x3D as u8) { has_id = 1 } // '='
270 }
271 }
272 }
273 p = p + 1
274 }
275 // inner text = bytes after '>' up to next '<' -- clean scan
276 let inner_start: i64 = tag_end + 1
277 var inner_end: i64 = inner_start
278 var idone: i64 = 0
279 while idone == 0 {
280 if inner_end >= n { idone = 1 }
281 if inner_end - inner_start >= NX_WTOC_MAX_HEAD_LEN { idone = 1 }
282 if idone == 0 {
283 if html[inner_end] == (NX_WTOC_LT as u8) { idone = 1 }
284 if html[inner_end] != (NX_WTOC_LT as u8) { inner_end = inner_end + 1 }
285 }
286 }
287 let inner_ptr: *u8 = (html as i64 + inner_start) as *u8
288 let inner_n: i64 = inner_end - inner_start
289 let an: i64 = nx_wiki_toc_slugify(inner_ptr, inner_n, anchor, NX_WTOC_MAX_HEAD_LEN + 2)
290 // copy the open tag verbatim up to (but not including) '>'
291 var t: i64 = i
292 while t < tag_end {
293 if o >= cap { return 0 - NX_WTOC_OVERFLOW }
294 out[o] = html[t]
295 o = o + 1
296 t = t + 1
297 }
298 // inject id="<anchor>" before '>' (only if absent + non-empty)
299 if has_id == 0 {
300 if an > 0 {
301 o = nx_wtoc_emit(out, o, cap, " id=\"" as *u8)
302 if o < 0 { return o }
303 o = nx_wtoc_emit_bytes(out, o, cap, anchor, an)
304 if o < 0 { return o }
305 if o >= cap { return 0 - NX_WTOC_OVERFLOW }
306 out[o] = (0x22 as u8) // '"'
307 o = o + 1
308 }
309 }
310 // now write the '>' and resume after it
311 if o >= cap { return 0 - NX_WTOC_OVERFLOW }
312 out[o] = NX_WTOC_GT as u8
313 o = o + 1
314 i = tag_end + 1
315 handled = 1
316 }
317 }
318 }
319 }
320 if handled == 0 {
321 if o >= cap { return 0 - NX_WTOC_OVERFLOW }
322 out[o] = html[i]
323 o = o + 1
324 i = i + 1
325 }
326 }
327 out[o] = 0 as u8
328 out_used[0] = o
329 return NX_WTOC_OK
330}