bfb8f6609280ea146f85168ba332bfb4f2d8dfb9
[catagits/HTML-Zoom.git] / lib / HTML / Zoom / FilterBuilder.pm
1 package HTML::Zoom::FilterBuilder;
2
3 use strictures 1;
4 use base qw(HTML::Zoom::SubObject);
5 use HTML::Zoom::CodeStream;
6
7 sub _stream_from_code {
8   shift->_zconfig->stream_utils->stream_from_code(@_)
9 }
10
11 sub _stream_from_array {
12   shift->_zconfig->stream_utils->stream_from_array(@_)
13 }
14
15 sub _stream_from_proto {
16   shift->_zconfig->stream_utils->stream_from_proto(@_)
17 }
18
19 sub _stream_concat {
20   shift->_zconfig->stream_utils->stream_concat(@_)
21 }
22
23 sub _flatten_stream_of_streams {
24   shift->_zconfig->stream_utils->flatten_stream_of_streams(@_)
25 }
26
27 sub set_attr { shift->set_attribute(@_); }
28
29 sub set_attribute {
30   my $self = shift;
31   my $attr = $self->_parse_attribute_args(@_);
32   sub {
33     my $a = (my $evt = $_[0])->{attrs};
34     my @kadd = grep {!exists $a->{$_}} keys %$attr;
35     +{ %$evt, raw => undef, raw_attrs => undef,
36        attrs => { %$a, %$attr },
37        @kadd ? (attr_names => [ @{$evt->{attr_names}}, @kadd ]) : ()
38      }
39    };
40 }
41
42 sub _parse_attribute_args {
43   my $self = shift;
44
45   my $opts = ref($_[0]) eq 'HASH' ? $_[0] : {$_[0] => $_[1]};
46   for (values %{$opts}) { $self->_zconfig->parser->html_escape($_); }
47   return $opts;
48 }
49
50 sub add_attribute {
51     die "renamed to add_to_attribute. killing this entirely for 1.0";
52 }
53
54 sub add_class { shift->add_to_attribute('class',@_) }
55
56 sub remove_class { shift->remove_from_attribute('class',@_) }
57
58 sub set_class { shift->set_attribute('class',@_) }
59
60 sub set_id { shift->set_attribute('id',@_) }
61
62 sub add_to_attribute {
63   my $self = shift;
64   my $attr = $self->_parse_attribute_args(@_);
65   sub {
66     my $a = (my $evt = $_[0])->{attrs};
67     my @kadd = grep {!exists $a->{$_}} keys %$attr;
68     +{ %$evt, raw => undef, raw_attrs => undef,
69        attrs => {
70          %$a,
71          map {$_ => join(' ', (exists $a->{$_} ? $a->{$_} : ()), $attr->{$_}) } 
72           keys %$attr
73       },
74       @kadd ? (attr_names => [ @{$evt->{attr_names}}, @kadd ]) : ()
75     }
76   };
77 }
78
79 sub remove_from_attribute {
80   my $self = shift;
81   my $attr = $self->_parse_attribute_args(@_);
82   sub {
83     my $a = (my $evt = $_[0])->{attrs};
84     +{ %$evt, raw => undef, raw_attrs => undef,
85        attrs => {
86          %$a,
87          #TODO needs to support multiple removes
88          map { my $tar = $_; $_ => join ' ', 
89           map {$attr->{$tar} ne $_} split ' ', $a->{$_} }
90             grep {exists $a->{$_}} keys %$attr
91       },
92     }
93   };
94 }
95
96 sub remove_attribute {
97   my ($self, $args) = @_;
98   my $name = (ref($args) eq 'HASH') ? $args->{name} : $args;
99   sub {
100     my $a = (my $evt = $_[0])->{attrs};
101     return $evt unless exists $a->{$name};
102     $a = { %$a }; delete $a->{$name};
103     +{ %$evt, raw => undef, raw_attrs => undef,
104        attrs => $a,
105        attr_names => [ grep $_ ne $name, @{$evt->{attr_names}} ]
106     }
107   };
108 }
109
110 sub transform_attribute {
111   my $self = shift;
112   my ( $name, $code ) = @_ > 1 ? @_ : @{$_[0]}{qw(name code)};
113
114   sub {
115     my $evt = $_[0];
116     my %a = %{ $evt->{attrs} };
117     my @names = @{ $evt->{attr_names} };
118
119     my $existed_before = exists $a{$name};
120     my $v = $code->( $a{$name} );
121     my $deleted =   $existed_before && ! defined $v;
122     my $added   = ! $existed_before &&   defined $v;
123     if( $added ) {
124         push @names, $name;
125         $a{$name} = $v;
126     }
127     elsif( $deleted ) {
128         delete $a{$name};
129         @names = grep $_ ne $name, @names;
130     } else {
131         $a{$name} = $v;
132     }
133     +{ %$evt, raw => undef, raw_attrs => undef,
134        attrs => \%a,
135       ( $deleted || $added
136         ? (attr_names => \@names )
137         : () )
138      }
139    };
140 }
141
142 sub collect {
143   my ($self, $options) = @_;
144   my ($into, $passthrough, $content, $filter, $flush_before) =
145     @{$options}{qw(into passthrough content filter flush_before)};
146   sub {
147     my ($evt, $stream) = @_;
148     # We wipe the contents of @$into here so that other actions depending
149     # on this (such as a repeater) can be invoked multiple times easily.
150     # I -suspect- it's better for that state reset to be managed here; if it
151     # ever becomes painful the decision should be revisited
152     if ($into) {
153       @$into = $content ? () : ($evt);
154     }
155     if ($evt->{is_in_place_close}) {
156       return $evt if $passthrough || $content;
157       return;
158     }
159     my $name = $evt->{name};
160     my $depth = 1;
161     my $_next = $content ? 'peek' : 'next';
162     if ($filter) {
163       if ($content) {
164         $stream = do { local $_ = $stream; $filter->($stream) };
165       } else {
166         $stream = do {
167           local $_ = $self->_stream_concat(
168                        $self->_stream_from_array($evt),
169                        $stream,
170                      );
171           $filter->($_);
172         };
173         $evt = $stream->next;
174       }
175     }
176     my $collector = $self->_stream_from_code(sub {
177       return unless $stream;
178       while (my ($evt) = $stream->$_next) {
179         $depth++ if ($evt->{type} eq 'OPEN');
180         $depth-- if ($evt->{type} eq 'CLOSE');
181         unless ($depth) {
182           undef $stream;
183           return if $content;
184           push(@$into, $evt) if $into;
185           return $evt if $passthrough;
186           return;
187         }
188         push(@$into, $evt) if $into;
189         $stream->next if $content;
190         return $evt if $passthrough;
191       }
192       die "Never saw closing </${name}> before end of source";
193     });
194     if ($flush_before) {
195       if ($passthrough||$content) {
196         $evt = { %$evt, flush => 1 };
197       } else {
198         $evt = { type => 'EMPTY', flush => 1 };
199       }
200     }
201     return ($passthrough||$content||$flush_before)
202              ? [ $evt, $collector ]
203              : $collector;
204   };
205 }
206
207 sub collect_content {
208   my ($self, $options) = @_;
209   $self->collect({ %{$options||{}}, content => 1 })
210 }
211
212 sub add_before {
213   my ($self, $events) = @_;
214   my $coll_proto = $self->collect({ passthrough => 1 });
215   sub {
216     my $emit = $self->_stream_from_proto($events);
217     my $coll = &$coll_proto;
218     if($coll) {
219       if(ref $coll eq 'ARRAY') {
220         my $firstbit = $self->_stream_from_proto([$coll->[0]]);
221         return $self->_stream_concat($emit, $firstbit, $coll->[1]);
222       } elsif(ref $coll eq 'HASH') {
223         return [$emit, $coll];
224       } else {
225         return $self->_stream_concat($emit, $coll);
226       }
227     } else { return $emit }
228   }
229 }
230
231 sub add_after {
232   my ($self, $events) = @_;
233   my $coll_proto = $self->collect({ passthrough => 1 });
234   sub {
235     my ($evt) = @_;
236     my $emit = $self->_stream_from_proto($events);
237     my $coll = &$coll_proto;
238     return ref($coll) eq 'HASH' # single event, no collect
239       ? [ $coll, $emit ]
240       : [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ];
241   };
242 }
243
244 sub prepend_content {
245   my ($self, $events) = @_;
246   my $coll_proto = $self->collect({ passthrough => 1, content => 1 });
247   sub {
248     my ($evt) = @_;
249     my $emit = $self->_stream_from_proto($events);
250     if ($evt->{is_in_place_close}) {
251       $evt = { %$evt }; delete @{$evt}{qw(raw is_in_place_close)};
252       return [ $evt, $self->_stream_from_array(
253         $emit->next, { type => 'CLOSE', name => $evt->{name} }
254       ) ];
255     }
256     my $coll = &$coll_proto;
257     return [ $coll->[0], $self->_stream_concat($emit, $coll->[1]) ];
258   };
259 }
260
261 sub append_content {
262   my ($self, $events) = @_;
263   my $coll_proto = $self->collect({ passthrough => 1, content => 1 });
264   sub {
265     my ($evt) = @_;
266     my $emit = $self->_stream_from_proto($events);
267     if ($evt->{is_in_place_close}) {
268       $evt = { %$evt }; delete @{$evt}{qw(raw is_in_place_close)};
269       return [ $evt, $self->_stream_from_array(
270         $emit->next, { type => 'CLOSE', name => $evt->{name} }
271       ) ];
272     }
273     my $coll = &$coll_proto;
274     return [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ];
275   };
276 }
277
278 sub replace {
279   my ($self, $replace_with, $options) = @_;
280   my $coll_proto = $self->collect($options);
281   sub {
282     my ($evt, $stream) = @_;
283     my $emit = $self->_stream_from_proto($replace_with);
284     my $coll = &$coll_proto;
285     # if we're replacing the contents of an in place close
286     # then we need to handle that here
287     if ($options->{content}
288         && ref($coll) eq 'HASH'
289         && $coll->{is_in_place_close}
290       ) {
291       my $close = $stream->next;
292       # shallow copy and nuke in place and raw (to force smart print)
293       $_ = { %$_ }, delete @{$_}{qw(is_in_place_close raw)} for ($coll, $close);
294       $emit = $self->_stream_concat(
295                 $emit,
296                 $self->_stream_from_array($close),
297               );
298     }
299     # For a straightforward replace operation we can, in fact, do the emit
300     # -before- the collect, and my first cut did so. However in order to
301     # use the captured content in generating the new content, we need
302     # the collect stage to happen first - and it seems highly unlikely
303     # that in normal operation the collect phase will take long enough
304     # for the difference to be noticeable
305     return
306       ($coll
307         ? (ref $coll eq 'ARRAY' # [ event, stream ]
308             ? [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ]
309             : (ref $coll eq 'HASH' # event or stream?
310                  ? [ $coll, $emit ]
311                  : $self->_stream_concat($coll, $emit))
312           )
313         : $emit
314       );
315   };
316 }
317
318 sub replace_content {
319   my ($self, $replace_with, $options) = @_;
320   $self->replace($replace_with, { %{$options||{}}, content => 1 })
321 }
322
323 sub repeat {
324   my ($self, $repeat_for, $options) = @_;
325   $options->{into} = \my @into;
326   my @between;
327   my $repeat_between = delete $options->{repeat_between};
328   if ($repeat_between) {
329     $options->{filter} = sub {
330       $_->select($repeat_between)->collect({ into => \@between })
331     }
332   }
333   my $repeater = sub {
334     my $s = $self->_stream_from_proto($repeat_for);
335     # We have to test $repeat_between not @between here because
336     # at the point we're constructing our return stream @between
337     # hasn't been populated yet - but we can test @between in the
338     # map routine because it has been by then and that saves us doing
339     # the extra stream construction if we don't need it.
340     $self->_flatten_stream_of_streams(do {
341       if ($repeat_between) {
342         $s->map(sub {
343               local $_ = $self->_stream_from_array(@into);
344               (@between && $s->peek)
345                 ? $self->_stream_concat(
346                     $_[0]->($_), $self->_stream_from_array(@between)
347                   )
348                 : $_[0]->($_)
349             })
350       } else {
351         $s->map(sub {
352               local $_ = $self->_stream_from_array(@into);
353               $_[0]->($_)
354           })
355       }
356     })
357   };
358   $self->replace($repeater, $options);
359 }
360
361 sub repeat_content {
362   my ($self, $repeat_for, $options) = @_;
363   $self->repeat($repeat_for, { %{$options||{}}, content => 1 })
364 }
365
366 sub extract_names {
367   my ($self, $to) = @_;
368   sub {
369     my ($evt) = @_;
370     push @$to, $evt->{'attrs'}->{'name'};
371     $evt;
372   }
373 };
374
375 sub validate_form {
376   my ($self,$to,$fill) = @_;
377   $self->collect({ 
378     filter => sub {
379       return 
380         $_->select('input')->validate_and_fill($to,$fill)
381         ->select('select')->validate_and_fill($to,$fill);
382     },
383     passthrough => 1,
384   });
385 }
386
387 sub val {
388   my ($self, $value) = @_;
389   #set value on an element. if its a select select its option
390   #if it's a checkbox check it otherwise set attr value
391   sub {
392     return $self->set_attribute(value => $value);
393   }
394 }
395
396 sub validate_and_fill {
397   my ($self, $to, $fill) = @_;
398   sub {
399     my ($evt) = @_;
400     my $nm = $evt->{'attrs'}->{'name'};
401     if(defined $fill && $fill->{$nm}) {
402       $evt->{'raw'} = undef;
403       $evt->{'raw_attrs'} = undef;
404       push @{$evt->{'attr_names'}}, 'value' unless exists $evt->{'attrs'}->{'value'};
405       $evt->{'attrs'}->{'value'} = $fill->{$nm};
406     }
407     $to->{$nm} = 
408       [split ' ', $evt->{'attrs'}->{'data-validate'}||""];
409     $evt;
410   }
411 }
412
413
414 1;
415
416 =head1 NAME
417
418 HTML::Zoom::FilterBuilder - Add Filters to a Stream
419
420 =head1 SYNOPSIS
421
422 Create an L<HTML::Zoom> instance:
423
424   use HTML::Zoom;
425   my $root = HTML::Zoom
426       ->from_html(<<MAIN);
427   <html>
428     <head>
429       <title>Default Title</title>
430     </head>
431     <body bad_attr='junk'>
432       Default Content
433     </body>
434   </html>
435   MAIN
436
437 Create a new attribute on the  C<body> tag:
438
439   $root = $root
440     ->select('body')
441     ->set_attribute(class=>'main');
442
443 Add a extra value to an existing attribute:
444
445   $root = $root
446     ->select('body')
447     ->add_to_attribute(class=>'one-column');
448
449 Set the content of the C<title> tag:
450
451   $root = $root
452     ->select('title')
453     ->replace_content('Hello World');
454
455 Set content from another L<HTML::Zoom> instance:
456
457   my $body = HTML::Zoom
458       ->from_html(<<BODY);
459   <div id="stuff">
460       <p>Well Now</p>
461       <p id="p2">Is the Time</p>
462   </div>
463   BODY
464
465   $root = $root
466     ->select('body')
467     ->replace_content($body);
468
469 Set an attribute on multiple matches:
470
471   $root = $root
472     ->select('p')
473     ->set_attribute(class=>'para');
474
475 Remove an attribute:
476
477   $root = $root
478     ->select('body')
479     ->remove_attribute('bad_attr');
480
481 will produce:
482
483 =begin testinfo
484
485   my $output = $root->to_html;
486   my $expect = <<HTML;
487
488 =end testinfo
489
490   <html>
491     <head>
492       <title>Hello World</title>
493     </head>
494     <body class="main one-column"><div id="stuff">
495       <p class="para">Well Now</p>
496       <p id="p2" class="para">Is the Time</p>
497   </div>
498   </body>
499   </html>
500
501 =begin testinfo
502
503   HTML
504   is($output, $expect, 'Synopsis code works ok');
505
506 =end testinfo
507
508 =head1 DESCRIPTION
509
510 Given a L<HTML::Zoom> stream, provide methods to apply filters which
511 alter the content of that stream.
512
513 =head1 METHODS
514
515 This class defines the following public API
516
517 =head2 set_attribute
518
519 Sets an attribute of a given name to a given value for all matching selections.
520
521     $html_zoom
522       ->select('p')
523       ->set_attribute(class=>'paragraph')
524       ->select('div')
525       ->set_attribute({class=>'paragraph', name=>'divider'});
526
527 Overrides existing values, if such exist.  When multiple L</set_attribute>
528 calls are made against the same or overlapping selection sets, the final
529 call wins.
530
531 =head2 add_to_attribute
532
533 Adds a value to an existing attribute, or creates one if the attribute does not
534 yet exist.  You may call this method with either an Array or HashRef of Args.
535
536     $html_zoom
537       ->select('p')
538       ->set_attribute({class => 'paragraph', name => 'test'})
539       ->then
540       ->add_to_attribute(class=>'divider');
541
542 Attributes with more than one value will have a dividing space.
543
544 =head2 remove_attribute
545
546 Removes an attribute and all its values.
547
548     $html_zoom
549       ->select('p')
550       ->set_attribute(class=>'paragraph')
551       ->then
552       ->remove_attribute('class');
553
554 =head2 remove_from_attribute
555
556 Removes a value from existing attribute
557
558     $html_zoom
559       ->select('p')
560       ->set_attribute(class=>'paragraph lead')
561       ->then
562       ->remove_from_attribute('class' => 'lead');
563
564 Removes attributes from the original stream or events already added.
565
566 =head2 add_class
567
568 Add to a class attribute
569
570 =head2 remove_class
571
572 Remove from a class attribute
573
574 =head2 transform_attribute
575
576 Transforms (or creates or deletes) an attribute by running the passed
577 coderef on it.  If the coderef returns nothing, the attribute is
578 removed.
579
580     $html_zoom
581       ->select('a')
582       ->transform_attribute( href => sub {
583             ( my $a = shift ) =~ s/localhost/example.com/;
584             return $a;
585           },
586         );
587
588 =head2 collect
589
590 Collects and extracts results of L<HTML::Zoom/select>.  It takes the following
591 optional common options as hash reference.
592
593 =over
594
595 =item into [ARRAY REFERENCE]
596
597 Where to save collected events (selected elements).
598
599     $z1->select('#main-content')
600        ->collect({ into => \@body })
601        ->run;
602     $z2->select('#main-content')
603        ->replace(\@body)
604        ->memoize;
605
606 =item filter [CODE]
607
608 Run filter on collected elements (locally setting $_ to stream, and passing
609 stream as an argument to given code reference).  Filtered stream would be
610 returned.
611
612     $z->select('.outer')
613       ->collect({
614         filter => sub { $_->select('.inner')->replace_content('bar!') },
615         passthrough => 1,
616       })
617
618 It can be used to further filter selection.  For example
619
620     $z->select('tr')
621       ->collect({
622         filter => sub { $_->select('td') },
623         passthrough => 1,
624       })
625
626 is equivalent to (not implemented yet) descendant selector combination, i.e.
627
628     $z->select('tr td')
629
630 =item passthrough [BOOLEAN]
631
632 Extract copy of elements; the stream is unchanged (it does not remove collected
633 elements).  For example without 'passthrough'
634
635     HTML::Zoom->from_html('<foo><bar /></foo>')
636       ->select('foo')
637       ->collect({ content => 1 })
638       ->to_html
639
640 returns '<foo></foo>', while with C<passthrough> option
641
642     HTML::Zoom->from_html('<foo><bar /></foo>')
643       ->select('foo')
644       ->collect({ content => 1, passthough => 1 })
645       ->to_html
646
647 returns '<foo><bar /></foo>'.
648
649 =item content [BOOLEAN]
650
651 Collect content of the element, and not the element itself.
652
653 For example
654
655     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
656       ->select('h1')
657       ->collect
658       ->to_html
659
660 would return '<p>foo</p>', while
661
662     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
663       ->select('h1')
664       ->collect({ content => 1 })
665       ->to_html
666
667 would return '<h1></h1><p>foo</p>'.
668
669 See also L</collect_content>.
670
671 =item flush_before [BOOLEAN]
672
673 Generate C<flush> event before collecting, to ensure that the HTML generated up
674 to selected element being collected is flushed throught to the browser.  Usually
675 used in L</repeat> or L</repeat_content>.
676
677 =back
678
679 =head2 collect_content
680
681 Collects contents of L<HTML::Zoom/select> result.
682
683     HTML::Zoom->from_file($foo)
684               ->select('#main-content')
685               ->collect_content({ into => \@foo_body })
686               ->run;
687     $z->select('#foo')
688       ->replace_content(\@foo_body)
689       ->memoize;
690
691 Equivalent to running L</collect> with C<content> option set.
692
693 =head2 add_before
694
695 Given a L<HTML::Zoom/select> result, add given content (which might be string,
696 array or another L<HTML::Zoom> object) before it.
697
698     $html_zoom
699         ->select('input[name="foo"]')
700         ->add_before(\ '<span class="warning">required field</span>');
701
702 =head2 add_after
703
704 Like L</add_before>, only after L<HTML::Zoom/select> result.
705
706     $html_zoom
707         ->select('p')
708         ->add_after("\n\n");
709
710 You can add zoom events directly
711
712     $html_zoom
713         ->select('p')
714         ->add_after([ { type => 'TEXT', raw => 'O HAI' } ]);
715
716 =head2 prepend_content
717
718 Similar to add_before, but adds the content to the match.
719
720   HTML::Zoom
721     ->from_html(q[<p>World</p>])
722     ->select('p')
723     ->prepend_content("Hello ")
724     ->to_html
725     
726   ## <p>Hello World</p>
727   
728 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
729
730 =head2 append_content
731
732 Similar to add_after, but adds the content to the match.
733
734   HTML::Zoom
735     ->from_html(q[<p>Hello </p>])
736     ->select('p')
737     ->prepend_content("World")
738     ->to_html
739     
740   ## <p>Hello World</p>
741
742 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
743
744 =head2 replace
745
746 Given a L<HTML::Zoom/select> result, replace it with a string, array or another
747 L<HTML::Zoom> object.  It takes the same optional common options as L</collect>
748 (via hash reference).
749
750 =head2 replace_content
751
752 Given a L<HTML::Zoom/select> result, replace the content with a string, array
753 or another L<HTML::Zoom> object.
754
755     $html_zoom
756       ->select('title, #greeting')
757       ->replace_content('Hello world!');
758
759 =head2 repeat
760
761 For a given selection, repeat over transformations, typically for the purposes
762 of populating lists.  Takes either an array of anonymous subroutines or a zoom-
763 able object consisting of transformation.
764
765 Example of array reference style (when it doesn't matter that all iterations are
766 pre-generated)
767
768     $zoom->select('table')->repeat([
769       map {
770         my $elem = $_;
771         sub {
772           $_->select('td')->replace_content($e);
773         }
774       } @list
775     ]);
776     
777 Subroutines would be run with $_ localized to result of L<HTML::Zoom/select> (of
778 collected elements), and with said result passed as parameter to subroutine.
779
780 You might want to use CodeStream when you don't have all elements upfront
781
782     $zoom->select('.contents')->repeat(sub {
783       HTML::Zoom::CodeStream->new({
784         code => sub {
785           while (my $line = $fh->getline) {
786             return sub {
787               $_->select('.lno')->replace_content($fh->input_line_number)
788                 ->select('.line')->replace_content($line)
789             }
790           }
791           return
792         },
793       })
794     });
795
796 In addition to common options as in L</collect>, it also supports:
797
798 =over
799
800 =item repeat_between [SELECTOR]
801
802 Selects object to be repeated between items.  In the case of array this object
803 is put between elements, in case of iterator it is put between results of
804 subsequent iterations, in the case of streamable it is put between events
805 (->to_stream->next).
806
807 See documentation for L</repeat_content>
808
809 =back
810
811 =head2 repeat_content
812
813 Given a L<HTML::Zoom/select> result, run provided iterator passing content of
814 this result to this iterator.  Accepts the same options as L</repeat>.
815
816 Equivalent to using C<contents> option with L</repeat>.
817
818     $html_zoom
819        ->select('#list')
820        ->repeat_content(
821           [
822              sub {
823                 $_->select('.name')->replace_content('Matt')
824                   ->select('.age')->replace_content('26')
825              },
826              sub {
827                 $_->select('.name')->replace_content('Mark')
828                   ->select('.age')->replace_content('0x29')
829              },
830              sub {
831                 $_->select('.name')->replace_content('Epitaph')
832                   ->select('.age')->replace_content('<redacted>')
833              },
834           ],
835           { repeat_between => '.between' }
836        );
837
838
839 =head1 ALSO SEE
840
841 L<HTML::Zoom>
842
843 =head1 AUTHORS
844
845 See L<HTML::Zoom> for authors.
846
847 =head1 LICENSE
848
849 See L<HTML::Zoom> for the license.
850
851 =cut
852