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.03;
7              
8 5     5   906787 use v5.26;
  5         38  
9 5     5   37 use warnings;
  5         10  
  5         134  
10 5     5   1900 use experimental 'signatures';
  5         14028  
  5         27  
11 5     5   848 use base qw( String::Tagged );
  5         12  
  5         3343  
12              
13 5     5   37723 use List::Util 1.45 qw( any uniqstr );
  5         109  
  5         10956  
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 64 "**" => "bold",
61             "*" => "italic",
62             "__" => "bold",
63             "_" => "italic",
64             "~~" => "strike",
65             "`" => "fixed",
66             }
67              
68             sub __cache_per_class ( $code )
69 30     30   58 {
  30         51  
  30         40  
70 30         43 my %cache;
71 89     89   145 return sub ( $self ) {
  89         128  
  89         137  
72 89   66     259 my $class = ref $self || $self;
73 89   66     305 return $cache{$class} //= $code->( $class );
74 30         92 };
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         43 sub parse_markdown ( $class, $str )
149 19     19 1 51176 {
  19         31  
  19         32  
150 19         68 my $self = $class->new;
151              
152 19         291 my %tags_in_effect;
153             my $link_start_pos;
154              
155 19         52 my $MARKER_PATTERN = $class->MARKER_PATTERN;
156 19         46 my $TAG_FOR_MARKER = $class->TAG_FOR_MARKER;
157              
158 19         63 pos $str = 0;
159 19         69 while( pos $str < length $str ) {
160 91 100 100     3013 if( $str =~ m/\G\\(.)/gc ) {
    100 100        
    100          
    100          
161             # escaped
162 2         7 $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         6 $self->apply_tag( $link_start_pos, length $self, link => $target );
173 2         88 undef $link_start_pos;
174             }
175             elsif( $str =~ m/\G($MARKER_PATTERN)/gc ) {
176 42         104 my $marker = $1;
177 42         77 my $tag = $TAG_FOR_MARKER->{$marker};
178              
179 42 100       93 if( $marker eq "`" ) {
180 5 100       22 if( $str =~ m/\G(`)+/gc ) {
181 1         4 $marker .= $1;
182             }
183 5         100 $str =~ m/\G(.*?)(?:\Q$marker\E|$)/gc;
184 5         16 my $inner = $1;
185 5 100       22 $inner =~ s/^ (.*) $/$1/ if length $marker > 1;
186 5         25 $self->append_tagged( $inner, %tags_in_effect, $tag => 1 );
187 5         324 next;
188             }
189              
190             $tags_in_effect{$tag} ? delete $tags_in_effect{$tag}
191 37 100       140 : $tags_in_effect{$tag}++;
192             }
193             else {
194 43         330 $str =~ m/\G(.*?)(?=$MARKER_PATTERN|\\|\[|\]|$)/gc;
195 43         170 $self->append_tagged( $1, %tags_in_effect );
196             }
197             }
198              
199 19         382 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 6 {
  2         7  
  2         3  
231 2         22 bold => "bold",
232             italic => "italic",
233             monospace => "fixed",
234             strike => "strike",
235             }
236              
237 3         7 sub new_from_formatting ( $class, $orig, %args )
  3         25  
238 3     3 1 1045 {
  3         15  
  3         6  
239 3         19 my $CONVERSIONS = $class->_TAGS_FROM_FORMATTING;
240              
241 3 100       13 if( $args{convert_tags} ) {
242 2         12 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
243             }
244              
245 3         31 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 5310 {
  18         35  
  18         29  
267 18         32 my $ret = "";
268 18         39 my @tags_in_effect; # need to remember the order
269             my $link_target;
270              
271 18         42 my $NEEDS_ESCAPE_PATTERN = $self->NEEDS_ESCAPE_PATTERN;
272 18         55 my $MARKER_FOR_TAG = $self->MARKER_FOR_TAG;
273              
274 70     70   122 $self->iter_substr_nooverlap( my $code = sub ( $substr, %tags ) {
  70         3114  
  70         118  
  70         103  
275 70   66     222 while( @tags_in_effect and !$tags{ $tags_in_effect[-1] } ) {
276 28         55 my $tag = pop @tags_in_effect;
277              
278 28 100       67 if( $tag eq "link" ) {
279 2         10 $ret .= "]($link_target)";
280             }
281             else {
282 26         48 my $marker = $MARKER_FOR_TAG->{$tag};
283 26         65 $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 70 100       226 ( "link", sort grep { $_ ne "link" } keys %tags ) :
  3         10  
292             ( sort keys %tags );
293              
294 70         139 foreach my $tag ( @tags ) {
295 28 50       161 next if any { $_ eq $tag } @tags_in_effect;
  1         2  
296              
297 28 100       114 if( $tag eq "link" ) {
298 2         5 $ret .= "[";
299 2         4 $link_target = $tags{link};
300             }
301             else {
302 26         54 my $marker = $MARKER_FOR_TAG->{$tag};
303 26         53 $ret .= $marker;
304             }
305              
306 28         60 push @tags_in_effect, $tag;
307              
308             }
309              
310             # Inside `fixed`, markers don't need escaping
311 70 100       137 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         10 my $more = "";
315 5         71 $more .= "`" while $substr =~ m/`$more/;
316              
317 5 100 66     50 $substr = "$more $substr $more" if length $more and $substr =~ m/^`|`$/;
318             }
319             else {
320 65         304 $substr =~ s/($NEEDS_ESCAPE_PATTERN)/\\$1/g;
321             }
322 70         198 $ret .= $substr;
323 18         169 } );
324             # Flush the final tags at the end
325 18         289 $code->( "", () );
326              
327 18         265 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 5 {
  2         4  
  2         3  
359 2         37 bold => "bold",
360             italic => "italic",
361             fixed => "monospace",
362             strike => "strike",
363             }
364              
365 3         8 sub as_formatting ( $self, %args )
366 3     3 1 6 {
  3         7  
  3         4  
367 3         22 my $CONVERSIONS = $self->_TAGS_TO_FORMATTING;
368 3 100       17 if( $args{convert_tags} ) {
369 2         14 $CONVERSIONS = { $CONVERSIONS->%*, $args{convert_tags}->%* };
370             }
371              
372 3         44 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;