also work in streambase
[catagits/HTML-Zoom.git] / lib / HTML / Zoom.pm
CommitLineData
d80786d0 1package HTML::Zoom;
2
1cf03540 3use strictures 1;
d80786d0 4
5use HTML::Zoom::ZConfig;
bf5a23d0 6use HTML::Zoom::ReadFH;
655965b3 7use HTML::Zoom::Transform;
eeeb0921 8use HTML::Zoom::TransformBuilder;
94a3ddd9 9use Scalar::Util ();
d80786d0 10
60d640ca 11our $VERSION = '0.009005';
7af7362d 12
13$VERSION = eval $VERSION;
14
d80786d0 15sub new {
16 my ($class, $args) = @_;
17 my $new = {};
18 $new->{zconfig} = HTML::Zoom::ZConfig->new($args->{zconfig}||{});
19 bless($new, $class);
20}
21
22sub zconfig { shift->_self_or_new->{zconfig} }
23
24sub _self_or_new {
25 ref($_[0]) ? $_[0] : $_[0]->new
26}
27
28sub _with {
29 bless({ %{$_[0]}, %{$_[1]} }, ref($_[0]));
30}
31
7567494d 32sub from_events {
d80786d0 33 my $self = shift->_self_or_new;
34 $self->_with({
7567494d 35 initial_events => shift,
d80786d0 36 });
37}
38
7567494d 39sub from_html {
40 my $self = shift->_self_or_new;
41 $self->from_events($self->zconfig->parser->html_to_events($_[0]))
42}
43
bf5a23d0 44sub from_file {
45 my $self = shift->_self_or_new;
46 my $filename = shift;
47 $self->from_html(do { local (@ARGV, $/) = ($filename); <> });
48}
49
d80786d0 50sub to_stream {
51 my $self = shift;
52 die "No events to build from - forgot to call from_html?"
53 unless $self->{initial_events};
54 my $sutils = $self->zconfig->stream_utils;
55 my $stream = $sutils->stream_from_array(@{$self->{initial_events}});
2f0c6a86 56 $stream = $_->apply_to_stream($stream) for @{$self->{transforms}||[]};
d80786d0 57 $stream
58}
59
bf5a23d0 60sub to_fh {
61 HTML::Zoom::ReadFH->from_zoom(shift);
62}
63
7567494d 64sub to_events {
65 my $self = shift;
66 [ $self->zconfig->stream_utils->stream_to_array($self->to_stream) ];
67}
68
bf5a23d0 69sub run {
70 my $self = shift;
7567494d 71 $self->to_events;
bf5a23d0 72 return
73}
74
75sub apply {
76 my ($self, $code) = @_;
77 local $_ = $self;
78 $self->$code;
79}
80
fdb039c6 81sub apply_if {
82 my ($self, $predicate, $code) = @_;
83 if($predicate) {
84 local $_ = $self;
85 $self->$code;
86 }
87 else {
88 $self;
89 }
90}
91
d80786d0 92sub to_html {
93 my $self = shift;
94 $self->zconfig->producer->html_from_stream($self->to_stream);
95}
96
97sub memoize {
98 my $self = shift;
99 ref($self)->new($self)->from_html($self->to_html);
100}
101
eeeb0921 102sub with_transform {
1c4455ae 103 my $self = shift->_self_or_new;
eeeb0921 104 my ($transform) = @_;
d80786d0 105 $self->_with({
2f0c6a86 106 transforms => [
107 @{$self->{transforms}||[]},
eeeb0921 108 $transform
2f0c6a86 109 ]
d80786d0 110 });
111}
eeeb0921 112
113sub with_filter {
114 my $self = shift->_self_or_new;
115 my ($selector, $filter) = @_;
116 $self->with_transform(
117 HTML::Zoom::Transform->new({
118 zconfig => $self->zconfig,
119 selector => $selector,
120 filters => [ $filter ]
121 })
122 );
123}
d80786d0 124
125sub select {
1c4455ae 126 my $self = shift->_self_or_new;
127 my ($selector) = @_;
eeeb0921 128 return HTML::Zoom::TransformBuilder->new({
129 zconfig => $self->zconfig,
130 selector => $selector,
131 proto => $self
132 });
d80786d0 133}
134
135# There's a bug waiting to happen here: if you do something like
136#
137# $zoom->select('.foo')
1c4455ae 138# ->remove_attribute(class => 'foo')
d80786d0 139# ->then
140# ->well_anything_really
141#
142# the second action won't execute because it doesn't match anymore.
143# Ideally instead we'd merge the match subs but that's more complex to
144# implement so I'm deferring it for the moment.
145
146sub then {
147 my $self = shift;
2f0c6a86 148 die "Can't call ->then without a previous transform"
149 unless $self->{transforms};
150 $self->select($self->{transforms}->[-1]->selector);
d80786d0 151}
152
94a3ddd9 153sub AUTOLOAD {
154 my ($self, $selector, @args) = @_;
155 my $sel = $self->select($selector);
156 my $meth = our $AUTOLOAD;
157 $meth =~ s/.*:://;
6af04df5 158 if (ref($selector) eq 'HASH') {
159 my $ret = $self;
160 $ret = $ret->_do($_, $meth, @{$selector->{$_}}) for keys %$selector;
161 $ret;
162 } else {
163 $self->_do($selector, $meth, @args);
164 }
165}
166
167sub _do {
168 my ($self, $selector, $meth, @args) = @_;
169 my $sel = $self->select($selector);
170 if( my $cr = $sel->_zconfig->filter_builder->can($meth)) {
94a3ddd9 171 return $sel->$meth(@args);
172 } else {
173 die "We can't do $meth on ->select('$selector')";
174 }
175}
176
d80786d0 1771;
178
179=head1 NAME
180
181HTML::Zoom - selector based streaming template engine
182
183=head1 SYNOPSIS
184
185 use HTML::Zoom;
186
187 my $template = <<HTML;
188 <html>
189 <head>
190 <title>Hello people</title>
191 </head>
192 <body>
193 <h1 id="greeting">Placeholder</h1>
194 <div id="list">
195 <span>
196 <p>Name: <span class="name">Bob</span></p>
197 <p>Age: <span class="age">23</span></p>
198 </span>
199 <hr class="between" />
200 </div>
201 </body>
202 </html>
203 HTML
204
205 my $output = HTML::Zoom
206 ->from_html($template)
207 ->select('title, #greeting')->replace_content('Hello world & dog!')
208 ->select('#list')->repeat_content(
209 [
210 sub {
211 $_->select('.name')->replace_content('Matt')
212 ->select('.age')->replace_content('26')
213 },
214 sub {
215 $_->select('.name')->replace_content('Mark')
216 ->select('.age')->replace_content('0x29')
217 },
218 sub {
219 $_->select('.name')->replace_content('Epitaph')
220 ->select('.age')->replace_content('<redacted>')
221 },
222 ],
223 { repeat_between => '.between' }
224 )
225 ->to_html;
226
227will produce:
228
229=begin testinfo
230
231 my $expect = <<HTML;
232
233=end testinfo
234
235 <html>
236 <head>
237 <title>Hello world &amp; dog!</title>
238 </head>
239 <body>
240 <h1 id="greeting">Hello world &amp; dog!</h1>
241 <div id="list">
242 <span>
243 <p>Name: <span class="name">Matt</span></p>
244 <p>Age: <span class="age">26</span></p>
245 </span>
246 <hr class="between" />
247 <span>
248 <p>Name: <span class="name">Mark</span></p>
249 <p>Age: <span class="age">0x29</span></p>
250 </span>
251 <hr class="between" />
252 <span>
253 <p>Name: <span class="name">Epitaph</span></p>
254 <p>Age: <span class="age">&lt;redacted&gt;</span></p>
255 </span>
256
257 </div>
258 </body>
259 </html>
260
261=begin testinfo
262
263 HTML
264 is($output, $expect, 'Synopsis code works ok');
265
266=end testinfo
267
1c4455ae 268=head1 DANGER WILL ROBINSON
269
270This is a 0.9 release. That means that I'm fairly happy the API isn't going
271to change in surprising and upsetting ways before 1.0 and a real compatibility
272freeze. But it also means that if it turns out there's a mistake the size of
273a politician's ego in the API design that I haven't spotted yet there may be
274a bit of breakage between here and 1.0. Hopefully not though. Appendages
275crossed and all that.
276
277Worse still, the rest of the distribution isn't documented yet. I'm sorry.
278I suck. But lots of people have been asking me to ship this, docs or no, so
279having got this class itself at least somewhat documented I figured now was
280a good time to cut a first real release.
281
282=head1 DESCRIPTION
283
284HTML::Zoom is a lazy, stream oriented, streaming capable, mostly functional,
285CSS selector based semantic templating engine for HTML and HTML-like
286document formats.
287
288Which is, on the whole, a bit of a mouthful. So let me step back a moment
289and explain why you care enough to understand what I mean:
290
291=head2 JQUERY ENVY
292
293HTML::Zoom is the cure for JQuery envy. When your javascript guy pushes a
294piece of data into a document by doing:
295
296 $('.username').replaceAll(username);
297
298In HTML::Zoom one can write
299
300 $zoom->select('.username')->replace_content($username);
301
302which is, I hope, almost as clear, hampered only by the fact that Zoom can't
303assume a global document and therefore has nothing quite so simple as the
304$() function to get the initial selection.
305
306L<HTML::Zoom::SelectorParser> implements a subset of the JQuery selector
307specification, and will continue to track that rather than the W3C standards
308for the forseeable future on grounds of pragmatism. Also on grounds of their
309spec is written in EN_US rather than EN_W3C, and I read the former much better.
310
311I am happy to admit that it's very, very much a subset at the moment - see the
312L<HTML::Zoom::SelectorParser> POD for what's currently there, and expect more
313and more to be supported over time as we need it and patch it in.
314
315=head2 CLEAN TEMPLATES
316
317HTML::Zoom is the cure for messy templates. How many times have you looked at
318templates like this:
319
320 <form action="/somewhere">
321 [% FOREACH field IN fields %]
322 <label for="[% field.id %]">[% field.label %]</label>
323 <input name="[% field.name %]" type="[% field.type %]" value="[% field.value %]" />
324 [% END %]
325 </form>
326
327and despaired of the fact that neither the HTML structure nor the logic are
328remotely easy to read? Fortunately, with HTML::Zoom we can separate the two
329cleanly:
330
331 <form class="myform" action="/somewhere">
332 <label />
333 <input />
334 </form>
335
336 $zoom->select('.myform')->repeat_content([
337 map { my $field = $_; sub {
338
339 $_->select('label')
2daa653a 340 ->add_to_attribute( for => $field->{id} )
1c4455ae 341 ->then
342 ->replace_content( $field->{label} )
343
344 ->select('input')
2daa653a 345 ->add_to_attribute( name => $field->{name} )
1c4455ae 346 ->then
2daa653a 347 ->add_to_attribute( type => $field->{type} )
1c4455ae 348 ->then
2daa653a 349 ->add_to_attribute( value => $field->{value} )
1c4455ae 350
351 } } @fields
352 ]);
353
354This is, admittedly, very much not shorter. However, it makes it extremely
355clear what's happening and therefore less hassle to maintain. Especially
356because it allows the designer to fiddle with the HTML without cutting
357himself on sharp ELSE clauses, and the developer to add available data to
358the template without getting angle bracket cuts on sensitive parts.
359
360Better still, HTML::Zoom knows that it's inserting content into HTML and
361can escape it for you - the example template should really have been:
362
363 <form action="/somewhere">
364 [% FOREACH field IN fields %]
365 <label for="[% field.id | html %]">[% field.label | html %]</label>
366 <input name="[% field.name | html %]" type="[% field.type | html %]" value="[% field.value | html %]" />
367 [% END %]
368 </form>
369
370and frankly I'll take slightly more code any day over *that* crawling horror.
371
372(addendum: I pick on L<Template Toolkit|Template> here specifically because
373it's the template system I hate the least - for text templating, I don't
374honestly think I'll ever like anything except the next version of Template
375Toolkit better - but HTML isn't text. Zoom knows that. Do you?)
376
377=head2 PUTTING THE FUN INTO FUNCTIONAL
378
379The principle of HTML::Zoom is to provide a reusable, functional container
380object that lets you build up a set of transforms to be applied; every method
381call you make on a zoom object returns a new object, so it's safe to do so
382on one somebody else gave you without worrying about altering state (with
383the notable exception of ->next for stream objects, which I'll come to later).
384
385So:
386
387 my $z2 = $z1->select('.name')->replace_content($name);
388
389 my $z3 = $z2->select('.title')->replace_content('Ms.');
390
391each time produces a new Zoom object. If you want to package up a set of
392transforms to re-use, HTML::Zoom provides an 'apply' method:
393
394 my $add_name = sub { $_->select('.name')->replace_content($name) };
395
396 my $same_as_z2 = $z1->apply($add_name);
397
398=head2 LAZINESS IS A VIRTUE
399
400HTML::Zoom does its best to defer doing anything until it's absolutely
401required. The only point at which it descends into state is when you force
402it to create a stream, directly by:
403
c9e76777 404 my $stream = $zoom->to_stream;
1c4455ae 405
406 while (my $evt = $stream->next) {
407 # handle zoom event here
408 }
409
410or indirectly via:
411
412 my $final_html = $zoom->to_html;
413
414 my $fh = $zoom->to_fh;
415
416 while (my $chunk = $fh->getline) {
417 ...
418 }
419
420Better still, the $fh returned doesn't create its stream until the first
421call to getline, which means that until you call that and force it to be
422stateful you can get back to the original stateless Zoom object via:
423
424 my $zoom = $fh->to_zoom;
425
426which is exceedingly handy for filtering L<Plack> PSGI responses, among other
427things.
428
429Because HTML::Zoom doesn't try and evaluate everything up front, you can
430generally put things together in whatever order is most appropriate. This
431means that:
432
433 my $start = HTML::Zoom->from_html($html);
434
435 my $zoom = $start->select('div')->replace_content('THIS IS A DIV!');
436
437and:
438
439 my $start = HTML::Zoom->select('div')->replace_content('THIS IS A DIV!');
440
441 my $zoom = $start->from_html($html);
442
443will produce equivalent final $zoom objects, thus proving that there can be
444more than one way to do it without one of them being a
445L<bait and switch|Switch>.
446
447=head2 STOCKTON TO DARLINGTON UNDER STREAM POWER
448
449HTML::Zoom's execution always happens in terms of streams under the hood
450- that is, the basic pattern for doing anything is -
451
452 my $stream = get_stream_from_somewhere
453
454 while (my ($evt) = $stream->next) {
455 # do something with the event
456 }
457
458More importantly, all selectors and filters are also built as stream
459operations, so a selector and filter pair is effectively:
460
461 sub next {
462 my ($self) = @_;
463 my $next_evt = $self->parent_stream->next;
464 if ($self->selector_matches($next_evt)) {
465 return $self->apply_filter_to($next_evt);
466 } else {
467 return $next_evt;
468 }
469 }
470
471Internally, things are marginally more complicated than that, but not enough
472that you as a user should normally need to care.
473
474In fact, an HTML::Zoom object is mostly just a container for the relevant
475information from which to build the final stream that does the real work. A
476stream built from a Zoom object is a stream of events from parsing the
477initial HTML, wrapped in a filter stream per selector/filter pair provided
478as described above.
479
480The upshot of this is that the application of filters works just as well on
481streams as on the original Zoom object - in fact, when you run a
482L</repeat_content> operation your subroutines are applied to the stream for
483that element of the repeat, rather than constructing a new zoom per repeat
484element as well.
485
486More concretely:
487
488 $_->select('div')->replace_content('I AM A DIV!');
489
490works on both HTML::Zoom objects themselves and HTML::Zoom stream objects and
491shares sufficient of the implementation that you can generally forget the
492difference - barring the fact that a stream already has state attached so
493things like to_fh are no longer available.
494
495=head2 POP! GOES THE WEASEL
496
497... and by Weasel, I mean layout.
498
499HTML::Zoom's filehandle object supports an additional event key, 'flush',
500that is transparent to the rest of the system but indicates to the filehandle
501object to end a getline operation at that point and return the HTML so far.
502
503This means that in an environment where streaming output is available, such
504as a number of the L<Plack> PSGI handlers, you can add the flush key to an
505event in order to ensure that the HTML generated so far is flushed through
506to the browser right now. This can be especially useful if you know you're
507about to call a web service or a potentially slow database query or similar
508to ensure that at least the header/layout of your page renders now, improving
509perceived user responsiveness while your application waits around for the
510data it needs.
511
512This is currently exposed by the 'flush_before' option to the collect filter,
513which incidentally also underlies the replace and repeat filters, so to
514indicate we want this behaviour to happen before a query is executed we can
515write something like:
516
517 $zoom->select('.item')->repeat(sub {
518 if (my $row = $db_thing->next) {
519 return sub { $_->select('.item-name')->replace_content($row->name) }
520 } else {
521 return
522 }
523 }, { flush_before => 1 });
524
525which should have the desired effect given a sufficiently lazy $db_thing (for
526example a L<DBIx::Class::ResultSet> object).
527
528=head2 A FISTFUL OF OBJECTS
529
530At the core of an HTML::Zoom system lurks an L<HTML::Zoom::ZConfig> object,
531whose purpose is to hang on to the various bits and pieces that things need
532so that there's a common way of accessing shared functionality.
533
534Were I a computer scientist I would probably call this an "Inversion of
535Control" object - which you'd be welcome to google to learn more about, or
536you can just imagine a computer scientist being suspended upside down over
537a pit. Either way works for me, I'm a pure maths grad.
538
539The ZConfig object hangs on to one each of the following for you:
540
541=over 4
542
543=item * An HTML parser, normally L<HTML::Zoom::Parser::BuiltIn>
544
545=item * An HTML producer (emitter), normally L<HTML::Zoom::Producer::BuiltIn>
546
547=item * An object to build event filters, normally L<HTML::Zoom::FilterBuilder>
548
549=item * An object to parse CSS selectors, normally L<HTML::Zoom::SelectorParser>
550
551=item * An object to build streams, normally L<HTML::Zoom::StreamUtils>
552
553=back
554
555In theory you could replace any of these with anything you like, but in
556practice you're probably best restricting yourself to subclasses, or at
557least things that manage to look like the original if you squint a bit.
558
559If you do something more clever than that, or find yourself overriding things
560in your ZConfig a lot, please please tell us about it via one of the means
561mentioned under L</SUPPORT>.
562
563=head2 SEMANTIC DIDACTIC
564
565Some will argue that overloading CSS selectors to do data stuff is a terrible
566idea, and possibly even a step towards the "Concrete Javascript" pattern
567(which I abhor) or Smalltalk's Morphic (which I ignore, except for the part
568where it keeps reminding me of the late, great Tony Hart's plasticine friend).
569
570To which I say, "eh", "meh", and possibly also "feh". If it really upsets
571you, either use extra classes for this (and remove them afterwards) or
572use special fake elements or, well, honestly, just use something different.
573L<Template::Semantic> provides a similar idea to zoom except using XPath
574and XML::LibXML transforms rather than a lightweight streaming approach -
575maybe you'd like that better. Or maybe you really did want
576L<Template Toolkit|Template> after all. It is still damn good at what it does,
577after all.
578
579So far, however, I've found that for new sites the designers I'm working with
580generally want to produce nice semantic HTML with classes that represent the
581nature of the data rather than the structure of the layout, so sharing them
582as a common interface works really well for us.
583
584In the absence of any evidence that overloading CSS selectors has killed
585children or unexpectedly set fire to grandmothers - and given microformats
586have been around for a while there's been plenty of opportunity for
587octagenarian combustion - I'd suggest you give it a try and see if you like it.
588
589=head2 GET THEE TO A SUMMARY!
590
591Erm. Well.
592
593HTML::Zoom is a lazy, stream oriented, streaming capable, mostly functional,
594CSS selector based semantic templating engine for HTML and HTML-like
595document formats.
596
597But I said that already. Although hopefully by now you have some idea what I
598meant when I said it. If you didn't have any idea the first time. I mean, I'm
599not trying to call you stupid or anything. Just saying that maybe it wasn't
600totally obvious without the explanation. Or something.
601
602Er.
603
604Maybe we should just move on to the method docs.
605
606=head1 METHODS
607
608=head2 new
609
610 my $zoom = HTML::Zoom->new;
611
612 my $zoom = HTML::Zoom->new({ zconfig => $zconfig });
613
614Create a new empty Zoom object. You can optionally pass an
615L<HTML::Zoom::ZConfig> instance if you're trying to override one or more of
616the default components.
617
618This method isn't often used directly since several other methods can also
619act as constructors, notable L</select> and L</from_html>
620
621=head2 zconfig
622
623 my $zconfig = $zoom->zconfig;
624
625Retrieve the L<HTML::Zoom::ZConfig> instance used by this Zoom object. You
626shouldn't usually need to call this yourself.
627
628=head2 from_html
629
630 my $zoom = HTML::Zoom->from_html($html);
631
632 my $z2 = $z1->from_html($html);
633
634Parses the HTML using the current zconfig's parser object and returns a new
635zoom instance with that as the source HTML to be transformed.
636
637=head2 from_file
638
639 my $zoom = HTML::Zoom->from_file($file);
640
641 my $z2 = $z1->from_file($file);
642
643Convenience method - slurps the contents of $file and calls from_html with it.
644
645=head2 to_stream
646
647 my $stream = $zoom->to_stream;
648
649 while (my ($evt) = $stream->next) {
650 ...
651
652Creates a stream, starting with a stream of the events from the HTML supplied
653via L</from_html> and then wrapping it in turn with each selector+filter pair
654that have been applied to the zoom object.
655
656=head2 to_fh
657
658 my $fh = $zoom->to_fh;
659
660 call_something_expecting_a_filehandle($fh);
661
662Returns an L<HTML::Zoom::ReadFH> instance that will create a stream the first
663time its getline method is called and then return all HTML up to the next
664event with 'flush' set.
665
666You can pass this filehandle to compliant PSGI handlers (and probably most
667web frameworks).
668
669=head2 run
670
671 $zoom->run;
672
673Runs the zoom object's transforms without doing anything with the results.
674
675Normally used to get side effects of a zoom run - for example when using
676L<HTML::Zoom::FilterBuilder/collect> to slurp events for scraping or layout.
677
678=head2 apply
679
680 my $z2 = $z1->apply(sub {
681 $_->select('div')->replace_content('I AM A DIV!') })
682 });
683
684Sets $_ to the zoom object and then runs the provided code. Basically syntax
685sugar, the following is entirely equivalent:
686
687 my $sub = sub {
688 shift->select('div')->replace_content('I AM A DIV!') })
689 };
690
691 my $z2 = $sub->($z1);
692
693=head2 to_html
694
695 my $html = $zoom->to_html;
696
697Runs the zoom processing and returns the resulting HTML.
698
699=head2 memoize
700
701 my $z2 = $z1->memoize;
702
703Creates a new zoom whose source HTML is the results of the original zoom's
704processing. Effectively syntax sugar for:
705
706 my $z2 = HTML::Zoom->from_html($z1->to_html);
707
708but preserves your L<HTML::Zoom::ZConfig> object.
709
710=head2 with_filter
711
712 my $zoom = HTML::Zoom->with_filter(
713 'div', $filter_builder->replace_content('I AM A DIV!')
714 );
715
716 my $z2 = $z1->with_filter(
717 'div', $filter_builder->replace_content('I AM A DIV!')
718 );
719
720Lower level interface than L</select> to adding filters to your zoom object.
721
722In normal usage, you probably don't need to call this yourself.
723
724=head2 select
725
726 my $zoom = HTML::Zoom->select('div')->replace_content('I AM A DIV!');
727
728 my $z2 = $z1->select('div')->replace_content('I AM A DIV!');
729
97192b02 730Returns an intermediary object of the class L<HTML::Zoom::TransformBuilder>
1c4455ae 731on which methods of your L<HTML::Zoom::FilterBuilder> object can be called.
732
733In normal usage you should generally always put the pair of method calls
734together; the intermediary object isn't designed or expected to stick around.
735
736=head2 then
737
2daa653a 738 my $z2 = $z1->select('div')->add_to_attribute(class => 'spoon')
1c4455ae 739 ->then
740 ->replace_content('I AM A DIV!');
741
742Re-runs the previous select to allow you to chain actions together on the
743same selector.
744
94a3ddd9 745=head1 AUTOLOAD METHODS
746
747L<HTML::Zoom> AUTOLOADS methods against L</select> so that you can reduce a
748certain amount of boilerplate typing. This allows you to replace:
749
750 $z->select('div')->replace_content("Hello World");
751
752With:
753
754 $z->replace_content(div => "Hello World");
755
756Besides saving a few keys per invocations, you may feel this looks neater
757in your code and increases understanding.
758
f107bef7 759=head1 AUTHOR
45b4cea1 760
f107bef7 761mst - Matt S. Trout (cpan:MSTROUT) <mst@shadowcat.co.uk>
45b4cea1 762
f107bef7 763=head1 CONTRIBUTORS
45b4cea1 764
f107bef7 765Oliver Charles
766
767Jakub Nareski
768
6af04df5 769Simon Elliott
f107bef7 770
771Joe Highton
772
773John Napiorkowski
774
5cac799e 775Robert Buels
776
f107bef7 777=head1 COPYRIGHT
778
779Copyright (c) 2010-2011 the HTML::Zoom L</AUTHOR> and L</CONTRIBUTORS>
780as listed above.
45b4cea1 781
782=head1 LICENSE
783
784This library is free software, you can redistribute it and/or modify
785it under the same terms as Perl itself.
786
d80786d0 787=cut
45b4cea1 788