apex-ruby 1.0.6 → 1.0.8

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
@@ -16,20 +16,164 @@
16
16
  #include <dirent.h>
17
17
  #include <sys/stat.h>
18
18
  #include <sys/ioctl.h>
19
+ #include <limits.h>
19
20
 
20
- /* Remote plugin directory helpers (from plugins_remote.c) */
21
+ /* Remote plugin directory helpers (from plugins_remote.c) */
21
22
  typedef struct apex_remote_plugin apex_remote_plugin;
22
23
  typedef struct apex_remote_plugin_list apex_remote_plugin_list;
23
24
 
24
25
  apex_remote_plugin_list *apex_remote_fetch_directory(const char *url);
26
+ apex_remote_plugin_list *apex_remote_fetch_filters_directory(const char *url);
25
27
  void apex_remote_print_plugins(apex_remote_plugin_list *list);
26
28
  void apex_remote_print_plugins_filtered(apex_remote_plugin_list *list,
27
29
  const char **installed_ids,
28
- size_t installed_count);
30
+ size_t installed_count,
31
+ const char *noun);
29
32
  apex_remote_plugin *apex_remote_find_plugin(apex_remote_plugin_list *list, const char *id);
30
33
  void apex_remote_free_plugins(apex_remote_plugin_list *list);
31
34
  const char *apex_remote_plugin_repo(apex_remote_plugin *p);
32
35
 
36
+ /* Shared JSON helpers used for filters as well */
37
+ char *apex_remote_fetch_json(const char *url);
38
+ char *apex_remote_extract_string(const char *obj, const char *key);
39
+
40
+ /* ------------------------------------------------------------------------- */
41
+ /* Syntax highlighting theme listing */
42
+ /* ------------------------------------------------------------------------- */
43
+
44
+ static int apex_cli_terminal_width(void) {
45
+ struct winsize ws;
46
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
47
+ return (int)ws.ws_col;
48
+ }
49
+ const char *cols_env = getenv("COLUMNS");
50
+ if (cols_env && *cols_env) {
51
+ long v = strtol(cols_env, NULL, 10);
52
+ if (v > 0 && v < INT_MAX) {
53
+ return (int)v;
54
+ }
55
+ }
56
+ return 80;
57
+ }
58
+
59
+ static void apex_cli_print_theme_section(const char *title,
60
+ const char *const *items,
61
+ size_t count) {
62
+ fprintf(stdout, "%s\n", title);
63
+ fprintf(stdout, "----------------------------------------\n");
64
+ if (count == 0) {
65
+ fprintf(stdout, "(no themes)\n\n");
66
+ return;
67
+ }
68
+
69
+ size_t max_len = 0;
70
+ for (size_t i = 0; i < count; i++) {
71
+ size_t len = strlen(items[i]);
72
+ if (len > max_len) max_len = len;
73
+ }
74
+
75
+ int term_width = apex_cli_terminal_width();
76
+ int col_width = (int)max_len + 2;
77
+ if (col_width <= 0) col_width = 16;
78
+ int max_cols = term_width > 0 ? term_width / col_width : 1;
79
+ if (max_cols < 1) max_cols = 1;
80
+ if (max_cols > 4) max_cols = 4;
81
+ int cols = max_cols;
82
+ int rows = (int)((count + (size_t)cols - 1) / (size_t)cols);
83
+ if (rows < 1) rows = 1;
84
+
85
+ for (int r = 0; r < rows; r++) {
86
+ for (int c = 0; c < cols; c++) {
87
+ size_t idx = (size_t)r + (size_t)rows * (size_t)c;
88
+ if (idx >= count) {
89
+ continue;
90
+ }
91
+ const char *name = items[idx];
92
+ int pad = col_width - (int)strlen(name);
93
+ if (pad < 1) pad = 1;
94
+ fprintf(stdout, "%s", name);
95
+ for (int p = 0; p < pad; p++) fputc(' ', stdout);
96
+ }
97
+ fputc('\n', stdout);
98
+ }
99
+ fputc('\n', stdout);
100
+ }
101
+
102
+ static void apex_cli_print_highlight_themes(void) {
103
+ static const char *const pygments_themes[] = {
104
+ "abap",
105
+ "algol",
106
+ "algol_nu",
107
+ "arduino",
108
+ "autumn",
109
+ "bw",
110
+ "borland",
111
+ "coffee",
112
+ "colorful",
113
+ "default",
114
+ "dracula",
115
+ "emacs",
116
+ "friendly_grayscale",
117
+ "friendly",
118
+ "fruity",
119
+ "github-dark",
120
+ "gruvbox-dark",
121
+ "gruvbox-light",
122
+ "igor",
123
+ "inkpot",
124
+ "lightbulb",
125
+ "lilypond",
126
+ "lovelace",
127
+ "manni",
128
+ "material",
129
+ "monokai",
130
+ "murphy",
131
+ "native",
132
+ "nord-darker",
133
+ "nord",
134
+ "one-dark",
135
+ "paraiso-dark",
136
+ "paraiso-light",
137
+ "pastie",
138
+ "perldoc",
139
+ "rainbow_dash",
140
+ "rrt",
141
+ "sas",
142
+ "solarized-dark",
143
+ "solarized-light",
144
+ "staroffice",
145
+ "stata-dark",
146
+ "stata-light",
147
+ "tango",
148
+ "trac",
149
+ "vim",
150
+ "vs",
151
+ "xcode",
152
+ "zenburn"
153
+ };
154
+ static const char *const skylighting_themes[] = {
155
+ "kate",
156
+ "breezeDark",
157
+ "pygments",
158
+ "espresso",
159
+ "tango",
160
+ "haddock",
161
+ "monochrome",
162
+ "zenburn"
163
+ };
164
+ static const char *const shiki_themes[] = {
165
+ "Bundled themes: see https://shiki.style/themes",
166
+ "Special theme: none (disables highlighting)"
167
+ };
168
+
169
+ apex_cli_print_theme_section("Pygments themes", pygments_themes,
170
+ sizeof(pygments_themes) / sizeof(pygments_themes[0]));
171
+ apex_cli_print_theme_section("Skylighting themes", skylighting_themes,
172
+ sizeof(skylighting_themes) / sizeof(skylighting_themes[0]));
173
+ apex_cli_print_theme_section("Shiki themes", shiki_themes,
174
+ sizeof(shiki_themes) / sizeof(shiki_themes[0]));
175
+ }
176
+
33
177
  /* ------------------------------------------------------------------------- */
34
178
  /* Git helpers (mirrored from src/plugins.c for CLI-only use) */
35
179
  /* */
@@ -469,17 +613,21 @@ static void print_usage(const char *program_name) {
469
613
  fprintf(stderr, " --base-dir DIR Base directory for resolving relative paths (for images, includes, wiki links)\n");
470
614
  fprintf(stderr, " --bibliography FILE Bibliography file (BibTeX, CSL JSON, or CSL YAML) - can be used multiple times\n");
471
615
  fprintf(stderr, " --captions POSITION Table caption position: above or below (default: below)\n");
472
- fprintf(stderr, " --code-highlight TOOL Use external tool for syntax highlighting (pygments, skylighting, or abbreviations p, s)\n");
616
+ fprintf(stderr, " --code-highlight TOOL Use external tool for syntax highlighting (pygments, skylighting, shiki, or abbreviations p, s, sh)\n");
617
+ fprintf(stderr, " --code-highlight-theme THEME Theme/style name for external syntax highlighters (tool-specific)\n");
618
+ fprintf(stderr, " --list-themes List available syntax highlighting themes for pygments, skylighting, and Shiki\n");
473
619
  fprintf(stderr, " --code-line-numbers Include line numbers in syntax-highlighted code blocks (requires --code-highlight)\n");
474
620
  fprintf(stderr, " --highlight-language-only Only highlight code blocks that have a language specified (requires --code-highlight)\n");
475
621
  fprintf(stderr, " --combine Concatenate Markdown files (expanding includes) into a single Markdown stream\n");
476
622
  fprintf(stderr, " When a SUMMARY.md file is provided, treat it as a GitBook index and combine\n");
477
623
  fprintf(stderr, " the linked files in order. Output is raw Markdown suitable for piping back into Apex.\n");
478
624
  fprintf(stderr, " --csl FILE Citation style file (CSL format)\n");
479
- fprintf(stderr, " --css FILE, --style FILE Link to CSS file(s) in document head (requires --standalone, overrides CSS metadata)\n");
480
- fprintf(stderr, " Can be used multiple times or accept comma-separated list (e.g., --css style.css,syntax.css)\n");
625
+ fprintf(stderr, " --css FILE, --style FILE Link to CSS file(s) in document head. With HTML: requires -s/--standalone.\n");
626
+ fprintf(stderr, " With -t man-html -s: include custom CSS in the man page. Can be used multiple times or comma-separated (e.g., --css style.css)\n");
481
627
  fprintf(stderr, " --embed-css Embed CSS file contents into a <style> tag in the document head (used with --css)\n");
482
628
  fprintf(stderr, " --embed-images Embed local images as base64 data URLs in HTML output\n");
629
+ fprintf(stderr, " --[no-]image-captions Wrap images with title or alt text in <figure>/<figcaption> (default: on in unified/mmd)\n");
630
+ fprintf(stderr, " --[no-]title-captions-only Only add captions for images with title; alt-only images get no caption\n");
483
631
  fprintf(stderr, " --hardbreaks Treat newlines as hard breaks\n");
484
632
  fprintf(stderr, " --header-anchors Generate <a> anchor tags instead of header IDs\n");
485
633
  fprintf(stderr, " -h, --help Show this help message\n");
@@ -488,6 +636,13 @@ static void print_usage(const char *program_name) {
488
636
  fprintf(stderr, " --[no-]includes Enable file inclusion (enabled by default in unified mode)\n");
489
637
  fprintf(stderr, " --indices Enable index processing (mmark, TextIndex, and Leanpub syntax)\n");
490
638
  fprintf(stderr, " --install-plugin ID Install plugin by id from directory, or by Git URL/GitHub shorthand (user/repo)\n");
639
+ fprintf(stderr, " --list-filters List installed filters and available filters from the remote directory\n");
640
+ fprintf(stderr, " --install-filter ID Install AST filter by id from the central filters directory or by Git URL/GitHub shorthand\n");
641
+ fprintf(stderr, " --uninstall-filter ID Uninstall filter by id\n");
642
+ fprintf(stderr, " --filter NAME Run a single AST filter from ~/.config/apex/filters/NAME (Pandoc-style JSON filter)\n");
643
+ fprintf(stderr, " --filters Run all executable filters in ~/.config/apex/filters (sorted by name)\n");
644
+ fprintf(stderr, " --lua-filter FILE Run a Lua script as an AST filter via 'lua FILE' (Pandoc-style JSON filter)\n");
645
+ fprintf(stderr, " --no-strict-filters Do not abort on AST filter errors/invalid JSON; skip failing filters instead\n");
491
646
  fprintf(stderr, " --link-citations Link citations to bibliography entries\n");
492
647
  fprintf(stderr, " --list-plugins List installed plugins and available plugins from the remote directory\n");
493
648
  fprintf(stderr, " --uninstall-plugin ID Uninstall plugin by id\n");
@@ -497,6 +652,7 @@ static void print_usage(const char *program_name) {
497
652
  fprintf(stderr, " --mmd-merge Merge files from one or more mmd_merge-style index files into a single Markdown stream\n");
498
653
  fprintf(stderr, " Index files list document parts line-by-line; indentation controls header level shifting.\n");
499
654
  fprintf(stderr, " -m, --mode MODE Processor mode: commonmark, gfm, mmd, kramdown, unified (default)\n");
655
+ fprintf(stderr, " -t, --to FORMAT Output format: html (default), json (before filters), json-filtered/ast-json/ast (after filters), markdown/md, mmd, commonmark/cmark, kramdown, gfm, terminal/cli, terminal256, man, man-html\n");
500
656
  fprintf(stderr, " --no-bibliography Suppress bibliography output\n");
501
657
  fprintf(stderr, " --no-footnotes Disable footnote support\n");
502
658
  fprintf(stderr, " --no-ids Disable automatic header ID generation\n");
@@ -509,6 +665,7 @@ static void print_usage(const char *program_name) {
509
665
  fprintf(stderr, " --no-smart Disable smart typography\n");
510
666
  fprintf(stderr, " --no-sup-sub Disable superscript/subscript syntax\n");
511
667
  fprintf(stderr, " --[no-]divs Enable or disable Pandoc fenced divs (Unified mode only)\n");
668
+ fprintf(stderr, " --[no-]one-line-definitions Enable or disable one-line definition lists (Term :: Definition)\n");
512
669
  fprintf(stderr, " --[no-]spans Enable or disable bracketed spans [text]{IAL} (Pandoc-style, enabled by default in unified mode)\n");
513
670
  fprintf(stderr, " --no-tables Disable table support\n");
514
671
  fprintf(stderr, " --no-transforms Disable metadata variable transforms\n");
@@ -520,13 +677,15 @@ static void print_usage(const char *program_name) {
520
677
  fprintf(stderr, " --[no-]progress Show progress indicator during processing (enabled by default for TTY)\n");
521
678
  fprintf(stderr, " --plugins Enable external/plugin processing\n");
522
679
  fprintf(stderr, " --pretty Pretty-print HTML with indentation and whitespace\n");
680
+ fprintf(stderr, " --xhtml HTML5 output with self-closing void tags (<br />, <meta ... />)\n");
681
+ fprintf(stderr, " --strict-xhtml Polyglot XHTML/XML for parsers (xmlns, application/xhtml+xml meta; implies --xhtml). Mutually exclusive with --xhtml.\n");
523
682
  fprintf(stderr, " --reject Reject all Critic Markup changes (revert edits)\n");
524
683
  fprintf(stderr, " --[no-]relaxed-tables Enable or disable relaxed table parsing (no separator rows required)\n");
525
684
  fprintf(stderr, " --[no-]per-cell-alignment Enable or disable per-cell alignment markers (colons at start/end of cells, enabled by default in unified mode)\n");
526
685
  fprintf(stderr, " --script VALUE Inject <script> tags before </body> (standalone) or at end of HTML (snippet).\n");
527
686
  fprintf(stderr, " VALUE can be a path, URL, or shorthand (mermaid, mathjax, katex). Can be used multiple times or as a comma-separated list.\n");
528
687
  fprintf(stderr, " --show-tooltips Show tooltips on citations\n");
529
- fprintf(stderr, " -s, --standalone Generate complete HTML document (with <html>, <head>, <body>)\n");
688
+ fprintf(stderr, " -s, --standalone Generate complete HTML document (with <html>, <head>, <body>). For -t man-html, -s adds nav sidebar and full page; without -s, output is snippet only.\n");
530
689
  fprintf(stderr, " --[no-]sup-sub Enable or disable MultiMarkdown-style superscript (^text^) and subscript (~text~) syntax\n");
531
690
  fprintf(stderr, " --[no-]strikethrough Enable or disable GFM-style ~~strikethrough~~ processing\n");
532
691
  fprintf(stderr, " --title TITLE Document title (requires --standalone, default: \"Document\")\n");
@@ -547,6 +706,9 @@ static void print_usage(const char *program_name) {
547
706
  fprintf(stderr, " --wikilink-space MODE Space replacement for wiki links: dash, none, underscore, space (default: dash)\n");
548
707
  fprintf(stderr, " --wikilink-extension EXT File extension to append to wiki links (e.g., html, md)\n");
549
708
  fprintf(stderr, " --[no-]wikilink-sanitize Sanitize wiki link URLs (lowercase, remove apostrophes, etc.)\n");
709
+ fprintf(stderr, " --theme NAME Terminal theme name for -t terminal/terminal256 (from ~/.config/apex/terminal/themes/NAME.theme)\n");
710
+ fprintf(stderr, " --width N Hard-wrap terminal/terminal256 output at N visible columns\n");
711
+ fprintf(stderr, " -p, --paginate Page terminal/cli/terminal256 output through a pager (APEX_PAGER, then PAGER, then less -R)\n");
550
712
  fprintf(stderr, "\n");
551
713
  fprintf(stderr, "If no file is specified, reads from stdin.\n");
552
714
  }
@@ -692,6 +854,121 @@ static int add_script_tag(char ***tags, size_t *count, size_t *capacity, const c
692
854
  return 0;
693
855
  }
694
856
 
857
+ /* Wrap ANSI-colored output to a fixed column width.
858
+ * This operates on the final rendered string and counts only visible
859
+ * characters toward the width, skipping over ANSI CSI sequences.
860
+ */
861
+ static char *wrap_ansi_to_width(const char *input, int width) {
862
+ if (!input || width <= 0) {
863
+ return NULL;
864
+ }
865
+
866
+ size_t in_len = strlen(input);
867
+ /* Heuristic for output capacity: input length plus space for added newlines. */
868
+ size_t cap = in_len + (in_len / (size_t)width + 2) * 2 + 1;
869
+ char *out = malloc(cap);
870
+ if (!out) {
871
+ return NULL;
872
+ }
873
+
874
+ size_t oi = 0;
875
+ int col = 0;
876
+
877
+ for (size_t i = 0; i < in_len; ) {
878
+ char c = input[i];
879
+
880
+ /* Newlines reset the column counter. */
881
+ if (c == '\n') {
882
+ if (oi + 1 >= cap) {
883
+ cap *= 2;
884
+ char *nb = realloc(out, cap);
885
+ if (!nb) {
886
+ free(out);
887
+ return NULL;
888
+ }
889
+ out = nb;
890
+ }
891
+ out[oi++] = c;
892
+ col = 0;
893
+ i++;
894
+ continue;
895
+ }
896
+
897
+ /* Simple handling for carriage return: pass through. */
898
+ if (c == '\r') {
899
+ if (oi + 1 >= cap) {
900
+ cap *= 2;
901
+ char *nb = realloc(out, cap);
902
+ if (!nb) {
903
+ free(out);
904
+ return NULL;
905
+ }
906
+ out = nb;
907
+ }
908
+ out[oi++] = c;
909
+ i++;
910
+ continue;
911
+ }
912
+
913
+ /* Preserve ANSI CSI sequences without counting them toward width. */
914
+ if (c == '\x1b' && i + 1 < in_len && input[i + 1] == '[') {
915
+ size_t start = i;
916
+ i += 2;
917
+ while (i < in_len && !((input[i] >= 'A' && input[i] <= 'Z') ||
918
+ (input[i] >= 'a' && input[i] <= 'z'))) {
919
+ i++;
920
+ }
921
+ if (i < in_len) {
922
+ i++; /* consume final letter */
923
+ }
924
+ size_t seq_len = i - start;
925
+ if (oi + seq_len + 1 >= cap) {
926
+ cap = cap + seq_len + 16;
927
+ char *nb = realloc(out, cap);
928
+ if (!nb) {
929
+ free(out);
930
+ return NULL;
931
+ }
932
+ out = nb;
933
+ }
934
+ memcpy(out + oi, input + start, seq_len);
935
+ oi += seq_len;
936
+ continue;
937
+ }
938
+
939
+ /* Insert a newline before adding another visible char if we've hit width. */
940
+ if (col >= width) {
941
+ if (oi + 1 >= cap) {
942
+ cap *= 2;
943
+ char *nb = realloc(out, cap);
944
+ if (!nb) {
945
+ free(out);
946
+ return NULL;
947
+ }
948
+ out = nb;
949
+ }
950
+ out[oi++] = '\n';
951
+ col = 0;
952
+ }
953
+
954
+ if (oi + 1 >= cap) {
955
+ cap *= 2;
956
+ char *nb = realloc(out, cap);
957
+ if (!nb) {
958
+ free(out);
959
+ return NULL;
960
+ }
961
+ out = nb;
962
+ }
963
+ out[oi++] = c;
964
+ col++;
965
+ i++;
966
+ }
967
+
968
+ out[oi] = '\0';
969
+ return out;
970
+ }
971
+
695
972
  /**
696
973
  * Normalize a plugin identifier to a Git repository URL.
697
974
  * Returns a newly allocated string that must be freed by caller, or NULL on error.
@@ -1248,8 +1525,14 @@ int main(int argc, char *argv[]) {
1248
1525
  bool plugins_cli_override = false;
1249
1526
  bool plugins_cli_value = false;
1250
1527
  bool list_plugins = false;
1528
+ bool list_themes = false;
1251
1529
  const char *install_plugin_id = NULL;
1252
1530
  const char *uninstall_plugin_id = NULL;
1531
+ bool list_filters = false;
1532
+ const char *uninstall_filter_id = NULL;
1533
+
1534
+ /* Filter install (AST filters) */
1535
+ const char *install_filter_id = NULL;
1253
1536
  const char *input_file = NULL;
1254
1537
  const char *output_file = NULL;
1255
1538
  const char *meta_file = NULL;
@@ -1284,6 +1567,24 @@ int main(int argc, char *argv[]) {
1284
1567
  size_t script_tag_count = 0;
1285
1568
  size_t script_tag_capacity = 4;
1286
1569
 
1570
+ /* AST filters (Pandoc-style JSON filters) configured from CLI */
1571
+ char **ast_filter_names = NULL; /* Filter names from --filter (resolved in config dir) */
1572
+ size_t ast_filter_name_count = 0;
1573
+ size_t ast_filter_name_capacity = 4;
1574
+ bool run_all_filters_dir = false; /* --filters */
1575
+ bool ast_filters_strict = true; /* default strict mode; --no-strict-filters disables */
1576
+
1577
+ /* Lua filters: explicit script paths run via 'lua <script>' */
1578
+ char **lua_filter_paths = NULL;
1579
+ size_t lua_filter_count = 0;
1580
+ size_t lua_filter_capacity = 4;
1581
+
1582
+ /* Optional fixed-width wrapping for terminal output */
1583
+ int width_override = 0;
1584
+
1585
+ /* Pagination for terminal/terminal256 output */
1586
+ bool paginate_cli = false;
1587
+
1287
1588
  /* Parse command-line arguments */
1288
1589
  for (int i = 1; i < argc; i++) {
1289
1590
  if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
@@ -1311,6 +1612,57 @@ int main(int argc, char *argv[]) {
1311
1612
  fprintf(stderr, "Error: Unknown mode '%s'\n", argv[i]);
1312
1613
  return 1;
1313
1614
  }
1615
+ } else if (strcmp(argv[i], "-t") == 0 || strcmp(argv[i], "--to") == 0) {
1616
+ if (++i >= argc) {
1617
+ fprintf(stderr, "Error: --to requires an argument\n");
1618
+ return 1;
1619
+ }
1620
+ if (strcmp(argv[i], "html") == 0) {
1621
+ options.output_format = APEX_OUTPUT_HTML;
1622
+ } else if (strcmp(argv[i], "json") == 0) {
1623
+ options.output_format = APEX_OUTPUT_JSON;
1624
+ } else if (strcmp(argv[i], "json-filtered") == 0 || strcmp(argv[i], "ast-json") == 0 || strcmp(argv[i], "ast") == 0) {
1625
+ options.output_format = APEX_OUTPUT_JSON_FILTERED;
1626
+ } else if (strcmp(argv[i], "markdown") == 0 || strcmp(argv[i], "md") == 0) {
1627
+ options.output_format = APEX_OUTPUT_MARKDOWN;
1628
+ } else if (strcmp(argv[i], "mmd") == 0) {
1629
+ options.output_format = APEX_OUTPUT_MMD;
1630
+ } else if (strcmp(argv[i], "commonmark") == 0 || strcmp(argv[i], "cmark") == 0) {
1631
+ options.output_format = APEX_OUTPUT_COMMONMARK;
1632
+ } else if (strcmp(argv[i], "kramdown") == 0) {
1633
+ options.output_format = APEX_OUTPUT_KRAMDOWN;
1634
+ } else if (strcmp(argv[i], "gfm") == 0) {
1635
+ options.output_format = APEX_OUTPUT_GFM;
1636
+ } else if (strcmp(argv[i], "terminal") == 0 || strcmp(argv[i], "cli") == 0) {
1637
+ options.output_format = APEX_OUTPUT_TERMINAL;
1638
+ } else if (strcmp(argv[i], "terminal256") == 0) {
1639
+ options.output_format = APEX_OUTPUT_TERMINAL256;
1640
+ } else if (strcmp(argv[i], "man") == 0) {
1641
+ options.output_format = APEX_OUTPUT_MAN;
1642
+ } else if (strcmp(argv[i], "man-html") == 0) {
1643
+ options.output_format = APEX_OUTPUT_MAN_HTML;
1644
+ } else {
1645
+ fprintf(stderr, "Error: Unknown output format '%s'\n", argv[i]);
1646
+ fprintf(stderr, "Supported formats: html, json, json-filtered/ast-json/ast, markdown/md, mmd, commonmark/cmark, kramdown, gfm, terminal/cli, terminal256, man, man-html\n");
1647
+ return 1;
1648
+ }
1649
+ } else if (strcmp(argv[i], "--theme") == 0) {
1650
+ if (++i >= argc) {
1651
+ fprintf(stderr, "Error: --theme requires a name argument\n");
1652
+ return 1;
1653
+ }
1654
+ options.theme_name = argv[i];
1655
+ } else if (strcmp(argv[i], "--width") == 0) {
1656
+ if (++i >= argc) {
1657
+ fprintf(stderr, "Error: --width requires a column width argument\n");
1658
+ return 1;
1659
+ }
1660
+ width_override = atoi(argv[i]);
1661
+ if (width_override < 0) {
1662
+ width_override = 0;
1663
+ }
1664
+ } else if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--paginate") == 0) {
1665
+ paginate_cli = true;
1314
1666
  } else if (strcmp(argv[i], "-o") == 0 || strcmp(argv[i], "--output") == 0) {
1315
1667
  if (++i >= argc) {
1316
1668
  fprintf(stderr, "Error: --output requires an argument\n");
@@ -1327,12 +1679,77 @@ int main(int argc, char *argv[]) {
1327
1679
  plugins_cli_value = false;
1328
1680
  } else if (strcmp(argv[i], "--list-plugins") == 0) {
1329
1681
  list_plugins = true;
1682
+ } else if (strcmp(argv[i], "--list-themes") == 0) {
1683
+ list_themes = true;
1330
1684
  } else if (strcmp(argv[i], "--install-plugin") == 0) {
1331
1685
  if (++i >= argc) {
1332
1686
  fprintf(stderr, "Error: --install-plugin requires an id argument\n");
1333
1687
  return 1;
1334
1688
  }
1335
1689
  install_plugin_id = argv[i];
1690
+ } else if (strcmp(argv[i], "--list-filters") == 0) {
1691
+ list_filters = true;
1692
+ } else if (strcmp(argv[i], "--install-filter") == 0) {
1693
+ if (++i >= argc) {
1694
+ fprintf(stderr, "Error: --install-filter requires an id argument\n");
1695
+ return 1;
1696
+ }
1697
+ install_filter_id = argv[i];
1698
+ } else if (strcmp(argv[i], "--uninstall-filter") == 0) {
1699
+ if (++i >= argc) {
1700
+ fprintf(stderr, "Error: --uninstall-filter requires an id argument\n");
1701
+ return 1;
1702
+ }
1703
+ uninstall_filter_id = argv[i];
1704
+ } else if (strcmp(argv[i], "--filter") == 0) {
1705
+ if (++i >= argc) {
1706
+ fprintf(stderr, "Error: --filter requires a name argument\n");
1707
+ return 1;
1708
+ }
1709
+ /* Collect filter names; resolution to full paths happens later */
1710
+ if (!ast_filter_names) {
1711
+ ast_filter_names = malloc(ast_filter_name_capacity * sizeof(char *));
1712
+ if (!ast_filter_names) {
1713
+ fprintf(stderr, "Error: Memory allocation failed\n");
1714
+ return 1;
1715
+ }
1716
+ } else if (ast_filter_name_count >= ast_filter_name_capacity) {
1717
+ size_t new_cap = ast_filter_name_capacity ? ast_filter_name_capacity * 2 : 4;
1718
+ char **tmp = realloc(ast_filter_names, new_cap * sizeof(char *));
1719
+ if (!tmp) {
1720
+ fprintf(stderr, "Error: Memory allocation failed\n");
1721
+ return 1;
1722
+ }
1723
+ ast_filter_names = tmp;
1724
+ ast_filter_name_capacity = new_cap;
1725
+ }
1726
+ ast_filter_names[ast_filter_name_count++] = argv[i];
1727
+ } else if (strcmp(argv[i], "--filters") == 0) {
1728
+ run_all_filters_dir = true;
1729
+ } else if (strcmp(argv[i], "--no-strict-filters") == 0) {
1730
+ ast_filters_strict = false;
1731
+ } else if (strcmp(argv[i], "--lua-filter") == 0) {
1732
+ if (++i >= argc) {
1733
+ fprintf(stderr, "Error: --lua-filter requires a script path argument\n");
1734
+ return 1;
1735
+ }
1736
+ if (!lua_filter_paths) {
1737
+ lua_filter_paths = malloc(lua_filter_capacity * sizeof(char *));
1738
+ if (!lua_filter_paths) {
1739
+ fprintf(stderr, "Error: Memory allocation failed\n");
1740
+ return 1;
1741
+ }
1742
+ } else if (lua_filter_count >= lua_filter_capacity) {
1743
+ size_t new_cap = lua_filter_capacity ? lua_filter_capacity * 2 : 4;
1744
+ char **tmp = realloc(lua_filter_paths, new_cap * sizeof(char *));
1745
+ if (!tmp) {
1746
+ fprintf(stderr, "Error: Memory allocation failed\n");
1747
+ return 1;
1748
+ }
1749
+ lua_filter_paths = tmp;
1750
+ lua_filter_capacity = new_cap;
1751
+ }
1752
+ lua_filter_paths[lua_filter_count++] = argv[i];
1336
1753
  } else if (strcmp(argv[i], "--uninstall-plugin") == 0) {
1337
1754
  if (++i >= argc) {
1338
1755
  fprintf(stderr, "Error: --uninstall-plugin requires an id argument\n");
@@ -1538,6 +1955,10 @@ int main(int argc, char *argv[]) {
1538
1955
  options.document_title = argv[i];
1539
1956
  } else if (strcmp(argv[i], "--pretty") == 0) {
1540
1957
  options.pretty = true;
1958
+ } else if (strcmp(argv[i], "--xhtml") == 0) {
1959
+ options.xhtml = true;
1960
+ } else if (strcmp(argv[i], "--strict-xhtml") == 0) {
1961
+ options.strict_xhtml = true;
1541
1962
  } else if (strcmp(argv[i], "--accept") == 0) {
1542
1963
  options.enable_critic_markup = true;
1543
1964
  options.critic_mode = 0; /* CRITIC_ACCEPT */
@@ -1586,7 +2007,7 @@ int main(int argc, char *argv[]) {
1586
2007
  }
1587
2008
  } else if (strcmp(argv[i], "--code-highlight") == 0) {
1588
2009
  if (++i >= argc) {
1589
- fprintf(stderr, "Error: --code-highlight requires a tool name (pygments, skylighting, or abbreviations p, s)\n");
2010
+ fprintf(stderr, "Error: --code-highlight requires a tool name (pygments, skylighting, shiki, or abbreviations p, s, sh)\n");
1590
2011
  return 1;
1591
2012
  }
1592
2013
  /* Accept full names and abbreviations */
@@ -1594,10 +2015,19 @@ int main(int argc, char *argv[]) {
1594
2015
  options.code_highlighter = "pygments";
1595
2016
  } else if (strcmp(argv[i], "skylighting") == 0 || strcmp(argv[i], "s") == 0 || strcmp(argv[i], "sky") == 0) {
1596
2017
  options.code_highlighter = "skylighting";
2018
+ } else if (strcmp(argv[i], "shiki") == 0 || strcmp(argv[i], "sh") == 0) {
2019
+ options.code_highlighter = "shiki";
1597
2020
  } else {
1598
- fprintf(stderr, "Error: --code-highlight tool must be 'pygments' (p) or 'skylighting' (s)\n");
2021
+ fprintf(stderr, "Error: --code-highlight tool must be 'pygments' (p), 'skylighting' (s), or 'shiki' (sh)\n");
1599
2022
  return 1;
1600
2023
  }
2024
+ } else if (strcmp(argv[i], "--code-highlight-theme") == 0 ||
2025
+ strcmp(argv[i], "--code-hilight-theme") == 0) {
2026
+ if (++i >= argc) {
2027
+ fprintf(stderr, "Error: --code-highlight-theme requires a theme name\n");
2028
+ return 1;
2029
+ }
2030
+ options.code_highlight_theme = argv[i];
1601
2031
  } else if (strcmp(argv[i], "--code-line-numbers") == 0) {
1602
2032
  options.code_line_numbers = true;
1603
2033
  } else if (strcmp(argv[i], "--highlight-language-only") == 0) {
@@ -1622,6 +2052,10 @@ int main(int argc, char *argv[]) {
1622
2052
  options.enable_divs = true;
1623
2053
  } else if (strcmp(argv[i], "--no-divs") == 0) {
1624
2054
  options.enable_divs = false;
2055
+ } else if (strcmp(argv[i], "--one-line-definitions") == 0) {
2056
+ options.enable_definition_lists = true;
2057
+ } else if (strcmp(argv[i], "--no-one-line-definitions") == 0) {
2058
+ options.enable_definition_lists = false;
1625
2059
  } else if (strcmp(argv[i], "--spans") == 0) {
1626
2060
  options.enable_spans = true;
1627
2061
  } else if (strcmp(argv[i], "--no-spans") == 0) {
@@ -1848,12 +2282,23 @@ int main(int argc, char *argv[]) {
1848
2282
  return 1;
1849
2283
  }
1850
2284
 
2285
+ if (options.xhtml && options.strict_xhtml) {
2286
+ fprintf(stderr, "Error: --xhtml and --strict-xhtml cannot be used together (use --strict-xhtml alone).\n");
2287
+ return 1;
2288
+ }
2289
+
1851
2290
  /* --combine and --mmd-merge are mutually exclusive */
1852
2291
  if (combine_mode && mmd_merge_mode) {
1853
2292
  fprintf(stderr, "Error: --combine and --mmd-merge cannot be used together\n");
1854
2293
  return 1;
1855
2294
  }
1856
2295
 
2296
+ /* Handle theme listing before normal conversion */
2297
+ if (list_themes) {
2298
+ apex_cli_print_highlight_themes();
2299
+ return 0;
2300
+ }
2301
+
1857
2302
  /* Handle plugin listing/installation/uninstallation commands before normal conversion */
1858
2303
  if (list_plugins || install_plugin_id || uninstall_plugin_id) {
1859
2304
  if ((install_plugin_id && uninstall_plugin_id) || (install_plugin_id && list_plugins && uninstall_plugin_id)) {
@@ -2037,7 +2482,7 @@ int main(int argc, char *argv[]) {
2037
2482
  cli_free_installed_plugins(installed_head);
2038
2483
  return 1;
2039
2484
  }
2040
- apex_remote_print_plugins_filtered(plist, (const char **)installed_ids, installed_count);
2485
+ apex_remote_print_plugins_filtered(plist, (const char **)installed_ids, installed_count, "plugins");
2041
2486
  apex_remote_free_plugins(plist);
2042
2487
  if (installed_ids) {
2043
2488
  for (size_t i = 0; i < installed_count; i++) free(installed_ids[i]);
@@ -2260,6 +2705,378 @@ int main(int argc, char *argv[]) {
2260
2705
  }
2261
2706
  }
2262
2707
 
2708
+ /* Handle filter listing/installation/uninstallation before normal conversion.
2709
+ * Filters are distributed from the apex-filters directory:
2710
+ * https://github.com/ApexMarkdown/apex-filters
2711
+ * and installed into:
2712
+ * $XDG_CONFIG_HOME/apex/filters or ~/.config/apex/filters
2713
+ */
2714
+ if (list_filters || install_filter_id || uninstall_filter_id) {
2715
+ if (install_filter_id && uninstall_filter_id) {
2716
+ fprintf(stderr, "Error: --install-filter and --uninstall-filter cannot be combined.\n");
2717
+ return 1;
2718
+ }
2719
+
2720
+ /* Determine filters root: $XDG_CONFIG_HOME/apex/filters or ~/.config/apex/filters */
2721
+ const char *xdg = getenv("XDG_CONFIG_HOME");
2722
+ char root[1024];
2723
+ if (xdg && *xdg) {
2724
+ snprintf(root, sizeof(root), "%s/apex/filters", xdg);
2725
+ } else {
2726
+ const char *home = getenv("HOME");
2727
+ if (!home || !*home) {
2728
+ fprintf(stderr, "Error: HOME not set; cannot determine filter install directory.\n");
2729
+ return 1;
2730
+ }
2731
+ snprintf(root, sizeof(root), "%s/.config/apex/filters", home);
2732
+ }
2733
+
2734
+ /* Uninstall filter */
2735
+ if (uninstall_filter_id) {
2736
+ char target[1200];
2737
+ snprintf(target, sizeof(target), "%s/%s", root, uninstall_filter_id);
2738
+
2739
+ struct stat st;
2740
+ if (stat(target, &st) != 0) {
2741
+ /* Try with common extensions (e.g. code-includes.lua from path install) */
2742
+ const char *exts[] = { ".lua", ".py", ".rb" };
2743
+ int found = 0;
2744
+ for (size_t e = 0; e < sizeof(exts)/sizeof(exts[0]); e++) {
2745
+ snprintf(target, sizeof(target), "%s/%s%s", root, uninstall_filter_id, exts[e]);
2746
+ if (stat(target, &st) == 0) {
2747
+ found = 1;
2748
+ break;
2749
+ }
2750
+ }
2751
+ if (!found) {
2752
+ snprintf(target, sizeof(target), "%s/%s", root, uninstall_filter_id);
2753
+ fprintf(stderr, "Error: filter '%s' is not installed at %s\n", uninstall_filter_id, target);
2754
+ return 1;
2755
+ }
2756
+ }
2757
+
2758
+ fprintf(stderr, "About to remove filter:\n %s\n", target);
2759
+ fprintf(stderr, "Proceed? [y/N]: ");
2760
+ fflush(stderr);
2761
+
2762
+ char answer[16];
2763
+ if (!fgets(answer, sizeof(answer), stdin)) {
2764
+ fprintf(stderr, "Aborted.\n");
2765
+ return 1;
2766
+ }
2767
+ if (answer[0] != 'y' && answer[0] != 'Y') {
2768
+ fprintf(stderr, "Aborted.\n");
2769
+ return 1;
2770
+ }
2771
+
2772
+ if (S_ISDIR(st.st_mode)) {
2773
+ char rm_cmd[1400];
2774
+ snprintf(rm_cmd, sizeof(rm_cmd), "rm -rf \"%s\"", target);
2775
+ int rm_rc = system(rm_cmd);
2776
+ if (rm_rc != 0) {
2777
+ fprintf(stderr, "Error: failed to remove filter directory '%s'.\n", target);
2778
+ return 1;
2779
+ }
2780
+ } else {
2781
+ if (unlink(target) != 0) {
2782
+ fprintf(stderr, "Error: failed to remove filter '%s'.\n", target);
2783
+ return 1;
2784
+ }
2785
+ }
2786
+
2787
+ fprintf(stderr, "Uninstalled filter '%s' from %s\n", uninstall_filter_id, target);
2788
+ return 0;
2789
+ }
2790
+
2791
+ /* List filters: installed from root + available from remote directory */
2792
+ if (list_filters) {
2793
+ const char *dir_url = "https://raw.githubusercontent.com/ApexMarkdown/apex-filters/refs/heads/main/apex-filters.json";
2794
+ apex_remote_plugin_list *flist = apex_remote_fetch_filters_directory(dir_url);
2795
+
2796
+ /* Collect installed filter ids (files and directories in root) */
2797
+ char *installed_ids[128];
2798
+ size_t installed_count = 0;
2799
+ DIR *dir = opendir(root);
2800
+ if (dir) {
2801
+ struct dirent *ent;
2802
+ while ((ent = readdir(dir)) != NULL && installed_count < 128) {
2803
+ if (ent->d_name[0] == '.' && (ent->d_name[1] == '\0' || (ent->d_name[1] == '.' && ent->d_name[2] == '\0')))
2804
+ continue;
2805
+ if (strncmp(ent->d_name, ".apex_", 6) == 0)
2806
+ continue;
2807
+ installed_ids[installed_count] = strdup(ent->d_name);
2808
+ if (installed_ids[installed_count])
2809
+ installed_count++;
2810
+ }
2811
+ closedir(dir);
2812
+ }
2813
+
2814
+ printf("## Installed Filters\n");
2815
+ if (installed_count == 0) {
2816
+ printf("(none)\n");
2817
+ } else {
2818
+ for (size_t i = 0; i < installed_count; i++)
2819
+ printf("%s\n", installed_ids[i]);
2820
+ }
2821
+
2822
+ printf("---\n");
2823
+ printf("## Available Filters\n");
2824
+ apex_remote_print_plugins_filtered(flist, (const char **)installed_ids, installed_count, "filters");
2825
+
2826
+ for (size_t i = 0; i < installed_count; i++)
2827
+ free(installed_ids[i]);
2828
+ if (flist) apex_remote_free_plugins(flist);
2829
+ return 0;
2830
+ }
2831
+
2832
+ /* Install filter */
2833
+ if (install_filter_id) {
2834
+ /* Check if install_filter_id is a direct URL/shorthand (GitHub repo) */
2835
+ char *normalized_repo = normalize_plugin_repo_url(install_filter_id);
2836
+ const char *repo = NULL;
2837
+ char *final_filter_id = NULL;
2838
+ char *filter_path = NULL; /* optional: single file path inside repo (e.g. "src/code-includes.lua") */
2839
+
2840
+ if (normalized_repo) {
2841
+ repo = normalized_repo;
2842
+
2843
+ fprintf(stderr,
2844
+ "Apex filters execute unverified code. Only install filters from trusted sources.\n"
2845
+ "Continue? (y/n) ");
2846
+ fflush(stderr);
2847
+ char answer[8] = {0};
2848
+ if (!fgets(answer, sizeof(answer), stdin) ||
2849
+ (answer[0] != 'y' && answer[0] != 'Y')) {
2850
+ fprintf(stderr, "Aborted filter install.\n");
2851
+ free(normalized_repo);
2852
+ return 1;
2853
+ }
2854
+ } else {
2855
+ /* Not a URL - look up in the remote filters directory */
2856
+ const char *dir_url = "https://raw.githubusercontent.com/ApexMarkdown/apex-filters/refs/heads/main/apex-filters.json";
2857
+ char *json = apex_remote_fetch_json(dir_url);
2858
+ if (!json) {
2859
+ fprintf(stderr, "Error: failed to fetch filter directory from %s\n", dir_url);
2860
+ return 1;
2861
+ }
2862
+
2863
+ /* Very small JSON scan: find entry with matching "id" and extract "repo" */
2864
+ const char *p = json;
2865
+ int found = 0;
2866
+ while ((p = strstr(p, "\"id\"")) != NULL) {
2867
+ const char *id_start = strchr(p, ':');
2868
+ if (!id_start) break;
2869
+ id_start++;
2870
+ while (*id_start == ' ' || *id_start == '\t') id_start++;
2871
+ if (*id_start != '\"') { p = id_start; continue; }
2872
+ id_start++;
2873
+ const char *id_end = strchr(id_start, '\"');
2874
+ if (!id_end) break;
2875
+ size_t id_len = (size_t)(id_end - id_start);
2876
+
2877
+ if (strlen(install_filter_id) == id_len &&
2878
+ strncmp(install_filter_id, id_start, id_len) == 0) {
2879
+ /* Found matching id; search for "repo" and optional "path" in this object */
2880
+ const char *obj_start = p;
2881
+ char *repo_val = apex_remote_extract_string(obj_start, "repo");
2882
+ if (!repo_val) {
2883
+ fprintf(stderr, "Error: filter '%s' missing repo URL in directory.\n", install_filter_id);
2884
+ free(json);
2885
+ return 1;
2886
+ }
2887
+ repo = repo_val;
2888
+ final_filter_id = strdup(install_filter_id);
2889
+ filter_path = apex_remote_extract_string(obj_start, "path");
2890
+ found = 1;
2891
+ break;
2892
+ }
2893
+ p = id_end;
2894
+ }
2895
+
2896
+ if (!found) {
2897
+ fprintf(stderr, "Error: filter '%s' not found in directory.\n", install_filter_id);
2898
+ free(json);
2899
+ return 1;
2900
+ }
2901
+ free(json);
2902
+ }
2903
+
2904
+ /* Ensure root directory exists */
2905
+ char mkdir_cmd[1200];
2906
+ snprintf(mkdir_cmd, sizeof(mkdir_cmd), "mkdir -p \"%s\"", root);
2907
+ int mkrc = system(mkdir_cmd);
2908
+ if (mkrc != 0) {
2909
+ fprintf(stderr, "Error: failed to create filter directory '%s'.\n", root);
2910
+ if (normalized_repo) free(normalized_repo);
2911
+ if (final_filter_id) free(final_filter_id);
2912
+ if (filter_path) free(filter_path);
2913
+ if (repo && repo != normalized_repo) free((void *)repo);
2914
+ return 1;
2915
+ }
2916
+
2917
+ /* Single-file install: clone to temp, copy path to root/<basename(path)>, remove temp */
2918
+ if (filter_path && final_filter_id) {
2919
+ const char *path_basename = strrchr(filter_path, '/');
2920
+ path_basename = path_basename ? (path_basename + 1) : filter_path;
2921
+ char final_file[1200];
2922
+ snprintf(final_file, sizeof(final_file), "%s/%s", root, path_basename);
2923
+ char test_cmd[1300];
2924
+ snprintf(test_cmd, sizeof(test_cmd), "[ -e \"%s\" ]", final_file);
2925
+ if (system(test_cmd) == 0) {
2926
+ fprintf(stderr, "Error: filter '%s' already exists at %s. Remove it first to reinstall.\n", final_filter_id, final_file);
2927
+ if (normalized_repo) free(normalized_repo);
2928
+ if (final_filter_id) free(final_filter_id);
2929
+ if (filter_path) free(filter_path);
2930
+ if (repo && repo != normalized_repo) free((void *)repo);
2931
+ return 1;
2932
+ }
2933
+ char temp_target[1200];
2934
+ snprintf(temp_target, sizeof(temp_target), "%s/.apex_install_%s", root, final_filter_id);
2935
+ char clone_cmd[2048];
2936
+ snprintf(clone_cmd, sizeof(clone_cmd), "git clone --depth 1 \"%s\" \"%s\"", repo, temp_target);
2937
+ int git_rc = system(clone_cmd);
2938
+ if (git_rc != 0) {
2939
+ fprintf(stderr, "Error: git clone failed for '%s'. Is git installed and the URL correct?\n", repo);
2940
+ if (normalized_repo) free(normalized_repo);
2941
+ if (final_filter_id) free(final_filter_id);
2942
+ if (filter_path) free(filter_path);
2943
+ if (repo && repo != normalized_repo) free((void *)repo);
2944
+ return 1;
2945
+ }
2946
+ char src_file[1800];
2947
+ snprintf(src_file, sizeof(src_file), "%s/%s", temp_target, filter_path);
2948
+ char cp_cmd[3000];
2949
+ snprintf(cp_cmd, sizeof(cp_cmd), "cp \"%s\" \"%s\"", src_file, final_file);
2950
+ if (system(cp_cmd) != 0) {
2951
+ fprintf(stderr, "Error: failed to copy '%s' from repo to %s\n", filter_path, final_file);
2952
+ snprintf(cp_cmd, sizeof(cp_cmd), "rm -rf \"%s\"", temp_target);
2953
+ system(cp_cmd);
2954
+ if (normalized_repo) free(normalized_repo);
2955
+ if (final_filter_id) free(final_filter_id);
2956
+ if (filter_path) free(filter_path);
2957
+ if (repo && repo != normalized_repo) free((void *)repo);
2958
+ return 1;
2959
+ }
2960
+ char chmod_cmd[1300];
2961
+ snprintf(chmod_cmd, sizeof(chmod_cmd), "chmod +x \"%s\"", final_file);
2962
+ system(chmod_cmd);
2963
+ snprintf(cp_cmd, sizeof(cp_cmd), "rm -rf \"%s\"", temp_target);
2964
+ system(cp_cmd);
2965
+ fprintf(stderr, "Installed filter '%s' into %s\n", final_filter_id, final_file);
2966
+ if (normalized_repo) free(normalized_repo);
2967
+ if (final_filter_id) free(final_filter_id);
2968
+ if (filter_path) free(filter_path);
2969
+ if (repo && repo != normalized_repo) free((void *)repo);
2970
+ return 0;
2971
+ }
2972
+
2973
+ /* Determine temporary or final target directory */
2974
+ char temp_target[1200];
2975
+ if (!final_filter_id) {
2976
+ /* Derive a temporary name from repo URL */
2977
+ const char *last_slash = strrchr(repo, '/');
2978
+ const char *name_start = last_slash ? (last_slash + 1) : repo;
2979
+ const char *name_end = strstr(name_start, ".git");
2980
+ if (!name_end) name_end = name_start + strlen(name_start);
2981
+ size_t name_len = name_end - name_start;
2982
+ if (name_len > 0 && name_len < 200) {
2983
+ char temp_name[256];
2984
+ memcpy(temp_name, name_start, name_len);
2985
+ temp_name[name_len] = '\0';
2986
+ snprintf(temp_target, sizeof(temp_target), "%s/.apex_install_%s", root, temp_name);
2987
+ } else {
2988
+ snprintf(temp_target, sizeof(temp_target), "%s/.apex_install_temp", root);
2989
+ }
2990
+ } else {
2991
+ snprintf(temp_target, sizeof(temp_target), "%s/%s", root, final_filter_id);
2992
+ }
2993
+
2994
+ /* Refuse to overwrite existing directory when using a final id */
2995
+ if (final_filter_id) {
2996
+ char test_cmd[1300];
2997
+ snprintf(test_cmd, sizeof(test_cmd), "[ -d \"%s\" ]", temp_target);
2998
+ int exists_rc = system(test_cmd);
2999
+ if (exists_rc == 0) {
3000
+ fprintf(stderr, "Error: filter directory '%s' already exists. Remove it first to reinstall.\n", temp_target);
3001
+ if (normalized_repo) free(normalized_repo);
3002
+ if (final_filter_id) free(final_filter_id);
3003
+ if (filter_path) free(filter_path);
3004
+ if (repo && repo != normalized_repo) free((void *)repo);
3005
+ return 1;
3006
+ }
3007
+ }
3008
+
3009
+ /* Clone repo using git */
3010
+ char clone_cmd[2048];
3011
+ snprintf(clone_cmd, sizeof(clone_cmd), "git clone \"%s\" \"%s\"", repo, temp_target);
3012
+ int git_rc = system(clone_cmd);
3013
+ if (git_rc != 0) {
3014
+ fprintf(stderr, "Error: git clone failed for '%s'. Is git installed and the URL correct?\n", repo);
3015
+ if (normalized_repo) free(normalized_repo);
3016
+ if (final_filter_id) free(final_filter_id);
3017
+ if (filter_path) free(filter_path);
3018
+ if (repo && repo != normalized_repo) free((void *)repo);
3019
+ return 1;
3020
+ }
3021
+
3022
+ /* If we didn't get a final id from the directory, use install_filter_id as the final name */
3023
+ if (!final_filter_id) {
3024
+ final_filter_id = strdup(install_filter_id);
3025
+ if (!final_filter_id) {
3026
+ fprintf(stderr, "Error: Memory allocation failed\n");
3027
+ if (normalized_repo) free(normalized_repo);
3028
+ if (filter_path) free(filter_path);
3029
+ if (repo && repo != normalized_repo) free((void *)repo);
3030
+ return 1;
3031
+ }
3032
+ }
3033
+
3034
+ char final_target[1200];
3035
+ snprintf(final_target, sizeof(final_target), "%s/%s", root, final_filter_id);
3036
+
3037
+ /* If temp_target != final_target, move into place */
3038
+ if (strcmp(temp_target, final_target) != 0) {
3039
+ char final_test_cmd[1300];
3040
+ snprintf(final_test_cmd, sizeof(final_test_cmd), "[ -d \"%s\" ]", final_target);
3041
+ int final_exists_rc = system(final_test_cmd);
3042
+ if (final_exists_rc == 0) {
3043
+ fprintf(stderr, "Error: filter directory '%s' already exists. Remove it first to reinstall.\n", final_target);
3044
+ char rm_cmd[1300];
3045
+ snprintf(rm_cmd, sizeof(rm_cmd), "rm -rf \"%s\"", temp_target);
3046
+ system(rm_cmd);
3047
+ free(final_filter_id);
3048
+ if (normalized_repo) free(normalized_repo);
3049
+ if (filter_path) free(filter_path);
3050
+ if (repo && repo != normalized_repo) free((void *)repo);
3051
+ return 1;
3052
+ }
3053
+
3054
+ char mv_cmd[2500];
3055
+ snprintf(mv_cmd, sizeof(mv_cmd), "mv \"%s\" \"%s\"", temp_target, final_target);
3056
+ int mv_rc = system(mv_cmd);
3057
+ if (mv_rc != 0) {
3058
+ fprintf(stderr, "Error: failed to move filter to final location '%s'.\n", final_target);
3059
+ char rm_cmd[1300];
3060
+ snprintf(rm_cmd, sizeof(rm_cmd), "rm -rf \"%s\"", temp_target);
3061
+ system(rm_cmd);
3062
+ free(final_filter_id);
3063
+ if (normalized_repo) free(normalized_repo);
3064
+ if (filter_path) free(filter_path);
3065
+ if (repo && repo != normalized_repo) free((void *)repo);
3066
+ return 1;
3067
+ }
3068
+ }
3069
+
3070
+ fprintf(stderr, "Installed filter '%s' into %s\n", final_filter_id, final_target);
3071
+
3072
+ if (normalized_repo) free(normalized_repo);
3073
+ if (final_filter_id) free(final_filter_id);
3074
+ if (filter_path) free(filter_path);
3075
+ if (repo && repo != normalized_repo) free((void *)repo);
3076
+ return 0;
3077
+ }
3078
+ }
3079
+
2263
3080
  /* mmd-merge mode: emulate MultiMarkdown mmd_merge.pl and exit */
2264
3081
  if (mmd_merge_mode) {
2265
3082
  FILE *out = stdout;
@@ -2604,9 +3421,11 @@ int main(int argc, char *argv[]) {
2604
3421
 
2605
3422
  /* Apply metadata to options - allows per-document control of command-line options */
2606
3423
  /* Note: Bibliography file loading from metadata will be handled in citations extension */
3424
+ apex_output_format_t saved_output_format = options.output_format;
2607
3425
  if (merged_metadata) {
2608
3426
  apex_apply_metadata_to_options(merged_metadata, &options);
2609
- /* Restore bibliography files if they were lost (e.g., if mode was set in metadata) */
3427
+ /* Restore explicit CLI choices that metadata mode reset */
3428
+ options.output_format = saved_output_format;
2610
3429
  if (saved_bibliography_files && !options.bibliography_files) {
2611
3430
  options.bibliography_files = saved_bibliography_files;
2612
3431
  }
@@ -2622,6 +3441,232 @@ int main(int argc, char *argv[]) {
2622
3441
  options.enable_plugins = plugins_cli_value;
2623
3442
  }
2624
3443
 
3444
+ /* Resolve AST filters configured from CLI into absolute command paths.
3445
+ * Filters live in $XDG_CONFIG_HOME/apex/filters or ~/.config/apex/filters.
3446
+ */
3447
+ char **ast_filter_commands = NULL;
3448
+ size_t ast_filter_count = 0;
3449
+ if (run_all_filters_dir || ast_filter_name_count > 0 || lua_filter_count > 0) {
3450
+ const char *xdg = getenv("XDG_CONFIG_HOME");
3451
+ char root[1024];
3452
+ if (xdg && *xdg) {
3453
+ snprintf(root, sizeof(root), "%s/apex/filters", xdg);
3454
+ } else {
3455
+ const char *home = getenv("HOME");
3456
+ if (!home || !*home) {
3457
+ fprintf(stderr, "Error: HOME not set; cannot determine filters directory.\n");
3458
+ return 1;
3459
+ }
3460
+ snprintf(root, sizeof(root), "%s/.config/apex/filters", home);
3461
+ }
3462
+
3463
+ /* Ensure root directory exists when running --filters or --filter */
3464
+ struct stat st_root;
3465
+ if (stat(root, &st_root) != 0 || !S_ISDIR(st_root.st_mode)) {
3466
+ if (run_all_filters_dir || ast_filter_name_count > 0) {
3467
+ fprintf(stderr, "Error: filters directory '%s' does not exist.\n", root);
3468
+ return 1;
3469
+ }
3470
+ }
3471
+
3472
+ /* Collect all filters from directory when --filters is set */
3473
+ if (run_all_filters_dir) {
3474
+ DIR *d = opendir(root);
3475
+ if (!d) {
3476
+ fprintf(stderr, "Error: cannot open filters directory '%s'\n", root);
3477
+ return 1;
3478
+ }
3479
+ struct dirent *ent;
3480
+ size_t capacity = 8;
3481
+ ast_filter_commands = malloc(capacity * sizeof(char *));
3482
+ if (!ast_filter_commands) {
3483
+ closedir(d);
3484
+ fprintf(stderr, "Error: Memory allocation failed\n");
3485
+ return 1;
3486
+ }
3487
+ while ((ent = readdir(d)) != NULL) {
3488
+ if (ent->d_name[0] == '.') continue;
3489
+ char path[1200];
3490
+ snprintf(path, sizeof(path), "%s/%s", root, ent->d_name);
3491
+ struct stat st;
3492
+ if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) {
3493
+ continue;
3494
+ }
3495
+ if (ast_filter_count == capacity) {
3496
+ capacity *= 2;
3497
+ char **tmp = realloc(ast_filter_commands, capacity * sizeof(char *));
3498
+ if (!tmp) {
3499
+ closedir(d);
3500
+ fprintf(stderr, "Error: Memory allocation failed\n");
3501
+ return 1;
3502
+ }
3503
+ ast_filter_commands = tmp;
3504
+ }
3505
+ ast_filter_commands[ast_filter_count] = strdup(path);
3506
+ if (!ast_filter_commands[ast_filter_count]) {
3507
+ closedir(d);
3508
+ fprintf(stderr, "Error: Memory allocation failed\n");
3509
+ return 1;
3510
+ }
3511
+ ast_filter_count++;
3512
+ }
3513
+ closedir(d);
3514
+ }
3515
+
3516
+ /* Resolve individual --filter NAME entries to absolute paths.
3517
+ *
3518
+ * Resolution rules:
3519
+ * - If root/NAME is a regular file, use that directly.
3520
+ * - If root/NAME is a directory, look for a script inside it:
3521
+ * root/NAME/NAME
3522
+ * root/NAME/NAME.lua
3523
+ * root/NAME/NAME.py
3524
+ * root/NAME/NAME.rb
3525
+ * and use the first regular file found.
3526
+ */
3527
+ if (ast_filter_name_count > 0) {
3528
+ size_t capacity = (ast_filter_commands ? ast_filter_count : 0) + ast_filter_name_count;
3529
+ if (!ast_filter_commands) {
3530
+ ast_filter_commands = malloc(capacity * sizeof(char *));
3531
+ if (!ast_filter_commands) {
3532
+ fprintf(stderr, "Error: Memory allocation failed\n");
3533
+ return 1;
3534
+ }
3535
+ } else {
3536
+ char **tmp = realloc(ast_filter_commands, capacity * sizeof(char *));
3537
+ if (!tmp) {
3538
+ fprintf(stderr, "Error: Memory allocation failed\n");
3539
+ return 1;
3540
+ }
3541
+ ast_filter_commands = tmp;
3542
+ }
3543
+
3544
+ for (size_t i = 0; i < ast_filter_name_count; i++) {
3545
+ const char *name = ast_filter_names[i];
3546
+ char path[1200];
3547
+ snprintf(path, sizeof(path), "%s/%s", root, name);
3548
+ struct stat st;
3549
+ int path_exists = (stat(path, &st) == 0);
3550
+
3551
+ char resolved[1400];
3552
+ int resolved_ok = 0;
3553
+
3554
+ /* Try root/NAME as regular file first */
3555
+ if (path_exists && S_ISREG(st.st_mode)) {
3556
+ snprintf(resolved, sizeof(resolved), "%s", path);
3557
+ resolved_ok = 1;
3558
+ }
3559
+ /* Else try root/NAME.lua, root/NAME.py, root/NAME.rb as regular files (single-file installs) */
3560
+ if (!resolved_ok) {
3561
+ const char *exts[] = { ".lua", ".py", ".rb" };
3562
+ for (size_t e = 0; e < sizeof(exts)/sizeof(exts[0]); e++) {
3563
+ char with_ext[1400];
3564
+ snprintf(with_ext, sizeof(with_ext), "%s/%s%s", root, name, exts[e]);
3565
+ struct stat ste;
3566
+ if (stat(with_ext, &ste) == 0 && S_ISREG(ste.st_mode)) {
3567
+ snprintf(resolved, sizeof(resolved), "%s", with_ext);
3568
+ resolved_ok = 1;
3569
+ break;
3570
+ }
3571
+ }
3572
+ }
3573
+ /* Else if root/NAME is a directory, look for script inside */
3574
+ if (!resolved_ok && path_exists && S_ISDIR(st.st_mode)) {
3575
+ const char *candidates[4];
3576
+ char buf0[1400], buf1[1400], buf2[1400], buf3[1400];
3577
+
3578
+ snprintf(buf0, sizeof(buf0), "%s/%s", path, name);
3579
+ snprintf(buf1, sizeof(buf1), "%s/%s.lua", path, name);
3580
+ snprintf(buf2, sizeof(buf2), "%s/%s.py", path, name);
3581
+ snprintf(buf3, sizeof(buf3), "%s/%s.rb", path, name);
3582
+
3583
+ candidates[0] = buf0;
3584
+ candidates[1] = buf1;
3585
+ candidates[2] = buf2;
3586
+ candidates[3] = buf3;
3587
+
3588
+ for (size_t c = 0; c < 4; c++) {
3589
+ struct stat stc;
3590
+ if (stat(candidates[c], &stc) == 0 && S_ISREG(stc.st_mode)) {
3591
+ snprintf(resolved, sizeof(resolved), "%s", candidates[c]);
3592
+ resolved_ok = 1;
3593
+ break;
3594
+ }
3595
+ }
3596
+
3597
+ if (!resolved_ok) {
3598
+ fprintf(stderr,
3599
+ "Error: filter '%s' is a directory at %s but no executable script was found inside.\n",
3600
+ name, path);
3601
+ fprintf(stderr,
3602
+ "Tried: %s/%s, %s/%s.lua, %s/%s.py, %s/%s.rb\n",
3603
+ path, name, path, name, path, name, path, name);
3604
+ return 1;
3605
+ }
3606
+ }
3607
+ if (!resolved_ok) {
3608
+ fprintf(stderr, "Error: filter '%s' not found at %s (or %s.lua, %s.py, %s.rb)\n",
3609
+ name, path, path, path, path);
3610
+ return 1;
3611
+ }
3612
+
3613
+ /* Lua scripts without shebang: run via `lua "path"` */
3614
+ size_t rlen = strlen(resolved);
3615
+ int is_lua = (rlen > 4 && strcmp(resolved + rlen - 4, ".lua") == 0);
3616
+ if (is_lua) {
3617
+ char cmd[1400];
3618
+ snprintf(cmd, sizeof(cmd), "lua \"%s\"", resolved);
3619
+ ast_filter_commands[ast_filter_count] = strdup(cmd);
3620
+ } else {
3621
+ ast_filter_commands[ast_filter_count] = strdup(resolved);
3622
+ }
3623
+ if (!ast_filter_commands[ast_filter_count]) {
3624
+ fprintf(stderr, "Error: Memory allocation failed\n");
3625
+ return 1;
3626
+ }
3627
+ ast_filter_count++;
3628
+ }
3629
+ }
3630
+
3631
+ /* Append explicit Lua filters as commands: `lua <script>` */
3632
+ if (lua_filter_count > 0) {
3633
+ size_t capacity = (ast_filter_commands ? ast_filter_count : 0) + lua_filter_count;
3634
+ if (!ast_filter_commands) {
3635
+ ast_filter_commands = malloc(capacity * sizeof(char *));
3636
+ if (!ast_filter_commands) {
3637
+ fprintf(stderr, "Error: Memory allocation failed\n");
3638
+ return 1;
3639
+ }
3640
+ } else {
3641
+ char **tmp = realloc(ast_filter_commands, capacity * sizeof(char *));
3642
+ if (!tmp) {
3643
+ fprintf(stderr, "Error: Memory allocation failed\n");
3644
+ return 1;
3645
+ }
3646
+ ast_filter_commands = tmp;
3647
+ }
3648
+
3649
+ for (size_t i = 0; i < lua_filter_count; i++) {
3650
+ const char *script = lua_filter_paths[i];
3651
+ /* Build a simple 'lua "<script>"' command */
3652
+ char cmd[1400];
3653
+ snprintf(cmd, sizeof(cmd), "lua \"%s\"", script);
3654
+ ast_filter_commands[ast_filter_count] = strdup(cmd);
3655
+ if (!ast_filter_commands[ast_filter_count]) {
3656
+ fprintf(stderr, "Error: Memory allocation failed\n");
3657
+ return 1;
3658
+ }
3659
+ ast_filter_count++;
3660
+ }
3661
+ }
3662
+
3663
+ if (ast_filter_count > 0) {
3664
+ options.ast_filter_commands = (const char **)ast_filter_commands;
3665
+ options.ast_filter_count = ast_filter_count;
3666
+ options.ast_filter_strict = ast_filters_strict;
3667
+ }
3668
+ }
3669
+
2625
3670
  /* Attach any collected script tags to options as a NULL-terminated array */
2626
3671
  if (script_tags) {
2627
3672
  /* Ensure NULL terminator */
@@ -2648,7 +3693,12 @@ int main(int argc, char *argv[]) {
2648
3693
  last_stage = NULL;
2649
3694
  }
2650
3695
 
2651
- /* Convert to HTML */
3696
+ /* Man page output must keep -- as literal double hyphen; option names must not become en-dash */
3697
+ if (options.output_format == APEX_OUTPUT_MAN || options.output_format == APEX_OUTPUT_MAN_HTML) {
3698
+ options.enable_smart_typography = false;
3699
+ }
3700
+
3701
+ /* Convert to output (HTML, Markdown, terminal, etc.) */
2652
3702
  char *html = apex_markdown_to_html(final_markdown, final_len, &options);
2653
3703
 
2654
3704
  /* Check if we should show delayed progress (in case processing took > 1s but no progress was shown) */
@@ -2675,9 +3725,61 @@ int main(int argc, char *argv[]) {
2675
3725
  return 1;
2676
3726
  }
2677
3727
 
2678
- /* Write output */
3728
+ /* For terminal output, optionally wrap to a fixed width when requested.
3729
+ * Precedence: CLI --width > metadata/config terminal.width > theme default.
3730
+ * (Theme files are currently not consulted for width.)
3731
+ */
3732
+ if (options.output_format == APEX_OUTPUT_TERMINAL ||
3733
+ options.output_format == APEX_OUTPUT_TERMINAL256) {
3734
+ int effective_width = 0;
3735
+ if (width_override > 0) {
3736
+ effective_width = width_override;
3737
+ } else if (options.terminal_width > 0) {
3738
+ effective_width = options.terminal_width;
3739
+ }
3740
+ if (effective_width > 0) {
3741
+ char *wrapped = wrap_ansi_to_width(html, effective_width);
3742
+ if (wrapped) {
3743
+ apex_free_string(html);
3744
+ html = wrapped;
3745
+ }
3746
+ }
3747
+ }
3748
+
3749
+ /* Determine whether to paginate terminal output.
3750
+ * Pagination is only applied for terminal/terminal256 output when writing to stdout.
3751
+ * Precedence: CLI -p/--paginate OR config/metadata paginate:true.
3752
+ */
3753
+ bool paginate_effective = false;
3754
+ if (!output_file &&
3755
+ (options.output_format == APEX_OUTPUT_TERMINAL ||
3756
+ options.output_format == APEX_OUTPUT_TERMINAL256)) {
3757
+ if (paginate_cli || options.paginate) {
3758
+ paginate_effective = true;
3759
+ }
3760
+ }
3761
+
3762
+ /* Write output (optionally via pager) */
2679
3763
  PROFILE_START(file_write);
2680
- if (output_file) {
3764
+ if (paginate_effective) {
3765
+ const char *pager_cmd = getenv("APEX_PAGER");
3766
+ if (!pager_cmd || !*pager_cmd) {
3767
+ pager_cmd = getenv("PAGER");
3768
+ }
3769
+ if (!pager_cmd || !*pager_cmd) {
3770
+ pager_cmd = "less -R";
3771
+ }
3772
+
3773
+ FILE *pager = popen(pager_cmd, "w");
3774
+ size_t html_len = strlen(html);
3775
+ if (!pager) {
3776
+ /* Fall back to direct stdout if pager cannot be started */
3777
+ fwrite(html, 1, html_len, stdout);
3778
+ } else {
3779
+ fwrite(html, 1, html_len, pager);
3780
+ pclose(pager);
3781
+ }
3782
+ } else if (output_file) {
2681
3783
  FILE *fp = fopen(output_file, "w");
2682
3784
  if (!fp) {
2683
3785
  fprintf(stderr, "Error: Cannot open output file '%s'\n", output_file);
@@ -2711,6 +3813,16 @@ int main(int argc, char *argv[]) {
2711
3813
  free(script_tags);
2712
3814
  }
2713
3815
 
3816
+ /* Free AST filter command paths allocated for this run */
3817
+ if (ast_filter_commands) {
3818
+ for (size_t i = 0; i < ast_filter_count; i++) {
3819
+ free(ast_filter_commands[i]);
3820
+ }
3821
+ free(ast_filter_commands);
3822
+ }
3823
+
3824
+ /* lua_filter_paths entries are argv pointers; no need to free them here */
3825
+
2714
3826
  /* Free base_directory if we allocated it */
2715
3827
  if (allocated_base_dir) {
2716
3828
  free(allocated_base_dir);