some simple shorthand helpers.
[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   my ($name, $value) = @_ > 1 ? @_ : @{$_[0]}{qw(name value)};
49   return ($name, $self->_zconfig->parser->html_escape($value));
50 }
51
52 sub add_attribute {
53     die "renamed to add_to_attribute. killing this entirely for 1.0";
54 }
55
56 sub add_class { shift->add_to_attribute('class',@_) }
57
58 sub remove_class { shift->remove_attribute('class',@_) }
59
60 sub set_class { shift->set_attribute('class',@_) }
61
62 sub set_id { shift->set_attribute('id',@_) }
63
64 sub add_to_attribute {
65   my $self = shift;
66   my ($name, $value) = $self->_parse_attribute_args(@_);
67   sub {
68     my $a = (my $evt = $_[0])->{attrs};
69     my $e = exists $a->{$name};
70     +{ %$evt, raw => undef, raw_attrs => undef,
71        attrs => {
72          %$a,
73          $name => join(' ', ($e ? $a->{$name} : ()), $value)
74       },
75       ($e # add to name list if not present
76         ? ()
77         : (attr_names => [ @{$evt->{attr_names}}, $name ]))
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
466 Overrides existing values, if such exist.  When multiple L</set_attribute>
467 calls are made against the same or overlapping selection sets, the final
468 call wins.
469
470 =head2 add_to_attribute
471
472 Adds a value to an existing attribute, or creates one if the attribute does not
473 yet exist.  You may call this method with either an Array or HashRef of Args.
474
475 Here's the 'long form' HashRef:
476
477     $html_zoom
478       ->select('p')
479       ->set_attribute(class=>'paragraph')
480       ->then
481       ->add_to_attribute({name=>'class', value=>'divider'});
482
483 And the exact same effect using the 'short form' Array:
484
485     $html_zoom
486       ->select('p')
487       ->set_attribute(class=>'paragraph')
488       ->then
489       ->add_to_attribute(class=>'divider');
490
491 Attributes with more than one value will have a dividing space.
492
493 =head2 remove_attribute
494
495 Removes an attribute and all its values.
496
497     $html_zoom
498       ->select('p')
499       ->set_attribute(class=>'paragraph')
500       ->then
501       ->remove_attribute('class');
502
503 Removes attributes from the original stream or events already added.
504
505 =head2 transform_attribute
506
507 Transforms (or creates or deletes) an attribute by running the passed
508 coderef on it.  If the coderef returns nothing, the attribute is
509 removed.
510
511     $html_zoom
512       ->select('a')
513       ->transform_attribute( href => sub {
514             ( my $a = shift ) =~ s/localhost/example.com/;
515             return $a;
516           },
517         );
518
519 =head2 collect
520
521 Collects and extracts results of L<HTML::Zoom/select>.  It takes the following
522 optional common options as hash reference.
523
524 =over
525
526 =item into [ARRAY REFERENCE]
527
528 Where to save collected events (selected elements).
529
530     $z1->select('#main-content')
531        ->collect({ into => \@body })
532        ->run;
533     $z2->select('#main-content')
534        ->replace(\@body)
535        ->memoize;
536
537 =item filter [CODE]
538
539 Run filter on collected elements (locally setting $_ to stream, and passing
540 stream as an argument to given code reference).  Filtered stream would be
541 returned.
542
543     $z->select('.outer')
544       ->collect({
545         filter => sub { $_->select('.inner')->replace_content('bar!') },
546         passthrough => 1,
547       })
548
549 It can be used to further filter selection.  For example
550
551     $z->select('tr')
552       ->collect({
553         filter => sub { $_->select('td') },
554         passthrough => 1,
555       })
556
557 is equivalent to (not implemented yet) descendant selector combination, i.e.
558
559     $z->select('tr td')
560
561 =item passthrough [BOOLEAN]
562
563 Extract copy of elements; the stream is unchanged (it does not remove collected
564 elements).  For example without 'passthrough'
565
566     HTML::Zoom->from_html('<foo><bar /></foo>')
567       ->select('foo')
568       ->collect({ content => 1 })
569       ->to_html
570
571 returns '<foo></foo>', while with C<passthrough> option
572
573     HTML::Zoom->from_html('<foo><bar /></foo>')
574       ->select('foo')
575       ->collect({ content => 1, passthough => 1 })
576       ->to_html
577
578 returns '<foo><bar /></foo>'.
579
580 =item content [BOOLEAN]
581
582 Collect content of the element, and not the element itself.
583
584 For example
585
586     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
587       ->select('h1')
588       ->collect
589       ->to_html
590
591 would return '<p>foo</p>', while
592
593     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
594       ->select('h1')
595       ->collect({ content => 1 })
596       ->to_html
597
598 would return '<h1></h1><p>foo</p>'.
599
600 See also L</collect_content>.
601
602 =item flush_before [BOOLEAN]
603
604 Generate C<flush> event before collecting, to ensure that the HTML generated up
605 to selected element being collected is flushed throught to the browser.  Usually
606 used in L</repeat> or L</repeat_content>.
607
608 =back
609
610 =head2 collect_content
611
612 Collects contents of L<HTML::Zoom/select> result.
613
614     HTML::Zoom->from_file($foo)
615               ->select('#main-content')
616               ->collect_content({ into => \@foo_body })
617               ->run;
618     $z->select('#foo')
619       ->replace_content(\@foo_body)
620       ->memoize;
621
622 Equivalent to running L</collect> with C<content> option set.
623
624 =head2 add_before
625
626 Given a L<HTML::Zoom/select> result, add given content (which might be string,
627 array or another L<HTML::Zoom> object) before it.
628
629     $html_zoom
630         ->select('input[name="foo"]')
631         ->add_before(\ '<span class="warning">required field</span>');
632
633 =head2 add_after
634
635 Like L</add_before>, only after L<HTML::Zoom/select> result.
636
637     $html_zoom
638         ->select('p')
639         ->add_after("\n\n");
640
641 You can add zoom events directly
642
643     $html_zoom
644         ->select('p')
645         ->add_after([ { type => 'TEXT', raw => 'O HAI' } ]);
646
647 =head2 prepend_content
648
649 Similar to add_before, but adds the content to the match.
650
651   HTML::Zoom
652     ->from_html(q[<p>World</p>])
653     ->select('p')
654     ->prepend_content("Hello ")
655     ->to_html
656     
657   ## <p>Hello World</p>
658   
659 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
660
661 =head2 append_content
662
663 Similar to add_after, but adds the content to the match.
664
665   HTML::Zoom
666     ->from_html(q[<p>Hello </p>])
667     ->select('p')
668     ->prepend_content("World")
669     ->to_html
670     
671   ## <p>Hello World</p>
672
673 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
674
675 =head2 replace
676
677 Given a L<HTML::Zoom/select> result, replace it with a string, array or another
678 L<HTML::Zoom> object.  It takes the same optional common options as L</collect>
679 (via hash reference).
680
681 =head2 replace_content
682
683 Given a L<HTML::Zoom/select> result, replace the content with a string, array
684 or another L<HTML::Zoom> object.
685
686     $html_zoom
687       ->select('title, #greeting')
688       ->replace_content('Hello world!');
689
690 =head2 repeat
691
692 For a given selection, repeat over transformations, typically for the purposes
693 of populating lists.  Takes either an array of anonymous subroutines or a zoom-
694 able object consisting of transformation.
695
696 Example of array reference style (when it doesn't matter that all iterations are
697 pre-generated)
698
699     $zoom->select('table')->repeat([
700       map {
701         my $elem = $_;
702         sub {
703           $_->select('td')->replace_content($e);
704         }
705       } @list
706     ]);
707     
708 Subroutines would be run with $_ localized to result of L<HTML::Zoom/select> (of
709 collected elements), and with said result passed as parameter to subroutine.
710
711 You might want to use CodeStream when you don't have all elements upfront
712
713     $zoom->select('.contents')->repeat(sub {
714       HTML::Zoom::CodeStream->new({
715         code => sub {
716           while (my $line = $fh->getline) {
717             return sub {
718               $_->select('.lno')->replace_content($fh->input_line_number)
719                 ->select('.line')->replace_content($line)
720             }
721           }
722           return
723         },
724       })
725     });
726
727 In addition to common options as in L</collect>, it also supports:
728
729 =over
730
731 =item repeat_between [SELECTOR]
732
733 Selects object to be repeated between items.  In the case of array this object
734 is put between elements, in case of iterator it is put between results of
735 subsequent iterations, in the case of streamable it is put between events
736 (->to_stream->next).
737
738 See documentation for L</repeat_content>
739
740 =back
741
742 =head2 repeat_content
743
744 Given a L<HTML::Zoom/select> result, run provided iterator passing content of
745 this result to this iterator.  Accepts the same options as L</repeat>.
746
747 Equivalent to using C<contents> option with L</repeat>.
748
749     $html_zoom
750        ->select('#list')
751        ->repeat_content(
752           [
753              sub {
754                 $_->select('.name')->replace_content('Matt')
755                   ->select('.age')->replace_content('26')
756              },
757              sub {
758                 $_->select('.name')->replace_content('Mark')
759                   ->select('.age')->replace_content('0x29')
760              },
761              sub {
762                 $_->select('.name')->replace_content('Epitaph')
763                   ->select('.age')->replace_content('<redacted>')
764              },
765           ],
766           { repeat_between => '.between' }
767        );
768
769
770 =head1 ALSO SEE
771
772 L<HTML::Zoom>
773
774 =head1 AUTHORS
775
776 See L<HTML::Zoom> for authors.
777
778 =head1 LICENSE
779
780 See L<HTML::Zoom> for the license.
781
782 =cut
783