File Coverage

blib/lib/Dancer2/Plugin/MarkdownFilesToHTML.pm
Criterion Covered Total %
statement 153 160 95.6
branch 38 50 76.0
condition 14 17 82.3
subroutine 20 20 100.0
pod 1 2 50.0
total 226 249 90.7


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::MarkdownFilesToHTML ;
2             $Dancer2::Plugin::MarkdownFilesToHTML::VERSION = '0.016';
3             # ABSTRACT: Easy conversion of markdown documents to HTML for display in your Dancer2 website
4 2     2   931569 use 5.010; use strict; use warnings;
  2     2   15  
  2     2   11  
  2         12  
  2         41  
  2         20  
  2         6  
  2         76  
5              
6 2     2   18 use Carp;
  2         5  
  2         137  
7 2     2   713 use Encode qw( decode );
  2         11075  
  2         107  
8 2     2   658 use Storable;
  2         3222  
  2         152  
9 2     2   15 use File::Path qw(make_path);
  2         4  
  2         136  
10 2     2   697 use Data::Dumper 'Dumper';
  2         6357  
  2         122  
11 2     2   15 use File::Basename;
  2         4  
  2         186  
12 2     2   1130 use Dancer2::Plugin;
  2         245445  
  2         15  
13 2     2   47364 use HTML::TreeBuilder;
  2         57565  
  2         30  
14 2     2   584 use File::Spec::Functions qw(catfile);
  2         1030  
  2         160  
15 2     2   983 use Text::Markdown::Hoedown;
  2         2782  
  2         4285  
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 103 my $s = shift;
35              
36             # add routes from config file
37 1         3 foreach my $route (@{$s->config->{routes}}) {
  1         23  
38              
39             # validate arguments supplied from config file
40 2 50       4862 if ((ref $route) ne 'HASH') {
41 0         0 die 'Config file misconfigured. Check syntax or consult documentation.';
42             }
43              
44 2         8 $s->_set_options($route);
45 2         5 my %options = %{$s->options};
  2         16  
46             $s->app->add_route(
47             method => 'get',
48             regexp => '/' . $options{prefix} . $options{path},
49             code => sub {
50 2     2   204621 my ($html, $toc) = $s->md2html($options{resource}, \%options);
51             $s->app->template($options{template},
52             { html => $html, toc => $toc },
53 2         43 { layout => $options{layout} });
54             },
55 2         33 );
56             }
57             }
58              
59             sub _set_options {
60 7     7   23 my ($s, $options) = @_;
61              
62 7         34 my @settings = qw( cache layout template file_root prefix header_class
63             generate_toc exclude_files include_files linkable_headers markdown_extensions );
64              
65 7         26 my ($path) = keys %$options;
66 7         149 my $defaults = $s->config->{defaults};
67 7 100       119 my $local_options = (ref $options->{$path}) ? $options->{$path} : $options;
68 7         51 my %options = (%$defaults, %$local_options);
69              
70 7         25 foreach my $setting (@settings) {
71 77   100     2001 $options{$setting} = $options->{$setting} // $s->$setting;
72             }
73              
74 7         80 $options{set} = 1;
75 7         19 $options{path} = $path;
76 7 50       22 $options{prefix} .= '/' if $options{prefix};
77 7 100       22 $options{linkable_headers} = 1 if $options{generate_toc};
78              
79 7 50       87 if (!File::Spec->file_name_is_absolute($options{resource})) {
80 7         85 $options{resource} = File::Spec->catfile($options{file_root}, $options{resource});
81             }
82 7         68 $s->options(\%options);
83             }
84              
85             # Keyword for generating HTML
86             sub md2html {
87 7     7 1 62626 my ($s, $resource, $options) = @_;
88              
89             # If keyword called directly, options won't be set yet
90 7 100       28 if (!$options->{set}) {
91 5         18 $options->{resource} = $resource;
92 5         38 $s->_set_options($options);
93             } else {
94 2         15 $s->options($options);
95             }
96              
97             #TODO: return a 202 status code
98 7 100       199 if (!-e $s->options->{resource}) {
99             my $html = 'This route is not properly configured. Resource: '
100 1         13 . $s->options->{resource} . ' does not exist on the server.';
101 1         7 return ($html, undef);
102             }
103              
104 6         36 my @files = $s->_gather_files;
105 6         19 my ($html, $toc) = '';
106 6         36 foreach my $file (sort @files) {
107 66         267 my ($file_html, $file_toc) = $s->_mdfile_2html($file);
108 66         583 $html .= $file_html;
109 66         341 $toc .= $file_toc;
110             }
111 6 100       413 return wantarray ? ($html, $toc) : $html;
112             }
113              
114             sub _gather_files {
115 6     6   20 my $s = shift;
116              
117 6         14 my @files;
118 6 100       105 if (-f $s->options->{resource}) {
119 2         13 push @files, $s->options->{resource};
120             } else {
121 4         22 my $dir = $s->options->{resource};
122             # gather the files according the options supplied
123 4 50       9 if (@{$s->options->{include_files}}) {
  4         18  
124 0         0 my @files = @{$s->options->{include_files}};
  0         0  
125             } else {
126 4 50       149 opendir my $d, $dir or die "Cannot open directory: $!";
127 4         171 @files = grep { $_ !~ /^\./ } readdir $d;
  72         170  
128 4         66 closedir $d;
129 4         18 my @matching_files = ();
130 4         8 foreach my $md_ext (@{$s->options->{markdown_extensions}}) {
  4         26  
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         14 foreach my $excluded_file (@{$s->options->{exclude_files}}) {
  4         17  
137 0         0 @files = grep { $_ ne $excluded_file } @files;
  0         0  
138             }
139              
140 4         12 @files = map { File::Spec->catfile($dir, $_) } @files;
  64         387  
141             }
142 6         29 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 66     66   180 my $s = shift;
149 66         142 my $file = shift;
150              
151             # chop off extension
152 66         2886 my ($base) = fileparse($file, qr/\.[^.]*/);
153              
154             # generate the cache directory if it doesn't exist
155 66         3172 my $cache_dir = File::Spec->catfile(dirname($s->options->{'file_root'}), 'md_file_cache');
156 66 100       1361 if (!-d $cache_dir) {
157 1 50       191 make_path $cache_dir or die "Cannot make cache directory $!";
158             }
159              
160             # generate unique cache file name appended with options
161 66         1952 my $cache_file = dirname($file);
162 66         578 my $sep = File::Spec->catfile('', '');
163 66         469 $cache_file =~ s/\Q$sep\E//g;
164 66         326 my $header_classes = $s->options->{header_class};
165 66         203 $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 66         654 . $header_classes);
172              
173             # check for cache hit
174             # TODO: Save options in separate file so they can be compared
175 66 50 66     1718 if (-f $cache_file && $s->options->{cache}) {
176 16 50       461 if (-M $cache_file eq -M $file) {
177 16 50 33     140 print Dumper 'cache hit: '. $file if $ENV{DANCER_ENVIRONMENT} && $ENV{DANCER_ENVIRONMENT} eq 'testing';
178 16         1156 my $data = retrieve $cache_file;
179 16         1585 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         235 my $markdown = '';
186             {
187 50         98 local $/;
  50         323  
188 50 50       2303 open my $md, '<:encoding(UTF-8)', $file or die "Can't open $file: $!";
189 50         6807 $markdown = <$md>;
190 50         4139 close $md;
191             }
192 50         404 my $out = markdown($markdown, extensions => HOEDOWN_EXT_FENCED_CODE, toc_nesting_lvl => 0);
193 50         8096 my $tree = HTML::TreeBuilder->new_from_content($out);
194              
195 50         1720762 my @code_els = $tree->find_by_tag_name('code');
196 50         44509 foreach my $code_el (@code_els) {
197 2711 100 100     49602 if (!$code_el->left && !$code_el->right) {
198 345         5591 $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     1400 if (!$s->options->{linkable_headers} && !$s->options->{header_class}) {
204 17         63 my $html = $tree->guts->as_HTML;
205 17 50       248861 _cache_data($cache_file, $file, $html) if $s->options->{cache};
206 17         891 return ($html, '');
207             }
208              
209             # add linkable_headers, toc, and header_class per options
210 33         366 my @elements = $tree->look_down(_tag => qr/^h\d$/);
211 33 100       35600 my $toc = HTML::TreeBuilder->new() if $s->options->{generate_toc};
212 33         4279 my $hdr_ct = 0;
213 33         109 foreach my $element (@elements) {
214 246         3646 my $id = 'header_' . ${hdr_ct};
215 246         396 $hdr_ct++;
216 246         932 $element->attr('id', $id . '_' . $base);
217 246 100       3759 $element->attr('class' => $s->options->{header_class}) if $s->options->{header_class};
218 246 100       1920 if ($s->options->{generate_toc}) {
219 117         261 my ($level) = $element->tag =~ /(\d)/;
220 117         1191 my $toc_link = HTML::Element->new('a', href=> "#${id}_${base}", class => 'header_' . $level);
221 117         4017 $toc_link->push_content($element->as_text);
222 117         4817 $toc->push_content($toc_link);
223 117         1743 $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       703 my ($html, $toc_out) = ($tree->guts->as_HTML, $toc ? $toc->guts->as_HTML : '');
230 33         535120 _cache_data($cache_file, $file, $html, $toc_out);
231 33         2198 return ($html, $toc_out);
232             }
233              
234             sub _cache_data {
235 50     50   246 my ($cache_file, $file, $content, $toc) = @_;
236 50   100     227 $toc //= '';
237              
238 50         468 store { html => $content, toc => $toc }, $cache_file;
239 50         15939 my ($read, $write) = (stat($file))[8,9];
240 50         1126 utime($read, $write, $cache_file);
241             }
242              
243             1;
244              
245             __END__