File Coverage

blib/lib/String/Tagged/Markdown.pm
Criterion Covered Total %
statement 136 136 100.0
branch 32 34 94.1
condition 14 18 77.7
subroutine 18 18 100.0
pod 4 7 57.1
total 204 213 95.7


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-2024 -- leonerd@leonerd.org.uk
5              
6             package String::Tagged::Markdown 0.06;
7              
8 5     5   896932 use v5.26;
  5         19  
9 5     5   24 use warnings;
  5         11  
  5         304  
10 5     5   1992 use experimental 'signatures';
  5         16695  
  5         38  
11 5     5   977 use base qw( String::Tagged );
  5         10  
  5         4992  
12              
13 5     5   43423 use List::Util 1.45 qw( any uniqstr );
  5         90  
  5         460  
14              
15 5     5   2707 use HTML::Entities qw( decode_entities );
  5         32271  
  5         16219  
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 104 "**" => "bold",
63             "*" => "italic",
64             "__" => "bold",
65             "_" => "italic",
66             "~~" => "strike",
67             "`" => "fixed",
68             }
69              
70             sub __cache_per_class ( $code )
71 30     30   69 {
  30         38  
  30         40  
72 30         40 my %cache;
73 111     111   173 return sub ( $self ) {
  111         161  
  111         143  
74 111   66     446 my $class = ref $self || $self;
75 111   66     354 return $cache{$class} //= $code->( $class );
76 30         101 };
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::Markdown->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 23         53 sub parse_markdown ( $class, $str )
154 23     23 1 586840 {
  23         66  
  23         51  
155 23         122 my $self = $class->new;
156              
157 23         1264 my %tags_in_effect;
158             my $link_start_pos;
159              
160 23         88 my $MARKER_PATTERN = $class->MARKER_PATTERN;
161 23         74 my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
162              
163 23         149 pos $str = 0;
164 23         104 while( pos $str < length $str ) {
165 96 100 100     4627 if( $str =~ m/\G\\(.)/gc ) {
    100 100        
    100          
    100          
166             # escaped
167 2         11 $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 3         17 $link_start_pos = length $self;
172             }
173             elsif( defined $link_start_pos and $str =~ m/\G\]\(/gc ) {
174 3         14 $str =~ m/\G(.*?)\)/gc; # TODO: if it fails?
175 3         10 my $target = $1;
176              
177 3         12 $self->apply_tag( $link_start_pos, length $self, link => $target );
178 3         130 undef $link_start_pos;
179             }
180             elsif( $str =~ m/\G($MARKER_PATTERN)/gc ) {
181 43         123 my $marker = $1;
182 43         109 my $tag = $TAG_FOR_MARKER->{$marker};
183              
184 43 100       107 if( $marker eq "`" ) {
185 6 100       19 if( $str =~ m/\G(`)+/gc ) {
186 1         3 $marker .= $1;
187             }
188 6         152 $str =~ m/\G(.*?)(?:\Q$marker\E|$)/gc;
189 6         22 my $inner = $1;
190 6 100       24 $inner =~ s/^ (.*) $/$1/ if length $marker > 1;
191             # No decode_entities() inside `code span`
192 6         47 $self->append_tagged( $inner, %tags_in_effect, $tag => 1 );
193 6         574 next;
194             }
195              
196             $tags_in_effect{$tag} ? delete $tags_in_effect{$tag}
197 37 100       149 : $tags_in_effect{$tag}++;
198             }
199             else {
200 45         502 $str =~ m/\G(.*?)(?=$MARKER_PATTERN|\\|\[|\]|$)/gc;
201 45         116 my $fragment = $1;
202 45         315 $fragment = decode_entities( $fragment );
203 45         181 $self->append_tagged( $fragment, %tags_in_effect );
204             }
205             }
206              
207 23         678 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, C and C tags are preserved. C
218             is 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 11 {
  2         10  
  2         3  
239 1         3 bold => "bold",
240             italic => "italic",
241             monospace => "fixed",
242             strike => "strike",
243 1     1   142 link => sub ( $k, $v ) {
  1         2  
  1         2  
244 1 50       5 defined( my $uri = $v->{uri} ) or return;
245 1         5 "link" => $uri
246             },
247 2         33 }
248              
249 5         11 sub new_from_formatting ( $class, $orig, %args )
  5         11  
250 5     5 1 204369 {
  5         12  
  5         22  
251 5         23 my $CONVERSIONS = $class->_TAGS_FROM_FORMATTING;
252              
253 5 100       21 if( $args{convert_tags} ) {
254 3         22 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
255             }
256              
257 5         52 return $class->clone( $orig,
258             only_tags => [ keys $CONVERSIONS->%* ],
259             convert_tags => $CONVERSIONS,
260             );
261             }
262              
263             =head1 METHODS
264              
265             =cut
266              
267             =head2 build_markdown
268              
269             $str = $st->build_markdown;
270              
271             Returns a plain text string containing Markdown-like inline formatting markers
272             to format the tags in the given instance. Uses the notation given in the
273             L method above.
274              
275             Non-ASCII Unicode characters are I generally emitted as HTML entities;
276             though C<&> and C< > are generated for convenience.
277              
278             =cut
279              
280             sub build_markdown ( $self )
281 23     23 1 279643 {
  23         40  
  23         30  
282 23         49 my $ret = "";
283 23         40 my @tags_in_effect; # need to remember the order
284             my $link_target;
285              
286 23         61 my $NEEDS_ESCAPE_PATTERN = $self->NEEDS_ESCAPE_PATTERN;
287 23         61 my $MARKER_FOR_TAG = $self->MARKER_FOR_TAG;
288              
289 79     79   3409 $self->iter_substr_nooverlap( my $code = sub ( $substr, %tags ) {
  79         124  
  79         106  
  79         99  
290 79   66     259 while( @tags_in_effect and !$tags{ $tags_in_effect[-1] } ) {
291 30         47 my $tag = pop @tags_in_effect;
292              
293 30 100       69 if( $tag eq "link" ) {
294 3         9 $ret .= "]($link_target)";
295             }
296             else {
297 27         45 my $marker = $MARKER_FOR_TAG->{$tag};
298 27         70 $ret .= $marker;
299             }
300             }
301              
302             # TODO: It'd be great if we could apply multiple tags in length order so
303             # as to minimise the need to undo them
304             my @tags = exists $tags{link} ?
305             # link should always be first
306 79 100       280 ( "link", sort grep { $_ ne "link" } keys %tags ) :
  4         15  
307             ( sort keys %tags );
308              
309 79         136 foreach my $tag ( @tags ) {
310 30 50       208 next if any { $_ eq $tag } @tags_in_effect;
  1         3  
311              
312 30 100       155 if( $tag eq "link" ) {
313 3         6 $ret .= "[";
314 3         7 $link_target = $tags{link};
315             }
316             else {
317 27         56 my $marker = $MARKER_FOR_TAG->{$tag};
318 27         56 $ret .= $marker;
319             }
320              
321 30         51 push @tags_in_effect, $tag;
322              
323             }
324              
325             # Inside `fixed`, markers don't need escaping
326 79 100       129 if( $tags{fixed} ) {
327             # If the interior contains literal `s then we'll have to use multiple
328             # and a space to surround it
329 6         9 my $more = "";
330 6         91 $more .= "`" while $substr =~ m/`$more/;
331              
332 6 100 66     40 $substr = "$more $substr $more" if length $more and $substr =~ m/^`|`$/;
333             }
334             else {
335 73         128 $substr =~ s/&/\&/g;
336 73         183 $substr =~ s/\xA0/\ /g;
337 73         421 $substr =~ s/($NEEDS_ESCAPE_PATTERN)/\\$1/g;
338             }
339 79         215 $ret .= $substr;
340 23         234 } );
341             # Flush the final tags at the end
342 23         333 $code->( "", () );
343              
344 23         318 return $ret;
345             }
346              
347             =head2 as_formatting
348              
349             $fmt = $st->as_formatting( %args );
350              
351             Returns a new C instance tagged with
352             L standard tags.
353              
354             The C, C, C and C tags are preserved, C is
355             renamed to C.
356              
357             Supports the following extra named arguments:
358              
359             =over 4
360              
361             =item convert_tags => HASH
362              
363             Optionally provides additional tag conversion callbacks, as defined by
364             L.
365              
366             =back
367              
368             =cut
369              
370             *_TAGS_TO_FORMATTING = __cache_per_class sub ( $class ) {
371             return +{ $class->tags_to_formatting };
372             };
373              
374             sub tags_to_formatting ( $class )
375 2     2 0 5 {
  2         4  
  2         4  
376 1         3 bold => "bold",
377             italic => "italic",
378             fixed => "monospace",
379             strike => "strike",
380 1     1   112 link => sub ( $k, $v ) {
  1         2  
  1         2  
381 1         28 "link" => { uri => $v }
382             },
383 2         39 }
384              
385 5         11 sub as_formatting ( $self, %args )
386 5     5 1 37 {
  5         15  
  5         47  
387 5         25 my $CONVERSIONS = $self->_TAGS_TO_FORMATTING;
388 5 100       19 if( $args{convert_tags} ) {
389 3         25 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
390             }
391              
392 5         48 return String::Tagged->clone( $self,
393             only_tags => [ keys $CONVERSIONS->%* ],
394             convert_tags => $CONVERSIONS,
395             );
396             }
397              
398             =head1 TODO
399              
400             =over 4
401              
402             =item *
403              
404             Fine-grained control of what HTML entities are generated on output.
405              
406             =back
407              
408             =head1 AUTHOR
409              
410             Paul Evans
411              
412             =cut
413              
414             0x55AA;