File Coverage

blib/lib/Yancy/Backend/Static.pm
Criterion Covered Total %
statement 137 151 90.7
branch 42 58 72.4
condition 24 30 80.0
subroutine 21 21 100.0
pod 1 7 14.2
total 225 267 84.2


line stmt bran cond sub pod time code
1             package Yancy::Backend::Static;
2             our $VERSION = '0.012';
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 '/*id', {
13             #pod controller => 'yancy',
14             #pod action => 'get',
15             #pod schema => 'pages',
16             #pod id => '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 '/*id', {
61             #pod controller => 'yancy',
62             #pod action => 'get',
63             #pod schema => 'pages',
64             #pod template => 'default',
65             #pod layout => 'default',
66             #pod id => '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   410034 use Mojo::Base -base;
  2         18  
  2         15  
176 2     2   375 use Mojo::File;
  2         6  
  2         85  
177 2     2   1265 use Text::Markdown;
  2         37596  
  2         132  
178 2     2   902 use YAML ();
  2         13719  
  2         46  
179 2     2   728 use JSON::PP ();
  2         13476  
  2         52  
180 2     2   541 use Yancy::Util qw( match order_by );
  2         17202  
  2         160  
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   965 use I18N::Langinfo qw( langinfo CODESET );
  2         1245  
  2         180  
186 2     2   15 use Encode qw( encode decode );
  2         4  
  2         5062  
187              
188             has schema =>;
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 822 my ( $class, $backend, $schema ) = @_;
195 2         11 my ( undef, $path ) = split /:/, $backend, 2;
196 2         10 $path =~ s/^([^?]+)\?(.+)$/$1/;
197 2   100     17 my %attrs = map { split /=/ } split /\&/, $2 // '';
  1         5  
198 2         16 return $class->SUPER::new( {
199             %attrs,
200             path => Mojo::File->new( $path ),
201             schema => $schema,
202             } );
203             }
204              
205             sub create {
206 3     3 0 923 my ( $self, $schema, $params ) = @_;
207              
208 3         9 my $path = $self->path->child( $self->_id_to_path( $params->{path} ) );
209 3         73 $self->_write_file( $path, $params );
210 3         20 return $params->{path};
211             }
212              
213             sub get {
214 13     13 0 703892 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     97 if ( $id =~ m{/$} && -d $self->path->child( $id ) ) {
219 2         116 $id .= 'index.markdown';
220             }
221             else {
222             # Clean up the input path
223 11         38 $id =~ s/\.\w+$//;
224 11         31 $id .= '.markdown';
225             }
226              
227 13         58 my $path = $self->path->child( $id );
228             #; say "Getting path $id: $path";
229 13 100       390 return undef unless -f $path;
230              
231 11         317 my $item = eval { $self->_read_file( $path ) };
  11         140  
232 11 50       55 if ( $@ ) {
233 0         0 warn sprintf 'Could not load file %s: %s', $path, $@;
234 0         0 return undef;
235             }
236 11         51 $item->{path} = $self->_path_to_id( $path->to_rel( $self->path ) );
237 11         543 return $item;
238             }
239              
240             sub list {
241 8     8 0 123158 my ( $self, $schema, $params, $opt ) = @_;
242 8   100     38 $params ||= {};
243 8   100     35 $opt ||= {};
244              
245 8         15 my @items;
246 8         12 my $total = 0;
247 8         29 PATH: for my $path ( sort $self->path->list_tree->each ) {
248 22 100       3276 next unless $path =~ /[.](?:markdown|md)$/;
249 18         169 my $item = eval { $self->_read_file( $path ) };
  18         48  
250 18 50       77 if ( $@ ) {
251 0         0 warn sprintf 'Could not load file %s: %s', $path, $@;
252 0         0 next;
253             }
254 18         72 $item->{path} = $self->_path_to_id( $path->to_rel( $self->path ) );
255 18 100       701 next unless match( $params, $item );
256 16         249 push @items, $item;
257 16         33 $total++;
258             }
259              
260 8   100     73 $opt->{order_by} //= 'path';
261 8         33 my $ordered_items = order_by( $opt->{order_by}, \@items );
262              
263 8   100     265 my $start = $opt->{offset} // 0;
264 8 100       30 my $end = $opt->{limit} ? $start + $opt->{limit} - 1 : $#items;
265 8 100       29 if ( $end > $#items ) {
266 1         3 $end = $#items;
267             }
268              
269             return {
270 8         20 items => [ @{$ordered_items}[ $start .. $end ] ],
  8         70  
271             total => $total,
272             };
273             }
274              
275             sub set {
276 3     3 0 2793 my ( $self, $schema, $id, $params ) = @_;
277 3         11 my $path = $self->path->child( $self->_id_to_path( $id ) );
278             # Load the current file to turn a partial set into a complete
279             # set
280             my %item = (
281 3 100       74 -f $path ? %{ $self->_read_file( $path ) } : (),
  2         50  
282             %$params,
283             );
284              
285 3 100       51 if ( $params->{path} ) {
286 2         8 my $new_path = $self->path->child( $self->_id_to_path( $params->{path} ) );
287 2 100 66     46 if ( -f $path and $new_path ne $path ) {
288 1         63 $path->remove;
289             }
290 2         99 $path = $new_path;
291             }
292 3         15 $self->_write_file( $path, \%item );
293 3         21 return 1;
294             }
295              
296             sub delete {
297 2     2 0 1921 my ( $self, $schema, $id ) = @_;
298 2         10 return !!unlink $self->path->child( $self->_id_to_path( $id ) );
299             }
300              
301             sub read_schema {
302 1     1 0 63 my ( $self, @schemas ) = @_;
303 1         25 my %page_schema = (
304             type => 'object',
305             title => 'Pages',
306             required => [qw( path markdown )],
307             'x-id-field' => 'path',
308             'x-view-item-url' => '/{path}',
309             'x-list-columns' => [ 'title', 'path' ],
310             properties => {
311             path => {
312             type => 'string',
313             'x-order' => 2,
314             },
315             title => {
316             type => 'string',
317             'x-order' => 1,
318             },
319             markdown => {
320             type => 'string',
321             format => 'markdown',
322             'x-html-field' => 'html',
323             'x-order' => 3,
324             },
325             html => {
326             type => 'string',
327             },
328             },
329             );
330 1 50       7 return @schemas ? \%page_schema : { pages => \%page_schema };
331             }
332              
333             sub _id_to_path {
334 10     10   71 my ( $self, $id ) = @_;
335             # Allow indexes to be created
336 10 100       56 if ( $id =~ m{(?:^|\/)index$} ) {
    50          
337 2         5 $id .= '.markdown';
338             }
339             # Allow full file paths to be created
340             elsif ( $id =~ m{\.\w+$} ) {
341 0         0 $id =~ s{\.\w+$}{.markdown};
342             }
343             # Anything else should create a file
344             else {
345 8         20 $id .= '.markdown';
346             }
347 10         58 return $id;
348             }
349              
350             sub _path_to_id {
351 29     29   3335 my ( $self, $path ) = @_;
352 29         104 my $dir = $path->dirname;
353 29         1276 $dir =~ s/^\.//;
354 29         370 return join '/', grep !!$_, $dir, $path->basename( '.markdown' );
355             }
356              
357             sub _read_file {
358 31     31   79 my ( $self, $path ) = @_;
359 31 50       282 open my $fh, '<', $path or die "Could not open $path for reading: $!";
360 31         1376 local $/;
361 31         140 return $self->_parse_content( decode( $self->encoding, scalar <$fh>, Encode::FB_CROAK ) );
362             }
363              
364             sub _write_file {
365 6     6   18 my ( $self, $path, $item ) = @_;
366 6 50       21 if ( !-d $path->dirname ) {
367 0         0 $path->dirname->make_path;
368             }
369             #; say "Writing to $path:\n$content";
370 6 50       476 open my $fh, '>', $path
371             or die "Could not open $path for overwriting: $!";
372 6         451 print $fh encode( $self->encoding, $self->_deparse_content( $item ), Encode::FB_CROAK );
373 6         17767 return;
374             }
375              
376             #=sub _parse_content
377             #
378             # my $item = $backend->_parse_content( $path->slurp );
379             #
380             # Parse a file's frontmatter and Markdown. Returns a hashref
381             # ready for use as an item.
382             #
383             #=cut
384              
385             sub _parse_content {
386 31     31   2750 my ( $self, $content ) = @_;
387 31         62 my %item;
388              
389 31         192 my @lines = split /\n/, $content;
390             # YAML frontmatter
391 31 100 66     261 if ( @lines && $lines[0] =~ /^---/ ) {
    50 33        
392              
393             # The next --- is the end of the YAML frontmatter
394 28         84 my ( $i ) = grep { $lines[ $_ ] =~ /^---/ } 1..$#lines;
  129         330  
395              
396             # If we did not find the marker between YAML and Markdown
397 28 50       80 if ( !defined $i ) {
398 0         0 die qq{Could not find end of YAML front matter (---)\n};
399             }
400              
401             # Before the marker is YAML
402 28         53 eval {
403 28         45 %item = %{ YAML::Load( join "\n", splice( @lines, 0, $i ), "" ) };
  28         163  
404 28         66248 %item = map {$_ => do {
  39         64  
405             # YAML.pm 1.29 doesn't parse 'true', 'false' as booleans
406             # like the schema suggests: https://yaml.org/spec/1.2/spec.html#id2803629
407 39         91 my $v = $item{$_};
408 39 100 100     211 $v = JSON::PP::false if $v and $v eq 'false';
409 39 50 66     187 $v = JSON::PP::true if $v and $v eq 'true';
410 39         259 $v
411             }} keys %item;
412             };
413 28 50       79 if ( $@ ) {
414 0         0 die qq{Error parsing YAML\n$@};
415             }
416              
417             # Remove the last '---' mark
418 28         54 shift @lines;
419             }
420             # JSON frontmatter
421             elsif ( @lines && $lines[0] =~ /^{/ ) {
422 3         7 my $json;
423 3 50       20 if ( $lines[0] =~ /\}$/ ) {
424             # The JSON is all on a single line
425 3         10 $json = shift @lines;
426             }
427             else {
428             # The } on a line by itself is the last line of JSON
429 0         0 my ( $i ) = grep { $lines[ $_ ] =~ /^}$/ } 0..$#lines;
  0         0  
430             # If we did not find the marker between YAML and Markdown
431 0 0       0 if ( !defined $i ) {
432 0         0 die qq{Could not find end of JSON front matter (\})\n};
433             }
434 0         0 $json = join "\n", splice( @lines, 0, $i+1 );
435             }
436 3         6 eval {
437 3         9 %item = %{ JSON::PP->new()->utf8(0)->decode( $json ) };
  3         30  
438             };
439 3 50       1433 if ( $@ ) {
440 0         0 die qq{Error parsing JSON: $@\n};
441             }
442             }
443              
444             # The remaining lines are content
445 31         123 $item{ markdown } = join "\n", @lines, "";
446 31         113 $item{ html } = $self->markdown_parser->markdown( $item{ markdown } );
447              
448 31         47033 return \%item;
449             }
450              
451             sub _deparse_content {
452 6     6   46 my ( $self, $item ) = @_;
453             my %data =
454 7         73 map { $_ => do {
455 7         18 my $v = $item->{ $_ };
456 7 50       22 JSON::PP::is_bool($v) ? $v ? 'true' : 'false' : $v
    100          
457             }}
458 6         28 grep { !/^(?:markdown|html|path)$/ }
  19         82  
459             keys %$item;
460 6 100 100     105 return ( %data ? YAML::Dump( \%data ) . "---\n" : "") . ( $item->{markdown} // "" );
461             }
462              
463             1;
464              
465             __END__