apex-ruby 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/ext/apex_ext/apex_ext.c +6 -0
  3. data/ext/apex_ext/apex_src/AGENTS.md +41 -0
  4. data/ext/apex_ext/apex_src/CHANGELOG.md +412 -2
  5. data/ext/apex_ext/apex_src/CMakeLists.txt +41 -29
  6. data/ext/apex_ext/apex_src/Formula/apex.rb +2 -2
  7. data/ext/apex_ext/apex_src/Package.swift +9 -0
  8. data/ext/apex_ext/apex_src/README.md +31 -9
  9. data/ext/apex_ext/apex_src/ROADMAP.md +5 -0
  10. data/ext/apex_ext/apex_src/VERSION +1 -1
  11. data/ext/apex_ext/apex_src/cli/main.c +1125 -13
  12. data/ext/apex_ext/apex_src/docs/index.md +459 -0
  13. data/ext/apex_ext/apex_src/include/apex/apex.h +67 -5
  14. data/ext/apex_ext/apex_src/include/apex/ast_man.h +20 -0
  15. data/ext/apex_ext/apex_src/include/apex/ast_markdown.h +39 -0
  16. data/ext/apex_ext/apex_src/include/apex/ast_terminal.h +40 -0
  17. data/ext/apex_ext/apex_src/include/apex/module.modulemap +1 -1
  18. data/ext/apex_ext/apex_src/man/apex-config.5 +333 -258
  19. data/ext/apex_ext/apex_src/man/apex-config.5.md +3 -1
  20. data/ext/apex_ext/apex_src/man/apex-plugins.7 +401 -316
  21. data/ext/apex_ext/apex_src/man/apex.1 +663 -620
  22. data/ext/apex_ext/apex_src/man/apex.1.html +703 -0
  23. data/ext/apex_ext/apex_src/man/apex.1.md +160 -90
  24. data/ext/apex_ext/apex_src/objc/Apex.swift +6 -0
  25. data/ext/apex_ext/apex_src/objc/NSString+Apex.h +12 -0
  26. data/ext/apex_ext/apex_src/objc/NSString+Apex.m +9 -0
  27. data/ext/apex_ext/apex_src/pages/index.md +459 -0
  28. data/ext/apex_ext/apex_src/src/_README.md +4 -4
  29. data/ext/apex_ext/apex_src/src/apex.c +702 -44
  30. data/ext/apex_ext/apex_src/src/ast_json.c +1130 -0
  31. data/ext/apex_ext/apex_src/src/ast_json.h +46 -0
  32. data/ext/apex_ext/apex_src/src/ast_man.c +948 -0
  33. data/ext/apex_ext/apex_src/src/ast_markdown.c +409 -0
  34. data/ext/apex_ext/apex_src/src/ast_terminal.c +2516 -0
  35. data/ext/apex_ext/apex_src/src/extensions/abbreviations.c +8 -5
  36. data/ext/apex_ext/apex_src/src/extensions/definition_list.c +491 -1514
  37. data/ext/apex_ext/apex_src/src/extensions/definition_list.h +8 -15
  38. data/ext/apex_ext/apex_src/src/extensions/emoji.c +207 -0
  39. data/ext/apex_ext/apex_src/src/extensions/emoji.h +14 -0
  40. data/ext/apex_ext/apex_src/src/extensions/header_ids.c +178 -71
  41. data/ext/apex_ext/apex_src/src/extensions/highlight.c +37 -5
  42. data/ext/apex_ext/apex_src/src/extensions/ial.c +416 -47
  43. data/ext/apex_ext/apex_src/src/extensions/includes.c +241 -10
  44. data/ext/apex_ext/apex_src/src/extensions/includes.h +1 -0
  45. data/ext/apex_ext/apex_src/src/extensions/metadata.c +166 -3
  46. data/ext/apex_ext/apex_src/src/extensions/metadata.h +7 -0
  47. data/ext/apex_ext/apex_src/src/extensions/sup_sub.c +34 -3
  48. data/ext/apex_ext/apex_src/src/extensions/syntax_highlight.c +55 -10
  49. data/ext/apex_ext/apex_src/src/extensions/syntax_highlight.h +7 -4
  50. data/ext/apex_ext/apex_src/src/extensions/table_html_postprocess.c +84 -52
  51. data/ext/apex_ext/apex_src/src/extensions/toc.c +133 -19
  52. data/ext/apex_ext/apex_src/src/filters_ast.c +194 -0
  53. data/ext/apex_ext/apex_src/src/filters_ast.h +36 -0
  54. data/ext/apex_ext/apex_src/src/html_renderer.c +1265 -35
  55. data/ext/apex_ext/apex_src/src/html_renderer.h +21 -0
  56. data/ext/apex_ext/apex_src/src/plugins_remote.c +40 -14
  57. data/ext/apex_ext/apex_src/tests/CMakeLists.txt +1 -0
  58. data/ext/apex_ext/apex_src/tests/README.md +11 -5
  59. data/ext/apex_ext/apex_src/tests/fixtures/comprehensive_test.md +13 -2
  60. data/ext/apex_ext/apex_src/tests/fixtures/filters/filter_output_with_rawblock.json +1 -0
  61. data/ext/apex_ext/apex_src/tests/fixtures/filters/unwrap.md +7 -0
  62. data/ext/apex_ext/apex_src/tests/fixtures/images/auto-wildcard.md +8 -0
  63. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu.avif +0 -0
  64. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu.jpg +0 -0
  65. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu.webp +0 -0
  66. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu@2x.avif +0 -0
  67. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu@2x.jpg +0 -0
  68. data/ext/apex_ext/apex_src/tests/fixtures/images/img/app-pass-1-profile-menu@2x.webp +0 -0
  69. data/ext/apex_ext/apex_src/tests/fixtures/images/media_formats_test.md +63 -0
  70. data/ext/apex_ext/apex_src/tests/fixtures/includes/data-semi.csv +3 -0
  71. data/ext/apex_ext/apex_src/tests/fixtures/includes/with space.txt +1 -0
  72. data/ext/apex_ext/apex_src/tests/fixtures/tables/inline_tables_test.md +4 -1
  73. data/ext/apex_ext/apex_src/tests/paginate_cli_test.sh +64 -0
  74. data/ext/apex_ext/apex_src/tests/terminal_width_test.sh +29 -0
  75. data/ext/apex_ext/apex_src/tests/test-swift-package.sh +14 -0
  76. data/ext/apex_ext/apex_src/tests/test_cmark_callback.c +189 -0
  77. data/ext/apex_ext/apex_src/tests/test_extensions.c +374 -0
  78. data/ext/apex_ext/apex_src/tests/test_metadata.c +68 -0
  79. data/ext/apex_ext/apex_src/tests/test_output.c +291 -2
  80. data/ext/apex_ext/apex_src/tests/test_runner.c +10 -0
  81. data/ext/apex_ext/apex_src/tests/test_syntax_highlight.c +1 -1
  82. data/ext/apex_ext/apex_src/tests/test_tables.c +17 -1
  83. data/lib/apex/version.rb +1 -1
  84. metadata +32 -2
  85. data/ext/apex_ext/apex_src/docs/FUTURE_FEATURES.md +0 -456
@@ -0,0 +1,2516 @@
1
+ /**
2
+ * ast_terminal.c - Convert cmark-gfm AST to ANSI-colored terminal output
3
+ *
4
+ * This module walks the cmark AST and emits formatted text using ANSI
5
+ * escape codes. It supports:
6
+ * - 8/16-color mode (standard + bright colors)
7
+ * - 256-color mode (xterm-256 palette)
8
+ * - Optional YAML theme files in:
9
+ * ~/.config/apex/terminal/themes/NAME.theme
10
+ *
11
+ * The theme format is intentionally similar to mdless themes so that
12
+ * existing configurations can be reused with minimal changes.
13
+ */
14
+
15
+ #include "apex/apex.h"
16
+ #include "apex/ast_terminal.h"
17
+ #include "table.h" /* For CMARK_NODE_TABLE, CMARK_NODE_TABLE_ROW, CMARK_NODE_TABLE_CELL */
18
+ #include "extensions/emoji.h"
19
+
20
+ #include <stdlib.h>
21
+ #include <string.h>
22
+ #include <strings.h>
23
+ #include <stdio.h>
24
+ #include <ctype.h>
25
+ #include <unistd.h>
26
+ #include <fcntl.h>
27
+ #include <sys/types.h>
28
+ #include <sys/wait.h>
29
+ #include <errno.h>
30
+
31
+ #ifdef APEX_HAVE_LIBYAML
32
+ #include <yaml.h>
33
+ #endif
34
+
35
+ /* ------------------------------------------------------------------------- */
36
+ /* Buffer helper */
37
+ /* ------------------------------------------------------------------------- */
38
+
39
+ typedef struct {
40
+ char *buf;
41
+ size_t len;
42
+ size_t capacity;
43
+ } terminal_buffer;
44
+
45
+ /* Alignment options for table cells */
46
+ typedef enum {
47
+ TERM_ALIGN_DEFAULT = 0,
48
+ TERM_ALIGN_LEFT,
49
+ TERM_ALIGN_CENTER,
50
+ TERM_ALIGN_RIGHT
51
+ } term_align_t;
52
+
53
+ /* Logical table cell/grid structures for span-aware layout */
54
+ typedef struct {
55
+ cmark_node *cell; /* Owning TABLE_CELL node */
56
+ int row_span; /* >= 1 */
57
+ int col_span; /* >= 1 */
58
+ bool is_owner; /* true only for top-left of a span */
59
+ bool removed; /* true if helper / data-remove */
60
+ bool printed; /* has this owner's content been rendered yet? */
61
+ } term_cell;
62
+
63
+ typedef struct {
64
+ term_cell *cells; /* Flattened rows*cols array */
65
+ int rows;
66
+ int cols;
67
+ } term_table_grid;
68
+
69
+ static term_cell *grid_at(term_table_grid *g, int r, int c) {
70
+ if (!g || r < 0 || r >= g->rows || c < 0 || c >= g->cols) return NULL;
71
+ return &g->cells[r * g->cols + c];
72
+ }
73
+
74
+ static void buffer_init(terminal_buffer *buf) {
75
+ buf->buf = NULL;
76
+ buf->len = 0;
77
+ buf->capacity = 0;
78
+ }
79
+
80
+ static void buffer_append(terminal_buffer *buf, const char *str, size_t len) {
81
+ if (!str || len == 0) return;
82
+
83
+ if (buf->len + len + 1 > buf->capacity) {
84
+ size_t new_cap = buf->capacity ? buf->capacity * 2 : 256;
85
+ if (new_cap < buf->len + len + 1) {
86
+ new_cap = buf->len + len + 1;
87
+ }
88
+ char *new_buf = (char *)realloc(buf->buf, new_cap);
89
+ if (!new_buf) return;
90
+ buf->buf = new_buf;
91
+ buf->capacity = new_cap;
92
+ }
93
+
94
+ memcpy(buf->buf + buf->len, str, len);
95
+ buf->len += len;
96
+ buf->buf[buf->len] = '\0';
97
+ }
98
+
99
+ static void buffer_append_str(terminal_buffer *buf, const char *str) {
100
+ if (str) {
101
+ buffer_append(buf, str, strlen(str));
102
+ }
103
+ }
104
+
105
+ /* Helper to detect alignment from cell/user attributes (advanced tables, IAL, etc.) */
106
+ static term_align_t
107
+ parse_alignment_from_attrs(const char *attrs) {
108
+ if (!attrs) {
109
+ return TERM_ALIGN_DEFAULT;
110
+ }
111
+
112
+ /* Prefer explicit text-align style if present */
113
+ if (strstr(attrs, "text-align: center") || strstr(attrs, "text-align:center")) {
114
+ return TERM_ALIGN_CENTER;
115
+ }
116
+ if (strstr(attrs, "text-align: right") || strstr(attrs, "text-align:right")) {
117
+ return TERM_ALIGN_RIGHT;
118
+ }
119
+ if (strstr(attrs, "text-align: left") || strstr(attrs, "text-align:left")) {
120
+ return TERM_ALIGN_LEFT;
121
+ }
122
+
123
+ /* Fallback: check legacy align attribute if it ever appears in user_data */
124
+ if (strstr(attrs, "align=\"center\"")) {
125
+ return TERM_ALIGN_CENTER;
126
+ }
127
+ if (strstr(attrs, "align=\"right\"")) {
128
+ return TERM_ALIGN_RIGHT;
129
+ }
130
+ if (strstr(attrs, "align=\"left\"")) {
131
+ return TERM_ALIGN_LEFT;
132
+ }
133
+
134
+ return TERM_ALIGN_DEFAULT;
135
+ }
136
+
137
+ /* Parse integer attribute value like colspan="3" from an attribute string. */
138
+ static int parse_span_attr(const char *attrs, const char *name) {
139
+ if (!attrs || !name) return 1;
140
+ const char *p = strstr(attrs, name);
141
+ if (!p) return 1;
142
+ p += strlen(name);
143
+ if (*p != '=') return 1;
144
+ p++;
145
+ if (*p == '"' || *p == '\'') {
146
+ char quote = *p++;
147
+ int val = atoi(p);
148
+ (void)quote;
149
+ if (val <= 0) val = 1;
150
+ return val;
151
+ } else {
152
+ int val = atoi(p);
153
+ if (val <= 0) val = 1;
154
+ return val;
155
+ }
156
+ }
157
+
158
+ /* ------------------------------------------------------------------------- */
159
+ /* ANSI helpers */
160
+ /* ------------------------------------------------------------------------- */
161
+
162
+ static void append_ansi_reset(terminal_buffer *buf) {
163
+ buffer_append_str(buf, "\x1b[0m");
164
+ }
165
+
166
+ /* Append a CSI sequence like "38;5;123m" wrapped with ESC[ ... */
167
+ static void append_ansi_seq(terminal_buffer *buf, const char *seq) {
168
+ buffer_append_str(buf, "\x1b[");
169
+ buffer_append_str(buf, seq);
170
+ buffer_append_str(buf, "m");
171
+ }
172
+
173
+ /* Basic 8/16-color mapping from name to SGR code (foreground). */
174
+ static int ansi_color_code_from_name(const char *name, bool *is_background) {
175
+ *is_background = false;
176
+ if (!name || !*name) return -1;
177
+
178
+ /* Background prefix: on_foo or bgFOO */
179
+ if (strncmp(name, "on_", 3) == 0) {
180
+ *is_background = true;
181
+ name += 3;
182
+ } else if (strncmp(name, "bg", 2) == 0 && isupper((unsigned char)name[2])) {
183
+ *is_background = true;
184
+ name += 2;
185
+ }
186
+
187
+ /* Intense / bright colors */
188
+ bool intense = false;
189
+ if (strncmp(name, "intense_", 8) == 0) {
190
+ intense = true;
191
+ name += 8;
192
+ } else if (strncmp(name, "bright_", 7) == 0) {
193
+ intense = true;
194
+ name += 7;
195
+ }
196
+
197
+ int base = -1;
198
+ if (strcmp(name, "black") == 0) base = 0;
199
+ else if (strcmp(name, "red") == 0) base = 1;
200
+ else if (strcmp(name, "green") == 0) base = 2;
201
+ else if (strcmp(name, "yellow") == 0) base = 3;
202
+ else if (strcmp(name, "blue") == 0) base = 4;
203
+ else if (strcmp(name, "magenta") == 0) base = 5;
204
+ else if (strcmp(name, "cyan") == 0) base = 6;
205
+ else if (strcmp(name, "white") == 0) base = 7;
206
+
207
+ if (base < 0) return -1;
208
+
209
+ if (*is_background) {
210
+ return (int)(intense ? (100 + base) : (40 + base));
211
+ } else {
212
+ return (int)(intense ? (90 + base) : (30 + base));
213
+ }
214
+ }
215
+
216
+ /* Convert a hex nibble to int [0,15] */
217
+ static int hex_nibble(char c) {
218
+ if (c >= '0' && c <= '9') return c - '0';
219
+ if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
220
+ if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
221
+ return -1;
222
+ }
223
+
224
+ /* Parse 3- or 6-digit hex to RGB (0-255 each). Returns 0 on success. */
225
+ static int parse_hex_rgb(const char *hex, int *r, int *g, int *b) {
226
+ size_t len = strlen(hex);
227
+ const char *p = hex;
228
+ if (*p == '#') {
229
+ p++;
230
+ len--;
231
+ }
232
+ if (len == 3) {
233
+ int r1 = hex_nibble(p[0]);
234
+ int g1 = hex_nibble(p[1]);
235
+ int b1 = hex_nibble(p[2]);
236
+ if (r1 < 0 || g1 < 0 || b1 < 0) return -1;
237
+ *r = (r1 << 4) | r1;
238
+ *g = (g1 << 4) | g1;
239
+ *b = (b1 << 4) | b1;
240
+ return 0;
241
+ } else if (len == 6) {
242
+ int r1 = hex_nibble(p[0]);
243
+ int r2 = hex_nibble(p[1]);
244
+ int g1 = hex_nibble(p[2]);
245
+ int g2 = hex_nibble(p[3]);
246
+ int b1 = hex_nibble(p[4]);
247
+ int b2 = hex_nibble(p[5]);
248
+ if (r1 < 0 || r2 < 0 || g1 < 0 || g2 < 0 || b1 < 0 || b2 < 0) return -1;
249
+ *r = (r1 << 4) | r2;
250
+ *g = (g1 << 4) | g2;
251
+ *b = (b1 << 4) | b2;
252
+ return 0;
253
+ }
254
+ return -1;
255
+ }
256
+
257
+ /* Map RGB 0–255 to nearest xterm-256 color index. */
258
+ static int rgb_to_256(int r, int g, int b) {
259
+ /* Grayscale range 232-255 for near-gray colors */
260
+ if (r == g && g == b) {
261
+ if (r < 8) return 16;
262
+ if (r > 248) return 231;
263
+ int gray = (int)((r - 8) / 247.0 * 24.0 + 0.5);
264
+ return 232 + (gray < 0 ? 0 : (gray > 23 ? 23 : gray));
265
+ }
266
+
267
+ /* 6x6x6 color cube 16-231 */
268
+ int rc = (int)((r / 255.0) * 5.0 + 0.5);
269
+ int gc = (int)((g / 255.0) * 5.0 + 0.5);
270
+ int bc = (int)((b / 255.0) * 5.0 + 0.5);
271
+ if (rc < 0) rc = 0; if (rc > 5) rc = 5;
272
+ if (gc < 0) gc = 0; if (gc > 5) gc = 5;
273
+ if (bc < 0) bc = 0; if (bc > 5) bc = 5;
274
+ return 16 + 36 * rc + 6 * gc + bc;
275
+ }
276
+
277
+ /* Append color/style according to a single token in a theme string. */
278
+ static void apply_style_token(terminal_buffer *buf,
279
+ const char *token,
280
+ bool use_256_color) {
281
+ if (!token || !*token) return;
282
+
283
+ /* Common style flags */
284
+ if (strcmp(token, "b") == 0 || strcmp(token, "bold") == 0) {
285
+ append_ansi_seq(buf, "1");
286
+ return;
287
+ }
288
+ if (strcmp(token, "d") == 0 || strcmp(token, "dark") == 0) {
289
+ append_ansi_seq(buf, "2");
290
+ return;
291
+ }
292
+ if (strcmp(token, "i") == 0 || strcmp(token, "italic") == 0) {
293
+ append_ansi_seq(buf, "3");
294
+ return;
295
+ }
296
+ if (strcmp(token, "u") == 0 || strcmp(token, "underline") == 0 ||
297
+ strcmp(token, "underscore") == 0) {
298
+ append_ansi_seq(buf, "4");
299
+ return;
300
+ }
301
+ if (strcmp(token, "r") == 0 || strcmp(token, "reverse") == 0 ||
302
+ strcmp(token, "negative") == 0) {
303
+ append_ansi_seq(buf, "7");
304
+ return;
305
+ }
306
+
307
+ /* Raw 256-color SGR like 38;5;123 or 48;5;45 */
308
+ if (strstr(token, "38;5;") == token || strstr(token, "48;5;") == token) {
309
+ append_ansi_seq(buf, token);
310
+ return;
311
+ }
312
+
313
+ /* Hex colors: #RGB, #RRGGBB, bgFF0ACC, on_FF0ACC, etc. */
314
+ if (token[0] == '#' || strncmp(token, "bg", 2) == 0 || strncmp(token, "on_", 3) == 0) {
315
+ const char *hex_part = token;
316
+ int is_bg = 0;
317
+ if (strncmp(token, "bg", 2) == 0) {
318
+ is_bg = 1;
319
+ hex_part = token + 2;
320
+ } else if (strncmp(token, "on_", 3) == 0) {
321
+ is_bg = 1;
322
+ hex_part = token + 3;
323
+ }
324
+ int r, g, b;
325
+ if (parse_hex_rgb(hex_part, &r, &g, &b) == 0) {
326
+ if (use_256_color) {
327
+ int idx = rgb_to_256(r, g, b);
328
+ char seq[32];
329
+ if (is_bg) {
330
+ snprintf(seq, sizeof(seq), "48;5;%d", idx);
331
+ } else {
332
+ snprintf(seq, sizeof(seq), "38;5;%d", idx);
333
+ }
334
+ append_ansi_seq(buf, seq);
335
+ } else {
336
+ /* Approximate to nearest 8-color: simple heuristic by max channel */
337
+ const char *name = "white";
338
+ if (r > g && r > b) name = "red";
339
+ else if (g > r && g > b) name = "green";
340
+ else if (b > r && b > g) name = "blue";
341
+ bool is_background;
342
+ int code = ansi_color_code_from_name(name, &is_background);
343
+ if (code >= 0) {
344
+ char seq[8];
345
+ snprintf(seq, sizeof(seq), "%d", code);
346
+ append_ansi_seq(buf, seq);
347
+ }
348
+ }
349
+ return;
350
+ }
351
+ }
352
+
353
+ /* Named colors (including intense_*) */
354
+ bool is_background;
355
+ int c = ansi_color_code_from_name(token, &is_background);
356
+ if (c >= 0) {
357
+ char seq[8];
358
+ snprintf(seq, sizeof(seq), "%d", c);
359
+ append_ansi_seq(buf, seq);
360
+ return;
361
+ }
362
+ }
363
+
364
+ /* Apply a full style string like "b white on_intense_black" */
365
+ static void apply_style_string(terminal_buffer *buf,
366
+ const char *style,
367
+ bool use_256_color) {
368
+ if (!style) return;
369
+ /* Split on whitespace */
370
+ const char *p = style;
371
+ while (*p) {
372
+ while (*p && isspace((unsigned char)*p)) p++;
373
+ if (!*p) break;
374
+ const char *start = p;
375
+ while (*p && !isspace((unsigned char)*p)) p++;
376
+ size_t len = (size_t)(p - start);
377
+ char token[64];
378
+ if (len >= sizeof(token)) len = sizeof(token) - 1;
379
+ memcpy(token, start, len);
380
+ token[len] = '\0';
381
+ apply_style_token(buf, token, use_256_color);
382
+ }
383
+ }
384
+
385
+ /* ------------------------------------------------------------------------- */
386
+ /* Theme data structure */
387
+ /* ------------------------------------------------------------------------- */
388
+
389
+ typedef struct span_class_style {
390
+ char *class_name;
391
+ char *style;
392
+ } span_class_style;
393
+
394
+ typedef struct terminal_theme {
395
+ /* We keep this simple and only store a few high-value styles.
396
+ * The YAML can define more, but we will use at least these keys.
397
+ */
398
+ char *h1_color;
399
+ char *h2_color;
400
+ char *h3_color;
401
+ char *h4_color;
402
+ char *h5_color;
403
+ char *h6_color;
404
+
405
+ char *link_text;
406
+ char *link_url;
407
+
408
+ char *code_span;
409
+ char *code_block;
410
+
411
+ char *blockquote_marker;
412
+ char *blockquote_color;
413
+
414
+ /* Table-related styles */
415
+ char *table_border; /* ANSI style for table borders (box-drawing chars) */
416
+
417
+ /* List marker style (bullets and ordered numbers). When NULL, falls back to
418
+ * the built-in default of bold bright red. */
419
+ char *list_marker;
420
+
421
+ /* Per-span class styles (for bracketed spans / HTML spans with classes) */
422
+ span_class_style *span_classes;
423
+ size_t span_classes_count;
424
+ } terminal_theme;
425
+
426
+ static void free_theme(terminal_theme *theme) {
427
+ if (!theme) return;
428
+ free(theme->h1_color);
429
+ free(theme->h2_color);
430
+ free(theme->h3_color);
431
+ free(theme->h4_color);
432
+ free(theme->h5_color);
433
+ free(theme->h6_color);
434
+ free(theme->link_text);
435
+ free(theme->link_url);
436
+ free(theme->code_span);
437
+ free(theme->code_block);
438
+ free(theme->blockquote_marker);
439
+ free(theme->blockquote_color);
440
+ free(theme->table_border);
441
+
442
+ free(theme->list_marker);
443
+
444
+ if (theme->span_classes) {
445
+ for (size_t i = 0; i < theme->span_classes_count; i++) {
446
+ free(theme->span_classes[i].class_name);
447
+ free(theme->span_classes[i].style);
448
+ }
449
+ free(theme->span_classes);
450
+ }
451
+ free(theme);
452
+ }
453
+
454
+ static char *dup_or_null(const char *s) {
455
+ if (!s) return NULL;
456
+ size_t len = strlen(s);
457
+ char *out = (char *)malloc(len + 1);
458
+ if (!out) return NULL;
459
+ memcpy(out, s, len + 1);
460
+ return out;
461
+ }
462
+
463
+ /* Parse a generic YAML-style boolean value ("true", "yes", "1", etc.). */
464
+ static bool parse_bool_value(const char *val) {
465
+ if (!val) return false;
466
+ if (strcasecmp(val, "true") == 0 ||
467
+ strcasecmp(val, "yes") == 0 ||
468
+ strcasecmp(val, "on") == 0 ||
469
+ strcmp(val, "1") == 0) {
470
+ return true;
471
+ }
472
+ return false;
473
+ }
474
+
475
+ /* Ensure that a style string has a bold token prefix. If *style already has a
476
+ * value, we prepend "b " to it. If it's NULL, we set it to "b". We don't try
477
+ * to deduplicate multiple bold tokens; emitting "b" more than once is harmless
478
+ * for ANSI SGR. */
479
+ static void ensure_bold_prefix(char **style) {
480
+ if (!style) return;
481
+ if (*style && **style) {
482
+ size_t old_len = strlen(*style);
483
+ char *out = (char *)malloc(old_len + 3); /* "b " + old + '\0' */
484
+ if (!out) return;
485
+ memcpy(out, "b ", 2);
486
+ memcpy(out + 2, *style, old_len + 1);
487
+ free(*style);
488
+ *style = out;
489
+ } else {
490
+ /* No existing style: just set to "b" */
491
+ free(*style);
492
+ *style = dup_or_null("b");
493
+ }
494
+ }
495
+
496
+ /* ------------------------------------------------------------------------- */
497
+ /* Theme loading (YAML, optional) */
498
+ /* ------------------------------------------------------------------------- */
499
+
500
+ static char *build_theme_path(const char *name) {
501
+ const char *home = getenv("HOME");
502
+ if (!home || !*home) return NULL;
503
+
504
+ /* ~/.config/apex/terminal/themes/NAME.theme */
505
+ const char *base = "/.config/apex/terminal/themes/";
506
+ size_t home_len = strlen(home);
507
+ size_t base_len = strlen(base);
508
+ size_t name_len = name ? strlen(name) : 0;
509
+ const char *ext = ".theme";
510
+ size_t ext_len = strlen(ext);
511
+
512
+ size_t total = home_len + base_len + name_len + ext_len + 1;
513
+ char *path = (char *)malloc(total);
514
+ if (!path) return NULL;
515
+
516
+ memcpy(path, home, home_len);
517
+ memcpy(path + home_len, base, base_len);
518
+ memcpy(path + home_len + base_len, name, name_len);
519
+ memcpy(path + home_len + base_len + name_len, ext, ext_len);
520
+ path[total - 1] = '\0';
521
+ return path;
522
+ }
523
+
524
+ /* Fallback theme parser when libyaml is not available.
525
+ * Supports a small subset of YAML used by Apex terminal themes:
526
+ *
527
+ * h1:
528
+ * color: "style tokens"
529
+ * bold: true
530
+ * link:
531
+ * text: "style tokens"
532
+ * url: "style tokens"
533
+ * bold: true # applies to link text style
534
+ * code_span:
535
+ * color: "style tokens"
536
+ * bold: true
537
+ * code_block:
538
+ * color: "style tokens"
539
+ * bold: true
540
+ * blockquote:
541
+ * marker:
542
+ * character: ">"
543
+ * color: "style tokens"
544
+ * bold: true
545
+ * table:
546
+ * border: "style tokens"
547
+ * bold: true
548
+ * span_classes:
549
+ * classname: "style tokens"
550
+ *
551
+ * list_marker: "style tokens"
552
+ */
553
+ static char *trim_whitespace(char *s) {
554
+ if (!s) return s;
555
+ while (*s && isspace((unsigned char)*s)) s++;
556
+ if (!*s) return s;
557
+ char *end = s + strlen(s) - 1;
558
+ while (end >= s && isspace((unsigned char)*end)) {
559
+ *end-- = '\0';
560
+ }
561
+ return s;
562
+ }
563
+
564
+ static terminal_theme *load_theme_from_simple_yaml(const char *path) {
565
+ FILE *fp = fopen(path, "r");
566
+ if (!fp) {
567
+ return NULL;
568
+ }
569
+
570
+ terminal_theme *theme = (terminal_theme *)calloc(1, sizeof(terminal_theme));
571
+ if (!theme) {
572
+ fclose(fp);
573
+ return NULL;
574
+ }
575
+
576
+ char line[1024];
577
+ char current_level1[64] = {0};
578
+ char current_level2[64] = {0};
579
+
580
+ while (fgets(line, sizeof(line), fp)) {
581
+ char *p = line;
582
+ /* Strip CR/LF */
583
+ char *nl = strpbrk(p, "\r\n");
584
+ if (nl) *nl = '\0';
585
+
586
+ /* Skip empty/comments */
587
+ char *t = p;
588
+ while (*t && isspace((unsigned char)*t)) t++;
589
+ if (!*t || *t == '#') {
590
+ continue;
591
+ }
592
+
593
+ int indent = (int)(t - p);
594
+ char *key_start = t;
595
+ char *colon = strchr(t, ':');
596
+ if (!colon) {
597
+ continue;
598
+ }
599
+ *colon = '\0';
600
+ char *key = trim_whitespace(key_start);
601
+ char *val = trim_whitespace(colon + 1);
602
+
603
+ if (indent == 0) {
604
+ /* Top-level key */
605
+ strncpy(current_level1, key, sizeof(current_level1) - 1);
606
+ current_level1[sizeof(current_level1) - 1] = '\0';
607
+ current_level2[0] = '\0';
608
+
609
+ /* Some keys are allowed to have scalar values at top level. */
610
+ if (strcmp(current_level1, "list_marker") == 0) {
611
+ free(theme->list_marker);
612
+ theme->list_marker = dup_or_null(val);
613
+ }
614
+ continue;
615
+ } else {
616
+ /* Nested key under current_level1 */
617
+ strncpy(current_level2, key, sizeof(current_level2) - 1);
618
+ current_level2[sizeof(current_level2) - 1] = '\0';
619
+ }
620
+
621
+ /* Strip surrounding quotes from value, if any */
622
+ if (val && (*val == '"' || *val == '\'')) {
623
+ char quote = *val;
624
+ char *end = strrchr(val + 1, quote);
625
+ if (end) *end = '\0';
626
+ val++;
627
+ val = trim_whitespace(val);
628
+ }
629
+
630
+ const char *l1 = current_level1;
631
+ const char *l2 = current_level2;
632
+
633
+ if (!val || !*val) {
634
+ continue;
635
+ }
636
+
637
+ if (strcmp(l1, "h1") == 0 && strcmp(l2, "color") == 0) {
638
+ free(theme->h1_color);
639
+ theme->h1_color = dup_or_null(val);
640
+ } else if (strcmp(l1, "h1") == 0 && strcmp(l2, "bold") == 0) {
641
+ if (parse_bool_value(val)) {
642
+ ensure_bold_prefix(&theme->h1_color);
643
+ }
644
+ } else if (strcmp(l1, "h2") == 0 && strcmp(l2, "color") == 0) {
645
+ free(theme->h2_color);
646
+ theme->h2_color = dup_or_null(val);
647
+ } else if (strcmp(l1, "h2") == 0 && strcmp(l2, "bold") == 0) {
648
+ if (parse_bool_value(val)) {
649
+ ensure_bold_prefix(&theme->h2_color);
650
+ }
651
+ } else if (strcmp(l1, "h3") == 0 && strcmp(l2, "color") == 0) {
652
+ free(theme->h3_color);
653
+ theme->h3_color = dup_or_null(val);
654
+ } else if (strcmp(l1, "h3") == 0 && strcmp(l2, "bold") == 0) {
655
+ if (parse_bool_value(val)) {
656
+ ensure_bold_prefix(&theme->h3_color);
657
+ }
658
+ } else if (strcmp(l1, "h4") == 0 && strcmp(l2, "color") == 0) {
659
+ free(theme->h4_color);
660
+ theme->h4_color = dup_or_null(val);
661
+ } else if (strcmp(l1, "h4") == 0 && strcmp(l2, "bold") == 0) {
662
+ if (parse_bool_value(val)) {
663
+ ensure_bold_prefix(&theme->h4_color);
664
+ }
665
+ } else if (strcmp(l1, "h5") == 0 && strcmp(l2, "color") == 0) {
666
+ free(theme->h5_color);
667
+ theme->h5_color = dup_or_null(val);
668
+ } else if (strcmp(l1, "h5") == 0 && strcmp(l2, "bold") == 0) {
669
+ if (parse_bool_value(val)) {
670
+ ensure_bold_prefix(&theme->h5_color);
671
+ }
672
+ } else if (strcmp(l1, "h6") == 0 && strcmp(l2, "color") == 0) {
673
+ free(theme->h6_color);
674
+ theme->h6_color = dup_or_null(val);
675
+ } else if (strcmp(l1, "h6") == 0 && strcmp(l2, "bold") == 0) {
676
+ if (parse_bool_value(val)) {
677
+ ensure_bold_prefix(&theme->h6_color);
678
+ }
679
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "text") == 0) {
680
+ free(theme->link_text);
681
+ theme->link_text = dup_or_null(val);
682
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "url") == 0) {
683
+ free(theme->link_url);
684
+ theme->link_url = dup_or_null(val);
685
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "bold") == 0) {
686
+ /* Bold link text when link.bold: true */
687
+ if (parse_bool_value(val)) {
688
+ ensure_bold_prefix(&theme->link_text);
689
+ }
690
+ } else if (strcmp(l1, "code_span") == 0 && strcmp(l2, "color") == 0) {
691
+ free(theme->code_span);
692
+ theme->code_span = dup_or_null(val);
693
+ } else if (strcmp(l1, "code_span") == 0 && strcmp(l2, "bold") == 0) {
694
+ if (parse_bool_value(val)) {
695
+ ensure_bold_prefix(&theme->code_span);
696
+ }
697
+ } else if (strcmp(l1, "code_block") == 0 && strcmp(l2, "color") == 0) {
698
+ free(theme->code_block);
699
+ theme->code_block = dup_or_null(val);
700
+ } else if (strcmp(l1, "code_block") == 0 && strcmp(l2, "bold") == 0) {
701
+ if (parse_bool_value(val)) {
702
+ ensure_bold_prefix(&theme->code_block);
703
+ }
704
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "color") == 0) {
705
+ free(theme->blockquote_color);
706
+ theme->blockquote_color = dup_or_null(val);
707
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "bold") == 0) {
708
+ if (parse_bool_value(val)) {
709
+ ensure_bold_prefix(&theme->blockquote_color);
710
+ }
711
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "character") == 0) {
712
+ free(theme->blockquote_marker);
713
+ theme->blockquote_marker = dup_or_null(val);
714
+ } else if (strcmp(l1, "table") == 0 && strcmp(l2, "border") == 0) {
715
+ free(theme->table_border);
716
+ theme->table_border = dup_or_null(val);
717
+ } else if (strcmp(l1, "table") == 0 && strcmp(l2, "bold") == 0) {
718
+ if (parse_bool_value(val)) {
719
+ ensure_bold_prefix(&theme->table_border);
720
+ }
721
+ } else if (strcmp(l1, "span_classes") == 0 && l2[0] != '\0') {
722
+ /* span_classes:
723
+ * classname: "style tokens"
724
+ */
725
+ const char *class_name = l2;
726
+ if (class_name && *class_name) {
727
+ /* Check for existing entry */
728
+ size_t idx = 0;
729
+ for (; idx < theme->span_classes_count; idx++) {
730
+ if (theme->span_classes[idx].class_name &&
731
+ strcmp(theme->span_classes[idx].class_name, class_name) == 0) {
732
+ break;
733
+ }
734
+ }
735
+ if (idx == theme->span_classes_count) {
736
+ span_class_style *new_arr = (span_class_style *)realloc(
737
+ theme->span_classes,
738
+ (theme->span_classes_count + 1) * sizeof(span_class_style));
739
+ if (new_arr) {
740
+ theme->span_classes = new_arr;
741
+ theme->span_classes[idx].class_name = dup_or_null(class_name);
742
+ theme->span_classes[idx].style = dup_or_null(val);
743
+ theme->span_classes_count++;
744
+ }
745
+ } else {
746
+ free(theme->span_classes[idx].style);
747
+ theme->span_classes[idx].style = dup_or_null(val);
748
+ }
749
+ }
750
+ }
751
+ }
752
+
753
+ fclose(fp);
754
+ return theme;
755
+ }
756
+
757
+ static terminal_theme *load_theme_from_yaml(const char *path) {
758
+ #ifdef APEX_HAVE_LIBYAML
759
+ /* Full YAML parser using libyaml, limited to the subset needed for
760
+ * terminal themes. Falls back to the simple line-based parser on
761
+ * failure so themes still work if parsing is slightly off.
762
+ */
763
+ FILE *fp = fopen(path, "rb");
764
+ if (!fp) {
765
+ return NULL;
766
+ }
767
+
768
+ yaml_parser_t parser;
769
+ yaml_event_t event;
770
+
771
+ if (!yaml_parser_initialize(&parser)) {
772
+ fclose(fp);
773
+ return NULL;
774
+ }
775
+ yaml_parser_set_input_file(&parser, fp);
776
+
777
+ terminal_theme *theme = (terminal_theme *)calloc(1, sizeof(terminal_theme));
778
+ if (!theme) {
779
+ yaml_parser_delete(&parser);
780
+ fclose(fp);
781
+ return NULL;
782
+ }
783
+
784
+ /* Track keys at depth 1 and 2 and whether the next scalar is a value. */
785
+ char key1[64] = {0};
786
+ char key2[64] = {0};
787
+ bool has_key1 = false;
788
+ bool has_key2 = false;
789
+ bool expect_value = false;
790
+ int depth = 0;
791
+
792
+ int done = 0;
793
+ while (!done) {
794
+ if (!yaml_parser_parse(&parser, &event)) {
795
+ /* On parse error, fall back to simple parser. */
796
+ yaml_parser_delete(&parser);
797
+ fclose(fp);
798
+ free_theme(theme);
799
+ return load_theme_from_simple_yaml(path);
800
+ }
801
+
802
+ switch (event.type) {
803
+ case YAML_MAPPING_START_EVENT:
804
+ depth++;
805
+ /* A new mapping value starts; next scalar is a key. */
806
+ expect_value = false;
807
+ if (depth == 1) {
808
+ has_key1 = false;
809
+ key1[0] = '\0';
810
+ } else if (depth == 2) {
811
+ has_key2 = false;
812
+ key2[0] = '\0';
813
+ }
814
+ break;
815
+
816
+ case YAML_MAPPING_END_EVENT:
817
+ if (depth == 2) {
818
+ has_key2 = false;
819
+ key2[0] = '\0';
820
+ } else if (depth == 1) {
821
+ has_key1 = false;
822
+ key1[0] = '\0';
823
+ }
824
+ depth--;
825
+ expect_value = false;
826
+ break;
827
+
828
+ case YAML_SCALAR_EVENT: {
829
+ const char *value = (const char *)event.data.scalar.value;
830
+
831
+ if (!expect_value) {
832
+ /* This scalar is a key at the current depth. */
833
+ if (depth == 1) {
834
+ strncpy(key1, value, sizeof(key1) - 1);
835
+ key1[sizeof(key1) - 1] = '\0';
836
+ has_key1 = true;
837
+ /* Reset nested key when starting a new section. */
838
+ has_key2 = false;
839
+ key2[0] = '\0';
840
+ } else if (depth == 2) {
841
+ strncpy(key2, value, sizeof(key2) - 1);
842
+ key2[sizeof(key2) - 1] = '\0';
843
+ has_key2 = true;
844
+ }
845
+ expect_value = true;
846
+ } else {
847
+ /* This scalar is a value for the last key. */
848
+ const char *l1 = has_key1 ? key1 : "";
849
+ const char *l2 = has_key2 ? key2 : "";
850
+ const char *val = value;
851
+
852
+ if (val && *val) {
853
+ if (strcmp(l1, "h1") == 0 && strcmp(l2, "color") == 0) {
854
+ free(theme->h1_color);
855
+ theme->h1_color = dup_or_null(val);
856
+ } else if (strcmp(l1, "h1") == 0 && strcmp(l2, "bold") == 0) {
857
+ if (parse_bool_value(val)) {
858
+ ensure_bold_prefix(&theme->h1_color);
859
+ }
860
+ } else if (strcmp(l1, "h2") == 0 && strcmp(l2, "color") == 0) {
861
+ free(theme->h2_color);
862
+ theme->h2_color = dup_or_null(val);
863
+ } else if (strcmp(l1, "h2") == 0 && strcmp(l2, "bold") == 0) {
864
+ if (parse_bool_value(val)) {
865
+ ensure_bold_prefix(&theme->h2_color);
866
+ }
867
+ } else if (strcmp(l1, "h3") == 0 && strcmp(l2, "color") == 0) {
868
+ free(theme->h3_color);
869
+ theme->h3_color = dup_or_null(val);
870
+ } else if (strcmp(l1, "h3") == 0 && strcmp(l2, "bold") == 0) {
871
+ if (parse_bool_value(val)) {
872
+ ensure_bold_prefix(&theme->h3_color);
873
+ }
874
+ } else if (strcmp(l1, "h4") == 0 && strcmp(l2, "color") == 0) {
875
+ free(theme->h4_color);
876
+ theme->h4_color = dup_or_null(val);
877
+ } else if (strcmp(l1, "h4") == 0 && strcmp(l2, "bold") == 0) {
878
+ if (parse_bool_value(val)) {
879
+ ensure_bold_prefix(&theme->h4_color);
880
+ }
881
+ } else if (strcmp(l1, "h5") == 0 && strcmp(l2, "color") == 0) {
882
+ free(theme->h5_color);
883
+ theme->h5_color = dup_or_null(val);
884
+ } else if (strcmp(l1, "h5") == 0 && strcmp(l2, "bold") == 0) {
885
+ if (parse_bool_value(val)) {
886
+ ensure_bold_prefix(&theme->h5_color);
887
+ }
888
+ } else if (strcmp(l1, "h6") == 0 && strcmp(l2, "color") == 0) {
889
+ free(theme->h6_color);
890
+ theme->h6_color = dup_or_null(val);
891
+ } else if (strcmp(l1, "h6") == 0 && strcmp(l2, "bold") == 0) {
892
+ if (parse_bool_value(val)) {
893
+ ensure_bold_prefix(&theme->h6_color);
894
+ }
895
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "text") == 0) {
896
+ free(theme->link_text);
897
+ theme->link_text = dup_or_null(val);
898
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "url") == 0) {
899
+ free(theme->link_url);
900
+ theme->link_url = dup_or_null(val);
901
+ } else if (strcmp(l1, "link") == 0 && strcmp(l2, "bold") == 0) {
902
+ /* Bold link text when link.bold: true */
903
+ if (parse_bool_value(val)) {
904
+ ensure_bold_prefix(&theme->link_text);
905
+ }
906
+ } else if (strcmp(l1, "code_span") == 0 && strcmp(l2, "color") == 0) {
907
+ free(theme->code_span);
908
+ theme->code_span = dup_or_null(val);
909
+ } else if (strcmp(l1, "code_span") == 0 && strcmp(l2, "bold") == 0) {
910
+ if (parse_bool_value(val)) {
911
+ ensure_bold_prefix(&theme->code_span);
912
+ }
913
+ } else if (strcmp(l1, "code_block") == 0 && strcmp(l2, "color") == 0) {
914
+ free(theme->code_block);
915
+ theme->code_block = dup_or_null(val);
916
+ } else if (strcmp(l1, "code_block") == 0 && strcmp(l2, "bold") == 0) {
917
+ if (parse_bool_value(val)) {
918
+ ensure_bold_prefix(&theme->code_block);
919
+ }
920
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "character") == 0) {
921
+ free(theme->blockquote_marker);
922
+ theme->blockquote_marker = dup_or_null(val);
923
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "color") == 0) {
924
+ free(theme->blockquote_color);
925
+ theme->blockquote_color = dup_or_null(val);
926
+ } else if (strcmp(l1, "blockquote") == 0 && strcmp(l2, "bold") == 0) {
927
+ if (parse_bool_value(val)) {
928
+ ensure_bold_prefix(&theme->blockquote_color);
929
+ }
930
+ } else if (strcmp(l1, "table") == 0 && strcmp(l2, "border") == 0) {
931
+ free(theme->table_border);
932
+ theme->table_border = dup_or_null(val);
933
+ } else if (strcmp(l1, "table") == 0 && strcmp(l2, "bold") == 0) {
934
+ if (parse_bool_value(val)) {
935
+ ensure_bold_prefix(&theme->table_border);
936
+ }
937
+ } else if (strcmp(l1, "list_marker") == 0 && !has_key2) {
938
+ /* Top-level scalar key, e.g. list_marker: "b red" */
939
+ free(theme->list_marker);
940
+ theme->list_marker = dup_or_null(val);
941
+ } else if (strcmp(l1, "span_classes") == 0 && has_key2) {
942
+ /* span_classes:
943
+ * purple: "b white on_magenta"
944
+ */
945
+ const char *class_name = key2;
946
+ if (class_name && *class_name) {
947
+ size_t idx = 0;
948
+ for (; idx < theme->span_classes_count; idx++) {
949
+ if (theme->span_classes[idx].class_name &&
950
+ strcmp(theme->span_classes[idx].class_name, class_name) == 0) {
951
+ break;
952
+ }
953
+ }
954
+ if (idx == theme->span_classes_count) {
955
+ span_class_style *new_arr = (span_class_style *)realloc(
956
+ theme->span_classes,
957
+ (theme->span_classes_count + 1) * sizeof(span_class_style));
958
+ if (new_arr) {
959
+ theme->span_classes = new_arr;
960
+ theme->span_classes[idx].class_name = dup_or_null(class_name);
961
+ theme->span_classes[idx].style = dup_or_null(val);
962
+ theme->span_classes_count++;
963
+ }
964
+ } else {
965
+ free(theme->span_classes[idx].style);
966
+ theme->span_classes[idx].style = dup_or_null(val);
967
+ }
968
+ }
969
+ }
970
+ }
971
+
972
+ expect_value = false;
973
+ }
974
+ break;
975
+ }
976
+
977
+ case YAML_STREAM_END_EVENT:
978
+ done = 1;
979
+ break;
980
+
981
+ default:
982
+ break;
983
+ }
984
+ yaml_event_delete(&event);
985
+ }
986
+
987
+ yaml_parser_delete(&parser);
988
+ fclose(fp);
989
+
990
+ /* If nothing was populated, fall back to the simple parser to
991
+ * keep behavior consistent with older themes. */
992
+ if (!theme->h1_color && !theme->code_span && theme->span_classes_count == 0) {
993
+ free_theme(theme);
994
+ return load_theme_from_simple_yaml(path);
995
+ }
996
+
997
+ return theme;
998
+ #else
999
+ /* No libyaml: use the simple line-based parser. */
1000
+ return load_theme_from_simple_yaml(path);
1001
+ #endif
1002
+ }
1003
+
1004
+ static terminal_theme *load_theme(const apex_options *options) {
1005
+ const char *name = options && options->theme_name ? options->theme_name : NULL;
1006
+
1007
+ /* 1. Explicit --theme NAME */
1008
+ if (name && *name) {
1009
+ char *path = build_theme_path(name);
1010
+ if (path) {
1011
+ terminal_theme *theme = load_theme_from_yaml(path);
1012
+ free(path);
1013
+ if (theme) {
1014
+ return theme;
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ /* 2. default.theme fallback */
1020
+ char *default_path = build_theme_path("default");
1021
+ if (default_path) {
1022
+ terminal_theme *theme = load_theme_from_yaml(default_path);
1023
+ free(default_path);
1024
+ if (theme) {
1025
+ return theme;
1026
+ }
1027
+ }
1028
+
1029
+ return NULL;
1030
+ }
1031
+
1032
+ /* ------------------------------------------------------------------------- */
1033
+ /* AST serialization */
1034
+ /* ------------------------------------------------------------------------- */
1035
+
1036
+ /* Compute a rough visible text width for a node, ignoring ANSI and styling.
1037
+ * Used for table column width calculation.
1038
+ */
1039
+ static int node_plain_text_width(cmark_node *node) {
1040
+ if (!node) return 0;
1041
+
1042
+ cmark_node_type t = cmark_node_get_type(node);
1043
+ const char *lit = cmark_node_get_literal(node);
1044
+ int width = 0;
1045
+
1046
+ switch (t) {
1047
+ case CMARK_NODE_TEXT:
1048
+ case CMARK_NODE_CODE:
1049
+ case CMARK_NODE_HTML_INLINE:
1050
+ if (lit) {
1051
+ for (const char *p = lit; *p; p++) {
1052
+ if (*p == '\n' || *p == '\r') {
1053
+ width++; /* treat newline as a space */
1054
+ } else {
1055
+ width++;
1056
+ }
1057
+ }
1058
+ }
1059
+ break;
1060
+ default: {
1061
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1062
+ width += node_plain_text_width(child);
1063
+ }
1064
+ break;
1065
+ }
1066
+ }
1067
+ return width;
1068
+ }
1069
+
1070
+ static void serialize_inline(terminal_buffer *buf,
1071
+ cmark_node *node,
1072
+ const apex_options *options,
1073
+ const terminal_theme *theme,
1074
+ bool use_256_color);
1075
+
1076
+ static void serialize_block(terminal_buffer *buf,
1077
+ cmark_node *node,
1078
+ const apex_options *options,
1079
+ const terminal_theme *theme,
1080
+ bool use_256_color,
1081
+ int indent_level);
1082
+
1083
+ static void indent_spaces(terminal_buffer *buf, int indent_level) {
1084
+ for (int i = 0; i < indent_level; i++) {
1085
+ buffer_append_str(buf, " ");
1086
+ }
1087
+ }
1088
+
1089
+ /* Look up a style string for a given class attribute value like
1090
+ * "atag other". Returns the first matching class's style or NULL.
1091
+ */
1092
+ static const char *theme_style_for_span_classes(const terminal_theme *theme,
1093
+ const char *class_attr) {
1094
+ if (!theme || !class_attr || !*class_attr || theme->span_classes_count == 0) {
1095
+ return NULL;
1096
+ }
1097
+
1098
+ const char *p = class_attr;
1099
+ while (*p) {
1100
+ /* Skip leading whitespace */
1101
+ while (*p && isspace((unsigned char)*p)) {
1102
+ p++;
1103
+ }
1104
+ if (!*p) break;
1105
+
1106
+ /* Capture one class token */
1107
+ const char *start = p;
1108
+ while (*p && !isspace((unsigned char)*p)) {
1109
+ p++;
1110
+ }
1111
+ size_t len = (size_t)(p - start);
1112
+
1113
+ /* Compare against known span_classes */
1114
+ for (size_t i = 0; i < theme->span_classes_count; i++) {
1115
+ span_class_style *entry = &theme->span_classes[i];
1116
+ if (!entry->class_name || !entry->style) continue;
1117
+ if (strlen(entry->class_name) == len &&
1118
+ strncmp(entry->class_name, start, len) == 0) {
1119
+ return entry->style;
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ return NULL;
1125
+ }
1126
+
1127
+ /* Extract class list from an attribute string like:
1128
+ * id="x" class="atag other" data-foo="bar"
1129
+ * and look up the first matching span_classes style.
1130
+ */
1131
+ static const char *theme_style_for_node_attrs(const terminal_theme *theme,
1132
+ const char *attrs) {
1133
+ if (!theme || !attrs || !*attrs || theme->span_classes_count == 0) {
1134
+ return NULL;
1135
+ }
1136
+
1137
+ const char *class_pos = strstr(attrs, "class=");
1138
+ if (!class_pos) {
1139
+ return NULL;
1140
+ }
1141
+ class_pos += 6; /* skip 'class=' */
1142
+
1143
+ char quote = 0;
1144
+ if (*class_pos == '"' || *class_pos == '\'') {
1145
+ quote = *class_pos;
1146
+ class_pos++;
1147
+ }
1148
+
1149
+ const char *class_end = class_pos;
1150
+ if (quote) {
1151
+ while (*class_end && *class_end != quote) {
1152
+ class_end++;
1153
+ }
1154
+ } else {
1155
+ while (*class_end &&
1156
+ !isspace((unsigned char)*class_end) &&
1157
+ *class_end != '>' &&
1158
+ *class_end != '/') {
1159
+ class_end++;
1160
+ }
1161
+ }
1162
+
1163
+ size_t len = (size_t)(class_end - class_pos);
1164
+ if (len == 0) {
1165
+ return NULL;
1166
+ }
1167
+
1168
+ char buf[256];
1169
+ if (len >= sizeof(buf)) {
1170
+ len = sizeof(buf) - 1;
1171
+ }
1172
+ memcpy(buf, class_pos, len);
1173
+ buf[len] = '\0';
1174
+
1175
+ return theme_style_for_span_classes(theme, buf);
1176
+ }
1177
+
1178
+ /* Convenience helper: get style for classes attached to a node via IAL.
1179
+ * Attributes are stored as a serialized HTML attribute string in user_data.
1180
+ */
1181
+ static const char *theme_style_for_node(const terminal_theme *theme,
1182
+ cmark_node *node) {
1183
+ if (!theme || !node || theme->span_classes_count == 0) {
1184
+ return NULL;
1185
+ }
1186
+ const char *attrs = (const char *)cmark_node_get_user_data(node);
1187
+ if (!attrs || !*attrs) {
1188
+ return NULL;
1189
+ }
1190
+ return theme_style_for_node_attrs(theme, attrs);
1191
+ }
1192
+
1193
+ /* Simple stack of active HTML <span class="..."> styles for RawInline HTML.
1194
+ * This is only used for CMARK_NODE_HTML_INLINE nodes so we don't have to
1195
+ * thread extra state through every serialize_* call.
1196
+ */
1197
+ static const char *html_span_style_stack[16];
1198
+ static int html_span_style_depth = 0;
1199
+
1200
+ static void serialize_inline(terminal_buffer *buf,
1201
+ cmark_node *node,
1202
+ const apex_options *options,
1203
+ const terminal_theme *theme,
1204
+ bool use_256_color) {
1205
+ cmark_node_type type = cmark_node_get_type(node);
1206
+ const char *literal = cmark_node_get_literal(node);
1207
+
1208
+ switch (type) {
1209
+ case CMARK_NODE_TEXT: {
1210
+ if (literal) {
1211
+ const char *class_style = theme_style_for_node(theme, node);
1212
+
1213
+ if (class_style) {
1214
+ apply_style_string(buf, class_style, use_256_color);
1215
+ }
1216
+
1217
+ /* Optionally replace :emoji: with Unicode for terminal output
1218
+ * when in GFM or unified mode, matching HTML behavior. */
1219
+ const char *text_src = literal;
1220
+ char *emoji_replaced = NULL;
1221
+ if (options &&
1222
+ (options->mode == APEX_MODE_GFM ||
1223
+ options->mode == APEX_MODE_UNIFIED)) {
1224
+ emoji_replaced = apex_replace_emoji_text(literal);
1225
+ if (emoji_replaced) {
1226
+ text_src = emoji_replaced;
1227
+ }
1228
+ }
1229
+
1230
+ /* Replace APEXLTLT placeholder (from escaped \<<) with << */
1231
+ const char *apexltlt = "APEXLTLT";
1232
+ const char *found = strstr(text_src, apexltlt);
1233
+ if (found) {
1234
+ /* Replace all occurrences */
1235
+ const char *p = text_src;
1236
+ while ((found = strstr(p, apexltlt)) != NULL) {
1237
+ /* Append text before the placeholder */
1238
+ if (found > p) {
1239
+ size_t len = (size_t)(found - p);
1240
+ char *before = (char *)malloc(len + 1);
1241
+ if (before) {
1242
+ memcpy(before, p, len);
1243
+ before[len] = '\0';
1244
+ buffer_append_str(buf, before);
1245
+ free(before);
1246
+ }
1247
+ }
1248
+ /* Append the replacement */
1249
+ buffer_append_str(buf, "<<");
1250
+ p = found + 8; /* Skip "APEXLTLT" (8 chars) */
1251
+ }
1252
+ /* Append remaining text */
1253
+ if (*p) {
1254
+ buffer_append_str(buf, p);
1255
+ }
1256
+ } else {
1257
+ buffer_append_str(buf, text_src);
1258
+ }
1259
+
1260
+ if (emoji_replaced) {
1261
+ free(emoji_replaced);
1262
+ }
1263
+
1264
+ if (class_style) {
1265
+ append_ansi_reset(buf);
1266
+ }
1267
+ }
1268
+ break;
1269
+ }
1270
+ case CMARK_NODE_SOFTBREAK:
1271
+ buffer_append_str(buf, "\n");
1272
+ break;
1273
+ case CMARK_NODE_LINEBREAK:
1274
+ buffer_append_str(buf, " \n");
1275
+ break;
1276
+ case CMARK_NODE_CODE:
1277
+ if (literal) {
1278
+ const char *class_style = theme_style_for_node(theme, node);
1279
+ if (class_style) {
1280
+ apply_style_string(buf, class_style, use_256_color);
1281
+ } else if (theme && theme->code_span) {
1282
+ apply_style_string(buf, theme->code_span, use_256_color);
1283
+ } else {
1284
+ apply_style_string(buf, "b white on_intense_black", use_256_color);
1285
+ }
1286
+ buffer_append_str(buf, literal);
1287
+ append_ansi_reset(buf);
1288
+ }
1289
+ break;
1290
+ case CMARK_NODE_EMPH:
1291
+ {
1292
+ const char *class_style = theme_style_for_node(theme, node);
1293
+ if (class_style) {
1294
+ apply_style_string(buf, class_style, use_256_color);
1295
+ }
1296
+ apply_style_string(buf, "i", use_256_color);
1297
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1298
+ serialize_inline(buf, child, options, theme, use_256_color);
1299
+ }
1300
+ append_ansi_reset(buf);
1301
+ break;
1302
+ }
1303
+ case CMARK_NODE_STRONG:
1304
+ {
1305
+ const char *class_style = theme_style_for_node(theme, node);
1306
+ if (class_style) {
1307
+ apply_style_string(buf, class_style, use_256_color);
1308
+ }
1309
+ apply_style_string(buf, "b", use_256_color);
1310
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1311
+ serialize_inline(buf, child, options, theme, use_256_color);
1312
+ }
1313
+ append_ansi_reset(buf);
1314
+ break;
1315
+ }
1316
+ case CMARK_NODE_LINK: {
1317
+ const char *url = cmark_node_get_url(node);
1318
+ const char *class_style = theme_style_for_node(theme, node);
1319
+ if (class_style) {
1320
+ apply_style_string(buf, class_style, use_256_color);
1321
+ } else if (theme && theme->link_text) {
1322
+ apply_style_string(buf, theme->link_text, use_256_color);
1323
+ } else {
1324
+ apply_style_string(buf, "u b blue", use_256_color);
1325
+ }
1326
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1327
+ serialize_inline(buf, child, options, theme, use_256_color);
1328
+ }
1329
+ append_ansi_reset(buf);
1330
+ if (url && *url) {
1331
+ buffer_append_str(buf, " ");
1332
+ if (theme && theme->link_url) {
1333
+ apply_style_string(buf, theme->link_url, use_256_color);
1334
+ } else {
1335
+ apply_style_string(buf, "cyan", use_256_color);
1336
+ }
1337
+ buffer_append_str(buf, "(");
1338
+ buffer_append_str(buf, url);
1339
+ buffer_append_str(buf, ")");
1340
+ append_ansi_reset(buf);
1341
+ }
1342
+ break;
1343
+ }
1344
+ case CMARK_NODE_IMAGE: {
1345
+ const char *url = cmark_node_get_url(node);
1346
+ buffer_append_str(buf, "![");
1347
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1348
+ serialize_inline(buf, child, options, theme, use_256_color);
1349
+ }
1350
+ buffer_append_str(buf, "]");
1351
+ if (url && *url) {
1352
+ buffer_append_str(buf, "(");
1353
+ buffer_append_str(buf, url);
1354
+ buffer_append_str(buf, ")");
1355
+ }
1356
+ break;
1357
+ }
1358
+ case CMARK_NODE_HTML_INLINE: {
1359
+ if (literal && *literal) {
1360
+ if (getenv("APEX_DEBUG_THEME")) {
1361
+ fprintf(stderr, "[APEX_DEBUG_THEME] html_inline literal: %s\n", literal);
1362
+ }
1363
+
1364
+ /* Handle split RawInline spans:
1365
+ * <span class="purple"> --> open, apply style
1366
+ * </span> --> close, reset style
1367
+ */
1368
+ if (strncmp(literal, "<span", 5) == 0 &&
1369
+ (literal[5] == ' ' || literal[5] == '>' )) {
1370
+ const char *tag_end = strchr(literal, '>');
1371
+ if (theme && tag_end) {
1372
+ const char *attrs = literal + 5; /* after 'span' */
1373
+ const char *style = theme_style_for_node_attrs(theme, attrs);
1374
+ if (style) {
1375
+ if (html_span_style_depth < (int)(sizeof(html_span_style_stack) / sizeof(html_span_style_stack[0]))) {
1376
+ html_span_style_stack[html_span_style_depth++] = style;
1377
+ }
1378
+ apply_style_string(buf, style, use_256_color);
1379
+ }
1380
+ }
1381
+ /* Do not emit tag text itself. */
1382
+ } else if (strncmp(literal, "</span", 6) == 0) {
1383
+ /* Closing span: reset style. We don't currently try to
1384
+ * re-apply any outer span styles; nested spans are rare
1385
+ * in terminal output.
1386
+ */
1387
+ if (html_span_style_depth > 0) {
1388
+ html_span_style_depth--;
1389
+ }
1390
+ append_ansi_reset(buf);
1391
+ } else {
1392
+ /* Fallback: strip generic inline HTML tags, keep text */
1393
+ const char *p = literal;
1394
+ bool in_tag = false;
1395
+ while (*p) {
1396
+ if (*p == '<') {
1397
+ in_tag = true;
1398
+ p++;
1399
+ continue;
1400
+ }
1401
+ if (in_tag) {
1402
+ if (*p == '>') {
1403
+ in_tag = false;
1404
+ }
1405
+ p++;
1406
+ continue;
1407
+ }
1408
+ buffer_append(buf, p, 1);
1409
+ p++;
1410
+ }
1411
+ }
1412
+ }
1413
+ break;
1414
+ }
1415
+ default:
1416
+ /* Recurse for any other inline container types */
1417
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1418
+ serialize_inline(buf, child, options, theme, use_256_color);
1419
+ }
1420
+ break;
1421
+ }
1422
+ }
1423
+
1424
+ static void serialize_block(terminal_buffer *buf,
1425
+ cmark_node *node,
1426
+ const apex_options *options,
1427
+ const terminal_theme *theme,
1428
+ bool use_256_color,
1429
+ int indent_level) {
1430
+ cmark_node_type type = cmark_node_get_type(node);
1431
+
1432
+ /* Table nodes come from the cmark-gfm table extension and use dynamic
1433
+ * node type IDs (CMARK_NODE_TABLE, CMARK_NODE_TABLE_ROW, etc.) that
1434
+ * are not compile-time constants, so they cannot be used directly in
1435
+ * switch case labels. Handle them up-front here instead. */
1436
+ if (type == CMARK_NODE_TABLE) {
1437
+ /* If advanced_tables or other processors marked this entire table for
1438
+ * removal, skip it. */
1439
+ char *table_attrs = (char *)cmark_node_get_user_data(node);
1440
+ if (table_attrs && strstr(table_attrs, "data-remove")) {
1441
+ return;
1442
+ }
1443
+
1444
+ /* Build a span-aware grid of logical rows/columns */
1445
+ int phys_rows = 0;
1446
+ for (cmark_node *row = cmark_node_first_child(node); row; row = cmark_node_next(row)) {
1447
+ if (cmark_node_get_type(row) == CMARK_NODE_TABLE_ROW) {
1448
+ phys_rows++;
1449
+ }
1450
+ }
1451
+ if (phys_rows <= 0) return;
1452
+
1453
+ /* First pass: estimate max logical columns (sum of colspans per row).
1454
+ * IMPORTANT: We must mirror advanced_tables' logical column indexing,
1455
+ * which counts helper cells (even ones later marked data-remove) so
1456
+ * col/row spans line up with HTML rendering. So we always advance
1457
+ * logical_cols by colspan, regardless of data-remove. */
1458
+ int max_cols = 0;
1459
+ for (cmark_node *row = cmark_node_first_child(node); row; row = cmark_node_next(row)) {
1460
+ if (cmark_node_get_type(row) != CMARK_NODE_TABLE_ROW) continue;
1461
+ int logical_cols = 0;
1462
+ for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell)) {
1463
+ if (cmark_node_get_type(cell) != CMARK_NODE_TABLE_CELL) continue;
1464
+ char *attrs = (char *)cmark_node_get_user_data(cell);
1465
+ int colspan = parse_span_attr(attrs, "colspan");
1466
+ if (colspan < 1) colspan = 1;
1467
+ logical_cols += colspan;
1468
+ }
1469
+ if (logical_cols > max_cols) max_cols = logical_cols;
1470
+ }
1471
+ if (max_cols <= 0) return;
1472
+
1473
+ term_table_grid grid;
1474
+ grid.rows = phys_rows;
1475
+ grid.cols = max_cols;
1476
+ grid.cells = (term_cell *)calloc((size_t)(phys_rows * max_cols), sizeof(term_cell));
1477
+ if (!grid.cells) return;
1478
+
1479
+ /* Populate grid with owners and spans */
1480
+ int r_index = 0;
1481
+ for (cmark_node *row = cmark_node_first_child(node); row; row = cmark_node_next(row)) {
1482
+ if (cmark_node_get_type(row) != CMARK_NODE_TABLE_ROW) continue;
1483
+ int logical_col = 0;
1484
+ for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell)) {
1485
+ if (cmark_node_get_type(cell) != CMARK_NODE_TABLE_CELL) continue;
1486
+
1487
+ char *attrs = (char *)cmark_node_get_user_data(cell);
1488
+ int colspan = parse_span_attr(attrs, "colspan");
1489
+ int rowspan = parse_span_attr(attrs, "rowspan");
1490
+ if (colspan < 1) colspan = 1;
1491
+ if (rowspan < 1) rowspan = 1;
1492
+
1493
+ bool removed = (attrs && strstr(attrs, "data-remove"));
1494
+
1495
+ if (!removed) {
1496
+ /* Find next free column (skip slots already occupied by rowspans) */
1497
+ while (logical_col < max_cols) {
1498
+ term_cell *slot = grid_at(&grid, r_index, logical_col);
1499
+ if (!slot || !slot->cell) break;
1500
+ logical_col++;
1501
+ }
1502
+ if (logical_col >= max_cols) break;
1503
+
1504
+ /* Place the span for real (visible) cells */
1505
+ for (int rr = 0; rr < rowspan; rr++) {
1506
+ for (int cc = 0; cc < colspan; cc++) {
1507
+ int gr = r_index + rr;
1508
+ int gc = logical_col + cc;
1509
+ term_cell *slot = grid_at(&grid, gr, gc);
1510
+ if (!slot) continue;
1511
+ slot->cell = cell;
1512
+ slot->row_span = rowspan;
1513
+ slot->col_span = colspan;
1514
+ slot->removed = false;
1515
+ slot->is_owner = (rr == 0 && cc == 0);
1516
+ slot->printed = false;
1517
+ }
1518
+ }
1519
+ }
1520
+
1521
+ /* Always advance logical_col by colspan, even for removed helpers,
1522
+ * so our logical column indices stay aligned with advanced_tables. */
1523
+ logical_col += colspan;
1524
+ }
1525
+ r_index++;
1526
+ }
1527
+
1528
+ /* Compute column widths from grid */
1529
+ int *col_widths = (int *)calloc((size_t)max_cols, sizeof(int));
1530
+ if (!col_widths) {
1531
+ free(grid.cells);
1532
+ return;
1533
+ }
1534
+ for (int r = 0; r < grid.rows; r++) {
1535
+ for (int c = 0; c < grid.cols; c++) {
1536
+ term_cell *tc = grid_at(&grid, r, c);
1537
+ if (!tc || !tc->cell || !tc->is_owner) continue;
1538
+ int w = node_plain_text_width(tc->cell);
1539
+ int span = (tc->col_span > 0) ? tc->col_span : 1;
1540
+ if (span == 1) {
1541
+ if (w > col_widths[c]) col_widths[c] = w;
1542
+ } else {
1543
+ int per = (w + span - 1) / span; /* ceil */
1544
+ for (int k = 0; k < span && c + k < grid.cols; k++) {
1545
+ if (per > col_widths[c + k]) col_widths[c + k] = per;
1546
+ }
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
+ /* Determine how many logical columns are actually used by any cell.
1552
+ * Some trailing columns may exist only as helpers for spans; we don't
1553
+ * want to render those as empty visual columns. */
1554
+ int last_used_col = -1;
1555
+ for (int r = 0; r < grid.rows; r++) {
1556
+ for (int c = 0; c < grid.cols; c++) {
1557
+ term_cell *tc = grid_at(&grid, r, c);
1558
+ if (tc && tc->cell) {
1559
+ if (c > last_used_col) last_used_col = c;
1560
+ }
1561
+ }
1562
+ }
1563
+ int visual_cols = (last_used_col >= 0) ? (last_used_col + 1) : 0;
1564
+ if (visual_cols <= 0) {
1565
+ free(col_widths);
1566
+ free(grid.cells);
1567
+ return;
1568
+ }
1569
+
1570
+ /* Per-column default alignment, primarily from header row attributes. */
1571
+ term_align_t *col_align = (term_align_t *)calloc((size_t)max_cols, sizeof(term_align_t));
1572
+ if (!col_align) {
1573
+ free(col_widths);
1574
+ free(grid.cells);
1575
+ return;
1576
+ }
1577
+ /* Derive default column alignment from first row's owners */
1578
+ for (int c = 0; c < max_cols; c++) {
1579
+ term_cell *tc = grid_at(&grid, 0, c);
1580
+ if (!tc || !tc->cell || !tc->is_owner) continue;
1581
+ const char *attrs = (const char *)cmark_node_get_user_data(tc->cell);
1582
+ term_align_t a = parse_alignment_from_attrs(attrs);
1583
+ if (a != TERM_ALIGN_DEFAULT) {
1584
+ col_align[c] = a;
1585
+ }
1586
+ }
1587
+
1588
+ /* Box-drawing characters for borders */
1589
+ const char *h_line = "─";
1590
+ const char *v_line = "│";
1591
+ const char *top_left = "┌";
1592
+ const char *top_sep = "┬";
1593
+ const char *top_right = "┐";
1594
+ const char *mid_left = "├";
1595
+ const char *mid_sep = "┼";
1596
+ const char *mid_right = "┤";
1597
+ const char *bot_left = "└";
1598
+ const char *bot_sep = "┴";
1599
+ const char *bot_right = "┘";
1600
+
1601
+ /* Border color: theme override, otherwise "dark" white / light gray. */
1602
+ const char *border_style = NULL;
1603
+ if (theme && theme->table_border) {
1604
+ border_style = theme->table_border;
1605
+ } else {
1606
+ border_style = use_256_color ? "38;5;250" : "white";
1607
+ }
1608
+
1609
+ /* Top border */
1610
+ indent_spaces(buf, indent_level);
1611
+ if (border_style) {
1612
+ apply_style_string(buf, border_style, use_256_color);
1613
+ }
1614
+ buffer_append_str(buf, top_left);
1615
+ for (int c = 0; c < visual_cols; c++) {
1616
+ int inner = col_widths[c] > 0 ? col_widths[c] : 1;
1617
+ for (int i = 0; i < inner + 2; i++) {
1618
+ buffer_append_str(buf, h_line);
1619
+ }
1620
+ if (c == visual_cols - 1) {
1621
+ buffer_append_str(buf, top_right);
1622
+ } else {
1623
+ buffer_append_str(buf, top_sep);
1624
+ }
1625
+ }
1626
+ buffer_append_str(buf, "\n");
1627
+ if (border_style) {
1628
+ append_ansi_reset(buf);
1629
+ }
1630
+
1631
+ /* Rows: render from grid */
1632
+ int row_index = 0;
1633
+ for (int r = 0; r < grid.rows; r++) {
1634
+ /* Detect footer separator rows: logical rows whose visible cell text is all '=' */
1635
+ bool is_footer_rule = false;
1636
+ {
1637
+ bool has_cells = false;
1638
+ bool all_equals = true;
1639
+ for (int c = 0; c < visual_cols; c++) {
1640
+ term_cell *tc = grid_at(&grid, r, c);
1641
+ if (!tc || !tc->cell || !tc->is_owner) continue;
1642
+ has_cells = true;
1643
+
1644
+ cmark_node *text_node = cmark_node_first_child(tc->cell);
1645
+ const char *text = NULL;
1646
+ if (text_node && cmark_node_get_type(text_node) == CMARK_NODE_TEXT) {
1647
+ text = cmark_node_get_literal(text_node);
1648
+ }
1649
+ if (!text) {
1650
+ all_equals = false;
1651
+ break;
1652
+ }
1653
+ const char *p = text;
1654
+ while (*p && isspace((unsigned char)*p)) p++;
1655
+ if (!*p) {
1656
+ all_equals = false;
1657
+ break;
1658
+ }
1659
+ const char *q = p + strlen(p) - 1;
1660
+ while (q > p && isspace((unsigned char)*q)) q--;
1661
+ for (const char *s = p; s <= q; s++) {
1662
+ if (*s != '=') {
1663
+ all_equals = false;
1664
+ break;
1665
+ }
1666
+ }
1667
+ if (!all_equals) break;
1668
+ }
1669
+ if (has_cells && all_equals) {
1670
+ is_footer_rule = true;
1671
+ }
1672
+ }
1673
+
1674
+ bool is_header = (row_index == 0); /* Heuristic: first row is header */
1675
+
1676
+ if (is_footer_rule) {
1677
+ indent_spaces(buf, indent_level);
1678
+ if (border_style) {
1679
+ apply_style_string(buf, border_style, use_256_color);
1680
+ }
1681
+ buffer_append_str(buf, mid_left);
1682
+ for (int c = 0; c < visual_cols; c++) {
1683
+ int inner = col_widths[c] > 0 ? col_widths[c] : 1;
1684
+ for (int i = 0; i < inner + 2; i++) {
1685
+ buffer_append_str(buf, h_line);
1686
+ }
1687
+ if (c == visual_cols - 1) {
1688
+ buffer_append_str(buf, mid_right);
1689
+ } else {
1690
+ buffer_append_str(buf, mid_sep);
1691
+ }
1692
+ }
1693
+ buffer_append_str(buf, "\n");
1694
+ if (border_style) {
1695
+ append_ansi_reset(buf);
1696
+ }
1697
+ row_index++;
1698
+ continue;
1699
+ }
1700
+
1701
+ /* Content line */
1702
+ indent_spaces(buf, indent_level);
1703
+ if (border_style) {
1704
+ apply_style_string(buf, border_style, use_256_color);
1705
+ }
1706
+ buffer_append_str(buf, v_line);
1707
+ if (border_style) {
1708
+ append_ansi_reset(buf);
1709
+ }
1710
+
1711
+ int c = 0;
1712
+ while (c < visual_cols) {
1713
+ term_cell *tc = grid_at(&grid, r, c);
1714
+ if (!tc || !tc->cell) {
1715
+ /* Empty slot */
1716
+ int target = col_widths[c] > 0 ? col_widths[c] : 1;
1717
+ buffer_append_str(buf, " ");
1718
+ for (int i = 0; i < target + 1; i++) {
1719
+ buffer_append_str(buf, " ");
1720
+ }
1721
+ if (border_style) {
1722
+ apply_style_string(buf, border_style, use_256_color);
1723
+ }
1724
+ buffer_append_str(buf, v_line);
1725
+ if (border_style) {
1726
+ append_ansi_reset(buf);
1727
+ }
1728
+ c++;
1729
+ continue;
1730
+ }
1731
+
1732
+ int span = tc->col_span > 0 ? tc->col_span : 1;
1733
+
1734
+ if (!tc->is_owner) {
1735
+ /* Inside a span: just draw empty space for this column block */
1736
+ int target = col_widths[c] > 0 ? col_widths[c] : 1;
1737
+ buffer_append_str(buf, " ");
1738
+ for (int i = 0; i < target + 1; i++) {
1739
+ buffer_append_str(buf, " ");
1740
+ }
1741
+ if (border_style) {
1742
+ apply_style_string(buf, border_style, use_256_color);
1743
+ }
1744
+ buffer_append_str(buf, v_line);
1745
+ if (border_style) {
1746
+ append_ansi_reset(buf);
1747
+ }
1748
+ c++;
1749
+ continue;
1750
+ }
1751
+
1752
+ /* Owner cell: compute total width across its span.
1753
+ * For 'span' columns normally: each column has border + space + content + space + border
1754
+ * Total content+padding area for span columns: sum(col_widths) + span*2 spaces
1755
+ * For colspan we render: space + content + space (the 2 borders are separate)
1756
+ * So content area should be: sum(col_widths) + span*2 - 2 = sum(col_widths) + 2*(span-1)
1757
+ * But we also need to account for the (span-1) borders that are removed,
1758
+ * each border takes 1 character, so add (span-1): sum(col_widths) + 2*(span-1) + (span-1)
1759
+ * = sum(col_widths) + 3*(span-1) = sum(col_widths) + 3*span - 3 */
1760
+ int block_width = 0;
1761
+ for (int k = 0; k < span && c + k < visual_cols; k++) {
1762
+ block_width += (col_widths[c + k] > 0 ? col_widths[c + k] : 1);
1763
+ }
1764
+ /* Add the padding/border spaces that would normally be between columns.
1765
+ * For span columns: sum(col_widths) + span*2 spaces + (span-1) borders removed
1766
+ * = sum(col_widths) + 2*span + (span-1) = sum(col_widths) + 3*span - 1
1767
+ * But we render 2 spaces (left+right pad), so: sum(col_widths) + 3*span - 1 - 2
1768
+ * = sum(col_widths) + 3*span - 3 */
1769
+ if (span > 1) {
1770
+ block_width += 3 * span - 3;
1771
+ }
1772
+ int target = block_width;
1773
+ int actual = 0;
1774
+
1775
+ if (!tc->printed) {
1776
+ /* First time rendering this owner: measure real content width */
1777
+ actual = node_plain_text_width(tc->cell);
1778
+ if (actual < 0) actual = 0;
1779
+ }
1780
+
1781
+ /* Resolve effective alignment */
1782
+ term_align_t align = TERM_ALIGN_LEFT;
1783
+ if (col_align[c] != TERM_ALIGN_DEFAULT) {
1784
+ align = col_align[c];
1785
+ }
1786
+ const char *attrs = (const char *)cmark_node_get_user_data(tc->cell);
1787
+ term_align_t cell_align = parse_alignment_from_attrs(attrs);
1788
+ if (cell_align != TERM_ALIGN_DEFAULT) {
1789
+ align = cell_align;
1790
+ }
1791
+
1792
+ int extra = target - actual;
1793
+ if (extra < 0) extra = 0;
1794
+ int left_extra = 0;
1795
+ int right_extra = 0;
1796
+ switch (align) {
1797
+ case TERM_ALIGN_RIGHT:
1798
+ left_extra = extra;
1799
+ right_extra = 0;
1800
+ break;
1801
+ case TERM_ALIGN_CENTER:
1802
+ left_extra = extra / 2;
1803
+ right_extra = extra - left_extra;
1804
+ break;
1805
+ case TERM_ALIGN_LEFT:
1806
+ default:
1807
+ left_extra = 0;
1808
+ right_extra = extra;
1809
+ break;
1810
+ }
1811
+ int left_pad = 1 + left_extra;
1812
+ int right_pad = 1 + right_extra;
1813
+
1814
+ for (int i = 0; i < left_pad; i++) {
1815
+ buffer_append_str(buf, " ");
1816
+ }
1817
+ if (!tc->printed) {
1818
+ if (is_header) {
1819
+ apply_style_string(buf, "b", use_256_color);
1820
+ }
1821
+ for (cmark_node *child = cmark_node_first_child(tc->cell); child; child = cmark_node_next(child)) {
1822
+ serialize_inline(buf, child, options, theme, use_256_color);
1823
+ }
1824
+ if (is_header) {
1825
+ append_ansi_reset(buf);
1826
+ }
1827
+ tc->printed = true;
1828
+ }
1829
+ for (int i = 0; i < right_pad; i++) {
1830
+ buffer_append_str(buf, " ");
1831
+ }
1832
+ if (border_style) {
1833
+ apply_style_string(buf, border_style, use_256_color);
1834
+ }
1835
+ buffer_append_str(buf, v_line);
1836
+ if (border_style) {
1837
+ append_ansi_reset(buf);
1838
+ }
1839
+
1840
+ c += span;
1841
+ }
1842
+
1843
+ buffer_append_str(buf, "\n");
1844
+
1845
+ /* Header separator after first row */
1846
+ if (is_header) {
1847
+ indent_spaces(buf, indent_level);
1848
+ if (border_style) {
1849
+ apply_style_string(buf, border_style, use_256_color);
1850
+ }
1851
+ buffer_append_str(buf, mid_left);
1852
+ for (int cc = 0; cc < visual_cols; cc++) {
1853
+ int inner = col_widths[cc] > 0 ? col_widths[cc] : 1;
1854
+ for (int i = 0; i < inner + 2; i++) {
1855
+ buffer_append_str(buf, h_line);
1856
+ }
1857
+ if (cc == visual_cols - 1) {
1858
+ buffer_append_str(buf, mid_right);
1859
+ } else {
1860
+ buffer_append_str(buf, mid_sep);
1861
+ }
1862
+ }
1863
+ buffer_append_str(buf, "\n");
1864
+ if (border_style) {
1865
+ append_ansi_reset(buf);
1866
+ }
1867
+ }
1868
+
1869
+ row_index++;
1870
+ }
1871
+
1872
+ /* Bottom border */
1873
+ indent_spaces(buf, indent_level);
1874
+ if (border_style) {
1875
+ apply_style_string(buf, border_style, use_256_color);
1876
+ }
1877
+ buffer_append_str(buf, bot_left);
1878
+ for (int c = 0; c < visual_cols; c++) {
1879
+ int inner = col_widths[c] > 0 ? col_widths[c] : 1;
1880
+ for (int i = 0; i < inner + 2; i++) {
1881
+ buffer_append_str(buf, h_line);
1882
+ }
1883
+ if (c == visual_cols - 1) {
1884
+ buffer_append_str(buf, bot_right);
1885
+ } else {
1886
+ buffer_append_str(buf, bot_sep);
1887
+ }
1888
+ }
1889
+ buffer_append_str(buf, "\n\n");
1890
+ if (border_style) {
1891
+ append_ansi_reset(buf);
1892
+ }
1893
+
1894
+ free(col_align);
1895
+ free(col_widths);
1896
+ free(grid.cells);
1897
+ return;
1898
+ }
1899
+
1900
+ switch (type) {
1901
+ case CMARK_NODE_DOCUMENT: {
1902
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1903
+ serialize_block(buf, child, options, theme, use_256_color, indent_level);
1904
+ }
1905
+ break;
1906
+ }
1907
+
1908
+ case CMARK_NODE_PARAGRAPH: {
1909
+ indent_spaces(buf, indent_level);
1910
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1911
+ if (getenv("APEX_DEBUG_THEME")) {
1912
+ cmark_node_type ctype = cmark_node_get_type(child);
1913
+ const char *lit = cmark_node_get_literal(child);
1914
+ const char *attrs = (const char *)cmark_node_get_user_data(child);
1915
+ fprintf(stderr,
1916
+ "[APEX_DEBUG_THEME] inline node type=%d literal='%s' attrs='%s'\n",
1917
+ (int)ctype,
1918
+ lit ? lit : "",
1919
+ attrs ? attrs : "");
1920
+ }
1921
+ serialize_inline(buf, child, options, theme, use_256_color);
1922
+ }
1923
+ /* Compact paragraphs inside list items to a single newline */
1924
+ cmark_node *parent = cmark_node_parent(node);
1925
+ cmark_node_type ptype = parent ? cmark_node_get_type(parent) : 0;
1926
+ if (ptype == CMARK_NODE_ITEM || ptype == CMARK_NODE_LIST) {
1927
+ buffer_append_str(buf, "\n");
1928
+ } else {
1929
+ buffer_append_str(buf, "\n\n");
1930
+ }
1931
+ break;
1932
+ }
1933
+
1934
+ case CMARK_NODE_HEADING: {
1935
+ int level = cmark_node_get_heading_level(node);
1936
+ const char *style = NULL;
1937
+ if (theme) {
1938
+ switch (level) {
1939
+ case 1: style = theme->h1_color; break;
1940
+ case 2: style = theme->h2_color; break;
1941
+ case 3: style = theme->h3_color; break;
1942
+ case 4: style = theme->h4_color; break;
1943
+ case 5: style = theme->h5_color; break;
1944
+ case 6: style = theme->h6_color; break;
1945
+ }
1946
+ }
1947
+ if (!style) {
1948
+ if (level == 1) style = "b intense_black on_white";
1949
+ else if (level == 2) style = "b white on_intense_black";
1950
+ else if (level == 3) style = "u b yellow";
1951
+ else style = "b white";
1952
+ }
1953
+
1954
+ indent_spaces(buf, indent_level);
1955
+ apply_style_string(buf, style, use_256_color);
1956
+ const char *text_start = buf->buf ? buf->buf + buf->len : NULL;
1957
+ (void)text_start;
1958
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
1959
+ serialize_inline(buf, child, options, theme, use_256_color);
1960
+ }
1961
+ append_ansi_reset(buf);
1962
+ buffer_append_str(buf, "\n\n");
1963
+ break;
1964
+ }
1965
+
1966
+ case CMARK_NODE_LIST: {
1967
+ cmark_list_type list_type = cmark_node_get_list_type(node);
1968
+ int start = cmark_node_get_list_start(node);
1969
+ if (start <= 0) start = 1;
1970
+
1971
+ int index = 0;
1972
+ for (cmark_node *item = cmark_node_first_child(node); item; item = cmark_node_next(item)) {
1973
+ indent_spaces(buf, indent_level);
1974
+ {
1975
+ const char *marker_style = NULL;
1976
+ if (theme && theme->list_marker) {
1977
+ marker_style = theme->list_marker;
1978
+ } else {
1979
+ /* Default: bold bright red for both bullet and ordered markers */
1980
+ marker_style = "b intense_red";
1981
+ }
1982
+ apply_style_string(buf, marker_style, use_256_color);
1983
+ if (list_type == CMARK_BULLET_LIST) {
1984
+ buffer_append_str(buf, "* ");
1985
+ } else {
1986
+ char num[32];
1987
+ snprintf(num, sizeof(num), "%d.", start + index);
1988
+ buffer_append_str(buf, num);
1989
+ buffer_append_str(buf, " ");
1990
+ }
1991
+ append_ansi_reset(buf);
1992
+ }
1993
+ serialize_block(buf, item, options, theme, use_256_color, indent_level + 1);
1994
+ index++;
1995
+ }
1996
+ buffer_append_str(buf, "\n");
1997
+ break;
1998
+ }
1999
+
2000
+ case CMARK_NODE_ITEM: {
2001
+ /* List item contents without additional marker (already printed) */
2002
+ cmark_node *child = cmark_node_first_child(node);
2003
+ while (child) {
2004
+ serialize_block(buf, child, options, theme, use_256_color, indent_level);
2005
+ child = cmark_node_next(child);
2006
+ }
2007
+ break;
2008
+ }
2009
+
2010
+ case CMARK_NODE_BLOCK_QUOTE: {
2011
+ const char *marker = theme && theme->blockquote_marker ? theme->blockquote_marker : ">";
2012
+ const char *marker_style = "yellow";
2013
+ /* Text style: theme override, otherwise italic + light gray */
2014
+ const char *text_style = NULL;
2015
+ if (theme && theme->blockquote_color) {
2016
+ text_style = theme->blockquote_color;
2017
+ } else {
2018
+ /* Default: italic + light gray; fall back to plain white in 8-color mode */
2019
+ text_style = use_256_color ? "i 38;5;250" : "i white";
2020
+ }
2021
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
2022
+ cmark_node_type child_type = cmark_node_get_type(child);
2023
+
2024
+ if (child_type == CMARK_NODE_PARAGRAPH) {
2025
+ /* For paragraphs, serialize inline content directly on same line as > */
2026
+ indent_spaces(buf, indent_level);
2027
+ apply_style_string(buf, marker_style, use_256_color);
2028
+ buffer_append_str(buf, marker);
2029
+ buffer_append_str(buf, " ");
2030
+ append_ansi_reset(buf);
2031
+
2032
+ cmark_node *inline_child = cmark_node_first_child(child);
2033
+
2034
+ while (inline_child) {
2035
+ cmark_node_type inline_type = cmark_node_get_type(inline_child);
2036
+
2037
+ if (inline_type == CMARK_NODE_SOFTBREAK) {
2038
+ /* For soft breaks in blockquotes, continue on next line with > */
2039
+ buffer_append_str(buf, "\n");
2040
+ indent_spaces(buf, indent_level);
2041
+ apply_style_string(buf, marker_style, use_256_color);
2042
+ buffer_append_str(buf, marker);
2043
+ buffer_append_str(buf, " ");
2044
+ append_ansi_reset(buf);
2045
+ } else {
2046
+ /* Apply text style (italic + light gray by default) */
2047
+ apply_style_string(buf, text_style, use_256_color);
2048
+ serialize_inline(buf, inline_child, options, theme, use_256_color);
2049
+ append_ansi_reset(buf);
2050
+ }
2051
+ inline_child = cmark_node_next(inline_child);
2052
+ }
2053
+
2054
+ if (cmark_node_next(child)) {
2055
+ buffer_append_str(buf, "\n");
2056
+ } else {
2057
+ buffer_append_str(buf, "\n\n");
2058
+ }
2059
+ } else {
2060
+ /* For other block types, prefix with > */
2061
+ indent_spaces(buf, indent_level);
2062
+ apply_style_string(buf, marker_style, use_256_color);
2063
+ buffer_append_str(buf, marker);
2064
+ buffer_append_str(buf, " ");
2065
+ append_ansi_reset(buf);
2066
+ serialize_block(buf, child, options, theme, use_256_color, indent_level);
2067
+ }
2068
+ }
2069
+ break;
2070
+ }
2071
+
2072
+
2073
+ case CMARK_NODE_CODE_BLOCK: {
2074
+ const char *info = cmark_node_get_fence_info(node);
2075
+ const char *literal = cmark_node_get_literal(node);
2076
+
2077
+ /* Try external syntax highlighter when configured */
2078
+ bool highlighted = false;
2079
+ if (options && options->code_highlighter && literal && *literal) {
2080
+ const char *tool = options->code_highlighter;
2081
+ const char *binary = NULL;
2082
+ bool use_pygments = false;
2083
+ bool use_skylighting = false;
2084
+
2085
+ if (strcmp(tool, "pygments") == 0) {
2086
+ binary = "pygmentize";
2087
+ use_pygments = true;
2088
+ } else if (strcmp(tool, "skylighting") == 0) {
2089
+ binary = "skylighting";
2090
+ use_skylighting = true;
2091
+ }
2092
+
2093
+ if (binary) {
2094
+ /* Extract language from info string (up to first space) */
2095
+ char lang[64] = {0};
2096
+ if (info && *info) {
2097
+ const char *p = info;
2098
+ char *w = lang;
2099
+ while (*p && !isspace((unsigned char)*p) && (size_t)(w - lang) < sizeof(lang) - 1) {
2100
+ *w++ = *p++;
2101
+ }
2102
+ *w = '\0';
2103
+ }
2104
+
2105
+ const char *theme_name = options->code_highlight_theme;
2106
+ char cmd[512];
2107
+
2108
+ if (use_pygments) {
2109
+ /* Pygments: terminal / terminal256 output, with optional style */
2110
+ const char *format = use_256_color ? "terminal256" : "terminal";
2111
+ const char *style = theme_name && *theme_name
2112
+ ? theme_name
2113
+ : (use_256_color ? "paraiso-dark" : "pastie");
2114
+
2115
+ if (lang[0]) {
2116
+ snprintf(cmd, sizeof(cmd), "%s -l %s -f %s -O style=%s",
2117
+ binary, lang, format, style);
2118
+ } else {
2119
+ snprintf(cmd, sizeof(cmd), "%s -g -f %s -O style=%s",
2120
+ binary, format, style);
2121
+ }
2122
+ } else if (use_skylighting) {
2123
+ /* Skylighting: ANSI output via -f ansi, with explicit color level.
2124
+ * Map Apex's terminal/terminal256 to --color-level=16/256 so that
2125
+ * skylighting does not have to auto-detect capabilities.
2126
+ * When a code-highlight-theme is provided, pass it through as --style. */
2127
+ const char *format = "ansi";
2128
+ const char *color_level = use_256_color ? "256" : "16";
2129
+ const char *style = (theme_name && *theme_name) ? theme_name : NULL;
2130
+ if (lang[0]) {
2131
+ if (style) {
2132
+ snprintf(cmd, sizeof(cmd), "%s --syntax %s --color-level=%s --style %s -f %s",
2133
+ binary, lang, color_level, style, format);
2134
+ } else {
2135
+ snprintf(cmd, sizeof(cmd), "%s --syntax %s --color-level=%s -f %s",
2136
+ binary, lang, color_level, format);
2137
+ }
2138
+ } else {
2139
+ if (style) {
2140
+ snprintf(cmd, sizeof(cmd), "%s --color-level=%s --style %s -f %s",
2141
+ binary, color_level, style, format);
2142
+ } else {
2143
+ snprintf(cmd, sizeof(cmd), "%s --color-level=%s -f %s",
2144
+ binary, color_level, format);
2145
+ }
2146
+ }
2147
+ } else {
2148
+ cmd[0] = '\0';
2149
+ }
2150
+
2151
+ if (cmd[0]) {
2152
+ /* Reuse run_command-style helper from syntax_highlight.c (local copy) */
2153
+ int in_pipe[2];
2154
+ int out_pipe[2];
2155
+ if (pipe(in_pipe) == 0 && pipe(out_pipe) == 0) {
2156
+ pid_t pid = fork();
2157
+ if (pid == 0) {
2158
+ /* Child */
2159
+ dup2(in_pipe[0], STDIN_FILENO);
2160
+ dup2(out_pipe[1], STDOUT_FILENO);
2161
+ int devnull = open("/dev/null", O_WRONLY);
2162
+ if (devnull != -1) {
2163
+ dup2(devnull, STDERR_FILENO);
2164
+ close(devnull);
2165
+ }
2166
+ close(in_pipe[0]); close(in_pipe[1]);
2167
+ close(out_pipe[0]); close(out_pipe[1]);
2168
+ execl("/bin/sh", "sh", "-c", cmd, (char *)NULL);
2169
+ _exit(127);
2170
+ } else if (pid > 0) {
2171
+ /* Parent */
2172
+ close(in_pipe[0]);
2173
+ close(out_pipe[1]);
2174
+
2175
+ /* Write code to child stdin */
2176
+ size_t input_len = strlen(literal);
2177
+ ssize_t to_write = (ssize_t)input_len;
2178
+ const char *p = literal;
2179
+ while (to_write > 0) {
2180
+ ssize_t written = write(in_pipe[1], p, (size_t)to_write);
2181
+ if (written <= 0) break;
2182
+ p += written;
2183
+ to_write -= written;
2184
+ }
2185
+ close(in_pipe[1]);
2186
+
2187
+ /* Read all of child's stdout */
2188
+ size_t cap = 8192;
2189
+ size_t size = 0;
2190
+ char *out = malloc(cap);
2191
+ if (out) {
2192
+ for (;;) {
2193
+ if (size + 4096 > cap) {
2194
+ cap *= 2;
2195
+ char *nb = realloc(out, cap);
2196
+ if (!nb) {
2197
+ free(out);
2198
+ out = NULL;
2199
+ break;
2200
+ }
2201
+ out = nb;
2202
+ }
2203
+ ssize_t n = read(out_pipe[0], out + size, 4096);
2204
+ if (n < 0) {
2205
+ if (errno == EINTR) continue;
2206
+ free(out);
2207
+ out = NULL;
2208
+ break;
2209
+ }
2210
+ if (n == 0) break;
2211
+ size += (size_t)n;
2212
+ }
2213
+ }
2214
+ close(out_pipe[0]);
2215
+
2216
+ int status;
2217
+ waitpid(pid, &status, 0);
2218
+
2219
+ if (out && WIFEXITED(status) && WEXITSTATUS(status) == 0) {
2220
+ out[size] = '\0';
2221
+ /* Emit highlighted ANSI directly */
2222
+ buffer_append_str(buf, out);
2223
+ /* Ensure a blank line after block */
2224
+ if (size == 0 || out[size - 1] != '\n') {
2225
+ buffer_append_str(buf, "\n");
2226
+ }
2227
+ buffer_append_str(buf, "\n");
2228
+ free(out);
2229
+ highlighted = true;
2230
+ } else if (out) {
2231
+ free(out);
2232
+ }
2233
+ } else {
2234
+ /* fork failed */
2235
+ close(in_pipe[0]); close(in_pipe[1]);
2236
+ close(out_pipe[0]); close(out_pipe[1]);
2237
+ }
2238
+ }
2239
+ }
2240
+ }
2241
+ }
2242
+
2243
+ if (!highlighted) {
2244
+ /* Fallback: render simple fenced block with theme colors */
2245
+ indent_spaces(buf, indent_level);
2246
+ if (theme && theme->code_block) {
2247
+ apply_style_string(buf, theme->code_block, use_256_color);
2248
+ } else {
2249
+ apply_style_string(buf, "white on_black", use_256_color);
2250
+ }
2251
+ buffer_append_str(buf, "```");
2252
+ if (info && *info) {
2253
+ buffer_append_str(buf, info);
2254
+ }
2255
+ buffer_append_str(buf, "\n");
2256
+ if (literal) {
2257
+ buffer_append_str(buf, literal);
2258
+ }
2259
+ buffer_append_str(buf, "\n```");
2260
+ append_ansi_reset(buf);
2261
+ buffer_append_str(buf, "\n\n");
2262
+ }
2263
+ break;
2264
+ }
2265
+
2266
+ case CMARK_NODE_HTML_BLOCK: {
2267
+ const char *literal = cmark_node_get_literal(node);
2268
+ if (literal) {
2269
+ /* Handle definition lists: <dl><dt>Term</dt><dd>Definition</dd></dl> */
2270
+ if (strstr(literal, "<dl>") || strstr(literal, "<dt>") || strstr(literal, "<dd>")) {
2271
+ const char *p = literal;
2272
+ bool in_dt = false;
2273
+ bool in_dd = false;
2274
+ bool first_dd = true;
2275
+
2276
+ while (*p) {
2277
+ if (strncmp(p, "<dt>", 4) == 0) {
2278
+ in_dt = true;
2279
+ p += 4;
2280
+ if (!first_dd) {
2281
+ buffer_append_str(buf, "\n");
2282
+ }
2283
+ first_dd = false;
2284
+ continue;
2285
+ }
2286
+ if (strncmp(p, "</dt>", 5) == 0) {
2287
+ in_dt = false;
2288
+ p += 5;
2289
+ buffer_append_str(buf, "\n");
2290
+ continue;
2291
+ }
2292
+ if (strncmp(p, "<dd>", 4) == 0) {
2293
+ in_dd = true;
2294
+ p += 4;
2295
+ indent_spaces(buf, indent_level + 1);
2296
+ continue;
2297
+ }
2298
+ if (strncmp(p, "</dd>", 5) == 0) {
2299
+ in_dd = false;
2300
+ p += 5;
2301
+ buffer_append_str(buf, "\n");
2302
+ continue;
2303
+ }
2304
+ if (strncmp(p, "<dl>", 4) == 0 || strncmp(p, "</dl>", 5) == 0) {
2305
+ p += (p[1] == 'd' && p[2] == 'l') ? 4 : 5;
2306
+ continue;
2307
+ }
2308
+
2309
+ if (in_dt || in_dd) {
2310
+ if (*p == '\n' || *p == '\r') {
2311
+ buffer_append_str(buf, " ");
2312
+ } else if (*p != '<') {
2313
+ buffer_append(buf, p, 1);
2314
+ }
2315
+ }
2316
+ p++;
2317
+ }
2318
+ buffer_append_str(buf, "\n");
2319
+ }
2320
+ /* Handle callouts: <div class="callout callout-TYPE">...</div> */
2321
+ else if (strstr(literal, "callout")) {
2322
+ const char *p = literal;
2323
+ bool found_title = false;
2324
+
2325
+ /* Extract callout type from class */
2326
+ const char *type_start = strstr(p, "callout-");
2327
+ if (type_start) {
2328
+ type_start += 8; /* Skip "callout-" */
2329
+ const char *type_end = type_start;
2330
+ while (*type_end && *type_end != ' ' && *type_end != '"' && *type_end != '>') {
2331
+ type_end++;
2332
+ }
2333
+ if (type_end > type_start) {
2334
+ indent_spaces(buf, indent_level);
2335
+ apply_style_string(buf, "b yellow", use_256_color);
2336
+ buffer_append_str(buf, "[");
2337
+ buffer_append(buf, type_start, (size_t)(type_end - type_start));
2338
+ buffer_append_str(buf, "]");
2339
+ append_ansi_reset(buf);
2340
+ found_title = true;
2341
+ }
2342
+ }
2343
+
2344
+ /* Extract title */
2345
+ const char *title_start = strstr(p, "<summary>");
2346
+ if (!title_start) title_start = strstr(p, "callout-title");
2347
+ if (title_start) {
2348
+ if (strstr(title_start, "<summary>")) {
2349
+ title_start = strstr(title_start, "<summary>") + 9;
2350
+ } else {
2351
+ title_start = strstr(title_start, ">") + 1;
2352
+ }
2353
+ const char *title_end = strstr(title_start, "</");
2354
+ if (title_end && title_end > title_start) {
2355
+ if (found_title) {
2356
+ buffer_append_str(buf, " ");
2357
+ }
2358
+ /* Strip HTML from title */
2359
+ const char *t = title_start;
2360
+ while (t < title_end) {
2361
+ if (*t == '<') {
2362
+ while (t < title_end && *t != '>') t++;
2363
+ if (t < title_end) t++;
2364
+ continue;
2365
+ }
2366
+ buffer_append(buf, t, 1);
2367
+ t++;
2368
+ }
2369
+ buffer_append_str(buf, "\n");
2370
+ }
2371
+ }
2372
+
2373
+ /* Extract content */
2374
+ const char *content_start = strstr(p, "callout-content");
2375
+ if (content_start) {
2376
+ content_start = strstr(content_start, ">") + 1;
2377
+ const char *content_end = strstr(content_start, "</div>");
2378
+ if (!content_end) content_end = strstr(content_start, "</details>");
2379
+ if (content_end && content_end > content_start) {
2380
+ /* Strip HTML and render content */
2381
+ const char *c = content_start;
2382
+ while (c < content_end) {
2383
+ if (*c == '<') {
2384
+ const char *tag_end = strchr(c, '>');
2385
+ if (tag_end && tag_end < content_end) {
2386
+ c = tag_end + 1;
2387
+ continue;
2388
+ }
2389
+ }
2390
+ if (*c == '\n' || *c == '\r') {
2391
+ buffer_append_str(buf, " ");
2392
+ } else {
2393
+ buffer_append(buf, c, 1);
2394
+ }
2395
+ c++;
2396
+ }
2397
+ buffer_append_str(buf, "\n\n");
2398
+ }
2399
+ }
2400
+ }
2401
+ /* Generic HTML: strip tags and render text */
2402
+ else {
2403
+ const char *p = literal;
2404
+ bool in_tag = false;
2405
+ bool skip_whitespace = true;
2406
+
2407
+ while (*p) {
2408
+ if (*p == '<') {
2409
+ in_tag = true;
2410
+ p++;
2411
+ continue;
2412
+ }
2413
+ if (in_tag) {
2414
+ if (*p == '>') {
2415
+ in_tag = false;
2416
+ skip_whitespace = true;
2417
+ p++;
2418
+ continue;
2419
+ }
2420
+ p++;
2421
+ continue;
2422
+ }
2423
+
2424
+ /* Skip leading whitespace after tags */
2425
+ if (skip_whitespace && (*p == ' ' || *p == '\t' || *p == '\n')) {
2426
+ p++;
2427
+ continue;
2428
+ }
2429
+ skip_whitespace = false;
2430
+
2431
+ /* Convert newlines to spaces, except for double newlines */
2432
+ if (*p == '\n') {
2433
+ if (p[1] == '\n' || p[1] == '\0') {
2434
+ buffer_append_str(buf, "\n\n");
2435
+ skip_whitespace = true;
2436
+ p++;
2437
+ if (*p == '\n') p++;
2438
+ continue;
2439
+ } else {
2440
+ buffer_append_str(buf, " ");
2441
+ p++;
2442
+ continue;
2443
+ }
2444
+ }
2445
+
2446
+ buffer_append(buf, p, 1);
2447
+ p++;
2448
+ }
2449
+
2450
+ /* Ensure trailing newline */
2451
+ if (buf->len > 0 && buf->buf[buf->len - 1] != '\n') {
2452
+ buffer_append_str(buf, "\n\n");
2453
+ } else if (buf->len > 1 && buf->buf[buf->len - 2] != '\n') {
2454
+ buffer_append_str(buf, "\n");
2455
+ }
2456
+ }
2457
+ }
2458
+ break;
2459
+ }
2460
+
2461
+ case CMARK_NODE_THEMATIC_BREAK:
2462
+ indent_spaces(buf, indent_level);
2463
+ buffer_append_str(buf, "----------------------------------------\n\n");
2464
+ break;
2465
+
2466
+ default: {
2467
+ /* Generic container: recurse into children */
2468
+ for (cmark_node *child = cmark_node_first_child(node); child; child = cmark_node_next(child)) {
2469
+ serialize_block(buf, child, options, theme, use_256_color, indent_level);
2470
+ }
2471
+ break;
2472
+ }
2473
+ }
2474
+ }
2475
+
2476
+ /* ------------------------------------------------------------------------- */
2477
+ /* Public API */
2478
+ /* ------------------------------------------------------------------------- */
2479
+
2480
+ char *apex_cmark_to_terminal(cmark_node *document,
2481
+ const apex_options *options,
2482
+ bool use_256) {
2483
+ if (!document || cmark_node_get_type(document) != CMARK_NODE_DOCUMENT) {
2484
+ return NULL;
2485
+ }
2486
+
2487
+ terminal_buffer buf;
2488
+ buffer_init(&buf);
2489
+
2490
+ /* Reset HTML span style stack at the start of each render. */
2491
+ html_span_style_depth = 0;
2492
+
2493
+ terminal_theme *theme = load_theme(options);
2494
+ if (getenv("APEX_DEBUG_THEME")) {
2495
+ const char *tname = (options && options->theme_name) ? options->theme_name : "(null)";
2496
+ fprintf(stderr,
2497
+ "[APEX_DEBUG_THEME] theme_name=%s code_span=%s span_classes_count=%zu\n",
2498
+ tname,
2499
+ (theme && theme->code_span) ? theme->code_span : "(null)",
2500
+ theme ? theme->span_classes_count : 0);
2501
+ }
2502
+
2503
+ serialize_block(&buf, document, options, theme, use_256, 0);
2504
+
2505
+ free_theme(theme);
2506
+
2507
+ if (!buf.buf) {
2508
+ /* Fallback: empty string */
2509
+ char *empty = (char *)malloc(1);
2510
+ if (empty) empty[0] = '\0';
2511
+ return empty;
2512
+ }
2513
+
2514
+ return buf.buf;
2515
+ }
2516
+