File Coverage

blib/lib/Yancy/Backend/Static.pm
Criterion Covered Total %
statement 145 160 90.6
branch 47 64 73.4
condition 25 33 75.7
subroutine 22 22 100.0
pod 1 7 14.2
total 240 286 83.9


line stmt bran cond sub pod time code
1             package Yancy::Backend::Static;
2             our $VERSION = '0.013';
3             # ABSTRACT: Build a Yancy site from static Markdown files
4              
5             #pod =head1 SYNOPSIS
6             #pod
7             #pod use Mojolicious::Lite;
8             #pod plugin Yancy => {
9             #pod backend => 'static:.',
10             #pod read_schema => 1,
11             #pod };
12             #pod get '/*path', {
13             #pod controller => 'yancy',
14             #pod action => 'get',
15             #pod schema => 'pages',
16             #pod path => 'index', # Default to index page
17             #pod template => 'default', # default.html.ep below
18             #pod };
19             #pod app->start;
20             #pod __DATA__
21             #pod @@ default.html.ep
22             #pod % title $item->{title};
23             #pod <%== $item->{html} %>
24             #pod
25             #pod =head1 DESCRIPTION
26             #pod
27             #pod This L allows Yancy to work with a site made up of
28             #pod Markdown files with YAML frontmatter, like a L site. In other
29             #pod words, this module works with a flat-file database made up of YAML
30             #pod + Markdown files.
31             #pod
32             #pod =head2 Schemas
33             #pod
34             #pod You should configure the C schema to have all of the fields
35             #pod that could be in the frontmatter of your Markdown files. This is JSON Schema
36             #pod and will be validated, but if you're using the Yancy editor, make sure only
37             #pod to use L.
38             #pod
39             #pod =head2 Limitations
40             #pod
41             #pod This backend should support everything L supports, though
42             #pod some list() queries may not work (please make a pull request).
43             #pod
44             #pod =head2 Future Developments
45             #pod
46             #pod This backend could be enhanced to provide schema for static files
47             #pod (CSS, JavaScript, etc...) and templates.
48             #pod
49             #pod =head1 GETTING STARTED
50             #pod
51             #pod To get started using this backend to make a simple static website, first
52             #pod create a file called C with the following contents:
53             #pod
54             #pod #!/usr/bin/env perl
55             #pod use Mojolicious::Lite;
56             #pod plugin Yancy => {
57             #pod backend => 'static:.',
58             #pod read_schema => 1,
59             #pod };
60             #pod get '/*path', {
61             #pod controller => 'yancy',
62             #pod action => 'get',
63             #pod schema => 'pages',
64             #pod template => 'default',
65             #pod layout => 'default',
66             #pod path => 'index',
67             #pod };
68             #pod app->start;
69             #pod __DATA__
70             #pod @@ default.html.ep
71             #pod % title $item->{title};
72             #pod <%== $item->{html} %>
73             #pod @@ layouts/default.html.ep
74             #pod
75             #pod
76             #pod
77             #pod <%= title %>
78             #pod
79             #pod
80             #pod
81             #pod
82             #pod %= content
83             #pod
84             #pod
85             #pod
86             #pod
87             #pod
88             #pod
89             #pod Once this is done, run the development webserver using C
90             #pod daemon>:
91             #pod
92             #pod $ perl myapp.pl daemon
93             #pod Server available at http://127.0.0.1:3000
94             #pod
95             #pod Then open C in your web browser to see the
96             #pod L editor.
97             #pod
98             #pod =for html
99             #pod
100             #pod You should first create an C page by clicking the "Add Item"
101             #pod button to create a new page and giving the page a C of C.
102             #pod
103             #pod =for html
104             #pod
105             #pod Once this page is created, you can visit your new page either by
106             #pod clicking the "eye" icon on the left side of the table, or by navigating
107             #pod to L.
108             #pod
109             #pod =for html
110             #pod
111             #pod =head2 Adding Images and Files
112             #pod
113             #pod To add other files to your site (images, scripts, stylesheets, etc...),
114             #pod create a directory called C and put your file in there. All the
115             #pod files in the C folder are available to use in your website.
116             #pod
117             #pod To add an image using Markdown, use C.
118             #pod
119             #pod =head2 Customize Template and Layout
120             #pod
121             #pod The easiest way to customize the look of the site is to edit the layout
122             #pod template. Templates in Mojolicious can be in external files in
123             #pod a C directory, or they can be in the C script below
124             #pod C<__DATA__>.
125             #pod
126             #pod The layout your site uses currently is called
127             #pod C. The two main things to put in a layout are
128             #pod C<< <%= title %> >> for the page's title and C<< <%= content %> >> for
129             #pod the page's content. Otherwise, the layout can be used to add design and
130             #pod navigation for your site.
131             #pod
132             #pod =head1 ADVANCED FEATURES
133             #pod
134             #pod =head2 Custom Metadata Fields
135             #pod
136             #pod You can add additional metadata fields to your page by adding them to
137             #pod your schema, like so:
138             #pod
139             #pod plugin Yancy => {
140             #pod backend => 'static:.',
141             #pod read_schema => 1,
142             #pod schema => {
143             #pod pages => {
144             #pod properties => {
145             #pod # Add an optional 'author' field
146             #pod author => { type => [ 'string', 'null' ] },
147             #pod },
148             #pod },
149             #pod },
150             #pod };
151             #pod
152             #pod These additional fields can be used in your template through the
153             #pod C<$item> hash reference (C<< $item->{author} >>). See
154             #pod L for more information about configuring a schema.
155             #pod
156             #pod =head2 Character Encoding
157             #pod
158             #pod By default, this backend detects the locale of your current environment
159             #pod and assumes the files you read and write should be in that encoding. If
160             #pod this is incorrect (if, for example, you always want to read/write UTF-8
161             #pod files), add a C to the backend string:
162             #pod
163             #pod use Mojolicious::Lite;
164             #pod plugin Yancy => {
165             #pod backend => 'static:.?encoding=UTF-8',
166             #pod read_schema => 1,
167             #pod };
168             #pod
169             #pod =head1 SEE ALSO
170             #pod
171             #pod L, L
172             #pod
173             #pod =cut
174              
175 2     2   353160 use Mojo::Base -base;
  2         12  
  2         12  
176 2     2   338 use Mojo::File;
  2         4  
  2         59  
177 2     2   1159 use Text::Markdown;
  2         32195  
  2         81  
178 2     2   767 use YAML ();
  2         11663  
  2         51  
179 2     2   614 use JSON::PP ();
  2         11407  
  2         47  
180 2     2   438 use Yancy::Util qw( match order_by );
  2         15060  
  2         111  
181              
182             # Can't use open ':locale' because it caches the current locale (so it
183             # won't work in tests unless we create a new process with the changed
184             # locale...)
185 2     2   818 use I18N::Langinfo qw( langinfo CODESET );
  2         1109  
  2         135  
186 2     2   12 use Encode qw( encode decode );
  2         4  
  2         5064  
187              
188             has schema => sub { +{} };
189             has path =>;
190             has markdown_parser => sub { Text::Markdown->new };
191             has encoding => sub { langinfo( CODESET ) };
192              
193             sub new {
194 2     2 1 808 my ( $class, $backend, $schema ) = @_;
195 2         9 my ( undef, $path ) = split /:/, $backend, 2;
196 2         9 $path =~ s/^([^?]+)\?(.+)$/$1/;
197 2   100     14 my %attrs = map { split /=/ } split /\&/, $2 // '';
  1         4  
198 2         12 return $class->SUPER::new( {
199             %attrs,
200             path => Mojo::File->new( $path ),
201             ( schema => $schema )x!!$schema,
202             } );
203             }
204              
205             sub create {
206 3     3 0 755 my ( $self, $schema, $params ) = @_;
207              
208 3         8 my $path = $self->path->child( $self->_id_to_path( $params->{path} ) );
209 3         55 $self->_write_file( $path, $params );
210 3         16 return $params->{path};
211             }
212              
213             sub get {
214 13     13 0 727111 my ( $self, $schema, $id ) = @_;
215              
216             # Allow directory path to work. Must have a trailing slash to ensure
217             # that relative links in the file work correctly.
218 13 100 66     75 if ( $id =~ m{/$} && -d $self->path->child( $id ) ) {
219 2         102 $id .= 'index.markdown';
220             }
221             else {
222             # Clean up the input path
223 11         29 $id =~ s/\.\w+$//;
224 11         28 $id .= '.markdown';
225             }
226              
227 13         45 my $path = $self->path->child( $id );
228             #; say "Getting path $id: $path";
229 13 100       331 return undef unless -f $path;
230              
231 11         282 my $item = eval { $self->_read_file( $path ) };
  11         136  
232 11 50       47 if ( $@ ) {
233 0         0 warn sprintf 'Could not load file %s: %s', $path, $@;
234 0         0 return undef;
235             }
236 11         43 $item->{path} = $self->_path_to_id( $path->to_rel( $self->path ) );
237 11         459 $self->_normalize_item( $schema, $item );
238 11         57 return $item;
239             }
240              
241             sub _normalize_item {
242 29     29   53 my ( $self, $schema_name, $item ) = @_;
243 29 100       69 return unless my $schema = $self->schema->{ $schema_name };
244 9         59 for my $prop_name ( keys %{ $item } ) {
  9         32  
245 36 100       78 next unless my $prop = $schema->{ properties }{ $prop_name };
246 34 50 33     83 if ( $prop->{type} eq 'array' && ref $item->{ $prop_name } ne 'ARRAY' ) {
247 0         0 $item->{ $prop_name } = [ $item->{ $prop_name } ];
248             }
249             }
250             }
251              
252             sub list {
253 8     8 0 106141 my ( $self, $schema, $params, $opt ) = @_;
254 8   100     33 $params ||= {};
255 8   100     24 $opt ||= {};
256              
257 8         13 my @items;
258 8         13 my $total = 0;
259 8         26 PATH: for my $path ( sort $self->path->list_tree->each ) {
260 22 100       2810 next unless $path =~ /[.](?:markdown|md)$/;
261 18         137 my $item = eval { $self->_read_file( $path ) };
  18         41  
262 18 50       65 if ( $@ ) {
263 0         0 warn sprintf 'Could not load file %s: %s', $path, $@;
264 0         0 next;
265             }
266 18         61 $item->{path} = $self->_path_to_id( $path->to_rel( $self->path ) );
267 18         571 $self->_normalize_item( $schema, $item );
268 18 100       118 next unless match( $params, $item );
269 16         229 push @items, $item;
270 16         28 $total++;
271             }
272              
273 8   100     53 $opt->{order_by} //= 'path';
274 8         25 my $ordered_items = order_by( $opt->{order_by}, \@items );
275              
276 8   100     203 my $start = $opt->{offset} // 0;
277 8 100       22 my $end = $opt->{limit} ? $start + $opt->{limit} - 1 : $#items;
278 8 100       20 if ( $end > $#items ) {
279 1         2 $end = $#items;
280             }
281              
282             return {
283 8         17 items => [ @{$ordered_items}[ $start .. $end ] ],
  8         59  
284             total => $total,
285             };
286             }
287              
288             sub set {
289 3     3 0 2170 my ( $self, $schema, $id, $params ) = @_;
290 3         10 my $path = $self->path->child( $self->_id_to_path( $id ) );
291             # Load the current file to turn a partial set into a complete
292             # set
293             my %item = (
294 3 100       60 -f $path ? %{ $self->_read_file( $path ) } : (),
  2         44  
295             %$params,
296             );
297              
298 3 100       46 if ( $params->{path} ) {
299 2         8 my $new_path = $self->path->child( $self->_id_to_path( $params->{path} ) );
300 2 100 66     39 if ( -f $path and $new_path ne $path ) {
301 1         33 $path->remove;
302             }
303 2         78 $path = $new_path;
304             }
305 3         30 $self->_write_file( $path, \%item );
306 3         19 return 1;
307             }
308              
309             sub delete {
310 2     2 0 1560 my ( $self, $schema, $id ) = @_;
311 2         7 return !!unlink $self->path->child( $self->_id_to_path( $id ) );
312             }
313              
314             sub read_schema {
315 1     1 0 49 my ( $self, @schemas ) = @_;
316 1         20 my %page_schema = (
317             type => 'object',
318             title => 'Pages',
319             required => [qw( path markdown )],
320             'x-id-field' => 'path',
321             'x-view-item-url' => '/{path}',
322             'x-list-columns' => [ 'title', 'path' ],
323             properties => {
324             path => {
325             type => 'string',
326             'x-order' => 2,
327             },
328             title => {
329             type => 'string',
330             'x-order' => 1,
331             },
332             markdown => {
333             type => 'string',
334             format => 'markdown',
335             'x-html-field' => 'html',
336             'x-order' => 3,
337             },
338             html => {
339             type => 'string',
340             },
341             },
342             );
343 1 50       6 return @schemas ? \%page_schema : { pages => \%page_schema };
344             }
345              
346             sub _id_to_path {
347 10     10   60 my ( $self, $id ) = @_;
348             # Allow indexes to be created
349 10 100       44 if ( $id =~ m{(?:^|\/)index$} ) {
    50          
350 2         5 $id .= '.markdown';
351             }
352             # Allow full file paths to be created
353             elsif ( $id =~ m{\.\w+$} ) {
354 0         0 $id =~ s{\.\w+$}{.markdown};
355             }
356             # Anything else should create a file
357             else {
358 8         17 $id .= '.markdown';
359             }
360 10         34 return $id;
361             }
362              
363             sub _path_to_id {
364 29     29   2528 my ( $self, $path ) = @_;
365 29         86 my $dir = $path->dirname;
366 29         1074 $dir =~ s/^\.//;
367 29         328 return join '/', grep !!$_, $dir, $path->basename( '.markdown' );
368             }
369              
370             sub _read_file {
371 31     31   62 my ( $self, $path ) = @_;
372 31 50       247 open my $fh, '<', $path or die "Could not open $path for reading: $!";
373 31         1172 local $/;
374 31         112 return $self->_parse_content( decode( $self->encoding, scalar <$fh>, Encode::FB_CROAK ) );
375             }
376              
377             sub _write_file {
378 6     6   14 my ( $self, $path, $item ) = @_;
379 6 50       16 if ( !-d $path->dirname ) {
380 0         0 $path->dirname->make_path;
381             }
382             #; say "Writing to $path:\n$content";
383 6 50       424 open my $fh, '>', $path
384             or die "Could not open $path for overwriting: $!";
385 6         376 print $fh encode( $self->encoding, $self->_deparse_content( $item ), Encode::FB_CROAK );
386 6         14710 return;
387             }
388              
389             #=sub _parse_content
390             #
391             # my $item = $backend->_parse_content( $path->slurp );
392             #
393             # Parse a file's frontmatter and Markdown. Returns a hashref
394             # ready for use as an item.
395             #
396             #=cut
397              
398             sub _parse_content {
399 31     31   2328 my ( $self, $content ) = @_;
400 31         53 my %item;
401              
402 31         161 my @lines = split /\n/, $content;
403             # YAML frontmatter
404 31 100 66     234 if ( @lines && $lines[0] =~ /^---/ ) {
    50 33        
405              
406             # The next --- is the end of the YAML frontmatter
407 28         73 my ( $i ) = grep { $lines[ $_ ] =~ /^---/ } 1..$#lines;
  129         256  
408              
409             # If we did not find the marker between YAML and Markdown
410 28 50       88 if ( !defined $i ) {
411 0         0 die qq{Could not find end of YAML front matter (---)\n};
412             }
413              
414             # Before the marker is YAML
415 28         42 eval {
416 28         40 %item = %{ YAML::Load( join "\n", splice( @lines, 0, $i ), "" ) };
  28         139  
417 28         54082 %item = map {$_ => do {
  39         52  
418             # YAML.pm 1.29 doesn't parse 'true', 'false' as booleans
419             # like the schema suggests: https://yaml.org/spec/1.2/spec.html#id2803629
420 39         77 my $v = $item{$_};
421 39 100 100     165 $v = JSON::PP::false if $v and $v eq 'false';
422 39 50 66     145 $v = JSON::PP::true if $v and $v eq 'true';
423 39         202 $v
424             }} keys %item;
425             };
426 28 50       64 if ( $@ ) {
427 0         0 die qq{Error parsing YAML\n$@};
428             }
429              
430             # Remove the last '---' mark
431 28         49 shift @lines;
432             }
433             # JSON frontmatter
434             elsif ( @lines && $lines[0] =~ /^{/ ) {
435 3         7 my $json;
436 3 50       17 if ( $lines[0] =~ /\}$/ ) {
437             # The JSON is all on a single line
438 3         7 $json = shift @lines;
439             }
440             else {
441             # The } on a line by itself is the last line of JSON
442 0         0 my ( $i ) = grep { $lines[ $_ ] =~ /^}$/ } 0..$#lines;
  0         0  
443             # If we did not find the marker between YAML and Markdown
444 0 0       0 if ( !defined $i ) {
445 0         0 die qq{Could not find end of JSON front matter (\})\n};
446             }
447 0         0 $json = join "\n", splice( @lines, 0, $i+1 );
448             }
449 3         7 eval {
450 3         7 %item = %{ JSON::PP->new()->utf8(0)->decode( $json ) };
  3         28  
451             };
452 3 50       1038 if ( $@ ) {
453 0         0 die qq{Error parsing JSON: $@\n};
454             }
455             }
456              
457             # The remaining lines are content
458 31         96 $item{ markdown } = join "\n", @lines, "";
459 31         105 $item{ html } = $self->markdown_parser->markdown( $item{ markdown } );
460              
461 31         38978 return \%item;
462             }
463              
464             sub _deparse_content {
465 6     6   37 my ( $self, $item ) = @_;
466             my %data =
467 7         36 map { $_ => do {
468 7         14 my $v = $item->{ $_ };
469 7 50       19 JSON::PP::is_bool($v) ? $v ? 'true' : 'false' : $v
    100          
470             }}
471 6         23 grep { !/^(?:markdown|html|path)$/ }
  19         69  
472             keys %$item;
473 6 100 100     103 return ( %data ? YAML::Dump( \%data ) . "---\n" : "") . ( $item->{markdown} // "" );
474             }
475              
476             1;
477              
478             __END__