first cut at docs for Zoom.pm
Matt S Trout [Wed, 17 Mar 2010 21:34:00 +0000 (21:34 +0000)]
lib/HTML/Zoom.pm
lib/HTML/Zoom/FilterBuilder.pm
lib/HTML/Zoom/StreamBase.pm

index 0ae18d2..dac8bd7 100644 (file)
@@ -76,7 +76,8 @@ sub memoize {
 }
 
 sub with_filter {
-  my ($self, $selector, $filter) = @_;
+  my $self = shift->_self_or_new;
+  my ($selector, $filter) = @_;
   my $match = $self->parse_selector($selector);
   $self->_with({
     filters => [ @{$self->{filters}||[]}, [ $match, $filter ] ]
@@ -84,7 +85,8 @@ sub with_filter {
 }
 
 sub select {
-  my ($self, $selector) = @_;
+  my $self = shift->_self_or_new;
+  my ($selector) = @_;
   my $match = $self->parse_selector($selector);
   return HTML::Zoom::MatchWithoutFilter->construct(
     $self, $match, $self->zconfig->filter_builder,
@@ -94,7 +96,7 @@ sub select {
 # There's a bug waiting to happen here: if you do something like
 #
 # $zoom->select('.foo')
-#      ->remove_attribute({ class => 'foo' })
+#      ->remove_attribute(class => 'foo')
 #      ->then
 #      ->well_anything_really
 #
@@ -206,6 +208,491 @@ will produce:
 
 =end testinfo
 
-=head1 SOMETHING ELSE
+=head1 DANGER WILL ROBINSON
+
+This is a 0.9 release. That means that I'm fairly happy the API isn't going
+to change in surprising and upsetting ways before 1.0 and a real compatibility
+freeze. But it also means that if it turns out there's a mistake the size of
+a politician's ego in the API design that I haven't spotted yet there may be
+a bit of breakage between here and 1.0. Hopefully not though. Appendages
+crossed and all that.
+
+Worse still, the rest of the distribution isn't documented yet. I'm sorry.
+I suck. But lots of people have been asking me to ship this, docs or no, so
+having got this class itself at least somewhat documented I figured now was
+a good time to cut a first real release.
+
+=head1 DESCRIPTION
+
+HTML::Zoom is a lazy, stream oriented, streaming capable, mostly functional,
+CSS selector based semantic templating engine for HTML and HTML-like
+document formats.
+
+Which is, on the whole, a bit of a mouthful. So let me step back a moment
+and explain why you care enough to understand what I mean:
+
+=head2 JQUERY ENVY
+
+HTML::Zoom is the cure for JQuery envy. When your javascript guy pushes a
+piece of data into a document by doing:
+
+  $('.username').replaceAll(username);
+
+In HTML::Zoom one can write
+
+  $zoom->select('.username')->replace_content($username);
+
+which is, I hope, almost as clear, hampered only by the fact that Zoom can't
+assume a global document and therefore has nothing quite so simple as the
+$() function to get the initial selection.
+
+L<HTML::Zoom::SelectorParser> implements a subset of the JQuery selector
+specification, and will continue to track that rather than the W3C standards
+for the forseeable future on grounds of pragmatism. Also on grounds of their
+spec is written in EN_US rather than EN_W3C, and I read the former much better.
+
+I am happy to admit that it's very, very much a subset at the moment - see the
+L<HTML::Zoom::SelectorParser> POD for what's currently there, and expect more
+and more to be supported over time as we need it and patch it in.
+
+=head2 CLEAN TEMPLATES
+
+HTML::Zoom is the cure for messy templates. How many times have you looked at
+templates like this:
+
+  <form action="/somewhere">
+  [% FOREACH field IN fields %]
+    <label for="[% field.id %]">[% field.label %]</label>
+    <input name="[% field.name %]" type="[% field.type %]" value="[% field.value %]" />
+  [% END %]
+  </form>
+
+and despaired of the fact that neither the HTML structure nor the logic are
+remotely easy to read? Fortunately, with HTML::Zoom we can separate the two
+cleanly:
+
+  <form class="myform" action="/somewhere">
+    <label />
+    <input />
+  </form>
+
+  $zoom->select('.myform')->repeat_content([
+    map { my $field = $_; sub {
+
+     $_->select('label')
+       ->add_attribute( for => $field->{id} )
+       ->then
+       ->replace_content( $field->{label} )
+
+       ->select('input')
+       ->add_attribute( name => $field->{name} )
+       ->then
+       ->add_attribute( type => $field->{type} )
+       ->then
+       ->add_attribute( value => $field->{value} )
+
+    } } @fields
+  ]);
+
+This is, admittedly, very much not shorter. However, it makes it extremely
+clear what's happening and therefore less hassle to maintain. Especially
+because it allows the designer to fiddle with the HTML without cutting
+himself on sharp ELSE clauses, and the developer to add available data to
+the template without getting angle bracket cuts on sensitive parts.
+
+Better still, HTML::Zoom knows that it's inserting content into HTML and
+can escape it for you - the example template should really have been:
+
+  <form action="/somewhere">
+  [% FOREACH field IN fields %]
+    <label for="[% field.id | html %]">[% field.label | html %]</label>
+    <input name="[% field.name | html %]" type="[% field.type | html %]" value="[% field.value | html %]" />
+  [% END %]
+  </form>
+
+and frankly I'll take slightly more code any day over *that* crawling horror.
+
+(addendum: I pick on L<Template Toolkit|Template> here specifically because
+it's the template system I hate the least - for text templating, I don't
+honestly think I'll ever like anything except the next version of Template
+Toolkit better - but HTML isn't text. Zoom knows that. Do you?)
+
+=head2 PUTTING THE FUN INTO FUNCTIONAL
+
+The principle of HTML::Zoom is to provide a reusable, functional container
+object that lets you build up a set of transforms to be applied; every method
+call you make on a zoom object returns a new object, so it's safe to do so
+on one somebody else gave you without worrying about altering state (with
+the notable exception of ->next for stream objects, which I'll come to later).
+
+So:
+
+  my $z2 = $z1->select('.name')->replace_content($name);
+
+  my $z3 = $z2->select('.title')->replace_content('Ms.');
+
+each time produces a new Zoom object. If you want to package up a set of
+transforms to re-use, HTML::Zoom provides an 'apply' method:
+
+  my $add_name = sub { $_->select('.name')->replace_content($name) };
+  my $same_as_z2 = $z1->apply($add_name);
+
+=head2 LAZINESS IS A VIRTUE
+
+HTML::Zoom does its best to defer doing anything until it's absolutely
+required. The only point at which it descends into state is when you force
+it to create a stream, directly by:
+
+  my $stream = $zoom->as_stream;
+
+  while (my $evt = $stream->next) {
+    # handle zoom event here
+  }
+
+or indirectly via:
+
+  my $final_html = $zoom->to_html;
+
+  my $fh = $zoom->to_fh;
+
+  while (my $chunk = $fh->getline) {
+    ...
+  }
+
+Better still, the $fh returned doesn't create its stream until the first
+call to getline, which means that until you call that and force it to be
+stateful you can get back to the original stateless Zoom object via:
+
+  my $zoom = $fh->to_zoom;
+
+which is exceedingly handy for filtering L<Plack> PSGI responses, among other
+things.
+
+Because HTML::Zoom doesn't try and evaluate everything up front, you can
+generally put things together in whatever order is most appropriate. This
+means that:
+
+  my $start = HTML::Zoom->from_html($html);
+
+  my $zoom = $start->select('div')->replace_content('THIS IS A DIV!');
+
+and:
+
+  my $start = HTML::Zoom->select('div')->replace_content('THIS IS A DIV!');
+
+  my $zoom = $start->from_html($html);
+
+will produce equivalent final $zoom objects, thus proving that there can be
+more than one way to do it without one of them being a
+L<bait and switch|Switch>.
+
+=head2 STOCKTON TO DARLINGTON UNDER STREAM POWER
+
+HTML::Zoom's execution always happens in terms of streams under the hood
+- that is, the basic pattern for doing anything is -
+
+  my $stream = get_stream_from_somewhere
+
+  while (my ($evt) = $stream->next) {
+    # do something with the event
+  }
+
+More importantly, all selectors and filters are also built as stream
+operations, so a selector and filter pair is effectively:
+
+  sub next {
+    my ($self) = @_;
+    my $next_evt = $self->parent_stream->next;
+    if ($self->selector_matches($next_evt)) {
+      return $self->apply_filter_to($next_evt);
+    } else {
+      return $next_evt;
+    }
+  }
+
+Internally, things are marginally more complicated than that, but not enough
+that you as a user should normally need to care.
+
+In fact, an HTML::Zoom object is mostly just a container for the relevant
+information from which to build the final stream that does the real work. A
+stream built from a Zoom object is a stream of events from parsing the
+initial HTML, wrapped in a filter stream per selector/filter pair provided
+as described above.
+
+The upshot of this is that the application of filters works just as well on
+streams as on the original Zoom object - in fact, when you run a
+L</repeat_content> operation your subroutines are applied to the stream for
+that element of the repeat, rather than constructing a new zoom per repeat
+element as well.
+
+More concretely:
+
+  $_->select('div')->replace_content('I AM A DIV!');
+
+works on both HTML::Zoom objects themselves and HTML::Zoom stream objects and
+shares sufficient of the implementation that you can generally forget the
+difference - barring the fact that a stream already has state attached so
+things like to_fh are no longer available.
+
+=head2 POP! GOES THE WEASEL
+
+... and by Weasel, I mean layout.
+
+HTML::Zoom's filehandle object supports an additional event key, 'flush',
+that is transparent to the rest of the system but indicates to the filehandle
+object to end a getline operation at that point and return the HTML so far.
+
+This means that in an environment where streaming output is available, such
+as a number of the L<Plack> PSGI handlers, you can add the flush key to an
+event in order to ensure that the HTML generated so far is flushed through
+to the browser right now. This can be especially useful if you know you're
+about to call a web service or a potentially slow database query or similar
+to ensure that at least the header/layout of your page renders now, improving
+perceived user responsiveness while your application waits around for the
+data it needs.
+
+This is currently exposed by the 'flush_before' option to the collect filter,
+which incidentally also underlies the replace and repeat filters, so to
+indicate we want this behaviour to happen before a query is executed we can
+write something like:
+
+  $zoom->select('.item')->repeat(sub {
+    if (my $row = $db_thing->next) {
+      return sub { $_->select('.item-name')->replace_content($row->name) }
+    } else {
+      return
+    }
+  }, { flush_before => 1 });
+
+which should have the desired effect given a sufficiently lazy $db_thing (for
+example a L<DBIx::Class::ResultSet> object).
+
+=head2 A FISTFUL OF OBJECTS
+
+At the core of an HTML::Zoom system lurks an L<HTML::Zoom::ZConfig> object,
+whose purpose is to hang on to the various bits and pieces that things need
+so that there's a common way of accessing shared functionality.
+
+Were I a computer scientist I would probably call this an "Inversion of
+Control" object - which you'd be welcome to google to learn more about, or
+you can just imagine a computer scientist being suspended upside down over
+a pit. Either way works for me, I'm a pure maths grad.
+
+The ZConfig object hangs on to one each of the following for you:
+
+=over 4
+
+=item * An HTML parser, normally L<HTML::Zoom::Parser::BuiltIn>
+
+=item * An HTML producer (emitter), normally L<HTML::Zoom::Producer::BuiltIn>
+
+=item * An object to build event filters, normally L<HTML::Zoom::FilterBuilder>
+
+=item * An object to parse CSS selectors, normally L<HTML::Zoom::SelectorParser>
+
+=item * An object to build streams, normally L<HTML::Zoom::StreamUtils>
+
+=back
+
+In theory you could replace any of these with anything you like, but in
+practice you're probably best restricting yourself to subclasses, or at
+least things that manage to look like the original if you squint a bit.
+
+If you do something more clever than that, or find yourself overriding things
+in your ZConfig a lot, please please tell us about it via one of the means
+mentioned under L</SUPPORT>.
+
+=head2 SEMANTIC DIDACTIC
+
+Some will argue that overloading CSS selectors to do data stuff is a terrible
+idea, and possibly even a step towards the "Concrete Javascript" pattern
+(which I abhor) or Smalltalk's Morphic (which I ignore, except for the part
+where it keeps reminding me of the late, great Tony Hart's plasticine friend).
+
+To which I say, "eh", "meh", and possibly also "feh". If it really upsets
+you, either use extra classes for this (and remove them afterwards) or
+use special fake elements or, well, honestly, just use something different.
+L<Template::Semantic> provides a similar idea to zoom except using XPath
+and XML::LibXML transforms rather than a lightweight streaming approach -
+maybe you'd like that better. Or maybe you really did want
+L<Template Toolkit|Template> after all. It is still damn good at what it does,
+after all.
+
+So far, however, I've found that for new sites the designers I'm working with
+generally want to produce nice semantic HTML with classes that represent the
+nature of the data rather than the structure of the layout, so sharing them
+as a common interface works really well for us.
+
+In the absence of any evidence that overloading CSS selectors has killed
+children or unexpectedly set fire to grandmothers - and given microformats
+have been around for a while there's been plenty of opportunity for
+octagenarian combustion - I'd suggest you give it a try and see if you like it.
+
+=head2 GET THEE TO A SUMMARY!
+
+Erm. Well.
+
+HTML::Zoom is a lazy, stream oriented, streaming capable, mostly functional,
+CSS selector based semantic templating engine for HTML and HTML-like
+document formats.
+
+But I said that already. Although hopefully by now you have some idea what I
+meant when I said it. If you didn't have any idea the first time. I mean, I'm
+not trying to call you stupid or anything. Just saying that maybe it wasn't
+totally obvious without the explanation. Or something.
+
+Er.
+
+Maybe we should just move on to the method docs.
+
+=head1 METHODS
+
+=head2 new
+
+  my $zoom = HTML::Zoom->new;
+
+  my $zoom = HTML::Zoom->new({ zconfig => $zconfig });
+
+Create a new empty Zoom object. You can optionally pass an
+L<HTML::Zoom::ZConfig> instance if you're trying to override one or more of
+the default components.
+
+This method isn't often used directly since several other methods can also
+act as constructors, notable L</select> and L</from_html>
+
+=head2 zconfig
+
+  my $zconfig = $zoom->zconfig;
+
+Retrieve the L<HTML::Zoom::ZConfig> instance used by this Zoom object. You
+shouldn't usually need to call this yourself.
+
+=head2 from_html
+
+  my $zoom = HTML::Zoom->from_html($html);
+
+  my $z2 = $z1->from_html($html);
+
+Parses the HTML using the current zconfig's parser object and returns a new
+zoom instance with that as the source HTML to be transformed.
+
+=head2 from_file
+
+  my $zoom = HTML::Zoom->from_file($file);
+
+  my $z2 = $z1->from_file($file);
+
+Convenience method - slurps the contents of $file and calls from_html with it.
+
+=head2 to_stream
+
+  my $stream = $zoom->to_stream;
+
+  while (my ($evt) = $stream->next) {
+    ...
+
+Creates a stream, starting with a stream of the events from the HTML supplied
+via L</from_html> and then wrapping it in turn with each selector+filter pair
+that have been applied to the zoom object.
+
+=head2 to_fh
+
+  my $fh = $zoom->to_fh;
+
+  call_something_expecting_a_filehandle($fh);
+
+Returns an L<HTML::Zoom::ReadFH> instance that will create a stream the first
+time its getline method is called and then return all HTML up to the next
+event with 'flush' set.
+
+You can pass this filehandle to compliant PSGI handlers (and probably most
+web frameworks).
+
+=head2 run
+
+  $zoom->run;
+
+Runs the zoom object's transforms without doing anything with the results.
+
+Normally used to get side effects of a zoom run - for example when using
+L<HTML::Zoom::FilterBuilder/collect> to slurp events for scraping or layout.
+
+=head2 apply
+
+  my $z2 = $z1->apply(sub {
+    $_->select('div')->replace_content('I AM A DIV!') })
+  });
+
+Sets $_ to the zoom object and then runs the provided code. Basically syntax
+sugar, the following is entirely equivalent:
+
+  my $sub = sub {
+    shift->select('div')->replace_content('I AM A DIV!') })
+  };
+
+  my $z2 = $sub->($z1);
+
+=head2 to_html
+
+  my $html = $zoom->to_html;
+
+Runs the zoom processing and returns the resulting HTML.
+
+=head2 memoize
+
+  my $z2 = $z1->memoize;
+
+Creates a new zoom whose source HTML is the results of the original zoom's
+processing. Effectively syntax sugar for:
+
+  my $z2 = HTML::Zoom->from_html($z1->to_html);
+
+but preserves your L<HTML::Zoom::ZConfig> object.
+
+=head2 with_filter
+
+  my $zoom = HTML::Zoom->with_filter(
+    'div', $filter_builder->replace_content('I AM A DIV!')
+  );
+
+  my $z2 = $z1->with_filter(
+    'div', $filter_builder->replace_content('I AM A DIV!')
+  );
+
+Lower level interface than L</select> to adding filters to your zoom object.
+
+In normal usage, you probably don't need to call this yourself.
+
+=head2 select
+
+  my $zoom = HTML::Zoom->select('div')->replace_content('I AM A DIV!');
+
+  my $z2 = $z1->select('div')->replace_content('I AM A DIV!');
+
+Returns an intermediary object of the class L<HTML::Zoom::MatchWithoutFilter>
+on which methods of your L<HTML::Zoom::FilterBuilder> object can be called.
+
+In normal usage you should generally always put the pair of method calls
+together; the intermediary object isn't designed or expected to stick around.
+
+=head2 then
+
+  my $z2 = $z1->select('div')->add_attribute(class => 'spoon')
+                             ->then
+                             ->replace_content('I AM A DIV!');
+
+Re-runs the previous select to allow you to chain actions together on the
+same selector.
+
+=head2 parse_selector
+
+  my $matcher = $zoom->parse_selector('div');
+
+Used by L</select> and L</with_filter> to invoke the current
+L<HTML::Zoom::SelectorParser> object to create a matcher object (currently
+a coderef but this is an implementation detail) for that selector.
+
+In normal usage, you probably don't need to call this yourself.
 
 =cut
index 316d56c..6b50bbb 100644 (file)
@@ -26,8 +26,8 @@ sub _flatten_stream_of_streams {
 }
 
 sub set_attribute {
-  my ($self, $args) = @_;
-  my ($name, $value) = @{$args}{qw(name value)};
+  my $self = shift;
+  my ($name, $value) = $self->_parse_attribute_args(@_);
   sub {
     my $a = (my $evt = $_[0])->{attrs};
     my $e = exists $a->{$name};
@@ -40,9 +40,17 @@ sub set_attribute {
    };
 }
 
+sub _parse_attribute_args {
+  my $self = shift;
+  # allow ->add_attribute(name => 'value')
+  #    or ->add_attribute({ name => 'name', value => 'value' })
+  my ($name, $value) = @_ > 1 ? @_ : @{$_[0]}{qw(name value)};
+  return ($name, $self->_zconfig->parser->html_escape($value));
+}
+
 sub add_attribute {
-  my ($self, $args) = @_;
-  my ($name, $value) = @{$args}{qw(name value)};
+  my $self = shift;
+  my ($name, $value) = $self->_parse_attribute_args(@_);
   sub {
     my $a = (my $evt = $_[0])->{attrs};
     my $e = exists $a->{$name};
@@ -60,7 +68,7 @@ sub add_attribute {
 
 sub remove_attribute {
   my ($self, $args) = @_;
-  my $name = $args->{name};
+  my $name = (ref($args) eq 'HASH') ? $args->{name} : $args;
   sub {
     my $a = (my $evt = $_[0])->{attrs};
     return $evt unless exists $a->{$name};
@@ -74,8 +82,8 @@ sub remove_attribute {
 
 sub collect {
   my ($self, $options) = @_;
-  my ($into, $passthrough, $content, $filter) =
-    @{$options}{qw(into passthrough content filter)};
+  my ($into, $passthrough, $content, $filter, $flush_before) =
+    @{$options}{qw(into passthrough content filter flush_before)};
   sub {
     my ($evt, $stream) = @_;
     # We wipe the contents of @$into here so that other actions depending
@@ -111,7 +119,16 @@ sub collect {
       }
       die "Never saw closing </${name}> before end of source";
     });
-    return ($passthrough||$content) ? [ $evt, $collector ] : $collector;
+    if ($flush_before) {
+      if ($passthrough||$content) {
+        $evt = { %$evt, flush => 1 };
+      } else {
+        $evt = { type => 'EMPTY', flush => 1 };
+      }
+    }
+    return ($passthrough||$content||$flush_before)
+             ? [ $evt, $collector ]
+             : $collector;
   };
 }
 
index a76a636..8b89fdf 100644 (file)
@@ -76,4 +76,10 @@ sub _parse_selector {
   $self->_zconfig->selector_parser->parse_selector($selector);
 }
 
+sub apply {
+  my ($self, $code) = @_;
+  local $_ = $self;
+  $self->$code;
+}
+
 1;