File Coverage

blib/lib/String/Tagged/Markdown.pm
Criterion Covered Total %
statement 125 125 100.0
branch 31 32 96.8
condition 14 18 77.7
subroutine 16 16 100.0
pod 4 7 57.1
total 190 198 95.9


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2022-2023 -- leonerd@leonerd.org.uk
5              
6             package String::Tagged::Markdown 0.05;
7              
8 5     5   925552 use v5.26;
  5         39  
9 5     5   27 use warnings;
  5         11  
  5         138  
10 5     5   2033 use experimental 'signatures';
  5         14625  
  5         37  
11 5     5   825 use base qw( String::Tagged );
  5         13  
  5         3349  
12              
13 5     5   38789 use List::Util 1.45 qw( any uniqstr );
  5         116  
  5         362  
14              
15 5     5   2706 use HTML::Entities qw( decode_entities );
  5         29689  
  5         10879  
16              
17             =head1 NAME
18              
19             C - parse and emit text with Markdown inline formatting
20              
21             =head1 SYNOPSIS
22              
23             use String::Tagged::Markdown;
24              
25             my $st = String::Tagged::Markdown->parse_markdown( $markdown );
26              
27             # Conforms to the String::Tagged::Formatting API
28             String::Tagged::Terminal->new_from_formatting(
29             $st->as_formatting
30             )->say_to_terminal;
31              
32             =head1 DESCRIPTION
33              
34             This subclass of L handles text that contains inline markers
35             to give formatting hints, in the style used by Markdown. For example, text
36             wrapped in double-asterisks indicates it should be bold (as C<**bold**>), or
37             single-asterisks to indicate italics (as C<*italics*>).
38              
39             This module does B provide a full Markdown parser, but it does handle
40             enough of the simple inline markers that it could be used to handle
41             Markdown-style formatting hints of small paragraphs of text.
42              
43             =head1 TAGS
44              
45             This module provides the following tags.
46              
47             =head2 bold, italic, strike, fixed
48              
49             Boolean values indicating bold, italics, strike-through or fixed-width.
50              
51             =head2 link
52              
53             String value indicating a link. The value itself is the link target.
54              
55             =cut
56              
57             # Use class methods that depend on the specific tags we parse, so we can
58             # easily extend the syntax using a subclass
59              
60             sub markdown_markers
61             {
62 4     4 0 66 "**" => "bold",
63             "*" => "italic",
64             "__" => "bold",
65             "_" => "italic",
66             "~~" => "strike",
67             "`" => "fixed",
68             }
69              
70             sub __cache_per_class ( $code )
71 30     30   50 {
  30         45  
  30         54  
72 30         42 my %cache;
73 99     99   125 return sub ( $self ) {
  99         156  
  99         148  
74 99   66     292 my $class = ref $self || $self;
75 99   66     312 return $cache{$class} //= $code->( $class );
76 30         96 };
77             }
78              
79             *TAG_FOR_MARKER = __cache_per_class sub ( $class ) {
80             return +{ $class->markdown_markers };
81             };
82              
83             # Reverse mapping of TAG_FOR_MARKER
84             *MARKER_FOR_TAG = __cache_per_class sub ( $class ) {
85             my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
86              
87             return +{ map {
88             # Don't emit _ markers
89             ( $_ =~ m/_/ ) ? () : ( $TAG_FOR_MARKER->{$_} => $_ ),
90             } keys %$TAG_FOR_MARKER };
91             };
92              
93             # Regexp to match any formatting marker
94             *MARKER_PATTERN = __cache_per_class sub ( $class ) {
95             my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
96              
97             my $re = join "|", map { quotemeta $_ }
98             sort { length $b <=> length $a }
99             keys %$TAG_FOR_MARKER;
100             qr/$re/;
101             };
102              
103             # Regexp to match any character that needs escaping
104             *NEEDS_ESCAPE_PATTERN = __cache_per_class sub ( $class ) {
105             my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
106              
107             my $chars = quotemeta join "", uniqstr map { substr( $_, 0, 1 ) } (
108             keys %$TAG_FOR_MARKER,
109             "\\", "[", "]"
110             );
111             my $re = "[$chars]";
112             $re = qr/$re/;
113             };
114              
115             =head1 CONSTRUCTORS
116              
117             =cut
118              
119             =head2 parse_markdown
120              
121             $st = String::Tagged::InlineFormatted->parse_markdown( $str )
122              
123             Parses a text string containing Markdown-like formatting as described above.
124              
125             Recognises the following kinds of inline text markers:
126              
127             **bold**
128              
129             *italic*
130              
131             ~~strike~~
132              
133             `fixed`
134              
135             [link](target)
136              
137             backslashes escape any special characters as \*
138              
139             In addition, within C<`fixed`> width spans, the other formatting markers are
140             not recognised and are interpreted literally. To include literal backticks
141             inside a C<`fixed`> width span, use multiple backticks and a space to surround
142             the sequence. Any sequence of fewer backticks within the sequence is
143             interpreted literally. A single space on each side immediately within the outer
144             backticks will be stripped, if present.
145              
146             `` fixed width with `literal backticks` inside it ``
147              
148             HTML entities - such as C<&>, C<–> or C<Ӓ> are decoded, but
149             only when not inside C<`fixed`> spans.
150              
151             =cut
152              
153 21         42 sub parse_markdown ( $class, $str )
154 21     21 1 36490 {
  21         34  
  21         33  
155 21         70 my $self = $class->new;
156              
157 21         327 my %tags_in_effect;
158             my $link_start_pos;
159              
160 21         55 my $MARKER_PATTERN = $class->MARKER_PATTERN;
161 21         62 my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
162              
163 21         65 pos $str = 0;
164 21         73 while( pos $str < length $str ) {
165 90 100 100     2803 if( $str =~ m/\G\\(.)/gc ) {
    100 100        
    100          
    100          
166             # escaped
167 2         6 $self->append_tagged( $1, %tags_in_effect );
168             }
169             elsif( !defined $link_start_pos and $str =~ m/\G\[/gc ) {
170             # start of a link
171 2         7 $link_start_pos = length $self;
172             }
173             elsif( defined $link_start_pos and $str =~ m/\G\]\(/gc ) {
174 2         9 $str =~ m/\G(.*?)\)/gc; # TODO: if it fails?
175 2         13 my $target = $1;
176              
177 2         6 $self->apply_tag( $link_start_pos, length $self, link => $target );
178 2         89 undef $link_start_pos;
179             }
180             elsif( $str =~ m/\G($MARKER_PATTERN)/gc ) {
181 41         105 my $marker = $1;
182 41         84 my $tag = $TAG_FOR_MARKER->{$marker};
183              
184 41 100       102 if( $marker eq "`" ) {
185 6 100       21 if( $str =~ m/\G(`)+/gc ) {
186 1         4 $marker .= $1;
187             }
188 6         115 $str =~ m/\G(.*?)(?:\Q$marker\E|$)/gc;
189 6         32 my $inner = $1;
190 6 100       25 $inner =~ s/^ (.*) $/$1/ if length $marker > 1;
191             # No decode_entities() inside `code span`
192 6         30 $self->append_tagged( $inner, %tags_in_effect, $tag => 1 );
193 6         391 next;
194             }
195              
196             $tags_in_effect{$tag} ? delete $tags_in_effect{$tag}
197 35 100       133 : $tags_in_effect{$tag}++;
198             }
199             else {
200 43         381 $str =~ m/\G(.*?)(?=$MARKER_PATTERN|\\|\[|\]|$)/gc;
201 43         115 my $fragment = $1;
202 43         185 $fragment = decode_entities( $fragment );
203 43         150 $self->append_tagged( $fragment, %tags_in_effect );
204             }
205             }
206              
207 21         447 return $self;
208             }
209              
210             =head2 new_from_formatting
211              
212             $st = String::Tagged::Markdown->new_from_formatting( $fmt, %args )
213              
214             Returns a new instance by convertig L standard
215             tags.
216              
217             The C, C and C tags are preserved. C is
218             renamed to C.
219              
220             Supports the following extra named arguments:
221              
222             =over 4
223              
224             =item convert_tags => HASH
225              
226             Optionally provides additional tag conversion callbacks, as defined by
227             L.
228              
229             =back
230              
231             =cut
232              
233             *_TAGS_FROM_FORMATTING = __cache_per_class sub ( $class ) {
234             return +{ $class->tags_from_formatting };
235             };
236              
237             sub tags_from_formatting ( $class )
238 2     2 0 3 {
  2         5  
  2         4  
239 2         24 bold => "bold",
240             italic => "italic",
241             monospace => "fixed",
242             strike => "strike",
243             }
244              
245 3         7 sub new_from_formatting ( $class, $orig, %args )
  3         8  
246 3     3 1 1075 {
  3         9  
  3         4  
247 3         23 my $CONVERSIONS = $class->_TAGS_FROM_FORMATTING;
248              
249 3 100       14 if( $args{convert_tags} ) {
250 2         12 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
251             }
252              
253 3         28 return $class->clone( $orig,
254             only_tags => [ keys $CONVERSIONS->%* ],
255             convert_tags => $CONVERSIONS,
256             );
257             }
258              
259             =head1 METHODS
260              
261             =cut
262              
263             =head2 build_markdown
264              
265             $str = $st->build_markdown
266              
267             Returns a plain text string containing Markdown-like inline formatting markers
268             to format the tags in the given instance. Uses the notation given in the
269             L method above.
270              
271             Non-ASCII Unicode characters are I generally emitted as HTML entities;
272             though C<&> and C< > are generated for convenience.
273              
274             =cut
275              
276             sub build_markdown ( $self )
277 21     21 1 5817 {
  21         39  
  21         27  
278 21         40 my $ret = "";
279 21         35 my @tags_in_effect; # need to remember the order
280             my $link_target;
281              
282 21         63 my $NEEDS_ESCAPE_PATTERN = $self->NEEDS_ESCAPE_PATTERN;
283 21         62 my $MARKER_FOR_TAG = $self->MARKER_FOR_TAG;
284              
285 75     75   101 $self->iter_substr_nooverlap( my $code = sub ( $substr, %tags ) {
  75         3099  
  75         119  
  75         112  
286 75   66     231 while( @tags_in_effect and !$tags{ $tags_in_effect[-1] } ) {
287 28         54 my $tag = pop @tags_in_effect;
288              
289 28 100       66 if( $tag eq "link" ) {
290 2         7 $ret .= "]($link_target)";
291             }
292             else {
293 26         40 my $marker = $MARKER_FOR_TAG->{$tag};
294 26         72 $ret .= $marker;
295             }
296             }
297              
298             # TODO: It'd be great if we could apply multiple tags in length order so
299             # as to minimise the need to undo them
300             my @tags = exists $tags{link} ?
301             # link should always be first
302 75 100       222 ( "link", sort grep { $_ ne "link" } keys %tags ) :
  3         10  
303             ( sort keys %tags );
304              
305 75         149 foreach my $tag ( @tags ) {
306 28 50       162 next if any { $_ eq $tag } @tags_in_effect;
  1         3  
307              
308 28 100       102 if( $tag eq "link" ) {
309 2         5 $ret .= "[";
310 2         4 $link_target = $tags{link};
311             }
312             else {
313 26         54 my $marker = $MARKER_FOR_TAG->{$tag};
314 26         54 $ret .= $marker;
315             }
316              
317 28         59 push @tags_in_effect, $tag;
318              
319             }
320              
321             # Inside `fixed`, markers don't need escaping
322 75 100       134 if( $tags{fixed} ) {
323             # If the interior contains literal `s then we'll have to use multiple
324             # and a space to surround it
325 6         22 my $more = "";
326 6         83 $more .= "`" while $substr =~ m/`$more/;
327              
328 6 100 66     74 $substr = "$more $substr $more" if length $more and $substr =~ m/^`|`$/;
329             }
330             else {
331 69         146 $substr =~ s/&/\&/g;
332 69         96 $substr =~ s/\xA0/\ /g;
333 69         334 $substr =~ s/($NEEDS_ESCAPE_PATTERN)/\\$1/g;
334             }
335 75         217 $ret .= $substr;
336 21         185 } );
337             # Flush the final tags at the end
338 21         329 $code->( "", () );
339              
340 21         298 return $ret;
341             }
342              
343             =head2 as_formatting
344              
345             $fmt = $st->as_formatting( %args )
346              
347             Returns a new C instance tagged with
348             L standard tags.
349              
350             The C, C and C tags are preserved, C is renamed
351             to C. The C tag is currently not represented at all.
352              
353             Supports the following extra named arguments:
354              
355             =over 4
356              
357             =item convert_tags => HASH
358              
359             Optionally provides additional tag conversion callbacks, as defined by
360             L.
361              
362             =back
363              
364             =cut
365              
366             *_TAGS_TO_FORMATTING = __cache_per_class sub ( $class ) {
367             return +{ $class->tags_to_formatting };
368             };
369              
370             sub tags_to_formatting ( $class )
371 2     2 0 13 {
  2         13  
  2         4  
372 2         28 bold => "bold",
373             italic => "italic",
374             fixed => "monospace",
375             strike => "strike",
376             }
377              
378 3         6 sub as_formatting ( $self, %args )
379 3     3 1 8 {
  3         8  
  3         4  
380 3         15 my $CONVERSIONS = $self->_TAGS_TO_FORMATTING;
381 3 100       18 if( $args{convert_tags} ) {
382 2         16 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
383             }
384              
385 3         25 return String::Tagged->clone( $self,
386             only_tags => [ keys $CONVERSIONS->%* ],
387             convert_tags => $CONVERSIONS,
388             );
389             }
390              
391             =head1 TODO
392              
393             =over 4
394              
395             =item *
396              
397             Fine-grained control of what HTML entities are generated on output.
398              
399             =back
400              
401             =head1 AUTHOR
402              
403             Paul Evans
404              
405             =cut
406              
407             0x55AA;