File Coverage

blib/lib/Plift.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1             package Plift;
2              
3 10     10   174303 use strict;
  10         21  
  10         261  
4 10     10   37 use warnings;
  10         11  
  10         246  
5 10     10   5719 use Moo;
  10         138202  
  10         57  
6 10     10   17872 use Class::Load ();
  10         169705  
  10         283  
7 10     10   8911 use Path::Tiny ();
  10         99001  
  10         303  
8 10     10   6967 use XML::LibXML::jQuery;
  0            
  0            
9             use Carp;
10             use Plift::Context;
11             use Digest::MD5 qw/ md5_hex /;
12             use namespace::clean;
13              
14             our $VERSION = "0.15";
15              
16             use constant {
17             XML_DOCUMENT_NODE => 9,
18             XML_DOCUMENT_FRAG_NODE => 11,
19             XML_DTD_NODE => 14
20             };
21              
22             has 'helper', is => 'ro';
23             has 'wrapper', is => 'ro';
24             has 'paths', is => 'rw', default => sub { ['.'] };
25             has 'snippet_namespaces', is => 'ro', default => sub { [] };
26             has 'plugins', is => 'ro', default => sub { [] };
27             has 'encoding', is => 'rw', default => 'UTF-8';
28             has 'debug', is => 'rw', default => sub { $ENV{PLIFT_DEBUG} };
29             has 'max_file_size', is => 'rw', default => 1024 * 1024;
30             has 'enable_cache', is => 'rw', default => 1;
31             has 'max_cached_files', is => 'rw', default => 50;
32              
33             has '_cache', is => 'ro', default => sub { {} };
34              
35              
36              
37             sub BUILD {
38             my $self = shift;
39              
40             # builtin handlers
41             my @components = qw/
42             Handler::Include
43             Handler::Wrap
44             Handler::Render
45             Handler::Snippet
46             Handler::Meta
47             /;
48              
49             # plugins
50             push @components, map { /^\+/ ? $_ : 'Plugin::'.$_ } @{ $self->plugins };
51              
52             $self->load_components(@components);
53              
54             ## remove-if / remove-unless
55             $self->hook('after_load_template', \&_after_load_template);
56             }
57              
58             sub _after_load_template {
59             my ($c, $dom) = @_;
60              
61             # remove-if/unless
62             my $xpath = join ' | ', map { ("./*[\@$_]", ".//*[\@$_]") }
63             map { ($_, "data-$_") }
64             qw/ remove-if remove-unless /;
65              
66             foreach my $node ( @{ $dom->xfilter($xpath)->{nodes} }, @{ $dom->xfind($xpath)->{nodes} } ) {
67              
68             # remove-if
69             if (exists $node->{'remove-if'} || exists $node->{'data-remove-if'} ) {
70              
71             if ($c->get($node->{'remove-if'} || $node->{'data-remove-if'})) {
72              
73             $node->unbindNode;
74             } else {
75              
76             delete $node->{'remove-if'};
77             delete $node->{'data-remove-if'};
78             }
79             }
80              
81             # remove-unless
82             if (exists $node->{'remove-unless'} || exists $node->{'data-remove-unless'} ) {
83              
84             unless ($c->get($node->{'remove-unless'} || $node->{'data-remove-unless'})) {
85              
86             $node->unbindNode;
87             } else {
88              
89             delete $node->{'remove-unless'};
90             delete $node->{'data-remove-unless'};
91             }
92             }
93             }
94             }
95              
96              
97             sub load_components {
98             my $self = shift;
99              
100             # instantiate and init
101             foreach my $name (@_) {
102              
103             my $class = $name =~ /^\+/ ? substr($name, 1)
104             : __PACKAGE__.'::'.$name;
105              
106             my $plugin = Class::Load::load_class($class)->new;
107             $plugin->register($self);
108             }
109             }
110              
111             sub has_template {
112             my ($self, $name) = @_;
113             return !! $self->_find_template_file($name, $self->paths);
114             }
115              
116              
117              
118             sub template {
119             my ($self, $name, $options) = @_;
120             $options ||= {};
121              
122             # path copy for the load_template closure
123             # this way we do not expose the engine nor the path to the context object
124             my @paths = @{ delete $options->{paths} || $self->paths };
125             my @ns = @{ delete $options->{snippet_namespaces} || $self->snippet_namespaces };
126              
127             $options->{$_} ||= $self->$_ for qw/ helper wrapper encoding /;
128              
129             $options->{data_stack} = [delete $options->{data}]
130             if defined $options->{data};
131              
132             Plift::Context->new(
133             %$options,
134             template => $name,
135             handlers => [@{ $self->{handlers}}],
136             load_template => sub {
137             my ($ctx, $name) = @_;
138             $self->_load_template($name, \@paths, $ctx)
139             },
140             load_snippet => sub {
141             $self->_load_snippet(\@ns, @_);
142             },
143             run_hooks => sub {
144             $self->run_hooks(@_);
145             },
146             );
147             }
148              
149             sub process {
150             my ($self, $template, $data, $schema) = @_;
151              
152             my $ctx = $self->template($template);
153              
154             $ctx->at($schema)
155             if $schema;
156              
157             $ctx->render($data);
158             }
159              
160             sub render {
161             my $self = shift;
162             $self->process(@_)->as_html;
163             }
164              
165              
166             sub add_handler {
167             my ($self, $config) = @_;
168              
169             confess "missing handler callback"
170             unless $config->{handler};
171              
172             confess "missing handler name"
173             unless $config->{name};
174              
175             my @match;
176              
177             for my $key (qw/ tag attribute /) {
178             $config->{$key} = [$config->{$key}]
179             if defined $config->{$key} && !ref $config->{$key};
180             }
181              
182             push(@match, map { ".//$_" } @{$config->{tag}})
183             if $config->{tag};
184              
185             push(@match, map { ".//*[\@$_]" } @{$config->{attribute}})
186             if $config->{attribute};
187              
188             push @match, $config->{xpath}
189             if $config->{xpath};
190              
191             my $match = join ' | ', @match;
192              
193             printf STDERR "[Plift] Adding handler: $match\n"
194             if $self->debug;
195              
196             # check config has one of tag/attribute/xpath
197             confess "Invalid handler. Missing at least one binding criteria (tag, attribute or xpath)."
198             unless $match;
199              
200             my $handler = {
201             tag => $config->{tag},
202             attribute => $config->{attribute},
203             name => $config->{name},
204             xpath => $match,
205             sub => $config->{handler}
206             };
207              
208             push @{$self->{handlers}}, $handler;
209             $self->{handlers_by_name}->{$handler->{name}} = $handler;
210              
211             $self;
212             }
213              
214             sub get_handler {
215             my ($self, $name) = @_;
216             $self->{handlers_by_name}->{$name};
217             }
218              
219              
220              
221             my %hooks;
222             sub hook {
223             my ($self, $step, $cb) = @_;
224              
225             croak "Usage: plift->hook(, )"
226             unless $step && $cb && ref $cb eq 'CODE';
227              
228             push @{$hooks{$step}}, $cb;
229             $self;
230             }
231              
232             sub run_hooks {
233             my ($self, $step, $args) = @_;
234              
235             foreach my $cb (@{ $hooks{$step} || [] }) {
236             $cb->(@{ $args || [] });
237             }
238             }
239              
240              
241              
242             sub _load_template {
243             my ($self, $template, $paths, $ctx) = @_;
244              
245             my ($tpl_source, $tpl_etag, $tpl_file, $tpl_path, $cache_key);
246              
247             # inline template
248             if (ref $template) {
249              
250             $tpl_source = $$template;
251             $tpl_etag = md5_hex($tpl_source);
252             $cache_key = 'inline:'.$tpl_etag;
253             }
254              
255             # file template
256             else {
257              
258             # add relative prefix
259             # (only './foo' and '../foo' are considered relative, not plain 'foo')
260             if (defined $ctx->{current_file} && $template =~ /^\.\.?\//) {
261             my $current_file = $ctx->{current_file};
262             my $current_path = $ctx->{current_path};
263             my $prefix = $current_file->parent->relative($current_path);
264             $template = "$prefix/$template"
265             unless $prefix eq '.';
266             }
267              
268             # resolve template file
269             ($tpl_file, $tpl_path) = $self->_find_template_file($template, $paths);
270             die sprintf "Can't find a template file for template '%s'. Tried:\n%s\n", $template, join(",\n", @$paths)
271             unless $tpl_file;
272              
273             # update contex current file/path
274             $ctx->{current_file} = $tpl_file;
275             $ctx->{current_path} = $tpl_path;
276              
277             $tpl_etag = $tpl_file->stat->mtime;
278             $cache_key = $tpl_file->stringify;
279             }
280              
281             # cached template
282             my $cache = $self->_cache;
283             my $dom;
284              
285             # get from cache
286             if ($self->enable_cache && (my $entry = $cache->{$cache_key})) {
287              
288             # cache hit
289             if ($entry->{etag} eq $tpl_etag) {
290              
291             $dom = $entry->{dom}->clone->contents;
292             $entry->{hits} += 1;
293             $entry->{last_hit} = time;
294             # printf STDERR "# Plift cache hit: '$tpl_file' => %d hits\n", $entry->{hits};
295             }
296              
297             # invalidade cache entry
298             else {
299             delete $cache->{$cache_key};
300             }
301             }
302              
303             unless ($dom) {
304              
305             # max file size
306             my $tpl_size = defined $tpl_file ? $tpl_file->stat->size : length $tpl_source;
307             die sprintf("Template '%s' exceeds the max_file_size option! (%d > %d)\n", $cache_key, $tpl_size, $self->max_file_size)
308             if $tpl_size > $self->max_file_size;
309              
310             # parse source
311             if (defined $tpl_file) {
312             $tpl_source = $ctx->encoding eq 'UTF-8' ? $tpl_file->slurp_utf8
313             : $tpl_file->slurp( binmode => ":unix:encoding(".$self->encoding.")")
314             }
315              
316             $dom = XML::LibXML::jQuery->new($tpl_source);
317              
318             # check for data-plift-template attr, and use that element
319             my $body = $dom->xfind('//body[@data-plift-template]');
320              
321             if ($body->size) {
322              
323             my $selector = $body->attr('data-plift-template');
324             my $template_element = $dom->find($selector);
325             confess "Can't find template via selector '$selector' (referenced at )."
326             unless $template_element->size;
327              
328             # create new document for the template elment
329             $dom = j()->document->append($template_element)->contents;
330             }
331              
332             # cache it
333             if ($self->enable_cache) {
334              
335             # control cache size
336             if (scalar keys(%$cache) == $self->max_cached_files) {
337              
338             my @least_used = sort { $cache->{$b}{last_hit} <=> $cache->{$a}{last_hit} }
339             keys %$cache;
340              
341             delete $cache->{$least_used[0]};
342             }
343              
344             $cache->{$cache_key} = {
345             dom => $dom->document->clone,
346             etag => $tpl_etag,
347             hits => 0,
348             last_hit => 0,
349             };
350             }
351             }
352              
353             # adopt into document
354             if (my $existing_document = $ctx->document) {
355              
356             $existing_document = $existing_document->get(0);
357              
358             # replace DTD
359             if (my $dtd = $dom->{document}->internalSubset) {
360             $existing_document->removeInternalSubset;
361             $existing_document->createInternalSubset( $dtd->getName, $dtd->publicId, $dtd->systemId );
362             }
363              
364             # adopt nodes
365             my @nodes = map { $existing_document->adoptNode($_); $_ }
366             grep { $_->nodeType != XML_DTD_NODE }
367             # grep { $_->getOwner->nodeType == XML_DOCUMENT_NODE }
368             @{ $dom->{nodes} };
369              
370             # reinstantitate on new document
371             $dom = $dom->_new_nodes(\@nodes, undef, $existing_document);
372             }
373              
374             # 1st tempalte loaded, set contex document
375             else {
376             $ctx->document($dom->document);
377             }
378              
379             $dom;
380             }
381              
382             sub _find_template_file {
383             my ($self, $template_name, $paths) = @_;
384              
385             # clean \x00 char that can be used to truncate our string
386             $template_name =~ tr/\x00//d;
387              
388             foreach my $path (@$paths) {
389              
390             if (-e (my $file = "$path/$template_name.html")) {
391              
392             $file = Path::Tiny->new($file);
393             $path = Path::Tiny->new($path);
394              
395             # if suspicious template name, check file is really child of path
396             if ($template_name =~ /\.\.\//) {
397             $file = $file->realpath;
398             $path = $path->realpath;
399              
400             die "[Plift] attempt to traverse out of path via '$template_name'"
401             unless $path->subsumes($file)
402             }
403              
404             return wantarray ? ($file, $path) : $file;
405             }
406             }
407              
408             return;
409             }
410              
411             sub _load_snippet {
412             my ($self, $ns, $name, $params) = @_;
413              
414             my $class_sufixx = _camelize($name);
415             my @try_classes = map { join '::', $_, $class_sufixx } @$ns;
416             my $snippet_class = Class::Load::load_first_existing_class @try_classes;
417              
418              
419             $snippet_class->new($params);
420             }
421              
422             # borrowed from Mojo::Util :)
423             sub _camelize {
424             my $str = shift;
425             return $str if $str =~ /^[A-Z]/;
426              
427             # CamelCase words
428             return join '::', map {
429             join( '', map { ucfirst lc } split '_' )
430             } split '-', $str;
431             }
432              
433              
434             1;
435             __END__