File Coverage

blib/lib/Dancer2/Plugin/MarkdownFilesToHTML.pm
Criterion Covered Total %
statement 153 160 95.6
branch 39 50 78.0
condition 14 17 82.3
subroutine 20 20 100.0
pod 1 2 50.0
total 227 249 91.1


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::MarkdownFilesToHTML ;
2             $Dancer2::Plugin::MarkdownFilesToHTML::VERSION = '0.017';
3             # ABSTRACT: Easy conversion of markdown documents to HTML for display in your Dancer2 website
4 2     2   1188394 use 5.010; use strict; use warnings;
  2     2   15  
  2     2   9  
  2         5  
  2         42  
  2         16  
  2         7  
  2         74  
5              
6 2     2   13 use Carp;
  2         3  
  2         146  
7 2     2   549 use Encode qw( decode );
  2         10305  
  2         113  
8 2     2   594 use Storable;
  2         3187  
  2         164  
9 2     2   22 use File::Path qw(make_path);
  2         7  
  2         132  
10 2     2   604 use Data::Dumper 'Dumper';
  2         6137  
  2         107  
11 2     2   14 use File::Basename;
  2         4  
  2         165  
12 2     2   1050 use Dancer2::Plugin;
  2         245011  
  2         20  
13 2     2   47364 use HTML::TreeBuilder;
  2         63879  
  2         28  
14 2     2   695 use File::Spec::Functions qw(catfile);
  2         878  
  2         173  
15 2     2   1127 use Text::Markdown::Hoedown;
  2         2635  
  2         3825  
16              
17             plugin_keywords qw( md2html );
18              
19             has options => (is => 'rw', default => sub { {} });
20             has cache => (is => 'ro', from_config => 'defaults.cache', default => sub { 1 } );
21             has prefix => (is => 'ro', from_config => 'defaults.prefix', default => sub { '' } );
22             has layout => (is => 'ro', from_config => 'defaults.layout', default => sub { 'main.tt' } );
23             has template => (is => 'ro', from_config => 'defaults.template', default => sub { 'index.tt' } );
24             has file_root => (is => 'ro', from_config => 'defaults.file_root', default => sub { 'lib/data/markdown_files' } );
25             has header_class => (is => 'ro', from_config => 'defaults.header_class', default => sub { '' } );
26             has generate_toc => (is => 'ro', from_config => 'defaults.generate_toc', default => sub { 0 } );
27             has exclude_files => (is => 'ro', from_config => 'defaults.exclude_files', default => sub { [] } );
28             has include_files => (is => 'ro', from_config => 'defaults.include_files', default => sub { [] } );
29             has linkable_headers => (is => 'ro', from_config => 'defaults.linkable_headers', default => sub { 0 } );
30             has markdown_extensions => (is => 'ro', from_config => 'defaults.markdown_extensions', default => sub { [] } );
31              
32             # Builds the routes from the config file
33             sub BUILD {
34 1     1 0 95 my $s = shift;
35              
36             # add routes from config file
37 1         2 foreach my $route (@{$s->config->{routes}}) {
  1         21  
38              
39             # validate arguments supplied from config file
40 3 50       13308 if ((ref $route) ne 'HASH') {
41 0         0 die 'Config file misconfigured. Check syntax or consult documentation.';
42             }
43              
44 3         15 $s->_set_options($route);
45 3         5 my %options = %{$s->options};
  3         30  
46             $s->app->add_route(
47             method => 'get',
48             regexp => '/' . $options{prefix} . $options{path},
49             code => sub {
50 3     3   396762 my ($html, $toc) = $s->md2html($options{resource}, \%options);
51             $s->app->template($options{template},
52             { html => $html, toc => $toc },
53 3         58 { layout => $options{layout} });
54             },
55 3         39 );
56             }
57             }
58              
59             sub _set_options {
60 8     8   22 my ($s, $options) = @_;
61              
62 8         47 my @settings = qw( cache layout template file_root prefix header_class
63             generate_toc exclude_files include_files linkable_headers markdown_extensions );
64              
65 8         30 my ($path) = keys %$options;
66 8         158 my $defaults = $s->config->{defaults};
67 8 100       100 my $local_options = (ref $options->{$path}) ? $options->{$path} : $options;
68 8         59 my %options = (%$defaults, %$local_options);
69              
70 8         26 foreach my $setting (@settings) {
71 88   100     1483 $options{$setting} = $options{$setting} // $s->$setting;
72             }
73              
74 8         72 $options{set} = 1;
75 8         25 $options{path} = $path;
76 8 100       26 $options{prefix} .= '/' if $options{prefix};
77 8 100       22 $options{linkable_headers} = 1 if $options{generate_toc};
78              
79 8 50       87 if (!File::Spec->file_name_is_absolute($options{resource})) {
80 8         110 $options{resource} = File::Spec->catfile($options{file_root}, $options{resource});
81             }
82 8         67 $s->options(\%options);
83             }
84              
85             # Keyword for generating HTML
86             sub md2html {
87 8     8 1 73887 my ($s, $resource, $options) = @_;
88              
89             # If keyword called directly, options won't be set yet
90 8 100       35 if (!$options->{set}) {
91 5         16 $options->{resource} = $resource;
92 5         24 $s->_set_options($options);
93             } else {
94 3         24 $s->options($options);
95             }
96              
97             #TODO: return a 202 status code
98 8 100       311 if (!-e $s->options->{resource}) {
99             my $html = 'This route is not properly configured. Resource: '
100 1         11 . $s->options->{resource} . ' does not exist on the server.';
101 1         6 return ($html, undef);
102             }
103              
104 7         38 my @files = $s->_gather_files;
105 7         24 my ($html, $toc) = '';
106 7         42 foreach my $file (sort @files) {
107 67         293 my ($file_html, $file_toc) = $s->_mdfile_2html($file);
108 67         842 $html .= $file_html;
109 67         392 $toc .= $file_toc;
110             }
111 7 100       399 return wantarray ? ($html, $toc) : $html;
112             }
113              
114             sub _gather_files {
115 7     7   16 my $s = shift;
116              
117 7         15 my @files;
118 7 100       100 if (-f $s->options->{resource}) {
119 3         19 push @files, $s->options->{resource};
120             } else {
121 4         59 my $dir = $s->options->{resource};
122             # gather the files according the options supplied
123 4 50       7 if (@{$s->options->{include_files}}) {
  4         20  
124 0         0 my @files = @{$s->options->{include_files}};
  0         0  
125             } else {
126 4 50       165 opendir my $d, $dir or die "Cannot open directory: $!";
127 4         164 @files = grep { $_ !~ /^\./ } readdir $d;
  72         182  
128 4         66 closedir $d;
129 4         15 my @matching_files = ();
130 4         9 foreach my $md_ext (@{$s->options->{markdown_extensions}}) {
  4         21  
131 0         0 push @matching_files, grep { $_ =~ /\.$md_ext$/ } @files;
  0         0  
132             }
133 4 50       26 @files = @matching_files if @matching_files;
134             }
135              
136 4         9 foreach my $excluded_file (@{$s->options->{exclude_files}}) {
  4         16  
137 0         0 @files = grep { $_ ne $excluded_file } @files;
  0         0  
138             }
139              
140 4         11 @files = map { File::Spec->catfile($dir, $_) } @files;
  64         331  
141             }
142 7         35 return @files;
143             }
144              
145             # Sends the markdown file to get parsed or retrieves html version from cache,
146             # if available. Also generates the table of contents.
147             sub _mdfile_2html {
148 67     67   150 my $s = shift;
149 67         173 my $file = shift;
150              
151             # chop off extension
152 67         4446 my ($base) = fileparse($file, qr/\.[^.]*/);
153              
154             # generate the cache directory if it doesn't exist
155 67         6152 my $cache_dir = File::Spec->catfile(dirname($s->options->{'file_root'}), 'md_file_cache');
156 67 100       1406 if (!-d $cache_dir) {
157 1 50       640 make_path $cache_dir or die "Cannot make cache directory $!";
158             }
159              
160             # generate unique cache file name appended with options
161 67         2516 my $cache_file = dirname($file);
162 67         656 my $sep = File::Spec->catfile('', '');
163 67         4753 $cache_file =~ s/\Q$sep\E//g;
164 67         627 my $header_classes = $s->options->{header_class};
165 67         169 $header_classes =~ s/ //g;
166             $cache_file = File::Spec->catfile($cache_dir,
167             $cache_file
168             . $base
169             . $s->options->{linkable_headers}
170             . $s->options->{generate_toc}
171 67         811 . $header_classes);
172              
173             # check for cache hit
174             # TODO: Save options in separate file so they can be compared
175 67 50 66     6320 if (-f $cache_file && $s->options->{cache}) {
176 17 50       391 if (-M $cache_file eq -M $file) {
177 17 50 33     133 print Dumper 'cache hit: '. $file if $ENV{DANCER_ENVIRONMENT} && $ENV{DANCER_ENVIRONMENT} eq 'testing';
178 17         1065 my $data = retrieve $cache_file;
179 17         1384 return ($data->{html}, $data->{toc});
180             }
181             }
182              
183             # no cache hit so we parse the file
184             # slurp the file and parse it with Hoedown's markdown function
185 50         175 my $markdown = '';
186             {
187 50         166 local $/;
  50         413  
188 50 50       2566 open my $md, '<:encoding(UTF-8)', $file or die "Can't open $file: $!";
189 50         7382 $markdown = <$md>;
190 50         8694 close $md;
191             }
192 50         361 my $out = markdown($markdown, extensions => HOEDOWN_EXT_FENCED_CODE, toc_nesting_lvl => 0);
193 50         8142 my $tree = HTML::TreeBuilder->new_from_content($out);
194              
195 50         1707173 my @code_els = $tree->find_by_tag_name('code');
196 50         38380 foreach my $code_el (@code_els) {
197 2711 100 100     43716 if (!$code_el->left && !$code_el->right) {
198 345         4769 $code_el->attr('class' => 'single-line');
199             }
200             }
201              
202             # See if we can cache and return the output without further processing
203 50 100 100     1333 if (!$s->options->{linkable_headers} && !$s->options->{header_class}) {
204 17         100 my $html = $tree->guts->as_HTML;
205 17 50       285723 _cache_data($cache_file, $file, $html) if $s->options->{cache};
206 17         1994 return ($html, '');
207             }
208              
209             # add linkable_headers, toc, and header_class per options
210 33         438 my @elements = $tree->look_down(_tag => qr/^h\d$/);
211 33 100       30032 my $toc = HTML::TreeBuilder->new() if $s->options->{generate_toc};
212 33         4193 my $hdr_ct = 0;
213 33         88 foreach my $element (@elements) {
214 246         2979 my $id = 'header_' . ${hdr_ct};
215 246         369 $hdr_ct++;
216 246         762 $element->attr('id', $id . '_' . $base);
217 246 100       3065 $element->attr('class' => $s->options->{header_class}) if $s->options->{header_class};
218 246 100       1523 if ($s->options->{generate_toc}) {
219 117         219 my ($level) = $element->tag =~ /(\d)/;
220 117         1053 my $toc_link = HTML::Element->new('a', href=> "#${id}_${base}", class => 'header_' . $level);
221 117         3282 $toc_link->push_content($element->as_text);
222 117         3948 $toc->push_content($toc_link);
223 117         1462 $toc->push_content(HTML::Element->new('br'));
224             }
225             }
226              
227             # Generate the final HTML from trees and cache
228             # "guts" method gets rid of and tags added by TreeBuilder
229 33 100       564 my ($html, $toc_out) = ($tree->guts->as_HTML, $toc ? $toc->guts->as_HTML : '');
230 33         454374 _cache_data($cache_file, $file, $html, $toc_out);
231 33         2404 return ($html, $toc_out);
232             }
233              
234             sub _cache_data {
235 50     50   225 my ($cache_file, $file, $content, $toc) = @_;
236 50   100     253 $toc //= '';
237              
238 50         508 store { html => $content, toc => $toc }, $cache_file;
239 50         18986 my ($read, $write) = (stat($file))[8,9];
240 50         1095 utime($read, $write, $cache_file);
241             }
242              
243             1;
244              
245             __END__