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