File Coverage

blib/lib/Text/ZTemplate.pm
Criterion Covered Total %
statement 189 207 91.3
branch 61 90 67.7
condition 12 22 54.5
subroutine 19 19 100.0
pod 2 2 100.0
total 283 340 83.2


line stmt bran cond sub pod time code
1 1     1   244582 use strict;
  1         3  
  1         40  
2 1     1   14 use warnings;
  1         2  
  1         102  
3              
4             package Text::ZTemplate;
5              
6 1     1   7 use Carp qw(croak);
  1         3  
  1         72  
7 1     1   9 use Cwd qw(abs_path);
  1         3  
  1         56  
8 1     1   470 use Data::ZPath;
  1         5  
  1         59  
9 1     1   12 use File::Basename qw(dirname);
  1         2  
  1         168  
10 1     1   9 use File::Spec;
  1         17  
  1         3205  
11              
12             our $VERSION = '0.001000';
13              
14             sub new {
15 11     11 1 437388 my ( $class, %args ) = @_;
16              
17 11         54 my $string = $args{string};
18 11         41 my $file = $args{file};
19              
20 11 50 66     105 if ( defined $string and defined $file ) {
21 0         0 croak q{Specify only one of "string" or "file"};
22             }
23 11 50 66     77 if ( not defined $string and not defined $file ) {
24 0         0 croak q{Missing template source: provide "string" or "file"};
25             }
26              
27 11 50       78 my $escape = defined $args{escape} ? lc $args{escape} : 'html';
28 11 50 66     107 if ( $escape ne 'html' and $escape ne 'raw' ) {
29 0         0 croak qq{Invalid escape mode "$escape"};
30             }
31              
32 11 100       44 if ( defined $file ) {
33 3         20 ( $string, $file ) = _read_template_file($file);
34             }
35              
36             my $tokens = _parse_template(
37             template => $string // q{},
38             current_file => $file,
39             seen_files => {},
40 11 100 50     108 includes => exists $args{includes} ? $args{includes} : 1,
41             );
42              
43 9         71 my $self = bless {
44             escape => $escape,
45             tree => $tokens,
46             }, $class;
47              
48 9         70 return $self;
49             }
50              
51             sub process {
52 11     11 1 4395 my ( $self, $data ) = @_;
53 11 50       67 croak 'process() requires a data model' unless defined $data;
54              
55             return _render_nodes(
56             nodes => $self->{tree},
57             context => $data,
58             default_escape => $self->{escape},
59 11         84 );
60             }
61              
62             sub _parse_template {
63 15     15   141 my ( %args ) = @_;
64 15         57 my $template = $args{template};
65 15         40 my $current_file = $args{current_file};
66 15   50     57 my $seen_files = $args{seen_files} || {};
67 15         38 my $includes = $args{includes};
68              
69 15         31 my $root = [];
70 15         78 my @stack = ({
71             expr => undef,
72             nodes => $root,
73             });
74              
75 15         30 my $pos = 0;
76 15         167 while ( $template =~ /\G(.*?)\{\{/sgc ) {
77 27         106 my $text = $1;
78 27 100       77 if ( length $text ) {
79 12         24 push @{$stack[-1]{nodes}}, {
  12         83  
80             type => 'text',
81             text => $text,
82             };
83             }
84              
85 27         107 my $tag_start = pos($template) - 2;
86 27 50       200 if ( $template !~ /\G(.*?)\}\}/sgc ) {
87 0         0 croak qq{Unterminated tag at character $tag_start};
88             }
89              
90 27         78 my $raw = $1;
91 27         63 my $trimmed = $raw;
92 27         112 $trimmed =~ s/^\s+//;
93 27         185 $trimmed =~ s/\s+\z//;
94              
95 27 100       187 if ( $trimmed =~ /^#(.*)\z/s ) {
    100          
    50          
96 5         15 my $inner = $1;
97 5         24 $inner =~ s/^\s+//;
98 5         21 $inner =~ s/\s+\z//;
99 5         23 my $parsed = _parse_expression_spec($inner);
100             my $block = {
101             type => 'block',
102             expr_src => $parsed->{expr},
103 5         48 expr => Data::ZPath->new( $parsed->{expr} ),
104             nodes => [],
105             };
106 5         12 push @{$stack[-1]{nodes}}, $block;
  5         21  
107             push @stack, {
108             expr => $parsed->{expr},
109             nodes => $block->{nodes},
110 5         29 };
111             }
112             elsif ( $trimmed =~ m{^/(.*)\z}s ) {
113 5         16 my $inner = $1;
114 5         17 $inner =~ s/^\s+//;
115 5         12 $inner =~ s/\s+\z//;
116 5         13 my $current = pop @stack;
117 5 50 33     45 if ( not defined $current or not defined $current->{expr} ) {
118 0         0 croak qq{Mismatched close tag {{/$inner}}};
119             }
120 5 100       23 if ( length $inner ) {
121 2         9 my $parsed = _parse_expression_spec($inner);
122 2 50       15 if ( $current->{expr} ne $parsed->{expr} ) {
123 0         0 croak qq{Mismatched close tag {{/$inner}} for {{$current->{expr}}}};
124             }
125             }
126             }
127             elsif ( length $trimmed ) {
128 17 100       97 if ( $trimmed =~ /^>(.*)\z/s ) {
129 6 100       467 croak 'Template includes are disabled'
130             unless $includes;
131              
132 5         28 my $include_path = $1;
133 5         27 $include_path =~ s/^\s+//;
134 5         18 $include_path =~ s/\s+\z//;
135 5 50       17 croak 'Empty include path in template tag'
136             unless length $include_path;
137              
138 5         18 my $resolved_file = _resolve_include_path(
139             include_path => $include_path,
140             current_file => $current_file,
141             );
142              
143 5         13 my $key = _canonical_path($resolved_file);
144 5 100       31 if ( $seen_files->{$key} ) {
145 1         295 croak qq{Circular include detected for "$resolved_file"};
146             }
147              
148 4         19 $seen_files->{$key} = 1;
149 4         16 my ( $include_text, $include_file ) =
150             _read_template_file($resolved_file);
151 4         50 my $include_nodes = _parse_template(
152             template => $include_text,
153             current_file => $include_file,
154             seen_files => $seen_files,
155             includes => $includes,
156             );
157 2         8 delete $seen_files->{$key};
158              
159 2         4 push @{$stack[-1]{nodes}}, @$include_nodes;
  2         14  
160             }
161             else {
162 11         44 my $parsed = _parse_expression_spec($trimmed);
163 11         130 push @{$stack[-1]{nodes}}, {
164             type => 'expr',
165             expr_src => $parsed->{expr},
166             escape => $parsed->{escape},
167 11         28 expr => Data::ZPath->new( $parsed->{expr} ),
168             };
169             }
170             }
171              
172 23         183 $pos = pos($template);
173             }
174              
175 11         33 my $tail = substr( $template, $pos );
176 11 100       34 if ( length $tail ) {
177 4         7 push @{$stack[-1]{nodes}}, {
  4         18  
178             type => 'text',
179             text => $tail,
180             };
181             }
182              
183 11 50       39 if ( @stack > 1 ) {
184 0         0 my $missing = $stack[-1]{expr};
185 0         0 croak qq{Missing close tag for {{$missing}}};
186             }
187              
188 11         68 return $root;
189             }
190              
191              
192             sub _read_template_file {
193 7     7   22 my ( $file ) = @_;
194              
195 1 50   1   6460 open my $fh, '<:encoding(UTF-8)', $file
  1         24  
  1         10  
  7         1667  
196             or croak qq{Unable to read template file "$file": $!};
197 7         2442 local $/;
198 7         553 my $text = <$fh>;
199 7         244 close $fh;
200              
201 7         32 my $canonical = _canonical_path($file);
202              
203 7         73 return ( $text, $canonical );
204             }
205              
206             sub _resolve_include_path {
207 5     5   26 my ( %args ) = @_;
208 5         16 my $include_path = $args{include_path};
209 5         8 my $current_file = $args{current_file};
210              
211 5 50       110 if ( File::Spec->file_name_is_absolute($include_path) ) {
212 0         0 return $include_path;
213             }
214              
215 5 50       17 croak qq{Relative include path "$include_path" requires file-based template source}
216             unless defined $current_file;
217              
218 5         365 my $base_dir = dirname($current_file);
219 5         90 my $resolved = File::Spec->catfile( $base_dir, $include_path );
220              
221 5         22 return $resolved;
222             }
223              
224             sub _canonical_path {
225 12     12   37 my ( $path ) = @_;
226 12         966 my $abs = abs_path($path);
227 12 50       67 return defined $abs ? $abs : File::Spec->rel2abs($path);
228             }
229              
230             sub _parse_expression_spec {
231 18     18   58 my ( $raw ) = @_;
232 18         1426 my $expr = $raw;
233 18         38 my $escape;
234              
235 18         78 my $split = _find_escape_separator($raw);
236 18 100       60 if ( defined $split ) {
237 2         6 my ( $lhs, $rhs ) = @$split;
238 2 50 66     12 if ( $rhs eq 'html' or $rhs eq 'raw' ) {
239 2         6 $expr = $lhs;
240 2         4 $escape = $rhs;
241             }
242             }
243              
244 18         71 $expr =~ s/^\s+//;
245 18         55 $expr =~ s/\s+\z//;
246 18 50       54 croak 'Empty expression in template tag' unless length $expr;
247              
248             return {
249 18         116 expr => $expr,
250             escape => $escape,
251             };
252             }
253              
254             sub _find_escape_separator {
255 18     18   47 my ( $text ) = @_;
256              
257 18         45 my $quote = q{};
258 18         108 for ( my $i = 0; $i < length $text; $i++ ) {
259 194         373 my $ch = substr( $text, $i, 1 );
260 194 50       387 if ( $quote ) {
261 0 0       0 if ( $ch eq '\\' ) {
262 0         0 $i++;
263 0         0 next;
264             }
265 0 0       0 if ( $ch eq $quote ) {
266 0         0 $quote = q{};
267             }
268 0         0 next;
269             }
270              
271 194 50 33     678 if ( $ch eq q{"} or $ch eq q{'} ) {
272 0         0 $quote = $ch;
273 0         0 next;
274             }
275              
276 194 100       548 next unless $ch eq ':';
277 2 50       12 next unless substr( $text, $i, 2 ) eq '::';
278              
279 2         6 my $lhs = substr( $text, 0, $i );
280 2         7 my $rhs = substr( $text, $i + 2 );
281 2         13 $lhs =~ s/\s+\z//;
282 2         8 $rhs =~ s/^\s+//;
283 2         5 $rhs =~ s/\s+\z//;
284 2         6 $rhs = lc $rhs;
285 2         9 return [ $lhs, $rhs ];
286             }
287              
288 16         63 return undef;
289             }
290              
291             sub _render_nodes {
292 18     18   138 my ( %args ) = @_;
293 18         77 my $nodes = $args{nodes};
294 18         40 my $context = $args{context};
295 18         62 my $default_escape = $args{default_escape};
296              
297 18         48 my $out = q{};
298              
299 18         46 for my $node ( @$nodes ) {
300 40 100       136 if ( $node->{type} eq 'text' ) {
301 20         68 $out .= $node->{text};
302 20         39 next;
303             }
304              
305 20 100       64 if ( $node->{type} eq 'expr' ) {
306 14         74 my @vals = $node->{expr}->evaluate( $context );
307 14         33 my $buf = q{};
308 14         31 for my $val ( @vals ) {
309 14         82 my $sv = $val->string_value;
310 14 50       46 next unless defined $sv;
311 14         38 $buf .= $sv;
312             }
313              
314             my $escape = defined $node->{escape}
315             ? $node->{escape}
316 14 100       62 : $default_escape;
317 14         78 $out .= _escape( $buf, $escape );
318 14         162 next;
319             }
320              
321 6 50       25 if ( $node->{type} eq 'block' ) {
322 6         41 my @vals = $node->{expr}->evaluate( $context );
323 6         19 for my $val ( @vals ) {
324 8 100       30 next unless _truthy( $val );
325 7 100       59 my $inner_context = defined $val->id ? $val : $context;
326             $out .= _render_nodes(
327             nodes => $node->{nodes},
328 7         37 context => $inner_context,
329             default_escape => $default_escape,
330             );
331             }
332 6         36 next;
333             }
334             }
335              
336 18         126 return $out;
337             }
338              
339             sub _truthy {
340 8     8   20 my ( $node ) = @_;
341 8 50       25 return !!0 unless $node;
342 8         38 return !!$node->primitive_value;
343             }
344              
345             sub _escape {
346 14     14   42 my ( $value, $mode ) = @_;
347 14 50       38 return q{} unless defined $value;
348              
349 14 100       43 if ( $mode eq 'raw' ) {
350 12         39 return $value;
351             }
352              
353 2 50       8 if ( $mode eq 'html' ) {
354 2         13 $value =~ s/\&/\&/g;
355 2         7 $value =~ s/\
356 2         6 $value =~ s/\>/\>/g;
357 2         6 $value =~ s/"/\"/g;
358 2         18 $value =~ s/'/\'/g;
359 2         8 return $value;
360             }
361              
362 0           croak qq{Unknown escape mode "$mode"};
363             }
364              
365             1;
366              
367             __END__