nx_markdown_block.nx source
↩ module page · 359 lines · 12239 B
1// markdown_block.nx -- block-level Markdown -> HTML.
2//
3// CommonMark §4 + §5 practical subset. Walks input line-by-line
4// recognising block constructs, sends runs of text through
5// markdown_inline for inline formatting.
6//
7// Supported block-level constructs:
8// # Heading -> <h1>...</h1> (levels 1-6 by # count)
9// ## Subheading -> <h2>...</h2>
10// (blank line) -> closes current paragraph
11// ```fenced -> <pre><code>...</code></pre>
12// - bullet -> <ul><li>...</li></ul>
13// * bullet -> <ul><li>...</li></ul>
14// 1. ordered -> <ol><li>...</li></ol>
15// regular text -> <p>...</p>
16//
17// NOT supported (future markdown_block_ext.nx): tables, blockquotes,
18// setext headings (=== / ---), horizontal rules, link reference
19// definitions, indented code blocks.
20//
21// Composes markdown_inline + html_escape.
22//
23// Invariants:
24// B1 Fenced code block content is NOT run through inline
25// markdown -- raw text with html_escape only.
26// B2 List items have inline markdown applied to their content.
27// B3 Consecutive list items collapse into one <ul> / <ol>.
28// B4 Output is valid HTML fragment (no <html>/<head>/<body>).
29//
30// nx_safety_envelope:
31// intended_use: "Markdown block-level parser (CommonMark
32// section 4) -- docs / README / nishi-library
33// catalog ingestion"
34// sil_target: SIL1
35// asil_target: QM
36// dal_target: NONE
37// evidence: [CommonMark_0_30_canonical_basis,
38// sealed_block_kind_enum, no_FP,
39// bounded_block_parse_state_machine]
40// hazard_register: [bug-tape-XSS-via-raw-HTML-passthrough,
41// bug-tape-list-nesting-depth-bomb]
42// residual_risk: "Output HTML must be sanitized by consumer
43// (substrate does not strip raw HTML)."
44// verdict: NOT_YET_EVALUATED
45
46import "nx_syscalls.nx"
47import "nx_markdown_inline.nx"
48import "nx_html_escape.nx"
49const MB_MAGIC_2048: i64 = 2048
50
51const MB_ERR_SHORT: i64 = -1
52
53func mb_put(out: *u8, cap: i64, off: i64, src: *u8, n: i64) -> i64 {
54 if off + n > cap { return MB_ERR_SHORT }
55 var i: i64 = 0
56 while i < n {
57 out[off + i] = src[i]
58 i = i + 1
59 }
60 return off + n
61}
62
63// Render src[a..b] as inline markdown into out[off..].
64func mb_put_inline(out: *u8, cap: i64, off: i64,
65 src: *u8, a: i64, b: i64) -> i64 {
66 let w: i64 = markdown_inline(src + a, b - a, out + off, cap - off)
67 if w < 0 { return MB_ERR_SHORT }
68 return off + w
69}
70
71// Render src[a..b] as html-escaped (for code blocks).
72func mb_put_esc(out: *u8, cap: i64, off: i64,
73 src: *u8, a: i64, b: i64) -> i64 {
74 let w: i64 = html_escape(out + off, cap - off, src + a, b - a)
75 if w < 0 { return MB_ERR_SHORT }
76 return off + w
77}
78
79// Find end of line from off; returns position of LF (or n).
80func mb_eol(buf: *u8, n: i64, off: i64) -> i64 {
81 var i: i64 = off
82 while i < n {
83 if buf[i] == 0x0A { return i }
84 i = i + 1
85 }
86 return n
87}
88
89// Count leading '#' chars up to max 6; returns count (0 if none).
90func mb_heading_level(buf: *u8, off: i64, eol: i64) -> i64 {
91 var i: i64 = off
92 var c: i64 = 0
93 while i < eol {
94 if buf[i] != 0x23 { break }
95 c = c + 1
96 i = i + 1
97 if c >= 6 { break }
98 }
99 // Must be followed by a space (CommonMark §4.2).
100 if i < eol {
101 if buf[i] != 0x20 { return 0 }
102 }
103 return c
104}
105
106// Is the trimmed line a fence line "```" (at least 3)?
107func mb_is_fence(buf: *u8, off: i64, eol: i64) -> i64 {
108 if eol - off < 3 { return 0 }
109 if buf[off] != 0x60 { return 0 }
110 if buf[off + 1] != 0x60 { return 0 }
111 if buf[off + 2] != 0x60 { return 0 }
112 return 1
113}
114
115// Is this line a bullet (starts with '-' or '*' + space)?
116// Returns column of the content after the bullet, or -1 if not
117// a bullet.
118func mb_is_bullet(buf: *u8, off: i64, eol: i64) -> i64 {
119 if off + 1 >= eol { return -1 }
120 let c: i64 = buf[off]
121 if c != 0x2D {
122 if c != 0x2A { return -1 }
123 }
124 if buf[off + 1] != 0x20 { return -1 }
125 return off + 2
126}
127
128// Is this line an ordered-list item ("1." / "12." etc.)?
129// Returns content start offset or -1.
130func mb_is_ordered(buf: *u8, off: i64, eol: i64) -> i64 {
131 var i: i64 = off
132 var any: i64 = 0
133 while i < eol {
134 let b: i64 = buf[i]
135 if b < 0x30 { break }
136 if b > 0x39 { break }
137 any = 1
138 i = i + 1
139 }
140 if any == 0 { return -1 }
141 if i >= eol { return -1 }
142 if buf[i] != 0x2E { return -1 }
143 i = i + 1
144 if i >= eol { return -1 }
145 if buf[i] != 0x20 { return -1 }
146 return i + 1
147}
148
149// Is this line empty / whitespace-only?
150func mb_is_blank(buf: *u8, off: i64, eol: i64) -> i64 {
151 var i: i64 = off
152 while i < eol {
153 let b: i64 = buf[i]
154 if b != 0x20 {
155 if b != 0x09 { return 0 }
156 }
157 i = i + 1
158 }
159 return 1
160}
161
162// Main block walker.
163func markdown_block(src: *u8, n: i64, out: *u8, cap: i64) -> i64 {
164 var pos: i64 = 0
165 var out_pos: i64 = 0
166 var in_ul: i64 = 0
167 var in_ol: i64 = 0
168
169 while pos < n {
170 let eol: i64 = mb_eol(src, n, pos)
171
172 // Blank line?
173 if mb_is_blank(src, pos, eol) == 1 {
174 // Close any open list.
175 if in_ul == 1 {
176 out_pos = mb_put(out, cap, out_pos, "</ul>\n", 6)
177 if out_pos < 0 { return out_pos }
178 in_ul = 0
179 }
180 if in_ol == 1 {
181 out_pos = mb_put(out, cap, out_pos, "</ol>\n", 6)
182 if out_pos < 0 { return out_pos }
183 in_ol = 0
184 }
185 pos = eol + 1
186 continue
187 }
188
189 // Fenced code?
190 if mb_is_fence(src, pos, eol) == 1 {
191 out_pos = mb_put(out, cap, out_pos, "<pre><code>", 11)
192 if out_pos < 0 { return out_pos }
193 pos = eol + 1
194 while pos < n {
195 let inner_eol: i64 = mb_eol(src, n, pos)
196 if mb_is_fence(src, pos, inner_eol) == 1 {
197 pos = inner_eol + 1
198 break
199 }
200 out_pos = mb_put_esc(out, cap, out_pos,
201 src, pos, inner_eol)
202 if out_pos < 0 { return out_pos }
203 out_pos = mb_put(out, cap, out_pos, "\n", 1)
204 if out_pos < 0 { return out_pos }
205 pos = inner_eol + 1
206 }
207 out_pos = mb_put(out, cap, out_pos, "</code></pre>\n", 14)
208 if out_pos < 0 { return out_pos }
209 continue
210 }
211
212 // Heading?
213 let hlvl: i64 = mb_heading_level(src, pos, eol)
214 if hlvl > 0 {
215 let open: *u8 = "<h1>"
216 // Write h-tag with dynamic level.
217 if out_pos + 4 > cap { return MB_ERR_SHORT }
218 out[out_pos] = 0x3C
219 out[out_pos + 1] = 0x68
220 out[out_pos + 2] = 0x30 + hlvl
221 out[out_pos + 3] = 0x3E
222 out_pos = out_pos + 4
223 let content_start: i64 = pos + hlvl + 1 // skip # + space
224 out_pos = mb_put_inline(out, cap, out_pos,
225 src, content_start, eol)
226 if out_pos < 0 { return out_pos }
227 if out_pos + 6 > cap { return MB_ERR_SHORT }
228 out[out_pos] = 0x3C
229 out[out_pos + 1] = 0x2F
230 out[out_pos + 2] = 0x68
231 out[out_pos + 3] = 0x30 + hlvl
232 out[out_pos + 4] = 0x3E
233 out[out_pos + 5] = 0x0A
234 out_pos = out_pos + 6
235 pos = eol + 1
236 continue
237 }
238
239 // Bullet list?
240 let b_start: i64 = mb_is_bullet(src, pos, eol)
241 if b_start >= 0 {
242 if in_ul == 0 {
243 if in_ol == 1 {
244 out_pos = mb_put(out, cap, out_pos, "</ol>\n", 6)
245 if out_pos < 0 { return out_pos }
246 in_ol = 0
247 }
248 out_pos = mb_put(out, cap, out_pos, "<ul>\n", 5)
249 if out_pos < 0 { return out_pos }
250 in_ul = 1
251 }
252 out_pos = mb_put(out, cap, out_pos, "<li>", 4)
253 if out_pos < 0 { return out_pos }
254 out_pos = mb_put_inline(out, cap, out_pos, src, b_start, eol)
255 if out_pos < 0 { return out_pos }
256 out_pos = mb_put(out, cap, out_pos, "</li>\n", 6)
257 if out_pos < 0 { return out_pos }
258 pos = eol + 1
259 continue
260 }
261
262 // Ordered list?
263 let o_start: i64 = mb_is_ordered(src, pos, eol)
264 if o_start >= 0 {
265 if in_ol == 0 {
266 if in_ul == 1 {
267 out_pos = mb_put(out, cap, out_pos, "</ul>\n", 6)
268 if out_pos < 0 { return out_pos }
269 in_ul = 0
270 }
271 out_pos = mb_put(out, cap, out_pos, "<ol>\n", 5)
272 if out_pos < 0 { return out_pos }
273 in_ol = 1
274 }
275 out_pos = mb_put(out, cap, out_pos, "<li>", 4)
276 if out_pos < 0 { return out_pos }
277 out_pos = mb_put_inline(out, cap, out_pos, src, o_start, eol)
278 if out_pos < 0 { return out_pos }
279 out_pos = mb_put(out, cap, out_pos, "</li>\n", 6)
280 if out_pos < 0 { return out_pos }
281 pos = eol + 1
282 continue
283 }
284
285 // Fallback: paragraph.
286 if in_ul == 1 {
287 out_pos = mb_put(out, cap, out_pos, "</ul>\n", 6)
288 if out_pos < 0 { return out_pos }
289 in_ul = 0
290 }
291 if in_ol == 1 {
292 out_pos = mb_put(out, cap, out_pos, "</ol>\n", 6)
293 if out_pos < 0 { return out_pos }
294 in_ol = 0
295 }
296 out_pos = mb_put(out, cap, out_pos, "<p>", 3)
297 if out_pos < 0 { return out_pos }
298 out_pos = mb_put_inline(out, cap, out_pos, src, pos, eol)
299 if out_pos < 0 { return out_pos }
300 out_pos = mb_put(out, cap, out_pos, "</p>\n", 5)
301 if out_pos < 0 { return out_pos }
302 pos = eol + 1
303 }
304
305 // Close any trailing open list.
306 if in_ul == 1 {
307 out_pos = mb_put(out, cap, out_pos, "</ul>\n", 6)
308 if out_pos < 0 { return out_pos }
309 }
310 if in_ol == 1 {
311 out_pos = mb_put(out, cap, out_pos, "</ol>\n", 6)
312 if out_pos < 0 { return out_pos }
313 }
314 return out_pos
315}
316
317// Compile-only smoke.
318func main() -> i64 {
319 let out: *u8 = sys_mmap(MB_MAGIC_2048)
320
321 // Heading.
322 let n1: i64 = markdown_block("# Hello\n", 8, out, MB_MAGIC_2048)
323 if n1 <= 0 { return 1 }
324 if out[0] != 0x3C { return 2 } // '<'
325 if out[1] != 0x68 { return 3 } // 'h'
326 if out[2] != 0x31 { return 4 } // '1'
327
328 // Paragraph + bullet list.
329 let post: *u8 = "Hello world\n\n- one\n- two\n"
330 let n2: i64 = markdown_block(post, 25, out, MB_MAGIC_2048)
331 if n2 <= 0 { return 5 }
332 // Must contain <ul> and </ul>
333 var has_ul: i64 = 0
334 var i: i64 = 0
335 while i < n2 - 3 {
336 if out[i] == 0x3C {
337 if out[i + 1] == 0x75 { // 'u'
338 if out[i + 2] == 0x6C { // 'l'
339 if out[i + 3] == 0x3E { // '>'
340 has_ul = 1
341 break
342 }
343 }
344 }
345 }
346 i = i + 1
347 }
348 if has_ul != 1 { return 6 }
349
350 // Fenced code.
351 let code: *u8 = "```\nlet x = 1\n```\n"
352 let n3: i64 = markdown_block(code, 18, out, MB_MAGIC_2048)
353 if n3 <= 0 { return 7 }
354 // Starts with <pre><code>
355 if out[0] != 0x3C { return 8 }
356 if out[1] != 0x70 { return 9 } // 'p'
357 if out[2] != 0x72 { return 10 } // 'r'
358 return 0
359}