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