File Coverage

blib/lib/Statocles/Store.pm
Criterion Covered Total %
statement 157 158 99.3
branch 57 60 95.0
condition 16 20 80.0
subroutine 25 25 100.0
pod 12 14 85.7
total 267 277 96.3


line stmt bran cond sub pod time code
1             package Statocles::Store;
2             our $VERSION = '0.085';
3             # ABSTRACT: The source for data documents and files
4              
5 68     68   436 use Statocles::Base 'Class';
  68         146  
  68         640  
6 68     68   493710 use Scalar::Util qw( weaken blessed );
  68         166  
  68         4288  
7 68     68   429 use Statocles::Util qw( derp );
  68         162  
  68         3047  
8 68     68   28457 use Statocles::Document;
  68         250  
  68         2176  
9 68     68   17134 use YAML;
  68         328907  
  68         3578  
10 68     68   510 use JSON::PP;
  68         144  
  68         3980  
11 68     68   18751 use File::Spec::Functions qw( splitdir );
  68         45009  
  68         3768  
12 68     68   448 use Module::Runtime qw( use_module );
  68         135  
  68         651  
13              
14             # A hash of PATH => COUNT for all the open store paths. Stores are not allowed to
15             # discover the files or documents of other stores (unless the two stores have the same
16             # path)
17             my %FILE_STORES = ();
18              
19             #pod =attr path
20             #pod
21             #pod The path to the directory containing the L<documents|Statocles::Document>.
22             #pod
23             #pod =cut
24              
25             has path => (
26             is => 'ro',
27             isa => AbsPath,
28             coerce => AbsPath->coercion,
29             required => 1,
30             );
31              
32             #pod =attr document_extensions
33             #pod
34             #pod An array of file extensions that should be considered documents. Defaults to
35             #pod "markdown" and "md".
36             #pod
37             #pod =cut
38              
39             has document_extensions => (
40             is => 'ro',
41             isa => ArrayRef[Str],
42             default => sub { [qw( markdown md )] },
43             coerce => sub {
44             my ( $ext ) = @_;
45             if ( !ref $ext ) {
46             return [ split /[, ]/, $ext ];
47             }
48             return $ext;
49             },
50             );
51              
52             #pod =attr documents
53             #pod
54             #pod All the L<documents|Statocles::Document> currently read by this store.
55             #pod
56             #pod =method clear
57             #pod
58             #pod $store->clear;
59             #pod
60             #pod Clear the cached documents in this Store.
61             #pod
62             #pod =cut
63              
64             has documents => (
65             is => 'rw',
66             isa => ArrayRef[InstanceOf['Statocles::Document']],
67             lazy => 1,
68             builder => 'read_documents',
69             clearer => 'clear',
70             );
71              
72             # Cache our realpath in case it disappears before we get demolished
73             has _realpath => (
74             is => 'ro',
75             isa => Path,
76             lazy => 1,
77             default => sub { $_[0]->path->realpath },
78             );
79              
80             # If true, we've already checked if this store's path exists. We need to
81             # check this lazily to ensure the site is created and the logger is
82             # ready to go.
83             #
84             # XXX: Making sure the logger is ready before the thing that needs it is
85             # the entire reason that dependency injection exists. We should use the
86             # container to make sure the logger is wired up with every object that
87             # needs it...
88             has _check_exists => (
89             is => 'rw',
90             isa => Bool,
91             lazy => 1,
92             default => sub {
93             my ( $self ) = @_;
94             if ( !$self->path->exists ) {
95             site->log->warn( sprintf qq{Store path "%s" does not exist}, $self->path );
96             }
97             return 1;
98             },
99             );
100              
101             sub BUILD {
102 896     896 0 240010 my ( $self ) = @_;
103 896         15786 $FILE_STORES{ $self->_realpath }++;
104             }
105              
106             sub DEMOLISH {
107 602     602 0 635039 my ( $self, $in_global_destruction ) = @_;
108 602 50       2068 return if $in_global_destruction; # We're ending, we don't need to care anymore
109 602 100       9738 if ( --$FILE_STORES{ $self->_realpath } <= 0 ) {
110 504         18975 delete $FILE_STORES{ $self->_realpath };
111             }
112             }
113              
114             #pod =method read_documents
115             #pod
116             #pod my $docs = $store->read_documents;
117             #pod
118             #pod Read the directory C<path> and create the L<document
119             #pod objects|Statocles::Document> inside. Returns an arrayref of document objects.
120             #pod
121             #pod =cut
122              
123             sub read_documents {
124 12     12 1 291939 my ( $self ) = @_;
125 12         212 $self->_check_exists;
126 12         298 my $root_path = $self->path;
127 12         22 my @docs;
128 12         66 my $iter = $root_path->iterator( { recurse => 1, follow_symlinks => 1 } );
129 12         375 while ( my $path = $iter->() ) {
130 116 100       11598 next unless $path->is_file;
131 86 100       1010 next unless $self->_is_owned_path( $path );
132 82 50       234 next unless $self->is_document( $path );
133 82         792 my $rel_path = rootdir->child( $path->relative( $root_path ) );
134 82         20286 push @docs, $self->read_document( $rel_path );
135             }
136 6         352 return \@docs;
137             }
138              
139             sub _is_owned_path {
140 3247     3247   5999 my ( $self, $path ) = @_;
141 3247         69731 my $self_path = $self->_realpath;
142 3247         26801 $path = $path->realpath;
143 3247         482154 my $dir = $path->parent;
144 3247         166847 for my $store_path ( keys %FILE_STORES ) {
145             # This is us!
146 23086 100       115471 next if $store_path eq $self_path;
147             # If our store is contained inside this store's path, we win
148 20187 100       233753 next if $self_path =~ /^\Q$store_path/;
149 19520 100       216134 return 0 if $path =~ /^\Q$store_path/;
150             }
151 2734         20303 return 1;
152             }
153              
154             #pod =method read_document
155             #pod
156             #pod my $doc = $store->read_document( $path )
157             #pod
158             #pod Read a single L<document|Statocles::Document> in Markdown with optional YAML
159             #pod or JSON frontmatter.
160             #pod
161             #pod =cut
162              
163             sub read_document {
164 583     583 1 14337 my ( $self, $path ) = @_;
165 583         3492 site->log->debug( "Read document: " . $path );
166 583         32146 my $full_path = $self->path->child( $path );
167 583         22326 my $relative_path = $full_path->relative( cwd );
168 583         136642 my %doc = $self->parse_frontmatter( $relative_path, $full_path->slurp_utf8 );
169 579 100       2248 my $class = $doc{class} ? use_module( delete $doc{class} ) : 'Statocles::Document';
170 579         1242 my $obj = eval { $class->new( %doc, path => $path, store => $self ) };
  579         15503  
171 579 100       30949 if ( $@ ) {
172 2 50 33     30 if ( ref $@ && $@->isa( 'Error::TypeTiny::Assertion' ) ) {
173 2 100       9 if ( $@->attribute_name eq 'date' ) {
174 1         8 die sprintf qq{Could not parse date "%s" in "%s": Does not match "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS"\n},
175             $@->value,
176             $relative_path;
177             }
178              
179 1         8 die sprintf qq{Error creating document in "%s": Value "%s" is not valid for attribute "%s" (expected "%s")\n},
180             $relative_path,
181             $@->value,
182             $@->attribute_name,
183             $@->type;
184             }
185             else {
186 0         0 die sprintf qq{Error creating document in "%s": %s\n},
187             $@;
188             }
189             }
190 577         4832 return $obj;
191             }
192              
193             #pod =method parse_frontmatter
194             #pod
195             #pod my %doc_attrs = $store->parse_frontmatter( $from, $content )
196             #pod
197             #pod Parse a document with YAML frontmatter. $from is a string identifying where the
198             #pod content comes from (a path or other identifier). $content is the content to
199             #pod parse for frontmatter.
200             #pod
201             #pod =cut
202              
203             sub parse_frontmatter {
204 592     592 1 94745 my ( $self, $from, $content ) = @_;
205 592 100       1729 return unless $content;
206 586         981 my $doc;
207              
208 586         5307 my @lines = split /\n/, $content;
209 586 100 100     4340 if ( @lines && $lines[0] =~ /^---/ ) {
    100 100        
210 566         1095 shift @lines;
211              
212             # The next --- is the end of the YAML frontmatter
213 566         1898 my ( $i ) = grep { $lines[ $_ ] =~ /^---/ } 0..$#lines;
  6582         11731  
214              
215             # If we did not find the marker between YAML and Markdown
216 566 100       1523 if ( !defined $i ) {
217 1         10 die qq{Could not find end of YAML front matter (---) in "$from"\n};
218             }
219              
220             # Before the marker is YAML
221 565         927 eval {
222 565         3903 $doc = YAML::Load( join "\n", splice( @lines, 0, $i ), "" );
223             };
224 565 100       1228143 if ( $@ ) {
225 1         9 die qq{Error parsing YAML in "$from"\n$@};
226             }
227              
228             # Remove the last '---' mark
229 564         1344 shift @lines;
230             }
231             elsif ( @lines && $lines[0] =~ /^{/ ) {
232 12         22 my $json;
233 12 100       50 if ( $lines[0] =~ /\}$/ ) {
234             # The JSON is all on a single line
235 5         11 $json = shift @lines;
236             }
237             else {
238             # The } on a line by itself is the last line of JSON
239 7         23 my ( $i ) = grep { $lines[ $_ ] =~ /^}$/ } 0..$#lines;
  33         96  
240             # If we did not find the marker between YAML and Markdown
241 7 100       26 if ( !defined $i ) {
242 1         4 die qq{Could not find end of JSON front matter (\}) in "$from"\n};
243             }
244 6         30 $json = join "\n", splice( @lines, 0, $i+1 );
245             }
246 11         22 eval {
247 11         50 $doc = decode_json( $json );
248             };
249 11 100       5823 if ( $@ ) {
250 1         6 die qq{Error parsing JSON in "$from"\n$@};
251             }
252             }
253              
254 582         3028 $doc->{content} = join "\n", @lines, "";
255              
256 582         4704 return %$doc;
257             }
258              
259             #pod =method write_document
260             #pod
261             #pod $store->write_document( $path, $doc );
262             #pod
263             #pod Write a L<document|Statocles::Document> to the store at the given store path.
264             #pod
265             #pod The document is written in Frontmatter format.
266             #pod
267             #pod =cut
268              
269             sub write_document {
270 10     10 1 16202 my ( $self, $path, $doc ) = @_;
271 10         56 $path = Path->coercion->( $path ); # Allow stringified paths, $path => $doc
272 10 100       873 if ( $path->is_absolute ) {
273 1         29 die "Cannot write document '$path': Path must not be absolute";
274             }
275 9         349 site->log->debug( "Write document: " . $path );
276              
277 9         986 $doc = { %{ $doc } }; # Shallow copy for safety
  9         48  
278 9   100     61 my $content = delete( $doc->{content} ) // '';
279 9         46 my $header = YAML::Dump( $self->_freeze_document( $doc ) );
280 9         39077 chomp $header;
281              
282 9         79 my $full_path = $self->path->child( $path );
283 9         456 $full_path->touchpath->spew_utf8( join "\n", $header, '---', $content );
284              
285 9 100       9570 if ( defined wantarray ) {
286 2         11 derp "Statocles::Store->write_document returning a value is deprecated and will be removed in v1.0. Use Statocles::Store->path to find the full path to the document.";
287             }
288 9         57 return $full_path;
289             }
290              
291             sub _freeze_document {
292 9     9   23 my ( $self, $doc ) = @_;
293 9         22 delete $doc->{path}; # Path should not be in the document
294 9         20 delete $doc->{store};
295 9 100       33 if ( exists $doc->{date} ) {
296 3         19 $doc->{date} = $doc->{date}->strftime('%Y-%m-%d %H:%M:%S');
297             }
298 9         363 for my $hash_type ( qw( links images ) ) {
299 18 100 66     68 if ( exists $doc->{ $hash_type } && !keys %{ $doc->{ $hash_type } } ) {
  2         14  
300 2         10 delete $doc->{ $hash_type };
301             }
302             }
303 9         62 return $doc;
304             }
305              
306             #pod =method is_document
307             #pod
308             #pod my $bool = $store->is_document( $path );
309             #pod
310             #pod Returns true if the path looks like a document path (matches the L</document_extensions>).
311             #pod
312             #pod =cut
313              
314             sub is_document {
315 2023     2023 1 5234 my ( $self, $path ) = @_;
316 2023         2835 my $match = join "|", @{ $self->document_extensions };
  2023         6776  
317 2023         10821 return $path =~ /[.](?:$match)$/;
318             }
319              
320             #pod =method read_file
321             #pod
322             #pod my $content = $store->read_file( $path )
323             #pod
324             #pod Read the file from the given C<path>.
325             #pod
326             #pod =cut
327              
328             sub read_file {
329 378     378 1 13082 my ( $self, $path ) = @_;
330 378         2462 site->log->debug( "Read file: " . $path );
331 378         20489 return $self->path->child( $path )->slurp_utf8;
332             }
333              
334             #pod =method has_file
335             #pod
336             #pod my $bool = $store->has_file( $path )
337             #pod
338             #pod Returns true if a file exists with the given C<path>.
339             #pod
340             #pod NOTE: This should not be used to check for directories, as not all stores have
341             #pod directories.
342             #pod
343             #pod =cut
344              
345             sub has_file {
346 292     292 1 1106 my ( $self, $path ) = @_;
347 292         932 return $self->path->child( $path )->is_file;
348             }
349              
350             #pod =method files
351             #pod
352             #pod my $iter = $store->files
353             #pod
354             #pod Returns an iterator which iterates over I<all> files in the store,
355             #pod regardless of type of file. The iterator returns a L<Path::Tiny>
356             #pod object or undef if no files remain. It is used by L<find_files>.
357             #pod
358             #pod =cut
359              
360             sub files {
361 190     190 1 482 my ( $self ) = @_;
362 190         1310 return $self->path->iterator({ recurse => 1 });
363             }
364              
365              
366             #pod =method find_files
367             #pod
368             #pod my $iter = $store->find_files( %opt )
369             #pod while ( my $path = $iter->() ) {
370             #pod # ...
371             #pod }
372             #pod
373             #pod Returns an iterator that, when called, produces a single path suitable to be passed
374             #pod to L<read_file>.
375             #pod
376             #pod Available options are:
377             #pod
378             #pod include_documents - If true, will include files that look like documents.
379             #pod Defaults to false.
380             #pod
381             #pod It obtains its list of files from L<files>.
382             #pod
383             #pod =cut
384              
385             sub find_files {
386 190     190 1 73448 my ( $self, %opt ) = @_;
387 190         4229 $self->_check_exists;
388 190         5039 my $iter = $self->files;
389             return sub {
390 2835     2835   531232 my $path;
391 2835         6834 while ( $path = $iter->() ) {
392 5580 100       545465 next if $path->is_dir;
393 3161 100       32313 next if !$self->_is_owned_path( $path );
394 2652 100 100     7471 next if !$opt{include_documents} && $self->is_document( $path );
395 2645         4336 last;
396             }
397 2835 100       13847 return unless $path; # iterator exhausted
398 2645         10718 return $path->relative( $self->path )->absolute( '/' );
399 190         7470 };
400             }
401              
402             #pod =method open_file
403             #pod
404             #pod my $fh = $store->open_file( $path )
405             #pod
406             #pod Open the file with the given path. Returns a filehandle.
407             #pod
408             #pod The filehandle opened is using raw bytes, not UTF-8 characters.
409             #pod
410             #pod =cut
411              
412             sub open_file {
413 1     1 1 205 my ( $self, $path ) = @_;
414 1         5 return $self->path->child( $path )->openr_raw;
415             }
416              
417             #pod =method write_file
418             #pod
419             #pod $store->write_file( $path, $content );
420             #pod
421             #pod Write the given C<content> to the given C<path>. This is mostly used to write
422             #pod out L<page objects|Statocles::Page>.
423             #pod
424             #pod C<content> may be a simple string or a filehandle. If given a string, will
425             #pod write the string using UTF-8 characters. If given a filehandle, will write out
426             #pod the raw bytes read from it with no special encoding.
427             #pod
428             #pod =cut
429              
430             sub write_file {
431 1457     1457 1 1434925 my ( $self, $path, $content ) = @_;
432 1457         12125 site->log->debug( "Write file: " . $path );
433 1457         94719 my $full_path = $self->path->child( $path );
434              
435 1457 100 66     81492 if ( ref $content eq 'GLOB' ) {
    100          
436 2         5 my $fh = $full_path->touchpath->openw_raw;
437 2         721 while ( my $line = <$content> ) {
438 25         167 $fh->print( $line );
439             }
440             }
441             elsif ( blessed $content && $content->isa( 'Path::Tiny' ) ) {
442 485         1475 $content->copy( $full_path->touchpath );
443             }
444             else {
445 970         4239 $full_path->touchpath->spew_utf8( $content );
446             }
447              
448 1457         1309747 return;
449             }
450              
451             #pod =method remove
452             #pod
453             #pod $store->remove( $path )
454             #pod
455             #pod Remove the given path from the store. If the path is a directory, the entire
456             #pod directory is removed.
457             #pod
458             #pod =cut
459              
460             sub remove {
461 2     2 1 359 my ( $self, $path ) = @_;
462 2         10 $self->path->child( $path )->remove_tree;
463 2         932 return;
464             }
465              
466             1;
467              
468             __END__
469              
470             =pod
471              
472             =encoding UTF-8
473              
474             =head1 NAME
475              
476             Statocles::Store - The source for data documents and files
477              
478             =head1 VERSION
479              
480             version 0.085
481              
482             =head1 DESCRIPTION
483              
484             A Statocles::Store reads and writes L<documents|Statocles::Document> and
485             files (mostly L<pages|Statocles::Page>).
486              
487             This class also handles the parsing and inflating of
488             L<"document objects"|Statocles::Document>.
489              
490             =head2 Frontmatter Document Format
491              
492             Documents are formatted with a YAML document on top, and Markdown content
493             on the bottom, like so:
494              
495             ---
496             title: This is a title
497             author: preaction
498             ---
499             # This is the markdown content
500            
501             This is a paragraph
502              
503             =head1 ATTRIBUTES
504              
505             =head2 path
506              
507             The path to the directory containing the L<documents|Statocles::Document>.
508              
509             =head2 document_extensions
510              
511             An array of file extensions that should be considered documents. Defaults to
512             "markdown" and "md".
513              
514             =head2 documents
515              
516             All the L<documents|Statocles::Document> currently read by this store.
517              
518             =head1 METHODS
519              
520             =head2 clear
521              
522             $store->clear;
523              
524             Clear the cached documents in this Store.
525              
526             =head2 read_documents
527              
528             my $docs = $store->read_documents;
529              
530             Read the directory C<path> and create the L<document
531             objects|Statocles::Document> inside. Returns an arrayref of document objects.
532              
533             =head2 read_document
534              
535             my $doc = $store->read_document( $path )
536              
537             Read a single L<document|Statocles::Document> in Markdown with optional YAML
538             or JSON frontmatter.
539              
540             =head2 parse_frontmatter
541              
542             my %doc_attrs = $store->parse_frontmatter( $from, $content )
543              
544             Parse a document with YAML frontmatter. $from is a string identifying where the
545             content comes from (a path or other identifier). $content is the content to
546             parse for frontmatter.
547              
548             =head2 write_document
549              
550             $store->write_document( $path, $doc );
551              
552             Write a L<document|Statocles::Document> to the store at the given store path.
553              
554             The document is written in Frontmatter format.
555              
556             =head2 is_document
557              
558             my $bool = $store->is_document( $path );
559              
560             Returns true if the path looks like a document path (matches the L</document_extensions>).
561              
562             =head2 read_file
563              
564             my $content = $store->read_file( $path )
565              
566             Read the file from the given C<path>.
567              
568             =head2 has_file
569              
570             my $bool = $store->has_file( $path )
571              
572             Returns true if a file exists with the given C<path>.
573              
574             NOTE: This should not be used to check for directories, as not all stores have
575             directories.
576              
577             =head2 files
578              
579             my $iter = $store->files
580              
581             Returns an iterator which iterates over I<all> files in the store,
582             regardless of type of file. The iterator returns a L<Path::Tiny>
583             object or undef if no files remain. It is used by L<find_files>.
584              
585             =head2 find_files
586              
587             my $iter = $store->find_files( %opt )
588             while ( my $path = $iter->() ) {
589             # ...
590             }
591              
592             Returns an iterator that, when called, produces a single path suitable to be passed
593             to L<read_file>.
594              
595             Available options are:
596              
597             include_documents - If true, will include files that look like documents.
598             Defaults to false.
599              
600             It obtains its list of files from L<files>.
601              
602             =head2 open_file
603              
604             my $fh = $store->open_file( $path )
605              
606             Open the file with the given path. Returns a filehandle.
607              
608             The filehandle opened is using raw bytes, not UTF-8 characters.
609              
610             =head2 write_file
611              
612             $store->write_file( $path, $content );
613              
614             Write the given C<content> to the given C<path>. This is mostly used to write
615             out L<page objects|Statocles::Page>.
616              
617             C<content> may be a simple string or a filehandle. If given a string, will
618             write the string using UTF-8 characters. If given a filehandle, will write out
619             the raw bytes read from it with no special encoding.
620              
621             =head2 remove
622              
623             $store->remove( $path )
624              
625             Remove the given path from the store. If the path is a directory, the entire
626             directory is removed.
627              
628             =head1 AUTHOR
629              
630             Doug Bell <preaction@cpan.org>
631              
632             =head1 COPYRIGHT AND LICENSE
633              
634             This software is copyright (c) 2016 by Doug Bell.
635              
636             This is free software; you can redistribute it and/or modify it under
637             the same terms as the Perl 5 programming language system itself.
638              
639             =cut