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