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