File Coverage

blib/lib/Dancer2/Plugin/MarkdownFilesToHTML.pm
Criterion Covered Total %
statement 152 162 93.8
branch 37 52 71.1
condition 16 19 84.2
subroutine 19 19 100.0
pod 1 2 50.0
total 225 254 88.5


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::MarkdownFilesToHTML ;
2             $Dancer2::Plugin::MarkdownFilesToHTML::VERSION = '0.015';
3 2     2   977560 use 5.010; use strict; use warnings;
  2     2   18  
  2     2   11  
  2         4  
  2         50  
  2         19  
  2         5  
  2         61  
4              
5 2     2   12 use Carp;
  2         6  
  2         138  
6 2     2   565 use Encode qw( decode );
  2         9963  
  2         107  
7 2     2   593 use Storable;
  2         3073  
  2         135  
8 2     2   15 use File::Path qw(make_path);
  2         5  
  2         163  
9 2     2   611 use Data::Dumper 'Dumper';
  2         6061  
  2         108  
10 2     2   15 use File::Basename;
  2         14  
  2         162  
11 2     2   1117 use Dancer2::Plugin;
  2         233991  
  2         19  
12 2     2   49537 use HTML::TreeBuilder;
  2         59140  
  2         23  
13 2     2   624 use File::Spec::Functions qw(catfile);
  2         922  
  2         184  
14 2     2   989 use Text::Markdown::Hoedown;
  2         2884  
  2         4198  
15              
16             plugin_keywords qw( md2html );
17              
18             has options => (is => 'rw', default => sub { {} });
19             has cache => (is => 'ro', from_config => 'defaults.cache', default => sub { 1 } );
20             has layout => (is => 'ro', from_config => 'defaults.layout', default => sub { 'main.tt' } );
21             has template => (is => 'ro', from_config => 'defaults.template', default => sub { 'index.tt' } );
22             has file_root => (is => 'ro', from_config => 'defaults.file_root', default => sub { 'lib/data/markdown_files' } );
23             has route_root => (is => 'ro', from_config => 'defaults.route_root', default => sub { '' } );
24             has header_class => (is => 'ro', from_config => 'defaults.header_class', default => sub { '' } );
25             has generate_toc => (is => 'ro', from_config => 'defaults.generate_toc', default => sub { 0 } );
26             has exclude_files => (is => 'ro', from_config => 'defaults.exclude_files', default => sub { [] } );
27             has include_files => (is => 'ro', from_config => 'defaults.include_files', default => sub { [] } );
28             has linkable_headers => (is => 'ro', from_config => 'defaults.linkable_headers', default => sub { 0 } );
29             has markdown_extensions => (is => 'ro', from_config => 'defaults.markdown_extensions', default => sub { [] } );
30              
31             # Builds the routes from config file
32             sub BUILD {
33 1     1 0 113 my $s = shift;
34 1         9 my $app = $s->app;
35 1         24 my $config = $s->config;
36              
37             # add routes from config file
38 1         57 foreach my $route (@{$config->{routes}}) {
  1         4  
39              
40             # validate arguments supplied from config file
41 1 50       7 if ((ref $route) ne 'HASH') {
42 0         0 die 'Config file misconfigured. Check syntax or consult documentation.';
43             }
44              
45 1         4 $s->_set_options($route);
46              
47             # Do the route addin'
48              
49 1         2 my %options = %{$s->options};
  1         9  
50             $s->app->add_route(
51             method => 'get',
52             regexp => '/' . $options{route_root} . $options{path},
53             code => sub {
54 1     1   197346 my $app = shift;
55 1         10 my ($html, $toc) = $s->md2html($options{resource}, \%options);
56             $app->template($options{template},
57             { html => $html, toc => $toc },
58 1         28 { layout => $options{layout} });
59             },
60 1         14 );
61             }
62             }
63              
64             sub _set_options {
65 6     6   15 my $s = shift;
66 6         19 my $options = shift;
67              
68 6         33 my @settings = qw(
69             cache layout template file_root route_root header_class generate_toc
70             exclude_files include_files linkable_headers markdown_extensions );
71              
72 6         33 my ($path) = keys %$options;
73 6         136 my $defaults = $s->config->{defaults};
74 6 100       104 my $local_options = (ref $options->{$path}) ? $options->{$path} : $options;
75 6         38 my %options = (%$defaults, %$local_options);
76              
77 6         24 foreach my $setting (@settings) {
78 66   100     1836 $options{$setting} = $options->{$setting} //= $s->$setting;
79             }
80              
81 6         88 $options{set} = 1;
82 6         16 $options{path} = $path;
83 6 50       34 $options{route_root} .= '/' if $options{route_root};
84 6 100       28 $options{linkable_headers} = 1 if $options{generate_toc};
85              
86 6         69 my $is_abs = File::Spec->file_name_is_absolute($options{resource});
87 6 50       21 if (!$is_abs) {
88 6         70 $options{resource} = File::Spec->catfile($options{file_root}, $options{resource});
89             }
90              
91 6         50 $s->options(\%options);
92              
93             }
94              
95             # Keyword for generating HTML from a markdown resource (file or directory)
96             # either through cache retrieval or parsing of the resource
97             sub md2html {
98 6     6 1 64422 my ($s, $resource, $options) = @_;
99              
100             # If keyword called directly, options won't be set yet
101 6 100       26 if (!$options->{set}) {
102 5         15 $options->{resource} = $resource;
103 5         26 $s->_set_options($options);
104             } else {
105 1         13 $s->options($options);
106             }
107              
108 6 50       161 if (!-e $s->options->{resource}) {
109             my $return = 'This route is not properly configured. Resource: '
110 0         0 . $s->options->{resource} . ' does not exist on the server.';
111 0         0 $s->options({});
112 0 0       0 return wantarray ? ($return, '') : $return;
113             }
114              
115 6         29 my $html = '';
116 6         25 my $toc = '';
117              
118 6         17 my @files = ();
119 6 100       105 if (-f $s->options->{resource}) {
120 2         14 push @files, $s->options->{resource};
121             } else {
122 4         28 my $dir = $s->options->{resource};
123             # gather the files according the options supplied
124 4 50       9 if (@{$s->options->{include_files}}) {
  4         24  
125 0         0 my @files = @{$s->options->{include_files}};
  0         0  
126             } else {
127 4 50       157 opendir my $d, $dir or die "Cannot open directory: $!";
128 4         183 @files = grep { $_ !~ /^\./ } readdir $d;
  72         193  
129 4         70 closedir $d;
130 4         18 my @matching_files = ();
131 4         8 foreach my $md_ext (@{$s->options->{markdown_extensions}}) {
  4         29  
132 0         0 push @matching_files, grep { $_ =~ /\.$md_ext$/ } @files;
  0         0  
133             }
134 4 50       29 @files = @matching_files if @matching_files;
135             }
136              
137 4         10 foreach my $excluded_file (@{$s->options->{exclude_files}}) {
  4         19  
138 0         0 @files = grep { $_ ne $excluded_file } @files;
  0         0  
139             }
140              
141 4         18 @files = map { File::Spec->catfile($dir, $_) } @files;
  64         390  
142             }
143              
144             # concatenate html and toc into two strings
145 6         42 foreach my $file (sort @files) {
146 66         339 my ($file_html, $file_toc) = $s->_mdfile_2html($file);
147 66         698 $html .= $file_html;
148 66   100     507 $toc .= $file_toc || '';
149             }
150 6 100       418 return wantarray ? ($html, $toc) : $html;
151             }
152              
153             # Sends the markdown file to get parsed or retrieves html version from cache,
154             # if available. Also generates the table of contents.
155             sub _mdfile_2html {
156 66     66   154 my $s = shift;
157 66         161 my $file = shift;
158              
159             # chop off extension
160 66         2862 my ($base) = fileparse($file, qr/\.[^.]*/);
161              
162             # generate the cache directory if it doesn't exist
163 66         3190 my $cache_dir = File::Spec->catfile(dirname($s->options->{'file_root'}), 'md_file_cache');
164 66 100       1382 if (!-d $cache_dir) {
165 1 50       233 make_path $cache_dir or die "Cannot make cache directory $!";
166             }
167              
168             # generate unique cache file name appended with values of two options
169 66         2028 my $cache_file = dirname($file);
170 66         614 my $sep = File::Spec->catfile('', '');
171 66         461 $cache_file =~ s/\Q$sep\E//g;
172             $cache_file = File::Spec->catfile($cache_dir,
173             $cache_file . $base . $s->options->{linkable_headers} . $s->options->{generate_toc}
174 66         837 . $s->options->{header_class} =~ s/ //gr);
175              
176             # check for cache hit
177             # TODO: Save options in separate file so they can be compared
178 66 50 66     1991 if (-f $cache_file && $s->options->{cache}) {
179 16 50       508 if (-M $cache_file eq -M $file) {
180 16 50 33     143 print Dumper 'cache hit: '. $file if $ENV{DANCER_ENVIRONMENT} && $ENV{DANCER_ENVIRONMENT} eq 'testing';
181 16         1185 my $data = retrieve $cache_file;
182 16         1637 return ($data->{html}, $data->{toc});
183             }
184             }
185              
186             # no cache hit so we parse the file
187             # slurp the file and parse it with Hoedown's markdown function
188 50         232 my $markdown = '';
189             {
190 50         114 local $/;
  50         285  
191 50 50       2326 open my $md, '<:encoding(UTF-8)', $file or die "Can't open $file: $!";
192 50         6619 $markdown = <$md>;
193 50         4096 close $md;
194             }
195 50         390 my $out = markdown($markdown, extensions => HOEDOWN_EXT_FENCED_CODE, toc_nesting_lvl => 0);
196 50         7987 my $tree = HTML::TreeBuilder->new_from_content($out);
197              
198 50         1752129 my @code_els = $tree->find_by_tag_name('code');
199 50         45143 foreach my $code_el (@code_els) {
200 2711 100 100     49913 if (!$code_el->left && !$code_el->right) {
201 345         5575 $code_el->attr('class' => 'single-line');
202             }
203             }
204              
205             # See if we can cache and return the output without further processing
206             # generate_toc makes linkable_headers true so we just need to test linkable_headers option
207 50 100 100     1349 if (!$s->options->{linkable_headers} && !$s->options->{header_class}) {
208 17         76 my $html = $tree->guts->as_HTML;
209 17 50       255470 _cache_data($cache_file, $file, $html) if $s->options->{cache};
210 17         844 return ($html, '');
211             }
212              
213             # generate linkable headers along with toc if option for it is set
214 33         344 my @elements = $tree->look_down(_tag => qr/^h\d$/);
215 33 100       37242 my $toc = HTML::TreeBuilder->new() if $s->options->{generate_toc};
216 33         4244 my $hdr_ct = 0;
217 33         90 foreach my $element (@elements) {
218 246         3623 my $id = 'header_' . ${hdr_ct};
219 246         416 $hdr_ct++;
220 246         913 $element->attr('id', $id . '_' . $base);
221 246 100       3845 $element->attr('class' => $s->options->{header_class}) if $s->options->{header_class};
222 246 100       1917 if ($s->options->{generate_toc}) {
223 117         250 my ($level) = $element->tag =~ /(\d)/;
224 117         1319 my $toc_link = HTML::Element->new('a', href=> "#${id}_${base}", class => 'header_' . $level);
225 117         4089 $toc_link->push_content($element->as_text);
226 117         4835 $toc->push_content($toc_link);
227 117         1790 $toc->push_content(HTML::Element->new('br'));
228             }
229             }
230              
231             # Generate the final HTML from trees and cache
232             # "guts" method gets rid of and tags added by TreeBuilder
233 33 100       656 my ($html, $toc_out) = ($tree->guts->as_HTML, $toc ? $toc->guts->as_HTML : '');
234 33         550646 _cache_data($cache_file, $file, $html, $toc_out);
235 33         2087 return ($html, $toc_out);
236             }
237              
238             sub _cache_data {
239 50     50   215 my ($cache_file, $file, $content, $toc) = @_;
240 50   100     228 $toc //= '';
241              
242 50         433 store { html => $content, toc => $toc }, $cache_file;
243 50         16714 my ($read, $write) = (stat($file))[8,9];
244 50         1083 utime($read, $write, $cache_file);
245             }
246              
247             1;
248              
249             __END__