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