File Coverage

blib/lib/App/Greple/md.pm
Criterion Covered Total %
statement 121 154 78.5
branch 43 66 65.1
condition 10 25 40.0
subroutine 24 26 92.3
pod 0 17 0.0
total 198 288 68.7


line stmt bran cond sub pod time code
1             # -*- mode: perl; coding: utf-8 -*-
2             # vim: set fileencoding=utf-8 filetype=perl :
3             package App::Greple::md;
4              
5 33     33   805451 use 5.024;
  33         132  
6 33     33   196 use warnings;
  33         74  
  33         5838  
7              
8             our $VERSION = "1.06";
9              
10             =encoding utf-8
11              
12             =head1 NAME
13              
14             App::Greple::md - Greple module for Markdown syntax highlighting
15              
16             =head1 SYNOPSIS
17              
18             greple -Mmd file.md
19              
20             greple -Mmd --mode=dark -- file.md
21              
22             greple -Mmd --base-color=Crimson -- file.md
23              
24             greple -Mmd --cm h1=RD -- file.md
25              
26             greple -Mmd --no-table -- file.md
27              
28             greple -Mmd --foldlist -- file.md
29              
30             greple -Mmd -- --fold file.md
31              
32             =head1 DESCRIPTION
33              
34             B is a L module for viewing
35             Markdown files in the terminal with syntax highlighting.
36              
37             It colorizes headings, bold, italic, strikethrough, inline code,
38             fenced code blocks, HTML comments, blockquotes, horizontal rules,
39             links, and images. Tables are formatted with aligned columns and
40             optional Unicode box-drawing borders. Long lines in list items can
41             be folded with proper indentation. Links become clickable via OSC 8
42             terminal hyperlinks in supported terminals.
43              
44             Nested elements are handled with cumulative coloring: for example,
45             a link inside a heading retains both its link color and the heading
46             background color.
47              
48             For a complete Markdown viewing experience with line folding,
49             multi-column output, and themes, see L, which uses this
50             module as its highlighting engine.
51              
52             =head1 COMMAND OPTIONS
53              
54             The following options are defined as greple command options
55             (specified after C<-->).
56              
57             =head2 B<--fold>
58              
59             Enable text folding for list items and definition lists. Long lines
60             are wrapped with proper indentation using L
61             via L. Code blocks, HTML comments, and tables are
62             excluded from folding. The fold width is controlled by the
63             C config parameter (default: 80).
64              
65             greple -Mmd -- --fold file.md
66             greple -Mmd::config(foldwidth=60) -- --fold file.md
67              
68             Supported list markers: C<*>, C<->, C<1.>, C<1)>, C<#.>, C<#)>.
69              
70             The module option C<--foldlist> is a convenient alternative that
71             enables folding via config.
72              
73             =head1 MODULE OPTIONS
74              
75             Module options are specified before C<--> to separate them from
76             greple's own options:
77              
78             greple -Mmd --mode=dark --cm h1=RD -- file.md
79              
80             =head2 B<-m> I, B<--mode>=I
81              
82             Set color mode. Available modes are C (default) and C.
83              
84             greple -Mmd -m dark -- file.md
85              
86             =head2 B<-B> I, B<--base-color>=I
87              
88             Override the base color used for headings, bold, links, and other
89             elements. Accepts a named color (e.g., C, C) or a
90             L color spec.
91              
92             greple -Mmd -B Crimson -- file.md
93              
94             =head2 B<--[no-]colorize>
95              
96             Enable or disable syntax highlighting. Enabled by default.
97             When disabled, no color is applied to Markdown elements.
98              
99             greple -Mmd --no-colorize -- file.md
100              
101             =head2 B<--[no-]foldlist>
102              
103             Enable or disable text folding. Disabled by default. When
104             enabled, long lines in list items and definition lists are wrapped
105             with proper indentation. The fold width is controlled by the
106             C config parameter (default: 80).
107              
108             greple -Mmd --foldlist -- file.md
109             greple -Mmd::config(foldlist=1,foldwidth=60) file.md
110              
111             See also the C<--fold> command option.
112              
113             =head2 B<--[no-]table>
114              
115             Enable or disable table formatting. When enabled (default),
116             Markdown tables (3 or more consecutive pipe-delimited rows) are
117             formatted with aligned columns using L.
118              
119             greple -Mmd --no-table -- file.md
120              
121             =head2 B<--[no-]rule>
122              
123             Enable or disable Unicode box-drawing characters for table borders.
124             When enabled (default), ASCII pipe characters (C<|>) are replaced
125             with vertical lines (C>), and separator row dashes become
126             horizontal rules (C>) with corner pieces (C>,
127             C>, C>).
128              
129             greple -Mmd --no-rule -- file.md
130              
131             =head2 B<--colormap> I
132              
133             Override the color for a specific element. I
134             the color labels listed in L. I follows
135             L format and supports C
136             function specs via L.
137              
138             greple -Mmd --cm h1=RD -- file.md
139             greple -Mmd --cm bold='${base}D' -- file.md
140              
141             =head2 B<--heading-markup>[=I], B<--hm>[=I]
142              
143             Control inline markup processing inside headings. By default,
144             headings are rendered with uniform heading color without processing
145             bold, italic, strikethrough, or inline code inside them. Links
146             are always processed as OSC 8 hyperlinks regardless of this option.
147              
148             Without an argument, all inline formatting becomes visible within
149             headings using cumulative coloring. With an argument, only the
150             specified steps are processed inside headings. Steps are separated
151             by colons.
152              
153             Available steps: C, C, C,
154             C, C.
155              
156             greple -Mmd --hm -- file.md # all markup
157             greple -Mmd --hm=bold -- file.md # bold only
158             greple -Mmd --hm=bold:italic -- file.md # bold and italic
159              
160             =head2 B<--hashed> I=I
161              
162             Append closing hashes to headings. For example, C<### Title>
163             becomes C<### Title ###>. Set per heading level:
164              
165             greple -Mmd --hashed h3=1 --hashed h4=1 -- file.md
166              
167             =head2 B<--show> I
168              
169             Control which elements are highlighted. This is useful for
170             focusing on specific elements or disabling unwanted highlighting.
171              
172             greple -Mmd --show bold=0 -- file.md # disable bold
173             greple -Mmd --show all= --show h1 -- file.md # only h1
174              
175             C<--show LABEL=0> or C<--show LABEL=> disables the label.
176             C<--show LABEL> or C<--show LABEL=1> enables it.
177             C is a special key that sets all labels at once.
178              
179             Controllable labels: C, C, C, C,
180             C
(h1-h6), C, C
.
181              
182             The following elements are always processed and cannot be disabled:
183             C, C (C, C),
184             C, C, C.
185             Use C<--cm LABEL=> to remove their color without disabling processing.
186              
187             =head1 CONFIGURATION
188              
189             Module parameters can also be set using the C function
190             in the C<-M> declaration:
191              
192             greple -Mmd::config(mode=dark,base_color=Crimson) file.md
193              
194             Nested hash parameters use dot notation:
195              
196             greple -Mmd::config(hashed.h3=1,hashed.h4=1) file.md
197              
198             Available parameters:
199              
200             mode light or dark (default: light)
201             base_color base color override
202             colorize syntax highlighting (default: 1)
203             foldlist text folding (default: 0)
204             foldwidth fold width in columns (default: 80)
205             table table formatting (default: 1)
206             rule box-drawing characters (default: 1)
207             osc8 OSC 8 hyperlinks (default: 1)
208             heading_markup inline markup in headings (default: 0)
209             0=off, 1/all=all, or colon-separated steps
210             tick_open inline code open marker (default: `)
211             tick_close inline code close marker (default: ´)
212             nofork nofork+raw mode for code ref calls (default: 1)
213             hashed.h1-h6 closing hashes per level (default: 0)
214              
215             =head2 OSC 8 Hyperlinks
216              
217             Links are converted to clickable OSC 8 terminal hyperlinks in
218             supported terminals (iTerm2, Kitty, WezTerm, Ghostty, etc.).
219             Disable with:
220              
221             greple -Mmd::config(osc8=0) file.md
222              
223             =head1 COLOR LABELS
224              
225             The following labels identify colorizable elements. Use them
226             with C<--colormap> (C<--cm>) to customize colors or C<--show> to control
227             visibility. Default values are shown as C.
228             Colors follow L format.
229              
230             =head2 Headings
231              
232             LABEL LIGHT DARK
233             h1 L25D/${base};E L00D/${base};E
234             h2 L25D/${base}+y20;E L00D/${base}-y15;E
235             h3 L25DN/${base}+y30 L00DN/${base}-y25
236             h4 ${base}UD ${base}UD
237             h5 ${base}U ${base}U
238             h6 ${base} ${base}
239              
240             =head2 Inline Formatting
241              
242             LABEL LIGHT DARK
243             bold D
244             italic I
245             strike X
246             emphasis_mark L18 L10
247             bold_mark - -
248             italic_mark - -
249             strike_mark - -
250              
251             Emphasis markers (C<**>, C<*>, C<__>, C<_>, C<~~>) are colored with
252             C, separately from the content text. C,
253             C, C are undefined by default and fall back
254             to C. Define them via C<--cm> to override per type:
255              
256             greple -Mmd --cm emphasis_mark=R -- file.md # all markers red
257             greple -Mmd --cm bold_mark=G -- file.md # bold markers green
258              
259             =head2 Code
260              
261             LABEL LIGHT DARK
262             code_mark L20 L10
263             code_tick L15/L23 L15/L05
264             code_info ${base_name}=y70 L10
265             code_block /L23;E /L05;E
266             code_inline L00/L23 L25/L05
267              
268             Inline code backticks are displayed as C<`contentE<0xb4>> using
269             C color. Multi-backtick delimiters are collapsed to a
270             single pair with optional surrounding spaces stripped (per CommonMark).
271             The open/close markers can be customized via C/C
272             config parameters.
273              
274             =head2 Block Elements
275              
276             LABEL LIGHT / DARK
277             blockquote ${base}D
278             horizontal_rule L15
279             comment ${base}+r60
280              
281             =head2 Links
282              
283             LABEL LIGHT / DARK
284             link I
285             image I
286             image_link I
287             link_mark - -
288              
289             C colors the brackets (C<[>, C<]>) around link and image
290             text. Undefined by default, falls back to C.
291             The C prefix for images uses the C or C
292             color. Hide brackets with C<--cm 'link_mark=sub{""}'> (used by
293             the C theme).
294              
295             =head1 SEE ALSO
296              
297             =over 4
298              
299             =item L
300              
301             Markdown viewer command with line folding, table formatting,
302             multi-column layout, and themes. Uses this module for syntax
303             highlighting.
304              
305             =item L
306              
307             General-purpose extensible grep tool that hosts this module.
308              
309             =item L
310              
311             Concise ANSI color specification format used for color labels.
312              
313             =item L
314              
315             ANSI-aware column formatting used for table alignment.
316              
317             =item L
318              
319             ANSI-aware text folding used for line wrapping in list items.
320              
321             =back
322              
323             =head1 AUTHOR
324              
325             Kazumasa Utashiro
326              
327             =head1 LICENSE
328              
329             Copyright 2025-2026 Kazumasa Utashiro.
330              
331             This library is free software; you can redistribute it and/or modify
332             it under the same terms as Perl itself.
333              
334             =cut
335              
336 33     33   18560 use URI::Escape;
  33         68486  
  33         3077  
337 33     33   18497 use Getopt::EX::Config;
  33         160753  
  33         379  
338 33     33   3689 use Getopt::EX::Colormap;
  33         250359  
  33         190569  
339              
340             my @color_labels = qw(
341             code_mark code_tick code_info code_block code_inline
342             comment link image image_link
343             h1 h2 h3 h4 h5 h6
344             bold italic strike emphasis_mark
345             bold_mark italic_mark strike_mark link_mark
346             blockquote horizontal_rule
347             );
348              
349             my $config = Getopt::EX::Config->new(
350             mode => '', # light / dark
351             osc8 => 1, # OSC 8 hyperlinks
352             base_color => '', # override base color
353             colorize => 1, # syntax highlighting
354             foldlist => 0, # text folding
355             foldwidth => 80, # fold width
356             table => 1, # table formatting
357             table_trim => 1, # trim cell content whitespace
358             rule => 1, # box-drawing characters for tables
359             nofork => 1, # use nofork+raw for code ref calls
360             heading_markup => 0, # inline formatting in headings
361             tick_open => '`', # inline code open marker
362             tick_close => "\x{b4}", # inline code close marker (´)
363             hashed => { h1 => 0, h2 => 0, h3 => 0, h4 => 0, h5 => 0, h6 => 0 },
364             (map { $_ => undef } @color_labels), # color labels (undef = not set)
365             );
366              
367             #
368             # Color definitions
369             #
370              
371             my %base_color = (
372             light => '=y25',
373             dark => '=y80',
374             );
375              
376             my %default_colors = (
377             code_mark => 'L20',
378             code_tick => 'L15/L23',
379             code_info => '${base_name}=y70',
380             code_block => '/L23;E',
381             code_inline => 'L00/L23',
382             comment => '${base}+r60',
383             link => 'I',
384             image => 'I',
385             image_link => 'I',
386             h1 => 'L25D/${base};E',
387             h2 => 'L25D/${base}+y20;E',
388             h3 => 'L25DN/${base}+y30',
389             h4 => '${base}UD',
390             h5 => '${base}U',
391             h6 => '${base}',
392             bold => 'D',
393             italic => 'I',
394             strike => 'X',
395             emphasis_mark => 'L18',
396             blockquote => '${base}D',
397             horizontal_rule => 'L15',
398             );
399              
400             my %dark_overrides = (
401             code_mark => 'L10',
402             code_tick => 'L15/L05',
403             code_info => 'L10',
404             code_block => '/L05;E',
405             code_inline => 'L25/L05',
406             h1 => 'L00D/${base};E',
407             h2 => 'L00D/${base}-y15;E',
408             h3 => 'L00DN/${base}-y25',
409             h4 => '${base}UD',
410             h5 => '${base}U',
411             h6 => '${base}',
412             emphasis_mark => 'L10',
413             );
414              
415             sub default_theme {
416 0   0 0 0 0 my $mode = shift // 'light';
417 0         0 my %colors = %default_colors;
418 0 0       0 if ($mode eq 'dark') {
419 0         0 @colors{keys %dark_overrides} = values %dark_overrides;
420             }
421 0         0 $colors{base} = $base_color{$mode};
422 0 0       0 if (defined wantarray) {
423 0         0 %colors;
424             } else {
425             # Print as bash array assignments: theme_MODE[key]='value'
426 0         0 for my $key (sort keys %colors) {
427 0         0 (my $val = $colors{$key}) =~ s/'/'\\''/g;
428 0         0 printf "theme_%s[%s]='%s'\n", $mode, $key, $val;
429             }
430             }
431             }
432              
433             my $cm;
434             my @opt_cm;
435             my %show;
436              
437             sub finalize {
438 30     30 0 106961 my($mod, $argv) = @_;
439 30         286 $config->deal_with($argv,
440             "mode|m=s", "base_color|B=s",
441             "colorize!", "nofork!", "foldlist!", "foldwidth=i", "table!", "table_trim!", "rule!",
442             "heading_markup|hm:s", "tick_open=s", "tick_close=s",
443             "hashed=s%",
444             "colormap|cm=s" => \@opt_cm,
445             "show=s%" => \%show);
446             # --hm with no argument gives "": treat as "all"
447 30         23330 my $hm = $config->{heading_markup};
448 30 50 33     385 if (defined $hm && $hm eq '') {
449 0         0 $config->{heading_markup} = 'all';
450             }
451 30 50       169 if (my $w = $config->{foldwidth}) {
452 30         599 $mod->setopt('--fold', "--fold-by $w");
453 30 100       7144 if ($config->{foldlist}) {
454 10         56 my @default = $mod->default;
455 10         1249 $mod->setopt('default', @default, "--fold-by $w");
456             }
457             }
458             }
459              
460             sub setup_colors {
461 29   100 29 0 254 my $mode = $config->{mode} || 'light';
462 29         1037 my %colors = %default_colors;
463 29 100       245 if ($mode eq 'dark') {
464 2         18 @colors{keys %dark_overrides} = values %dark_overrides;
465             }
466             # Determine base color
467 29         130 my $base = $config->{base_color};
468 29 50       141 if ($base) {
469             # Color names get automatic luminance adjustment
470 0 0       0 $base = "<$base>" . ($mode eq 'dark' ? '=y80' : '=y25')
    0          
471             if $base =~ /^[A-Za-z]\w*$/;
472             } else {
473 29   33     132 $base = $base_color{$mode} || $base_color{light};
474             }
475             # Override with config params (e.g., config(h1=RD))
476 29         137 for my $key (@color_labels) {
477 725         1319 my $val = $config->{$key};
478 725 50       1445 if (defined $val) {
479 0         0 $colors{$key} = $val;
480             }
481             }
482             # ${base_name}: color without luminance (e.g., '')
483 29         278 (my $base_name = $base) =~ s/=y\d+$//;
484             # Expand placeholders
485 29         234 for my $key (keys %colors) {
486 609         1305 $colors{$key} =~ s/\$\{base_name\}/$base_name/g;
487 609         1712 $colors{$key} =~ s/\$\{base\}/$base/g;
488             }
489             # Handle + prefix: prepend current color value before load_params
490             # (load_params' built-in + doesn't work correctly with sub{...})
491 29         122 my @final_cm;
492 29         80 for my $entry (@opt_cm) {
493 3         17 my $expanded = $entry =~ s/\$\{base_name\}/$base_name/gr
494             =~ s/\$\{base\}/$base/gr;
495 3 50       19 if ($expanded =~ /^(\w+)=\+(.*)/) {
496 0         0 my ($label, $append) = ($1, $2);
497 0   0     0 my $current = $colors{$label} // '';
498 0         0 push @final_cm, "$label=$current$append";
499             } else {
500 3         13 push @final_cm, $expanded;
501             }
502             }
503              
504 29         228 $cm = Getopt::EX::Colormap->new(
505             HASH => \%colors,
506             NEWLABEL => 1,
507             );
508 29         2244 $cm->load_params(@final_cm);
509             }
510              
511             sub active {
512 394     394 0 791 my $label = shift;
513 394 100 100     1502 return 0 if exists $show{$label} && !$show{$label};
514 377 100       1669 return 1 unless exists $cm->{HASH}{$label};
515 350         1713 $cm->{HASH}{$label} ne '';
516             }
517              
518             #
519             # Apply color by label
520             #
521              
522             sub md_color {
523 2653     2653 0 77670 my($label, $text) = @_;
524 2653         8090 $cm->color($label, $text);
525             }
526              
527             sub mark_color {
528 704     704 0 37256 my($type, $text) = @_;
529 704         1496 my $label = "${type}_mark";
530             $label = 'emphasis_mark' unless exists $cm->{HASH}{$label}
531 704 50 33     2758 && $cm->{HASH}{$label} ne '';
532 704         1386 md_color($label, $text);
533             }
534              
535             #
536             # Protection mechanism
537             #
538             # SGR 256 placeholders protect processed regions (inline code,
539             # comments, links) from being matched by later patterns.
540             #
541              
542             my @protected;
543             my($PS, $PE) = ("\e[256m", "\e[m"); # protect start/end markers
544             my $PR = qr/\e\[256m(\d+)\e\[m/; # protect restore pattern
545             my($OS, $OE) = ("\e]8;;", "\e\\"); # OSC 8 start/end markers
546              
547             sub protect {
548 758     758 0 885175 my $text = shift;
549 758         1845 push @protected, $text;
550 758         96989 $PS . $#protected . $PE;
551             }
552              
553             sub restore {
554 282     282 0 594 my $s = shift;
555 282   50     2601 1 while $s =~ s{$PR}{$protected[$1] // die "restore failed: index $1"}ge;
  758         4590  
556 282         1184 $s;
557             }
558              
559             #
560             # OSC 8 hyperlink generation
561             #
562              
563             sub osc8 {
564 150 50   150 0 14278 return $_[1] unless $config->{osc8};
565 150         742 my($url, $text) = @_;
566 150         542 my $escaped = uri_escape_utf8($url, "^\\x20-\\x7e");
567 150         5998 "${OS}${escaped}${OE}${text}${OS}${OE}";
568             }
569              
570             #
571             # Link text inner pattern: backtick spans, backslash escapes, normal chars
572             #
573              
574             my $LT = qr/(?:`[^`\n]*+`|\\.|[^`\\\n\]]++)+/;
575              
576             # Code span pattern (both single and multi-backtick).
577             # Captures: _bt (backtick delimiter), _content (code body).
578             # Used directly in inline_code step and as basis for $SKIP_CODE.
579             my $CODE = qr{(?x)
580             (?<_bt> `++ ) # opening backtick(s)
581             (?<_content>
582             (?: (?! \g{_bt} ) . )+? # content (not containing same-length backticks)
583             )
584             \g{_bt} # closing backtick(s) matching opener
585             };
586              
587             # Skip code spans in link/image patterns.
588             # Used as the first alternative in s{$SKIP_CODE|}{...}ge
589             # so that code spans are matched and skipped, preventing link/image
590             # patterns from matching inside them.
591             my $SKIP_CODE = qr{$CODE (*SKIP)(*FAIL)}x;
592              
593             #
594             # colorize() - the main function
595             #
596             # Receives entire file content in $_ (--begin with -G --filter).
597             # Processes all patterns with multiline regexes.
598             #
599              
600             #
601             # Pipeline step class
602             #
603              
604             package App::Greple::md::Step {
605             sub new {
606 429     429   1014 my($class, %args) = @_;
607 429         2047 bless \%args, $class;
608             }
609 0     0   0 sub label { $_[0]->{label} }
610 377 100   377   2521 sub active { !$_[0]->{label} || App::Greple::md::active($_[0]->{label}) }
611 360     360   1269 sub run { $_[0]->{code}->() }
612             }
613              
614             sub Step {
615 429     429 0 666 my $code = pop;
616 429         659 my $label = shift;
617 429         954 App::Greple::md::Step->new(label => $label, code => $code);
618             }
619              
620             #
621             # Pipeline steps: Step(sub{}) = always active, Step(label => sub{}) = controllable
622             #
623              
624             my %colorize = (
625              
626             code_blocks => Step(sub {
627             s{^( {0,3})(`{3,}|~{3,})(.*)\n((?s:.*?))^( {0,3})\2(\h*)$}{
628             my($oi, $fence, $lang, $body, $ci, $trail) = ($1, $2, $3, $4, $5, $6);
629             my $result = md_color('code_mark', "$oi$fence");
630             $result .= md_color('code_info', $lang) if length($lang);
631             $result .= "\n";
632             if (length($body)) {
633             $result .= join '', map { md_color('code_block', $_) }
634             split /(?<=\n)/, $body;
635             }
636             $result .= md_color('code_mark', "$ci$fence") . $trail;
637             protect($result)
638             }mge;
639             }),
640              
641             comments => Step(sub {
642             s/(^)/protect(md_color('comment', $1))/mge;
643             }),
644              
645             image_links => Step(sub {
646             s{$SKIP_CODE|\[!\[(?$LT)\]\((?[^)\n]+)\)\]\([^>)\s\n]+)>?\)}{
647             protect(
648             osc8($+{img}, md_color('image_link', "!"))
649             . osc8($+{url}, mark_color('link', "[") . md_color('image_link', $+{text}) . mark_color('link', "]"))
650             )
651             }ge;
652             }),
653              
654             images => Step(sub {
655             s{$SKIP_CODE|!\[(?$LT)\]\([^>)\s\n]+)>?\)}{
656             protect(osc8($+{url}, md_color('image', "!") . mark_color('link', "[") . md_color('image', $+{text}) . mark_color('link', "]")))
657             }ge;
658             }),
659              
660             links => Step(sub {
661             s{$SKIP_CODE|(?$LT)\]\([^>)\s\n]+)>?\)}{
662             protect(osc8($+{url}, mark_color('link', "[") . md_color('link', $+{text}) . mark_color('link', "]")))
663             }ge;
664             }),
665              
666             inline_code => Step(code_inline => sub {
667             state $to = $config->{tick_open};
668             state $tc = $config->{tick_close};
669             s{$CODE}{
670             my $content = $+{_content};
671             # Strip optional leading/trailing space for multi-backtick (CommonMark)
672             $content =~ s/^ (.+) $/$1/ if length($+{_bt}) >= 2;
673             protect(md_color('code_tick', $to) . md_color('code_inline', $content) . md_color('code_tick', $tc))
674             }ge;
675             }),
676              
677             headings => Step(header => sub {
678             my $hashed = $config->{hashed};
679             for my $n (reverse 1..6) {
680             next unless active("h$n");
681             my $hdr = '#' x $n;
682             s{^($hdr\h+.*)$}{
683             my $line = $1;
684             $line .= " $hdr"
685             if $hashed->{"h$n"} && $line !~ /\#$/;
686             protect(md_color("h$n", restore($line)));
687             }mge;
688             }
689             }),
690              
691             horizontal_rules => Step(horizontal_rule => sub {
692             s/^([ ]{0,3}(?:[-*_][ ]*){3,})$/protect(md_color('horizontal_rule', $1))/mge;
693             }),
694              
695             bold_italic => Step(bold => sub {
696             s{$SKIP_CODE|(?\*\*\*)(?.*?)(?
697             protect(mark_color('bold', $+{m}) . md_color('bold', md_color('italic', $+{t})) . mark_color('bold', $+{m}))
698             }gep;
699             s{$SKIP_CODE|(?___)(?.*?)(?
700             protect(mark_color('bold', $+{m}) . md_color('bold', md_color('italic', $+{t})) . mark_color('bold', $+{m}))
701             }gep;
702             }),
703              
704             bold => Step(bold => sub {
705             s{$SKIP_CODE|(?\*\*)(?.*?)(?
706             mark_color('bold', $+{m}) . md_color('bold', $+{t}) . mark_color('bold', $+{m})
707             }gep;
708             s{$SKIP_CODE|(?__)(?.*?)(?
709             mark_color('bold', $+{m}) . md_color('bold', $+{t}) . mark_color('bold', $+{m})
710             }gep;
711             }),
712              
713             italic => Step(italic => sub {
714             s{$SKIP_CODE|(?_)(?(?:(?!_).)+)(?
715             mark_color('italic', $+{m}) . md_color('italic', $+{t}) . mark_color('italic', $+{m})
716             }gep;
717             s{$SKIP_CODE|(?\*)(?(?:(?!\*).)+)(?
718             mark_color('italic', $+{m}) . md_color('italic', $+{t}) . mark_color('italic', $+{m})
719             }gep;
720             }),
721              
722             strike => Step(strike => sub {
723             s{$SKIP_CODE|(?~~)(?.+?)(?
724             mark_color('strike', $+{m}) . md_color('strike', $+{t}) . mark_color('strike', $+{m})
725             }gep;
726             }),
727              
728             blockquotes => Step(blockquote => sub {
729             s/^(>+\h?)(.*)$/md_color('blockquote', $1) . $2/mge;
730             }),
731             );
732              
733             #
734             # Pipeline configuration
735             #
736              
737             # Always before headings (protection + links)
738             my @protect_steps = qw(code_blocks comments image_links images links);
739              
740             # Inline steps controlled by heading_markup
741             my @inline_steps = qw(inline_code horizontal_rules bold_italic bold italic strike);
742              
743             # Always last
744             my @final_steps = qw(blockquotes);
745              
746             sub build_pipeline {
747 29     29 0 84 my $hm = $config->{heading_markup};
748              
749             # heading_markup disabled: headings before all inline steps
750 29 50       137 if (!$hm) {
751 29         238 return (@protect_steps, 'headings', @inline_steps, @final_steps);
752             }
753              
754             # "all" or "1": all inline steps before headings
755 0         0 my %before;
756 0 0 0     0 if ($hm eq '1' || $hm =~ /^all$/i) {
757 0         0 %before = map { $_ => 1 } @inline_steps;
  0         0  
758             } else {
759             # "bold:italic" → collect word tokens, filter to valid inline steps
760 0         0 my %valid = map { $_ => 1 } @inline_steps;
  0         0  
761 0         0 %before = map { $_ => 1 } grep { $valid{$_} } ($hm =~ /(\w+)/g);
  0         0  
  0         0  
762             }
763              
764 0         0 my @before_h = grep { $before{$_} } @inline_steps;
  0         0  
765 0         0 my @after_h = grep { !$before{$_} } @inline_steps;
  0         0  
766              
767 0         0 return (@protect_steps, @before_h, 'headings', @after_h, @final_steps);
768             }
769              
770             sub colorize {
771 29     29 0 265 setup_colors();
772 29         1701 @protected = ();
773              
774 29         124 for my $name (build_pipeline()) {
775 377         119746 my $step = $colorize{$name};
776 377 100       1215 $step->run if $step->active;
777             }
778              
779 29         2029 $_ = restore($_);
780 29         97 $_;
781             }
782              
783             #
784             # Table formatting
785             #
786              
787             sub begin {
788 30 100   30 0 735 colorize() if $config->{colorize};
789 30 100       329 format_table() if $config->{table};
790             }
791              
792             sub format_table {
793 22 100   22 0 136 my $sep = $config->{rule} ? "\x{2502}" : '|'; # │ or |
794              
795 22         710 s{(^ {0,3}\|.+\|\n){3,}}{
796 43         233 my $block = $&;
797 43         218 my($right, $center) = parse_separator(\$block);
798 43         115 my @sep_opt;
799 43 50       250 if ($config->{table_trim}) {
800 43         175 @sep_opt = ('-rs', '\s*\|\s*', '--item-format= %s ', '--table-remove=1,-0', '--padding');
801             } else {
802 0         0 $_++ for @$right, @$center;
803 0         0 @sep_opt = ('-s', '|');
804             }
805 43 100       355 my @align = (
    100          
806             @$right ? ('--table-right=' . join(',', @$right)) : (),
807             @$center ? ('--table-center=' . join(',', @$center)) : (),
808             );
809 43         274 my $formatted = call_ansicolumn($block, @sep_opt, '-o', $sep, '-t', '--cu=1', @align);
810 41         6799498 fix_separator($formatted, $sep);
811             }mge;
812             }
813              
814             sub parse_separator {
815 50     50 0 8357 my $blockref = shift;
816 50         254 my $SEP = qr/^\h*+\|(\h*+:?+-++:?+\h*+\|)++\h*+$/mn;
817 50         2108 my ($sep_line) = $$blockref =~ /($SEP)/;
818 50 100       309 return ([], []) unless defined $sep_line;
819 49         345 my @cells = split /\|/, $sep_line, -1;
820 49         161 shift @cells; pop @cells;
  49         118  
821 49         538 s/^\h+|\h+$//g for @cells;
822 49         212 my @right = grep { $cells[$_-1] =~ /^-+:$/ } 1..@cells;
  166         635  
823 49         168 my @center = grep { $cells[$_-1] =~ /^:-+:$/ } 1..@cells;
  166         469  
824             # Minimize dashes so separator width doesn't inflate column widths
825 49         820 $$blockref =~ s{$SEP}{ ${^MATCH} =~ s/:?-+:?/-/gr }mpe;
  49         839  
826 49         416 (\@right, \@center);
827             }
828              
829             sub call_ansicolumn {
830 43     43 0 222 my ($text, @args) = @_;
831 43         12306 require Command::Run;
832 43         221292 require App::ansicolumn;
833             Command::Run->new
834             ->command(\&App::ansicolumn::ansicolumn, @args)
835             ->with(stdin => $text,
836 43 100 50     2857179 $config->{nofork} ? (nofork => 1, raw => 1) : ())
837             ->update
838             ->data // '';
839             }
840              
841             sub fix_separator {
842 41     41 0 353 my ($text, $sep) = @_;
843 41 100       284 my $sep_re = $sep eq "\x{2502}" ? "\x{2502}" : '\\|';
844 41         2969 $text =~ s{^(\h*?)($sep_re)?((?:\h*-+\h*$sep_re)*\h*-+\h*)($sep_re)?(\h*?)$}{
845 41         574 my($pre, $left, $mid, $right, $post) = ($1, $2, $3, $4, $5);
846 41 100       221 if ($sep eq "\x{2502}") {
847 39 50       843 ($pre =~ tr[ ][\x{2500}]r)
    50          
848             . (defined $left ? "\x{251C}" : '')
849             . ($mid =~ tr[\x{2502} -][\x{253C}\x{2500}\x{2500}]r)
850             . (defined $right ? "\x{2524}" : '')
851             . ($post =~ tr[ ][\x{2500}]r)
852             } else {
853 2 50       32 ($pre =~ tr[ ][-]r)
    50          
854             . (defined $left ? '|' : '')
855             . ($mid =~ tr[ ][-]r)
856             . (defined $right ? '|' : '')
857             . ($post =~ tr[ ][-]r)
858             }
859             }xmeg;
860 41         1288 $text;
861             }
862              
863             1;
864              
865             __DATA__