File Coverage

blib/lib/Statocles/Store.pm
Criterion Covered Total %
statement 142 143 99.3
branch 49 52 94.2
condition 13 17 76.4
subroutine 24 24 100.0
pod 12 14 85.7
total 240 250 96.0


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