File Coverage

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