File Coverage

lib/HP/Handy.pm
Criterion Covered Total %
statement 716 793 90.2
branch 419 526 79.6
condition 175 230 76.0
subroutine 37 40 92.5
pod 5 5 100.0
total 1352 1594 84.8


line stmt bran cond sub pod time code
1             package HP::Handy;
2             ######################################################################
3             #
4             # HP::Handy - A tiny Jinja2-compatible template engine for Perl 5.5.3+
5             #
6             # https://metacpan.org/dist/HP-Handy
7             #
8             # Copyright (c) 2026 INABA Hitoshi
9             ######################################################################
10             #
11             # Compatible : Perl 5.005_03 and later
12             # Platform : Windows and UNIX/Linux
13             #
14             ######################################################################
15              
16 12     12   226937 use 5.00503; # Universal Consensus 1998 for primetools
  12         48  
17             # Perl 5.005_03 compatibility for historical toolchains
18             # use 5.008001; # Lancaster Consensus 2013 for toolchains
19              
20 12     12   62 use strict;
  12         22  
  12         1174  
21 12 50 33 12   410 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
22 12     12   67 use warnings; local $^W = 1;
  12         21  
  12         1057  
23 12 50   12   408 BEGIN { pop @INC if $INC[-1] eq '.' }
24              
25 12     12   71 use Carp qw(croak);
  12         21  
  12         841  
26 12     12   66 use File::Spec;
  12         37  
  12         357  
27 12     12   53 use vars qw($VERSION);
  12         21  
  12         242536  
28             $VERSION = '0.01';
29             $VERSION = $VERSION;
30             # $VERSION self-assignment suppresses "used only once" warning under strict.
31              
32             ###############################################################################
33             # Built-in filters
34             ###############################################################################
35             my %FILTERS = (
36             'upper' => sub { my $s = defined $_[0] ? $_[0] : ''; $s =~ tr/a-z/A-Z/; $s },
37             'lower' => sub { my $s = defined $_[0] ? $_[0] : ''; $s =~ tr/A-Z/a-z/; $s },
38             'trim' => sub { my $s = defined $_[0] ? $_[0] : ''; $s =~ s/^\s+|\s+$//g; $s },
39             'length' => sub {
40             return 0 unless defined $_[0];
41             ref($_[0]) eq 'ARRAY' ? scalar @{$_[0]}
42             : ref($_[0]) eq 'HASH' ? scalar keys %{$_[0]}
43             : length($_[0])
44             },
45             'reverse' => sub {
46             return '' unless defined $_[0];
47             ref($_[0]) eq 'ARRAY' ? [ reverse @{$_[0]} ]
48             : scalar reverse("$_[0]")
49             },
50             'escape' => \&_html_escape,
51             'e' => \&_html_escape,
52             'safe' => sub { bless \(my $s = defined $_[0] ? $_[0] : ''), 'HP::Handy::SafeString' },
53             'default' => sub { (defined $_[0] && $_[0] ne '') ? $_[0] : $_[1] },
54             'd' => sub { (defined $_[0] && $_[0] ne '') ? $_[0] : $_[1] },
55             'replace' => sub {
56             my ($s, $from, $to) = @_;
57             $s = defined $s ? $s : '';
58             $from = defined $from ? $from : '';
59             $to = defined $to ? $to : '';
60             $s =~ s/\Q$from\E/$to/g;
61             $s
62             },
63             'truncate' => sub {
64             my ($s, $len, $end) = @_;
65             $s = defined $s ? $s : '';
66             $len = defined $len ? int($len) : 255;
67             $end = defined $end ? $end : '...';
68             (length($s) > $len) ? substr($s, 0, $len) . $end : $s
69             },
70             'join' => sub {
71             my ($list, $sep) = @_;
72             $sep = defined $sep ? $sep : '';
73             ref($list) eq 'ARRAY' ? join($sep, @$list) : (defined $list ? $list : '')
74             },
75             'first' => sub { ref($_[0]) eq 'ARRAY' && @{$_[0]} ? $_[0][0] : undef },
76             'last' => sub { ref($_[0]) eq 'ARRAY' && @{$_[0]} ? $_[0][-1] : undef },
77             'list' => sub { ref($_[0]) eq 'ARRAY' ? $_[0] : [defined $_[0] ? $_[0] : ()] },
78             'abs' => sub { abs(defined $_[0] ? $_[0] : 0) },
79             'int' => sub { int(defined $_[0] ? $_[0] : 0) },
80             'float' => sub { defined $_[0] ? ($_[0] + 0.0) : 0.0 },
81             'string' => sub { defined $_[0] ? "$_[0]" : '' },
82             'title' => sub {
83             my $s = defined $_[0] ? $_[0] : '';
84             $s =~ s/\b([a-z])/uc($1)/ge;
85             $s
86             },
87             'capitalize' => sub {
88             my $s = defined $_[0] ? lc($_[0]) : '';
89             $s =~ s/^([a-z])/uc($1)/e;
90             $s
91             },
92             'urlencode' => sub {
93             my $s = defined $_[0] ? $_[0] : '';
94             $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/ge;
95             $s
96             },
97             'wordcount' => sub {
98             my $s = defined $_[0] ? $_[0] : '';
99             my @w = split /\s+/, $s;
100             @w = grep { $_ ne '' } @w;
101             scalar @w
102             },
103             'batch' => sub {
104             my ($list, $size, $fill) = @_;
105             return [] unless ref($list) eq 'ARRAY';
106             $size = int($size || 1);
107             $size = 1 if $size < 1;
108             my @result;
109             my @flat = @$list;
110             while (@flat) {
111             my @chunk = splice(@flat, 0, $size);
112             if (defined $fill) {
113             push @chunk, $fill while @chunk < $size;
114             }
115             push @result, \@chunk;
116             }
117             \@result
118             },
119             'slice' => sub {
120             my ($list, $slices) = @_;
121             return [] unless ref($list) eq 'ARRAY';
122             $slices = int($slices || 1);
123             $slices = 1 if $slices < 1;
124             my $total = scalar @$list;
125             my $per = int($total / $slices);
126             my $extra = $total % $slices;
127             my @result;
128             my $idx = 0;
129             for my $i (0 .. $slices - 1) {
130             my $n = $per + ($i < $extra ? 1 : 0);
131             push @result, [ @{$list}[$idx .. $idx + $n - 1] ];
132             $idx += $n;
133             }
134             \@result
135             },
136             'sort' => sub {
137             my ($list, $attr) = @_;
138             return [] unless ref($list) eq 'ARRAY';
139             if (defined $attr) {
140             return [ sort { _get_attr($a, $attr) cmp _get_attr($b, $attr) } @$list ];
141             }
142             [ sort @$list ]
143             },
144             'unique' => sub {
145             return [] unless ref($_[0]) eq 'ARRAY';
146             my %seen;
147             [ grep { !$seen{$_}++ } @{$_[0]} ]
148             },
149             'min' => sub {
150             return undef unless ref($_[0]) eq 'ARRAY' && @{$_[0]};
151             my $m = $_[0][0];
152             for (@{$_[0]}) { $m = $_ if $_ < $m }
153             $m
154             },
155             'max' => sub {
156             return undef unless ref($_[0]) eq 'ARRAY' && @{$_[0]};
157             my $m = $_[0][0];
158             for (@{$_[0]}) { $m = $_ if $_ > $m }
159             $m
160             },
161             'sum' => sub {
162             return 0 unless ref($_[0]) eq 'ARRAY';
163             my $s = 0; $s += $_ for @{$_[0]}; $s
164             },
165             'map' => sub {
166             my ($list, $attr) = @_;
167             return [] unless ref($list) eq 'ARRAY';
168             [ map { _get_attr($_, $attr) } @$list ]
169             },
170             'select' => sub {
171             my ($list, $attr) = @_;
172             return [] unless ref($list) eq 'ARRAY';
173             [ grep { _get_attr($_, $attr) } @$list ]
174             },
175             'reject' => sub {
176             my ($list, $attr) = @_;
177             return [] unless ref($list) eq 'ARRAY';
178             [ grep { !_get_attr($_, $attr) } @$list ]
179             },
180             'count' => sub { ref($_[0]) eq 'ARRAY' ? scalar @{$_[0]} : 0 },
181             'pprint' => sub { _to_json($_[0]) },
182             'nl2br' => sub {
183             my $s = defined $_[0] ? $_[0] : '';
184             $s =~ s/\n/
\n/g;
185             $s
186             },
187             'striptags' => sub {
188             my $s = defined $_[0] ? $_[0] : '';
189             $s =~ s/<[^>]*>//g;
190             $s
191             },
192             'format' => sub {
193             my ($s, $fmt) = @_;
194             defined $s ? sprintf($fmt, $s) : ''
195             },
196             'center' => sub {
197             my ($s, $width) = @_;
198             $s = defined $s ? $s : '';
199             $width = defined $width ? int($width) : 80;
200             my $pad = $width - length($s);
201             return $s if $pad <= 0;
202             my $left = int($pad / 2);
203             my $right = $pad - $left;
204             (' ' x $left) . $s . (' ' x $right)
205             },
206             'indent' => sub {
207             my ($s, $width, $first) = @_;
208             $s = defined $s ? $s : '';
209             $width = defined $width ? int($width) : 4;
210             $first = defined $first ? $first : 0;
211             my $pad = ' ' x $width;
212             my @lines = split /\n/, $s, -1;
213             for my $i (0 .. $#lines) {
214             $lines[$i] = $pad . $lines[$i] if $i > 0 || $first;
215             }
216             join("\n", @lines)
217             },
218             'xmlattr' => sub {
219             my $h = $_[0];
220             return '' unless ref($h) eq 'HASH';
221             join(' ', map {
222             my $v = defined $h->{$_} ? $h->{$_} : '';
223             $v =~ s/&/&/g; $v =~ s/"/"/g;
224             "$_=\"$v\""
225             } sort keys %$h)
226             },
227             'tojson' => sub {
228             my $v = $_[0];
229             _to_json($v)
230             },
231             'forceescape' => \&_html_escape,
232             );
233              
234             ###############################################################################
235             # Built-in tests (is_xxx)
236             ###############################################################################
237             my %TESTS = (
238             'defined' => sub { defined $_[0] },
239             'none' => sub { !defined $_[0] },
240             'string' => sub { defined $_[0] && !ref($_[0]) },
241             'number' => sub { defined $_[0] && !ref($_[0]) && $_[0] =~ /^-?(?:\d+\.?\d*|\.\d+)$/ },
242             'sequence' => sub { ref($_[0]) eq 'ARRAY' },
243             'mapping' => sub { ref($_[0]) eq 'HASH' },
244             'iterable' => sub { ref($_[0]) eq 'ARRAY' || ref($_[0]) eq 'HASH' },
245             'callable' => sub { ref($_[0]) eq 'CODE' },
246             'odd' => sub { defined $_[0] && int($_[0]) % 2 != 0 },
247             'even' => sub { defined $_[0] && int($_[0]) % 2 == 0 },
248             'divisibleby' => sub { defined $_[0] && defined $_[1] && $_[1] != 0 && int($_[0]) % int($_[1]) == 0 },
249             'upper' => sub { defined $_[0] && $_[0] eq uc($_[0]) && $_[0] =~ /[A-Z]/ },
250             'lower' => sub { defined $_[0] && $_[0] eq lc($_[0]) && $_[0] =~ /[a-z]/ },
251             'equalto' => sub { defined $_[0] && defined $_[1] && $_[0] eq $_[1] },
252             'ne' => sub { !(defined $_[0] && defined $_[1] && $_[0] eq $_[1]) },
253             'lt' => sub { defined $_[0] && defined $_[1] && $_[0] < $_[1] },
254             'le' => sub { defined $_[0] && defined $_[1] && $_[0] <= $_[1] },
255             'gt' => sub { defined $_[0] && defined $_[1] && $_[0] > $_[1] },
256             'ge' => sub { defined $_[0] && defined $_[1] && $_[0] >= $_[1] },
257             'in' => sub {
258             my ($val, $container) = @_;
259             return 0 unless defined $val && defined $container;
260             if (ref($container) eq 'ARRAY') { return (grep { defined $_ && $_ eq $val } @$container) ? 1 : 0 }
261             if (ref($container) eq 'HASH') { return exists $container->{$val} ? 1 : 0 }
262             return index($container, $val) >= 0 ? 1 : 0
263             },
264             );
265              
266             ###############################################################################
267             # Constructor
268             ###############################################################################
269             sub new {
270 683     683 1 1819093 my ($class, %args) = @_;
271             my $self = {
272             template_dir => defined $args{template_dir} ? $args{template_dir} : '.',
273             auto_escape => defined $args{auto_escape} ? $args{auto_escape} : 1,
274             trim_blocks => defined $args{trim_blocks} ? $args{trim_blocks} : 0,
275             lstrip_blocks => defined $args{lstrip_blocks} ? $args{lstrip_blocks} : 0,
276             block_start => defined $args{block_start} ? $args{block_start} : '{%',
277             block_end => defined $args{block_end} ? $args{block_end} : '%}',
278             var_start => defined $args{var_start} ? $args{var_start} : '{{',
279             var_end => defined $args{var_end} ? $args{var_end} : '}}',
280             comment_start => defined $args{comment_start} ? $args{comment_start} : '{#',
281 683 100       21076 comment_end => defined $args{comment_end} ? $args{comment_end} : '#}',
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
    50          
282             _filters => { %FILTERS },
283             _tests => { %TESTS },
284             _blocks => {}, # block name => template string (for inheritance)
285             _macros => {}, # macro name => { args=>[], defaults=>[], body=>'' }
286             _extends => '', # parent template name
287             };
288 683         3414 bless $self, $class;
289 683         2182 return $self;
290             }
291              
292             ###############################################################################
293             # add_filter - Register a custom filter
294             ###############################################################################
295             sub add_filter {
296 7     7 1 59 my ($self, $name, $code) = @_;
297 7 50       21 croak "add_filter: name required" unless defined $name;
298 7 100       164 croak "add_filter: code must be coderef" unless ref($code) eq 'CODE';
299 6         14 $self->{_filters}{$name} = $code;
300 6         13 return $self;
301             }
302              
303             ###############################################################################
304             # add_test - Register a custom test
305             ###############################################################################
306             sub add_test {
307 4     4 1 37 my ($self, $name, $code) = @_;
308 4 50       16 croak "add_test: name required" unless defined $name;
309 4 50       14 croak "add_test: code must be coderef" unless ref($code) eq 'CODE';
310 4         9 $self->{_tests}{$name} = $code;
311 4         10 return $self;
312             }
313              
314             ###############################################################################
315             # render_file - Render a template file with variables
316             ###############################################################################
317             sub render_file {
318 11     11 1 63 my ($self, $filename, $vars) = @_;
319 11 50       57 $vars = {} unless defined $vars;
320 11         30 my $source = $self->_load_file($filename);
321 9         34 return $self->_render($source, $vars, $filename);
322             }
323              
324             ###############################################################################
325             # render_string - Render a template string with variables
326             ###############################################################################
327             sub render_string {
328 669     669 1 2111 my ($self, $source, $vars) = @_;
329 669 100       1280 $vars = {} unless defined $vars;
330 669         1285 return $self->_render($source, $vars, '');
331             }
332              
333             ###############################################################################
334             # _load_file - Read template from disk
335             ###############################################################################
336             sub _load_file {
337 26     26   48 my ($self, $filename) = @_;
338              
339             # Prevent path traversal
340 26 100       104 if ($filename =~ /\.\./) {
341 1         140 croak "HP::Handy: path traversal not allowed in '$filename'";
342             }
343              
344 25         36 my $path;
345 25 50       168 if (File::Spec->file_name_is_absolute($filename)) {
346 0         0 $path = $filename;
347             }
348             else {
349 25         307 $path = File::Spec->catfile($self->{template_dir}, $filename);
350             }
351              
352 25         84 local *HPHANDY_FH;
353 25 100       1582 open(HPHANDY_FH, "< $path") or croak "HP::Handy: cannot open '$path': $!";
354 23         136 local $/;
355 23         756 my $source = ;
356 23         259 close(HPHANDY_FH);
357 23 50       185 return defined $source ? $source : '';
358             }
359              
360             ###############################################################################
361             # _render - Core rendering engine
362             ###############################################################################
363             sub _render {
364 678     678   1214 my ($self, $source, $vars, $filename) = @_;
365 678 50       1174 $filename = defined $filename ? $filename : '';
366              
367             # Reset per-render state
368 678         1402 $self->{_blocks} = {};
369 678         1149 $self->{_macros} = {};
370 678         1093 $self->{_extends} = '';
371              
372             # First pass: collect blocks and macros, detect extends
373 678         1375 $source = $self->_preprocess($source, $vars);
374              
375             # If extends was found, do inheritance (iteratively for multi-level)
376 678         1670 while ($self->{_extends} ne '') {
377 7         15 my $parent_src = $self->_load_file($self->{_extends});
378 7         21 $self->{_extends} = '';
379             # Collect parent blocks/macros without overwriting child definitions
380 7         24 $parent_src = $self->_preprocess_parent($parent_src, $vars);
381             # Apply child blocks to parent template
382 7         31 $source = $self->_apply_inheritance($parent_src, $vars);
383             # Now resolve all known blocks so deeper levels see merged content.
384             # For each block in _blocks, expand any other known blocks inside it.
385 7         13 my $merged = 1;
386 7         8 my $passes = 10;
387 7   66     35 while ($merged && $passes-- > 0) {
388 7         12 $merged = 0;
389 7         9 for my $bname (keys %{$self->{_blocks}}) {
  7         27  
390 10         17 my $prev = $self->{_blocks}{$bname};
391 10         29 my $val = $prev;
392 10         12 my $lim2 = 10;
393 10         21 while ($lim2-- > 0) {
394 10         12 my $prev2 = $val;
395 10         15 $val =~ s/\{%-?\s*block\s+(\w+)\s*-?%\}((?:(?!\{%-?\s*block\b)[\s\S])*?)\{%-?\s*endblock(?:\s+\1)?\s*-?%\}/
396 0 0       0 exists $self->{_blocks}{$1} ? $self->{_blocks}{$1} : "\x00BLK\x01$1\x01$2\x00EBLK\x00"
397             /gse;
398 10 50       21 last if $val eq $prev2;
399             }
400 10         16 $val =~ s/\x00BLK\x01(\w+)\x01(.*?)\x00EBLK\x00/$2/gs;
401 10         13 $val =~ s/\{%-?\s*endblock(?:\s+\w+)?\s*-?%\}//g;
402 10 50       45 if ($val ne $prev) {
403 0         0 $self->{_blocks}{$bname} = $val;
404 0         0 $merged = 1;
405             }
406             }
407             }
408             }
409              
410 678         1461 return $self->_eval_template($source, $vars, $filename);
411             }
412              
413             ###############################################################################
414             # _preprocess - Scan for extends/block/macro; strip them for non-inherited use
415             ###############################################################################
416             sub _preprocess {
417 678     678   1126 my ($self, $source, $vars) = @_;
418              
419             # Detect {% extends "..." %}
420 678 100       1752 if ($source =~ s/\{%-?\s*extends\s+["']([^"']+)["']\s*-?%\}[ \t]*\n?//) {
421 6         22 $self->{_extends} = $1;
422             }
423              
424             # Collect {% macro name(args) %}...{% endmacro %}
425 678         1560 while ($source =~ s/\{%-?\s*macro\s+(\w+)\s*\(([^)]*)\)\s*-?%\}(.*?)\{%-?\s*endmacro\s*-?%\}//s) {
426 10         48 my ($mname, $argstr, $body) = ($1, $2, $3);
427 10         16 my (@args, @defaults);
428 10         42 for my $a (split /\s*,\s*/, $argstr) {
429 16         34 $a =~ s/^\s+|\s+$//g;
430 16 50       31 next if $a eq '';
431 16 100       31 if ($a =~ /^(\w+)\s*=\s*(.+)$/) {
432 2         4 push @args, $1;
433 2         3 push @defaults, $2;
434             }
435             else {
436 14         24 push @args, $a;
437 14         27 push @defaults, undef;
438             }
439             }
440 10         90 $self->{_macros}{$mname} = { args => \@args, defaults => \@defaults, body => $body };
441             }
442              
443             # Collect {% block name %}...{% endblock %} from child
444 678         1389 while ($source =~ s/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock(?:\s+\1)?\s*-?%\}/$self->_store_block($1, $2)/se) {}
  10         27  
445              
446 678         1271 return $source;
447             }
448              
449             sub _store_block {
450 10     10   36 my ($self, $name, $content) = @_;
451 10 50       38 $self->{_blocks}{$name} = $content unless exists $self->{_blocks}{$name};
452 10         79 return '';
453             }
454              
455              
456             # _preprocess_parent - Scan parent for extends/macro; collect NEW blocks only
457             # (does NOT strip block tags -- those must survive for _apply_inheritance)
458             sub _preprocess_parent {
459 7     7   14 my ($self, $source, $vars) = @_;
460              
461             # Detect {% extends "..." %} in parent (for multi-level)
462 7 100       35 if ($source =~ s/\{%-?\s*extends\s+["']([^"']+)["']\s*-?%\}[ \t]*\n?//) {
463 1         5 $self->{_extends} = $1;
464             }
465              
466             # Collect macros from parent (without overwriting child macros)
467 7         22 while ($source =~ s/\{%-?\s*macro\s+(\w+)\s*\(([^)]*)\)\s*-?%\}(.*?)\{%-?\s*endmacro\s*-?%\}//s) {
468 0         0 my ($mname, $argstr, $body) = ($1, $2, $3);
469 0 0       0 next if exists $self->{_macros}{$mname};
470 0         0 my (@args, @defaults);
471 0         0 for my $a (split /\s*,\s*/, $argstr) {
472 0         0 $a =~ s/^\s+|\s+$//g;
473 0 0       0 next if $a eq "";
474 0 0       0 if ($a =~ /^(\w+)\s*=\s*(.+)$/) {
475 0         0 push @args, $1;
476 0         0 push @defaults, $2;
477             }
478             else {
479 0         0 push @args, $a;
480 0         0 push @defaults, undef;
481             }
482             }
483 0         0 $self->{_macros}{$mname} = { args => \@args, defaults => \@defaults, body => $body };
484             }
485              
486             # Collect NEW blocks (without overwriting child blocks).
487             # Expand already-known child blocks within the body before storing.
488 7         34 my $tmp = $source;
489 7         69 while ($tmp =~ s/\{%-?\s*block\s+(\w+)\s*-?%\}(.*)\{%-?\s*endblock(?:\s+\1)?\s*-?%\}/
490 7         23 $self->_store_block_expanded($1, $2)
491             /se) {}
492              
493 7         17 return $source; # keep block tags intact for _apply_inheritance
494             }
495              
496             sub _store_block_expanded {
497 7     7   31 my ($self, $name, $body) = @_;
498 7 100       23 unless (exists $self->{_blocks}{$name}) {
499             # Expand already-resolved child blocks inside this body.
500             # Use _apply_inheritance logic on the snippet.
501 1         3 my $expanded = $body;
502 1         2 my $lim = 10;
503 1         4 while ($lim-- > 0) {
504 2         5 my $prev = $expanded;
505             # Replace innermost (non-nested) blocks that are known
506 2         12 $expanded =~ s/\{%-?\s*block\s+(\w+)\s*-?%\}((?:(?!\{%-?\s*block\b)[\s\S])*?)\{%-?\s*endblock(?:\s+\1)?\s*-?%\}/
507 1 50       7 exists $self->{_blocks}{$1} ? $self->{_blocks}{$1} : "__BLK__" . $1 . "__SEP__" . $2 . "__EBLK__"
508             /gse;
509 2 100       7 last if $expanded eq $prev;
510             }
511             # Clean up any un-expanded block tags
512 1         2 $expanded =~ s/__BLK__(\w+)__SEP__(.*?)__EBLK__/$2/gs;
513 1         3 $expanded =~ s/\{%-?\s*endblock(?:\s+\w+)?\s*-?%\}//g;
514 1         4 $self->{_blocks}{$name} = $expanded;
515             }
516 7         33 return '';
517             }
518              
519              
520             ###############################################################################
521             # _apply_inheritance - Render parent with child blocks
522             ###############################################################################
523             sub _apply_inheritance {
524 7     7   14 my ($self, $parent_src, $vars) = @_;
525              
526             # Iteratively substitute innermost (non-nested) blocks first.
527             # A block body that contains no other {% block %} tags is "innermost".
528 7         12 my $limit = 20;
529 7         18 while ($limit-- > 0) {
530 15         20 my $changed = 0;
531 15         66 $parent_src =~ s/\{%-?\s*block\s+(\w+)\s*-?%\}((?:(?!\{%-?\s*block\b)[\s\S])*?)\{%-?\s*endblock(?:\s+\1)?\s*-?%\}/
532 10 100       13 do { $changed = 1; exists $self->{_blocks}{$1} ? $self->{_blocks}{$1} : $2 }
  10         13  
  10         50  
533             /gse;
534 15 100       35 last unless $changed;
535             }
536              
537             # Remove any orphaned {% endblock %} left from outer block substitutions
538 7         14 $parent_src =~ s/\{%-?\s*endblock(?:\s+\w+)?\s*-?%\}//g;
539              
540 7         14 return $parent_src;
541             }
542              
543             ###############################################################################
544             # _eval_template - Evaluate template directives recursively
545             ###############################################################################
546             sub _eval_template {
547 697     697   1140 my ($self, $source, $vars, $filename) = @_;
548 697 50       1188 $filename = defined $filename ? $filename : '';
549              
550             # Apply trim_blocks / lstrip_blocks
551 697 100       1326 if ($self->{trim_blocks}) {
552 8         116 $source =~ s/(%\}|#\})[ \t]*\n/$1\n/g; # already minimal; remove newline after tag
553 8         48 $source =~ s/(%\}|#\})\n/$1/g;
554             }
555 697 100       1219 if ($self->{lstrip_blocks}) {
556 8         56 $source =~ s/^[ \t]*(\{[%#])/$1/mg;
557             }
558              
559 697         1132 my $bs = quotemeta($self->{block_start});
560 697         1019 my $be = quotemeta($self->{block_end});
561 697         971 my $vs = quotemeta($self->{var_start});
562 697         929 my $ve = quotemeta($self->{var_end});
563 697         953 my $cs = quotemeta($self->{comment_start});
564 697         926 my $ce = quotemeta($self->{comment_end});
565              
566             # Protect {% raw %}...{% endraw %} blocks BEFORE comment stripping,
567             # so that {# ... #} inside raw blocks is preserved as-is.
568 697         848 my @raw_chunks;
569 697         822 my $raw_ph = "\x00RAW\x00";
570 697         938 my $bs_re = quotemeta($self->{block_start});
571 697         1007 my $be_re = quotemeta($self->{block_end});
572 697         5334 $source =~ s/${bs_re}-?\s*raw\s*-?${be_re}(.*?)${bs_re}-?\s*endraw\s*-?${be_re}/
573 9         11 my $idx = scalar @raw_chunks;
574 9         15 push @raw_chunks, $1;
575 9         20 "${raw_ph}${idx}\x00"
576             /gse;
577              
578             # Remove comments (after raw protection so {# #} inside raw is safe)
579 697         2017 $source =~ s/$cs.*?$ce//gs;
580              
581             # Tokenise into chunks
582             # Tokens: [ 'text', $str ] or [ 'var', $expr ] or [ 'tag', $stmt ]
583 697         1556 my @tokens = $self->_tokenize($source);
584              
585             # Restore raw chunks in text tokens
586 697         1167 for my $tok (@tokens) {
587 1692 100 100     4719 if ($tok->[0] eq 'text' && $tok->[1] =~ /\Q$raw_ph\E/) {
588 9         76 $tok->[1] =~ s/\Q${raw_ph}\E(\d+)\x00/$raw_chunks[$1]/g;
589             }
590             }
591              
592 697         1680 return $self->_eval_tokens(\@tokens, $vars, 0, $filename);
593             }
594              
595             ###############################################################################
596             # _tokenize - Split source into text/var/tag tokens
597             ###############################################################################
598             sub _tokenize {
599 697     697   1156 my ($self, $source) = @_;
600              
601 697         982 my $bs = $self->{block_start}; # {%
602 697         965 my $be = $self->{block_end}; # %}
603 697         971 my $vs = $self->{var_start}; # {{
604 697         917 my $ve = $self->{var_end}; # }}
605              
606             # Build split pattern
607 697         1421 my $qbs = quotemeta($bs); my $qbe = quotemeta($be);
  697         897  
608 697         858 my $qvs = quotemeta($vs); my $qve = quotemeta($ve);
  697         878  
609              
610 697         773 my @tokens;
611 697         833 my $pos = 0;
612 697         903 my $len = length($source);
613              
614 697         1420 while ($pos < $len) {
615             # Find next tag start
616 1355         2165 my $var_pos = index($source, $vs, $pos);
617 1355         1745 my $block_pos = index($source, $bs, $pos);
618              
619             # No more tags
620 1355 100 100     3167 if ($var_pos < 0 && $block_pos < 0) {
621 52         132 push @tokens, [ 'text', substr($source, $pos) ];
622 52         120 last;
623             }
624              
625             # Determine which comes first
626 1303         1425 my $next;
627 1303 100 100     3696 if ($var_pos >= 0 && ($block_pos < 0 || $var_pos <= $block_pos)) {
      100        
628 479         575 $next = $var_pos;
629 479         750 my $end = index($source, $ve, $next + length($vs));
630 479 50       829 if ($end < 0) {
631 0         0 push @tokens, [ 'text', substr($source, $pos) ];
632 0         0 last;
633             }
634 479 100       800 if ($next > $pos) {
635 37         129 push @tokens, [ 'text', substr($source, $pos, $next - $pos) ];
636             }
637 479         2088 my $raw_expr = substr($source, $next + length($vs), $end - $next - length($vs));
638             # Whitespace control dash: "{{- expr -}}"
639             # The dash must be the very first/last non-whitespace character AND
640             # must be followed/preceded by whitespace (not part of the expression).
641             # Rule: if raw_expr starts with "-" followed by space/end, it's WS control.
642             # "-5" or "-x" is a negative expression, NOT whitespace control.
643 479         845 my $strip_left = ($raw_expr =~ /^-(?:\s|$)/);
644 479         725 my $strip_right = ($raw_expr =~ /(?:^|\s)-\s*$/);
645 479         659 my $expr = $raw_expr;
646 479         2654 $expr =~ s/^\s+|\s+$//g;
647 479 100       896 $expr =~ s/^-\s+// if $strip_left; # remove leading "- "
648 479 100       837 $expr =~ s/\s+-$// if $strip_right; # remove trailing " -"
649             # Strip whitespace from preceding text token if {{- }}
650 479 100 100     854 if ($strip_left && @tokens && $tokens[-1][0] eq 'text') {
      66        
651 6         26 $tokens[-1][1] =~ s/\s+$//;
652 6 100       21 splice @tokens, -1 if $tokens[-1][1] eq '';
653             }
654 479         1037 push @tokens, [ 'var', $expr, $strip_right ];
655 479         633 $pos = $end + length($ve);
656             # Strip leading whitespace from next text if {{ -}}
657 479 100 100     2405 if ($strip_right && $pos < $len) {
658 6         56 my $rest = substr($source, $pos);
659 6         15 my $stripped = $rest;
660 6         20 $stripped =~ s/^\s+//;
661 6         26 $pos += length($rest) - length($stripped);
662             }
663             }
664             else {
665 824         1089 $next = $block_pos;
666 824         1266 my $end = index($source, $be, $next + length($bs));
667 824 50       1335 if ($end < 0) {
668 0         0 push @tokens, [ 'text', substr($source, $pos) ];
669 0         0 last;
670             }
671 824 100       1378 if ($next > $pos) {
672             # Whitespace control: {% - strips preceding whitespace
673 304         554 my $text_before = substr($source, $pos, $next - $pos);
674 304         540 my $stmt_raw = substr($source, $next + length($bs), $end - $next - length($bs));
675 304 100       606 if ($stmt_raw =~ /^-/) {
676 2         12 $text_before =~ s/\s+$//;
677             }
678 304 100       861 push @tokens, [ 'text', $text_before ] if $text_before ne '';
679             }
680 824         1622 my $stmt_full = substr($source, $next + length($bs), $end - $next - length($bs));
681 824         1466 my $rstrip_tag = ($stmt_full =~ /\s*-\s*$/);
682 824         1061 my $stmt = $stmt_full;
683 824         4839 $stmt =~ s/^\s+|\s+$//g;
684 824         3583 $stmt =~ s/^-\s*|\s*-$//g;
685 824         1671 push @tokens, [ 'tag', $stmt ];
686 824         1115 $pos = $end + length($be);
687             # Whitespace control: -%} strips following whitespace
688 824 100 66     1586 if ($rstrip_tag && $pos < $len) {
689 2         14 substr($source, $pos) =~ s/^\s+//;
690 2 50       10 $pos = $len - length($source) + $pos if $source =~ s/^(\s+)//;
691             # Simpler: just advance pos past leading whitespace
692 2         5 my $rest = substr($source, $pos);
693 2         4 my $stripped = $rest;
694 2         3 $stripped =~ s/^\s+//;
695 2         5 $pos += length($rest) - length($stripped);
696             }
697             # trim_blocks: eat newline after %}
698 824 50 100     3720 if (!$rstrip_tag && $self->{trim_blocks} && $pos < $len && substr($source, $pos, 1) eq "\n") {
      100        
      66        
699 0         0 $pos++;
700             }
701             }
702             }
703 697         2202 return @tokens;
704             }
705              
706             ###############################################################################
707             # _eval_tokens - Execute a token list and return rendered string
708             ###############################################################################
709             sub _eval_tokens {
710 1256     1256   2494 my ($self, $tokens, $vars, $start, $filename) = @_;
711 1256 50       2368 $start = 0 unless defined $start;
712              
713 1256         1625 my $out = '';
714 1256         1617 my $i = $start;
715              
716 1256         2487 while ($i <= $#$tokens) {
717 1528         1845 my ($type, $content) = @{$tokens->[$i]};
  1528         2929  
718              
719 1528 100       3433 if ($type eq 'text') {
    100          
    50          
720 378         611 $out .= $content;
721 378         782 $i++;
722             }
723             elsif ($type eq 'var') {
724 663         1256 my $val = $self->_eval_expr($content, $vars);
725 663 100 66     2446 if (ref($val) eq 'HP::Handy::SafeString') {
    100 66        
726 20         60 $val = $$val;
727             }
728             elsif ($self->{auto_escape} && defined $val && !ref($val)) {
729 15         51 $val = _html_escape($val);
730             }
731 663 100       1335 $out .= defined $val ? $val : '';
732 663         1537 $i++;
733             }
734             elsif ($type eq 'tag') {
735 487         656 my $stmt = $content;
736              
737             # --- set ---
738 487 100 33     2771 if ($stmt =~ /^set\s+(\w+)\s*=\s*(.+)$/) {
    50 0        
    100          
    100          
    100          
    50          
    50          
    50          
    0          
    0          
739 54         134 my ($vname, $expr) = ($1, $2);
740 54         81 $vars->{$vname} = $self->_eval_expr($expr, $vars);
741 54         115 $i++;
742             }
743             # --- set block (multi-line) ---
744             elsif ($stmt =~ /^set\s+(\w+)\s*$/) {
745 0         0 my $vname = $1;
746 0         0 my ($body, $ni) = $self->_collect_until($tokens, $i + 1, 'endset');
747 0         0 $vars->{$vname} = $self->_eval_tokens($tokens, $vars, $i + 1, $filename);
748             # re-render just the body tokens
749 0         0 my @body_tokens = @{$tokens}[$i+1 .. $ni-1];
  0         0  
750 0         0 $vars->{$vname} = $self->_eval_tokens(\@body_tokens, $vars, 0, $filename);
751 0         0 $i = $ni + 1;
752             }
753             # --- if / elif / else / endif ---
754             elsif ($stmt =~ /^if\s+(.+)$/) {
755 287         746 my $cond_expr = $1;
756 287         734 my ($result, $ni) = $self->_eval_if($tokens, $i, $vars, $filename);
757 287         440 $out .= $result;
758 287         699 $i = $ni;
759             }
760             # --- for / endfor ---
761             elsif ($stmt =~ /^for\s+(.+?)\s+in\s+(.+?)(?:\s+if\s+(.+))?$/) {
762 125         615 my ($loop_var, $iter_expr, $cond_expr) = ($1, $2, $3);
763 125         374 my ($result, $ni) = $self->_eval_for($tokens, $i, $vars, $loop_var, $iter_expr, $cond_expr, $filename);
764 125         201 $out .= $result;
765 125         342 $i = $ni;
766             }
767             # --- include ---
768             elsif ($stmt =~ /^include\s+["']([^"']+)["'](?:\s+ignore\s+missing)?$/) {
769 8         26 my $inc_file = $1;
770 8         10 my $inc_src;
771 8         12 eval { $inc_src = $self->_load_file($inc_file) };
  8         17  
772 8 100       68 if ($@) {
773 1 50       7 $inc_src = '' if $stmt =~ /ignore\s+missing/;
774 1 50       4 croak $@ unless $stmt =~ /ignore\s+missing/;
775             }
776 8 50       53 $out .= $self->_eval_template($inc_src, { %$vars }, $inc_file) if defined $inc_src;
777 8         42 $i++;
778             }
779             # --- call macro ---
780             elsif ($stmt =~ /^(\w+)\s*\(([^)]*)\)$/ && exists $self->{_macros}{$1}) {
781 0         0 my ($mname, $argstr) = ($1, $2);
782 0         0 $out .= $self->_call_macro($mname, $argstr, $vars, $filename);
783 0         0 $i++;
784             }
785             # --- raw / endraw ---
786             elsif ($stmt eq 'raw') {
787 0         0 my ($raw, $ni) = $self->_collect_raw($tokens, $i + 1);
788 0         0 $out .= $raw;
789 0         0 $i = $ni + 1;
790             }
791             # --- with / endwith ---
792             elsif ($stmt =~ /^with(?:\s+(.+))?$/) {
793 13         24 my $assigns = $1;
794 13         34 my %local_vars = %$vars;
795 13 50       20 if (defined $assigns) {
796 13         18 for my $pair (_split_args($assigns)) {
797 15 50       47 if ($pair =~ /^(\w+)\s*=\s*(.+)$/) {
798 15         39 $local_vars{$1} = $self->_eval_expr($2, { %local_vars });
799             }
800             }
801             }
802 13         28 my ($body_tokens_ref, $ni) = $self->_collect_until($tokens, $i + 1, 'endwith');
803 13         56 $out .= $self->_eval_tokens($body_tokens_ref, { %local_vars }, 0, $filename);
804 13         46 $i = $ni + 1;
805             }
806             # --- block (standalone, no inheritance) ---
807             elsif ($stmt =~ /^block\s+(\w+)$/) {
808 0         0 my $bname = $1;
809 0         0 my ($body_tokens_ref, $ni) = $self->_collect_until($tokens, $i + 1, "endblock $bname", 'endblock');
810 0 0       0 if (exists $self->{_blocks}{$bname}) {
811 0         0 $out .= $self->_eval_template($self->{_blocks}{$bname}, $vars, $filename);
812             }
813             else {
814 0         0 $out .= $self->_eval_tokens($body_tokens_ref, $vars, 0, $filename);
815             }
816 0         0 $i = $ni + 1;
817             }
818             # skip endxxx tags (consumed by their openers above)
819             elsif ($stmt =~ /^end(if|for|block|macro|raw|with|set)/ || $stmt =~ /^(else|elif)/) {
820 0         0 $i++;
821             }
822             else {
823             # Unknown tag: pass through as-is for forward compatibility
824 0         0 $out .= $self->{block_start} . ' ' . $stmt . ' ' . $self->{block_end};
825 0         0 $i++;
826             }
827             }
828             else {
829 0         0 $i++;
830             }
831             }
832 1256         11051 return $out;
833             }
834              
835             ###############################################################################
836             # _eval_if - Evaluate if/elif/else/endif block
837             ###############################################################################
838             sub _eval_if {
839 287     287   512 my ($self, $tokens, $start, $vars, $filename) = @_;
840              
841             # Collect all branches: [ [cond_expr, [tokens...]], ..., [undef, [tokens...]] ]
842 287         392 my @branches;
843 287         455 my $cur_cond = $tokens->[$start][1];
844 287         838 $cur_cond =~ s/^if\s+//;
845 287         408 my @cur_body;
846 287         363 my $depth = 1;
847 287         384 my $i = $start + 1;
848              
849 287         550 while ($i <= $#$tokens) {
850 661         796 my ($type, $content) = @{$tokens->[$i]};
  661         1110  
851 661 100       1042 if ($type eq 'tag') {
852 343 100 100     956 if ($content =~ /^if\b/) {
    100 100        
    100          
    100          
853 8         6 $depth++;
854 8         10 push @cur_body, $tokens->[$i];
855             }
856             elsif ($content eq 'endif') {
857 295         400 $depth--;
858 295 100       481 if ($depth == 0) {
859 287         950 push @branches, [ $cur_cond, [@cur_body] ];
860 287         389 $i++;
861 287         517 last;
862             }
863             else {
864 8         8 push @cur_body, $tokens->[$i];
865             }
866             }
867             elsif ($depth == 1 && $content =~ /^elif\s+(.+)$/) {
868 13         24 push @branches, [ $cur_cond, [@cur_body] ];
869 13         15 $cur_cond = $1;
870 13         15 @cur_body = ();
871             }
872             elsif ($depth == 1 && $content eq 'else') {
873 16         47 push @branches, [ $cur_cond, [@cur_body] ];
874 16         104 $cur_cond = undef;
875 16         23 @cur_body = ();
876             }
877             else {
878 11         13 push @cur_body, $tokens->[$i];
879             }
880             }
881             else {
882 318         549 push @cur_body, $tokens->[$i];
883             }
884 374         724 $i++;
885             }
886 287 50 33     909 push @branches, [ $cur_cond, [@cur_body] ] if @cur_body || !defined $cur_cond;
887              
888 287         439 my $result = '';
889 287         509 for my $branch (@branches) {
890 376         667 my ($cond, $body) = @$branch;
891 376 100 100     1090 if (!defined $cond || $self->_eval_expr($cond, $vars)) {
892 214         760 $result = $self->_eval_tokens($body, $vars, 0, $filename);
893 214         430 last;
894             }
895             }
896 287         1237 return ($result, $i);
897             }
898              
899             ###############################################################################
900             # _eval_for - Evaluate for loop
901             ###############################################################################
902             sub _eval_for {
903 125     125   332 my ($self, $tokens, $start, $vars, $loop_var, $iter_expr, $cond_expr, $filename) = @_;
904              
905             # Collect loop body tokens (until endfor at depth 1)
906 125         197 my (@body_tokens, @else_tokens);
907 125         199 my $in_else = 0;
908 125         176 my $depth = 1;
909 125         185 my $i = $start + 1;
910              
911 125         300 while ($i <= $#$tokens) {
912 388         496 my ($type, $content) = @{$tokens->[$i]};
  388         748  
913 388 100       780 if ($type eq 'tag') {
914 193 100 100     906 if ($content =~ /^(?:for|if|block|macro|with)\b/) { $depth++ }
  25 100       50  
    100          
915             elsif ($content =~ /^end(?:for|if|block|macro|with)/) {
916 150         214 $depth--;
917 150 100 66     502 if ($depth == 0 && $content eq 'endfor') { $i++; last }
  125         161  
  125         274  
918             }
919             elsif ($depth == 1 && $content eq 'else') {
920 10         18 $in_else = 1; $i++; next;
  10         16  
  10         52  
921             }
922             }
923 253 100       386 if ($in_else) { push @else_tokens, $tokens->[$i] }
  10         21  
924 243         357 else { push @body_tokens, $tokens->[$i] }
925 253         518 $i++;
926             }
927              
928             # Evaluate iterable
929 125         328 my $iter = $self->_eval_expr($iter_expr, $vars);
930 125         198 my @items;
931 125 100       311 if (ref($iter) eq 'ARRAY') {
    100          
    100          
932 114         310 @items = @$iter;
933             }
934             elsif (ref($iter) eq 'HASH') {
935 5         31 @items = map { [ $_, $iter->{$_} ] } sort keys %$iter;
  10         70  
936             }
937             elsif (defined $iter) {
938 5         10 @items = ($iter);
939             }
940              
941             # Apply loop filter
942 125 100       236 if (defined $cond_expr) {
943 11         32 my @filtered;
944 11         18 for my $item (@items) {
945 35         106 my %loop_vars = %$vars;
946 35         98 $self->_assign_loop_var(\%loop_vars, $loop_var, $item);
947 35 100       66 push @filtered, $item if $self->_eval_expr($cond_expr, \%loop_vars);
948             }
949 11         30 @items = @filtered;
950             }
951              
952 125 100       227 if (!@items) {
953 9         29 return ($self->_eval_tokens(\@else_tokens, $vars, 0, $filename), $i);
954             }
955              
956 116         165 my $total = scalar @items;
957 116         194 my $result = '';
958              
959 116         321 for my $idx (0 .. $#items) {
960 323         1023 my %loop_vars = %$vars;
961 323         1217 $self->_assign_loop_var(\%loop_vars, $loop_var, $items[$idx]);
962              
963             # loop special variable
964             $loop_vars{loop} = {
965             index => $idx + 1,
966             index0 => $idx,
967             revindex => $total - $idx,
968             revindex0 => $total - $idx - 1,
969             first => ($idx == 0) ? 1 : 0,
970             last => ($idx == $#items) ? 1 : 0,
971             length => $total,
972             depth => 1,
973             depth0 => 0,
974             odd => ($idx % 2 == 0) ? 0 : 1,
975             even => ($idx % 2 == 0) ? 1 : 0,
976 0 0   0   0 changed => sub { my $attr = shift; _loop_changed($attr, $items[$idx], $idx > 0 ? $items[$idx-1] : undef) },
  0         0  
977 323 100       4527 };
    100          
    100          
    100          
978              
979 323         1001 $result .= $self->_eval_tokens(\@body_tokens, \%loop_vars, 0, $filename);
980             }
981              
982 116         548 return ($result, $i);
983             }
984              
985             sub _assign_loop_var {
986 358     358   718 my ($self, $vars, $loop_var, $item) = @_;
987             # Tuple unpacking: "k, v" in "for k, v in ..."
988 358 100       702 if ($loop_var =~ /^(\w+)\s*,\s*(\w+)$/) {
989 10         39 my ($k, $v) = ($1, $2);
990 10 50       26 if (ref($item) eq 'ARRAY') {
991 10         27 $vars->{$k} = $item->[0];
992 10         25 $vars->{$v} = $item->[1];
993             }
994             else {
995 0         0 $vars->{$k} = $item;
996 0         0 $vars->{$v} = undef;
997             }
998             }
999             else {
1000 348         917 $vars->{$loop_var} = $item;
1001             }
1002             }
1003              
1004             sub _loop_changed {
1005 0     0   0 my ($attr, $cur, $prev) = @_;
1006 0 0       0 return 1 unless defined $prev;
1007 0         0 my $cv = _get_attr($cur, $attr);
1008 0         0 my $pv = _get_attr($prev, $attr);
1009 0 0 0     0 return (!defined $cv && !defined $pv) ? 0
    0 0        
    0          
1010             : (!defined $cv || !defined $pv) ? 1
1011             : $cv ne $pv ? 1 : 0;
1012             }
1013              
1014             ###############################################################################
1015             # _collect_until - Collect tokens until end tag (depth-aware)
1016             ###############################################################################
1017             sub _collect_until {
1018 13     13   21 my ($self, $tokens, $start, @end_tags) = @_;
1019 13         20 my %ends = map { $_ => 1 } @end_tags;
  13         27  
1020 13         12 my @body;
1021 13         13 my $depth = 1;
1022 13         14 my $i = $start;
1023              
1024             # Map each opener tag to its closer. We track depth only for
1025             # openers whose closer matches one of the @end_tags we are looking for,
1026             # plus any nested use of the SAME opener.
1027 13         49 my %opener_to_closer = (
1028             'if' => 'endif',
1029             'for' => 'endfor',
1030             'block' => 'endblock',
1031             'macro' => 'endmacro',
1032             'with' => 'endwith',
1033             'raw' => 'endraw',
1034             'set' => 'endset',
1035             );
1036 13         55 my %closer_to_opener = reverse %opener_to_closer;
1037              
1038 13         21 while ($i <= $#$tokens) {
1039 42         40 my ($type, $content) = @{$tokens->[$i]};
  42         51  
1040 42 100       48 if ($type eq 'tag') {
1041 24         23 my $bare = $content; $bare =~ s/\s.*//;
  24         30  
1042 24 100       45 if (exists $ends{$content}) {
    100          
1043 14         8 $depth--;
1044 14 100       32 last if $depth == 0;
1045             }
1046             elsif (exists $opener_to_closer{$bare}) {
1047             # Push only when the closer of THIS opener is in @end_tags
1048             # (i.e. same kind of nesting as what we're looking for)
1049 6         8 my $closer = $opener_to_closer{$bare};
1050 6 100       18 if (exists $ends{$closer}) {
1051 1         2 $depth++;
1052             }
1053             }
1054             }
1055 29         40 push @body, $tokens->[$i];
1056 29         34 $i++;
1057             }
1058 13         50 return (\@body, $i);
1059             }
1060              
1061             ###############################################################################
1062             # _collect_raw - Collect raw text tokens until endraw
1063             ###############################################################################
1064             sub _collect_raw {
1065 0     0   0 my ($self, $tokens, $start) = @_;
1066 0         0 my $raw = '';
1067 0         0 my $i = $start;
1068 0         0 while ($i <= $#$tokens) {
1069 0         0 my ($type, $content) = @{$tokens->[$i]};
  0         0  
1070 0 0 0     0 last if $type eq 'tag' && $content eq 'endraw';
1071 0         0 $raw .= $content;
1072 0         0 $i++;
1073             }
1074 0         0 return ($raw, $i);
1075             }
1076              
1077             ###############################################################################
1078             # _call_macro - Call a defined macro
1079             ###############################################################################
1080             sub _call_macro {
1081 11     11   30 my ($self, $mname, $argstr, $vars, $filename) = @_;
1082 11         20 my $macro = $self->{_macros}{$mname};
1083 11 50       25 return '' unless defined $macro;
1084              
1085             # Parse positional args
1086 11         25 my @arg_vals = _split_args($argstr);
1087 11         35 my %macro_vars = %$vars;
1088              
1089 11         17 my @margs = @{ $macro->{args} };
  11         61  
1090 11         15 my @mdefaults = @{ $macro->{defaults} };
  11         18  
1091              
1092 11         27 for my $idx (0 .. $#margs) {
1093 18 100       33 if ($idx < @arg_vals) {
    50          
1094 17         45 $macro_vars{$margs[$idx]} = $self->_eval_expr($arg_vals[$idx], $vars);
1095             }
1096             elsif (defined $mdefaults[$idx]) {
1097 1         4 $macro_vars{$margs[$idx]} = $self->_eval_expr($mdefaults[$idx], $vars);
1098             }
1099             else {
1100 0         0 $macro_vars{$margs[$idx]} = undef;
1101             }
1102             }
1103              
1104             # varargs / kwargs accessible as special vars
1105 11         24 $macro_vars{varargs} = [];
1106 11         21 $macro_vars{kwargs} = {};
1107              
1108 11         124 return $self->_eval_template($macro->{body}, { %macro_vars }, $filename);
1109             }
1110              
1111             ###############################################################################
1112             # _eval_expr - Evaluate a Jinja2 expression
1113             ###############################################################################
1114             sub _eval_expr {
1115 2681     2681   5984 my ($self, $expr, $vars) = @_;
1116 2681         8471 $expr =~ s/^\s+|\s+$//g;
1117 2681 50 33     7621 return undef unless defined $expr && $expr ne '';
1118              
1119             # Literal: None / True / False
1120 2681 100 100     9565 return undef if $expr eq 'none' || $expr eq 'None' || $expr eq 'undefined';
      66        
1121 2678 100 66     6919 return 1 if $expr eq 'true' || $expr eq 'True';
1122 2675 100 66     6496 return 0 if $expr eq 'false' || $expr eq 'False';
1123              
1124             # Literal: integer / float
1125 2671 100       8155 return $expr + 0 if $expr =~ /^-?\d+(\.\d+)?$/;
1126              
1127             # Unary minus: -expr (where expr is not a literal number)
1128 2344 100 100     4757 if ($expr =~ /^-(.+)$/ && $1 !~ /^\d/) {
1129 1         2 my $val = $self->_eval_expr($1, $vars);
1130 1 50       4 return defined $val ? -$val : undef;
1131             }
1132              
1133             # Literal: string
1134 2343 100 100     7292 if ($expr =~ /^"((?:[^"\\]|\\.)*)"$/ || $expr =~ /^'((?:[^'\\]|\\.)*)'$/) {
1135 203         390 my $s = $1;
1136 203         269 $s =~ s/\\n/\n/g; $s =~ s/\\t/\t/g; $s =~ s/\\r/\r/g;
  203         245  
  203         237  
1137 203         242 $s =~ s/\\(.)/$1/g;
1138 203         562 return $s;
1139             }
1140              
1141             # Literal: [] list
1142 2140 100       3688 if ($expr =~ /^\[(.+)\]$/) {
1143 7         16 my @elems = _split_args($1);
1144 7         13 return [ map { $self->_eval_expr($_, $vars) } @elems ];
  20         32  
1145             }
1146              
1147             # Literal: {} dict
1148 2133 100       3554 if ($expr =~ /^\{(.+)\}$/) {
1149 1         9 return $self->_eval_dict($1, $vars);
1150             }
1151              
1152             # Literal: range() -- only when no pipe follows (e.g. range(3) but not range(3)|join)
1153 2132 100       3537 if ($expr =~ /^range\(([^|]*)\)$/) {
1154 22         100 return $self->_eval_range($1, $vars);
1155             }
1156              
1157             # Conditional expression: a if cond else b
1158 2110 100       3753 if ($expr =~ /^(.+?)\s+if\s+(.+?)\s+else\s+(.+)$/) {
1159 25         103 my ($a, $cond, $b) = ($1, $2, $3);
1160 25 100       60 return $self->_eval_expr($cond, $vars) ? $self->_eval_expr($a, $vars) : $self->_eval_expr($b, $vars);
1161             }
1162              
1163             # 'not' operator
1164 2085 100       3487 if ($expr =~ /^not\s+(.+)$/) {
1165 67 100       197 return $self->_eval_expr($1, $vars) ? 0 : 1;
1166             }
1167              
1168             # 'in' / 'not in' operator
1169 2018 100       3351 if ($expr =~ /^(.+?)\s+not\s+in\s+(.+)$/) {
1170 4 100       6 return $TESTS{in}->($self->_eval_expr($1, $vars), $self->_eval_expr($2, $vars)) ? 0 : 1;
1171             }
1172 2014 100       3894 if ($expr =~ /^(.+?)\s+in\s+(.+)$/) {
1173 19         45 return $TESTS{in}->($self->_eval_expr($1, $vars), $self->_eval_expr($2, $vars));
1174             }
1175              
1176             # Boolean operators: and / or
1177 1995 100       3322 if ($expr =~ /^(.+?)\s+or\s+(.+)$/) {
1178 12         27 my $a = $self->_eval_expr($1, $vars);
1179 12 100       32 return $a if $a;
1180 8         11 return $self->_eval_expr($2, $vars);
1181             }
1182 1983 100       5411 if ($expr =~ /^(.+?)\s+and\s+(.+)$/) {
1183 17         34 my $a = $self->_eval_expr($1, $vars);
1184 17 100       53 return $a ? $self->_eval_expr($2, $vars) : $a;
1185             }
1186              
1187             # Filter pipeline: expr | filter1 | filter2(arg)
1188             # Must be checked before comparison operators so that ">" inside
1189             # filter arg strings (e.g. replace("-->","x")) is not misread.
1190 1966 100       3887 if ($expr =~ /^(.+?)\s*\|\s*(\w+)(.*)$/) {
1191 241         711 my ($lhs, $fname, $rest) = ($1, $2, $3);
1192 241         501 my $val = $self->_eval_expr($lhs, $vars);
1193              
1194 241         315 my $fargs_str = '';
1195 241         286 my $remaining = '';
1196 241 100       596 if ($rest =~ /^\s*\(/) {
    100          
1197             # Find matching closing paren, respecting quotes and nesting
1198 102         113 my $depth2 = 0; my $in_sq2 = 0; my $in_dq2 = 0;
  102         91  
  102         94  
1199 102         83 my $found2 = -1;
1200 102         115 my $rs = $rest; $rs =~ s/^\s*//;
  102         229  
1201 102         198 for my $ci (0 .. length($rs) - 1) {
1202 577         621 my $c = substr($rs, $ci, 1);
1203 577 100 100     1418 if (!$in_sq2 && !$in_dq2 && $c eq "'") { $in_sq2 = 1; next }
  4   100     7  
  4         10  
1204 573 100 100     789 if ($in_sq2 && $c eq "'") { $in_sq2 = 0; next }
  4         5  
  4         6  
1205 569 100 100     1327 if (!$in_sq2 && !$in_dq2 && $c eq '"') { $in_dq2 = 1; next }
  82   100     75  
  82         99  
1206 487 100 100     925 if ($in_dq2 && $c eq '"') { $in_dq2 = 0; next }
  82         89  
  82         82  
1207 405 100 100     779 if (!$in_sq2 && !$in_dq2) {
1208 240 100       347 if ($c eq '(') { $depth2++ }
  102 100       135  
1209 102 50       109 elsif ($c eq ')') { $depth2--; if ($depth2 == 0) { $found2 = $ci; last } }
  102         185  
  102         96  
  102         127  
1210             }
1211             }
1212 102 50       163 if ($found2 >= 0) {
1213 102         128 $fargs_str = substr($rs, 1, $found2 - 1);
1214 102         145 $remaining = substr($rs, $found2 + 1);
1215             }
1216             }
1217             elsif ($rest =~ /^\s*(\|.+)$/) {
1218 35         52 $remaining = $1;
1219             }
1220              
1221 241         267 my @fargs;
1222 241 100       393 if ($fargs_str ne '') {
1223 102         204 @fargs = map { $self->_eval_expr($_, $vars) } _split_args($fargs_str);
  110         229  
1224             }
1225              
1226 241         413 my $fn = $self->{_filters}{$fname};
1227 241 50       527 $val = $fn ? $fn->($val, @fargs) : $val;
1228              
1229             # Continue pipeline
1230 241 100       536 if ($remaining =~ s/^\s*\|\s*//) {
1231 49         240 return $self->_eval_expr("__PIPEVAL__ | $remaining", { %$vars, '__PIPEVAL__' => $val });
1232             }
1233 192         528 return $val;
1234             }
1235              
1236             # Comparison operators
1237 1725 100       4908 if ($expr =~ /^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/) {
1238 107         289 my ($lhs, $op, $rhs) = ($1, $2, $3);
1239 107         204 my $l = $self->_eval_expr($lhs, $vars);
1240 107         191 my $r = $self->_eval_expr($rhs, $vars);
1241 107 50 33     305 return 0 unless defined $l && defined $r;
1242 107   66     413 my $numeric = ($l =~ /^-?(?:\d+\.?\d*|\.\d+)$/ && $r =~ /^-?(?:\d+\.?\d*|\.\d+)$/);
1243 107 100       187 if ($op eq '==') { return $numeric ? ($l == $r ? 1 : 0) : ($l eq $r ? 1 : 0) }
  22 100       78  
    100          
    100          
1244 85 100       127 if ($op eq '!=') { return $numeric ? ($l != $r ? 1 : 0) : ($l ne $r ? 1 : 0) }
  15 100       68  
    100          
    100          
1245 70 100       116 if ($op eq '>') { return $l > $r ? 1 : 0 }
  42 100       219  
1246 28 100       39 if ($op eq '<') { return $l < $r ? 1 : 0 }
  10 100       30  
1247 18 100       56 if ($op eq '>=') { return $l >= $r ? 1 : 0 }
  12 100       39  
1248 6 100       10 if ($op eq '<=') { return $l <= $r ? 1 : 0 }
  6 50       20  
1249             }
1250              
1251             # Arithmetic: match ** and // before single-char operators
1252 1618 100       5000 if ($expr =~ /^(.+?)\s*(\*\*|\/\/)\s*(.+)$/) {
1253 4         14 my ($lhs, $op, $rhs) = ($1, $2, $3);
1254 4         6 my $l = $self->_eval_expr($lhs, $vars);
1255 4         6 my $r = $self->_eval_expr($rhs, $vars);
1256 4 50 33     12 if (defined $l && defined $r) {
1257 4 50       5 if ($op eq '//') { return $r != 0 ? int($l / $r) : undef }
  3 100       8  
1258 1 50       2 if ($op eq '**') { return $l ** $r }
  1         3  
1259             }
1260 0         0 return undef;
1261             }
1262              
1263             # Arithmetic: scan for operator at depth-0 (respects parentheses)
1264             {
1265 1614         1992 my $depth3 = 0; my $in_sq3 = 0; my $in_dq3 = 0;
  1614         2241  
  1614         1914  
  1614         1953  
1266 1614         1937 my $op_pos = -1; my $op_char = '';
  1614         2273  
1267 1614         3841 for my $ci (0 .. length($expr) - 1) {
1268 6561         10506 my $c = substr($expr, $ci, 1);
1269 6561 100 100     25794 if (!$in_sq3 && !$in_dq3 && $c eq "'") { $in_sq3 = 1; next }
  2   100     4  
  2         4  
1270 6559 100 100     11994 if ($in_sq3 && $c eq "'") { $in_sq3 = 0; next }
  2         4  
  2         3  
1271 6557 100 100     23796 if (!$in_sq3 && !$in_dq3 && $c eq '"') { $in_dq3 = 1; next }
  26   100     35  
  26         45  
1272 6531 100 100     11521 if ($in_dq3 && $c eq '"') { $in_dq3 = 0; next }
  26         37  
  26         42  
1273 6505 100 100     17898 next if $in_sq3 || $in_dq3;
1274 6461 100 100     24238 if ($c eq '(' || $c eq '[' || $c eq '{') { $depth3++; next }
  82   100     119  
  82         228  
1275 6379 100 100     23726 if ($c eq ')' || $c eq ']' || $c eq '}') { $depth3--; next }
  78   100     115  
  78         170  
1276 6301 100       10784 next if $depth3 != 0;
1277 5802 100       15040 if ($c =~ /^[+\-*\/%~]$/) {
1278 59 100       103 next if $ci == 0;
1279 58         86 my $prev = substr($expr, $ci - 1, 1);
1280 58 50 33     198 next if $prev =~ /^[+\-*\/%~(]$/ || $prev eq "";
1281 58 50 66     168 next if $c eq '*' && $ci + 1 < length($expr) && substr($expr, $ci + 1, 1) eq '*';
      66        
1282 58 50 66     880 next if $c eq '/' && $ci + 1 < length($expr) && substr($expr, $ci + 1, 1) eq '/';
      66        
1283 58         80 $op_pos = $ci; $op_char = $c;
  58         62  
1284 58         92 last;
1285             }
1286             }
1287 1614 100       3263 if ($op_pos > 0) {
1288 58         91 my $lhs = substr($expr, 0, $op_pos);
1289 58         83 my $rhs = substr($expr, $op_pos + 1);
1290 58         223 $lhs =~ s/\s+$//; $rhs =~ s/^\s+//;
  58         118  
1291 58         237 my $l = $self->_eval_expr($lhs, $vars);
1292 58         123 my $r = $self->_eval_expr($rhs, $vars);
1293 58 50 33     154 if (defined $l && defined $r) {
1294 58 100       82 if ($op_char eq '+') {
1295 26 50 33     230 return ($l =~ /^-?\d/ && $r =~ /^-?\d/) ? $l + $r : $l . $r;
1296             }
1297 32 100       52 if ($op_char eq '~') { return $l . $r }
  16         59  
1298 16 100       26 if ($op_char eq '-') { return $l - $r }
  2         4  
1299 14 100       22 if ($op_char eq '*') { return $l * $r }
  12         29  
1300 2 50       4 if ($op_char eq '/') { return $r != 0 ? $l / $r : undef }
  1 100       4  
1301 1 50       2 if ($op_char eq '%') { return $r != 0 ? $l % $r : undef }
  1 50       3  
1302             }
1303             }
1304             }
1305              
1306             # Parenthesised expression -- must be before is/is not so that
1307             # (expr is test) does not mis-parse the leading "(" as part of lhs.
1308 1556 100       3347 if ($expr =~ /^\((.+)\)$/) {
1309 35         175 return $self->_eval_expr($1, $vars);
1310             }
1311              
1312             # is / is not test
1313 1521 100       2782 if ($expr =~ /^(.+?)\s+is\s+not\b\s+(\w+)(?:\s+(.+))?$/) {
1314 9         58 my ($lhs, $test, $arg) = ($1, $2, $3);
1315 9         40 my $val = $self->_eval_expr($lhs, $vars);
1316 9 50       29 my $targ = defined $arg ? $self->_eval_expr($arg, $vars) : undef;
1317 9         26 my $fn = $self->{_tests}{$test};
1318 9 100       36 return $fn ? ($fn->($val, $targ) ? 0 : 1) : 1;
    50          
1319             }
1320 1512 100       3966 if ($expr =~ /^(.+?)\s+is\s+(\w+)(?:\s+(.+))?$/) {
1321 134         700 my ($lhs, $test, $arg) = ($1, $2, $3);
1322 134         540 my $val = $self->_eval_expr($lhs, $vars);
1323 134 100       388 my $targ = defined $arg ? $self->_eval_expr($arg, $vars) : undef;
1324 134         381 my $fn = $self->{_tests}{$test};
1325 134 100       445 return $fn ? ($fn->($val, $targ) ? 1 : 0) : 0;
    50          
1326             }
1327              
1328             # Attribute access: obj.attr or obj["key"] or obj['key']
1329 1378 100       3119 if ($expr =~ /^(.+?)\.(\w+)(?:\(([^)]*)\))?$/) {
1330 157         819 my ($obj_expr, $attr, $call_args) = ($1, $2, $3);
1331 157         694 my $obj = $self->_eval_expr($obj_expr, $vars);
1332 157 50       1973 if (defined $call_args) {
1333             # Method call (filters on object)
1334 0         0 my $fn = $self->{_filters}{$attr};
1335 0         0 my @args = map { $self->_eval_expr($_, $vars) } _split_args($call_args);
  0         0  
1336 0 0       0 return $fn ? $fn->($obj, @args) : undef;
1337             }
1338 157         397 return _get_attr($obj, $attr);
1339             }
1340              
1341 1221 100       2075 if ($expr =~ /^(.+?)\[["'](\w+)["']\]$/) {
1342 5         21 my ($obj_expr, $key) = ($1, $2);
1343 5         16 my $obj = $self->_eval_expr($obj_expr, $vars);
1344 5         15 return _get_attr($obj, $key);
1345             }
1346              
1347 1216 100       3210 if ($expr =~ /^(.+?)\[(-?\d+)\]$/) {
1348 12         56 my ($obj_expr, $idx) = ($1, $2);
1349 12         38 my $obj = $self->_eval_expr($obj_expr, $vars);
1350 12 50       59 return ref($obj) eq 'ARRAY' ? $obj->[$idx] : undef;
1351             }
1352              
1353             # Slice: list[start:end]
1354 1204 100       2082 if ($expr =~ /^(.+?)\[(-?\d*):(-?\d*)\]$/) {
1355 5         24 my ($obj_expr, $s, $e) = ($1, $2, $3);
1356 5         16 my $obj = $self->_eval_expr($obj_expr, $vars);
1357 5 50       19 return undef unless ref($obj) eq 'ARRAY';
1358 5         9 my $len = scalar @$obj;
1359 5 100       15 my $si = ($s ne '') ? int($s) : 0;
1360 5 100       13 my $ei = ($e ne '') ? int($e) : $len;
1361 5 50       12 $si += $len if $si < 0;
1362 5 100       10 $ei += $len if $ei < 0;
1363 5 50       12 $si = 0 if $si < 0;
1364 5 50       30 $ei = $len if $ei > $len;
1365 5         16 return [ @{$obj}[$si .. $ei - 1] ];
  5         24  
1366             }
1367              
1368             # Function/macro call: name(args)
1369 1199 100       3087 if ($expr =~ /^(\w+)\s*\(([^)]*)\)$/) {
1370 11         45 my ($fname, $argstr) = ($1, $2);
1371             # Macro call
1372 11 50       34 if (exists $self->{_macros}{$fname}) {
1373 11         55 return $self->_call_macro($fname, $argstr, $vars, '');
1374             }
1375             # Built-in functions
1376 0 0       0 if ($fname eq 'range') {
1377 0         0 return $self->_eval_range($argstr, $vars);
1378             }
1379             }
1380              
1381             # Variable lookup
1382 1188 100       4056 if ($expr =~ /^(\w+)$/) {
1383 1178 100       4455 return exists $vars->{$expr} ? $vars->{$expr} : undef;
1384             }
1385              
1386 10         44 return undef;
1387             }
1388              
1389             ###############################################################################
1390             # _eval_dict - Parse and evaluate a dict literal { key: val, ... }
1391             ###############################################################################
1392             sub _eval_dict {
1393 1     1   6 my ($self, $inner, $vars) = @_;
1394 1         3 my %h;
1395             # Simple k:v split (no nested dicts)
1396 1         3 for my $pair (_split_args($inner)) {
1397 1 50       9 if ($pair =~ /^(.+?)\s*:\s*(.+)$/) {
1398 1         5 my $k = $self->_eval_expr($1, $vars);
1399 1         4 my $v = $self->_eval_expr($2, $vars);
1400 1 50       28 $h{$k} = $v if defined $k;
1401             }
1402             }
1403 1         8 return { %h };
1404             }
1405              
1406             ###############################################################################
1407             # _eval_range - Evaluate range(stop) / range(start, stop[, step])
1408             ###############################################################################
1409             sub _eval_range {
1410 22     22   72 my ($self, $args_str, $vars) = @_;
1411 22         61 my @args = map { $self->_eval_expr($_, $vars) } _split_args($args_str);
  34         76  
1412 22         44 my ($start, $stop, $step);
1413 22 100       52 if (@args == 1) { ($start, $stop, $step) = (0, int($args[0]), 1) }
  14 100       33  
1414 4         10 elsif (@args == 2) { ($start, $stop, $step) = (int($args[0]), int($args[1]), 1) }
1415 4   50     15 else { ($start, $stop, $step) = (int($args[0]), int($args[1]), int($args[2] || 1)) }
1416 22 50       43 $step = 1 if $step == 0;
1417 22         27 my @result;
1418 22 100       42 if ($step > 0) { for (my $n = $start; $n < $stop; $n += $step) { push @result, $n } }
  20         54  
  56         125  
1419 2         8 else { for (my $n = $start; $n > $stop; $n += $step) { push @result, $n } }
  10         13  
1420 22         71 return [ @result ];
1421             }
1422              
1423             ###############################################################################
1424             # _get_attr - Get attribute from hash or array
1425             ###############################################################################
1426             sub _get_attr {
1427 189     189   419 my ($obj, $attr) = @_;
1428 189 50       343 return undef unless defined $obj;
1429 189 50       454 if (ref($obj) eq 'HASH') {
1430 189 100       901 return exists $obj->{$attr} ? $obj->{$attr} : undef;
1431             }
1432 0 0       0 if (ref($obj) eq 'ARRAY') {
1433 0 0       0 return $attr =~ /^-?\d+$/ ? $obj->[$attr] : scalar @$obj;
1434             }
1435 0         0 return undef;
1436             }
1437              
1438             ###############################################################################
1439             # _split_args - Split comma-separated arguments (respects nested parens/quotes)
1440             ###############################################################################
1441             sub _split_args {
1442 156     156   276 my ($str) = @_;
1443 156 100 66     575 return () unless defined $str && $str =~ /\S/;
1444 155         156 my @args;
1445 155         175 my $cur = '';
1446 155         149 my $depth = 0;
1447 155         184 my $in_sq = 0;
1448 155         157 my $in_dq = 0;
1449              
1450 155         428 for my $ch (split //, $str) {
1451 681 100 100     1850 if (!$in_sq && !$in_dq && $ch eq "'") { $in_sq = 1; $cur .= $ch; next }
  4   100     6  
  4         6  
  4         8  
1452 677 100 100     999 if ($in_sq && $ch eq "'") { $in_sq = 0; $cur .= $ch; next }
  4         7  
  4         5  
  4         7  
1453 673 100 100     1698 if (!$in_sq && !$in_dq && $ch eq '"') { $in_dq = 1; $cur .= $ch; next }
  99   100     104  
  99         110  
  99         144  
1454 574 100 100     1034 if ($in_dq && $ch eq '"') { $in_dq = 0; $cur .= $ch; next }
  99         95  
  99         102  
  99         112  
1455 475 100 100     1047 if (!$in_sq && !$in_dq) {
1456 275 100 66     1728 if ($ch eq '(' || $ch eq '[' || $ch eq '{') { $depth++ }
  1 100 66     1  
    100 66        
      66        
      100        
1457 1         1 elsif ($ch eq ')' || $ch eq ']' || $ch eq '}') { $depth-- }
1458             elsif ($ch eq ',' && $depth == 0) {
1459 42         142 $cur =~ s/^\s+|\s+$//g;
1460 42         83 push @args, $cur;
1461 42         54 $cur = '';
1462 42         61 next;
1463             }
1464             }
1465 433         621 $cur .= $ch;
1466             }
1467 155         544 $cur =~ s/^\s+|\s+$//g;
1468 155 50       371 push @args, $cur if $cur ne '';
1469 155         393 return @args;
1470             }
1471              
1472             ###############################################################################
1473             # _html_escape - Escape HTML special characters
1474             ###############################################################################
1475             sub _html_escape {
1476 26     26   35 my ($s) = @_;
1477 26 50       31 return '' unless defined $s;
1478 26         38 $s =~ s/&/&/g;
1479 26         38 $s =~ s/
1480 26         36 $s =~ s/>/>/g;
1481 26         36 $s =~ s/"/"/g;
1482 26         24 $s =~ s/'/'/g;
1483 26         35 return $s;
1484             }
1485              
1486             ###############################################################################
1487             # _to_json - Minimal JSON serializer (no external dependency)
1488             ###############################################################################
1489             sub _to_json {
1490 13     13   16 my ($val) = @_;
1491 13 100       21 return 'null' unless defined $val;
1492 12 50 33     22 return 'true' if ref($val) eq 'SCALAR' && $$val == 1;
1493 12 50 33     19 return 'false' if ref($val) eq 'SCALAR' && $$val == 0;
1494 12 100       16 if (ref($val) eq 'ARRAY') {
1495 2         3 return '[' . join(',', map { _to_json($_) } @$val) . ']';
  6         9  
1496             }
1497 10 100       12 if (ref($val) eq 'HASH') {
1498             return '{' . join(',', map {
1499 1         6 _to_json($_) . ':' . _to_json($val->{$_})
  0         0  
1500             } sort keys %$val) . '}';
1501             }
1502 9 100       25 if ($val =~ /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/) {
1503 6         31 return $val;
1504             }
1505 3         5 $val =~ s/\\/\\\\/g;
1506 3         5 $val =~ s/"/\\"/g;
1507 3         4 $val =~ s/\n/\\n/g;
1508 3         4 $val =~ s/\r/\\r/g;
1509 3         3 $val =~ s/\t/\\t/g;
1510 3         6 return "\"$val\"";
1511             }
1512              
1513             ###############################################################################
1514             # Back to main package -- demo when run directly
1515             ###############################################################################
1516              
1517             # Run as script: perl lib/HP/Handy.pm
1518             unless (caller) {
1519             my $tmpl = HP::Handy->new(auto_escape => 1);
1520              
1521             my $source = <<'TMPL';
1522            
1523            
1524             HP::Handy Demo
1525            
1531            
1532            
1533            

HP::Handy Demo

1534            

Version: {{ version }}

1535              
1536            

Variable and Filter

1537            

Hello, {{ name | upper }}!

1538            

Escaped: {{ snippet }}

1539              
1540            

For Loop

1541            
1542            
#ItemFirst?Last?
1543             {% for item in items %}
1544            
1545             {{ loop.index }}
1546             {{ item }}
1547             {{ "yes" if loop.first else "no" }}
1548             {{ "yes" if loop.last else "no" }}
1549            
1550             {% endfor %}
1551            
1552              
1553            

Conditional

1554             {% if score >= 90 %}
1555            

Grade: A

1556             {% elif score >= 70 %}
1557            

Grade: B

1558             {% else %}
1559            

Grade: C

1560             {% endif %}
1561              
1562            

Set and Default Filter

1563             {% set greeting = "Konnichiwa" %}
1564            

{{ greeting | default("Hello") }}

1565            

Missing: {{ missing | default("(none)") }}

1566            
1567            
1568             TMPL
1569              
1570             my $html = $tmpl->render_string($source, {
1571             version => $HP::Handy::VERSION,
1572             name => 'World',
1573             snippet => '',
1574             items => [ 'Perl', 'Python', 'Ruby', 'JavaScript' ],
1575             score => 85,
1576             });
1577              
1578             print $html;
1579             }
1580              
1581              
1582             ###############################################################################
1583             # HP::Handy::SafeString - trusted HTML string (bypasses auto_escape)
1584             ###############################################################################
1585             package HP::Handy::SafeString;
1586             use overload
1587 2     2   3 q("") => sub { ${$_[0]} },
  2         5  
1588 12     12   8415 fallback => 1;
  12         20703  
  12         138  
1589              
1590             package HP::Handy;
1591              
1592             1;
1593              
1594             __END__