--- /dev/null
+package HTML::Zoom;
+
+use strict;
+use warnings FATAL => 'all';
+
+use HTML::Zoom::ZConfig;
+use HTML::Zoom::MatchWithoutFilter;
+
+sub new {
+ my ($class, $args) = @_;
+ my $new = {};
+ $new->{zconfig} = HTML::Zoom::ZConfig->new($args->{zconfig}||{});
+ bless($new, $class);
+}
+
+sub zconfig { shift->_self_or_new->{zconfig} }
+
+sub _self_or_new {
+ ref($_[0]) ? $_[0] : $_[0]->new
+}
+
+sub _with {
+ bless({ %{$_[0]}, %{$_[1]} }, ref($_[0]));
+}
+
+sub from_html {
+ my $self = shift->_self_or_new;
+ $self->_with({
+ initial_events => $self->zconfig->parser->html_to_events($_[0])
+ });
+}
+
+sub to_stream {
+ my $self = shift;
+ die "No events to build from - forgot to call from_html?"
+ unless $self->{initial_events};
+ my $sutils = $self->zconfig->stream_utils;
+ my $stream = $sutils->stream_from_array(@{$self->{initial_events}});
+ foreach my $filter_spec (@{$self->{filters}||[]}) {
+ $stream = $sutils->wrap_with_filter($stream, @{$filter_spec});
+ }
+ $stream
+}
+
+sub to_html {
+ my $self = shift;
+ $self->zconfig->producer->html_from_stream($self->to_stream);
+}
+
+sub memoize {
+ my $self = shift;
+ ref($self)->new($self)->from_html($self->to_html);
+}
+
+sub with_filter {
+ my ($self, $selector, $filter) = @_;
+ my $match = $self->parse_selector($selector);
+ $self->_with({
+ filters => [ @{$self->{filters}||[]}, [ $match, $filter ] ]
+ });
+}
+
+sub select {
+ my ($self, $selector) = @_;
+ my $match = $self->parse_selector($selector);
+ return HTML::Zoom::MatchWithoutFilter->construct(
+ $self, $match, $self->zconfig->filter_builder,
+ );
+}
+
+# There's a bug waiting to happen here: if you do something like
+#
+# $zoom->select('.foo')
+# ->remove_attribute({ class => 'foo' })
+# ->then
+# ->well_anything_really
+#
+# the second action won't execute because it doesn't match anymore.
+# Ideally instead we'd merge the match subs but that's more complex to
+# implement so I'm deferring it for the moment.
+
+sub then {
+ my $self = shift;
+ die "Can't call ->then without a previous filter"
+ unless $self->{filters};
+ $self->select($self->{filters}->[-1][0]);
+}
+
+sub parse_selector {
+ my ($self, $selector) = @_;
+ return $selector if ref($selector); # already a match sub
+ $self->zconfig->selector_parser->parse_selector($selector);
+}
+
+1;
+
+=head1 NAME
+
+HTML::Zoom - selector based streaming template engine
+
+=head1 SYNOPSIS
+
+ use HTML::Zoom;
+
+ my $template = <<HTML;
+ <html>
+ <head>
+ <title>Hello people</title>
+ </head>
+ <body>
+ <h1 id="greeting">Placeholder</h1>
+ <div id="list">
+ <span>
+ <p>Name: <span class="name">Bob</span></p>
+ <p>Age: <span class="age">23</span></p>
+ </span>
+ <hr class="between" />
+ </div>
+ </body>
+ </html>
+ HTML
+
+ my $output = HTML::Zoom
+ ->from_html($template)
+ ->select('title, #greeting')->replace_content('Hello world & dog!')
+ ->select('#list')->repeat_content(
+ [
+ sub {
+ $_->select('.name')->replace_content('Matt')
+ ->select('.age')->replace_content('26')
+ },
+ sub {
+ $_->select('.name')->replace_content('Mark')
+ ->select('.age')->replace_content('0x29')
+ },
+ sub {
+ $_->select('.name')->replace_content('Epitaph')
+ ->select('.age')->replace_content('<redacted>')
+ },
+ ],
+ { repeat_between => '.between' }
+ )
+ ->to_html;
+
+will produce:
+
+=begin testinfo
+
+ my $expect = <<HTML;
+
+=end testinfo
+
+ <html>
+ <head>
+ <title>Hello world & dog!</title>
+ </head>
+ <body>
+ <h1 id="greeting">Hello world & dog!</h1>
+ <div id="list">
+ <span>
+ <p>Name: <span class="name">Matt</span></p>
+ <p>Age: <span class="age">26</span></p>
+ </span>
+ <hr class="between" />
+ <span>
+ <p>Name: <span class="name">Mark</span></p>
+ <p>Age: <span class="age">0x29</span></p>
+ </span>
+ <hr class="between" />
+ <span>
+ <p>Name: <span class="name">Epitaph</span></p>
+ <p>Age: <span class="age"><redacted></span></p>
+ </span>
+
+ </div>
+ </body>
+ </html>
+
+=begin testinfo
+
+ HTML
+ is($output, $expect, 'Synopsis code works ok');
+
+=end testinfo
+
+=head1 SOMETHING ELSE
+
+=cut
sub new {
my ($class, $args) = @_;
- bless({ _code => $args->{code} }, $class);
+ bless({ _code => $args->{code}, _zconfig => $args->{zconfig} }, $class);
}
sub next {
use strict;
use warnings FATAL => 'all';
+use base qw(HTML::Zoom::SubObject);
use HTML::Zoom::CodeStream;
-sub new { bless({}, shift) }
-
sub _stream_from_code {
- HTML::Zoom::CodeStream->new({ code => $_[1] })
+ shift->_zconfig->stream_utils->stream_from_code(@_)
}
sub _stream_from_array {
- shift; # lose $self
- HTML::Zoom::CodeStream->from_array(@_)
+ shift->_zconfig->stream_utils->stream_from_array(@_)
}
sub _stream_from_proto {
- my ($self, $proto) = @_;
- my $ref = ref $proto;
- if (not $ref) {
- require HTML::Zoom::Parser::BuiltIn;
- return $self->_stream_from_array({
- type => 'TEXT',
- raw => HTML::Zoom::Parser::BuiltIn->html_escape($proto)
- });
- } elsif ($ref eq 'ARRAY') {
- return $self->_stream_from_array(@$proto);
- } elsif ($ref eq 'CODE') {
- return $proto->();
- } elsif ($ref eq 'SCALAR') {
- require HTML::Zoom::Parser::BuiltIn;
- return HTML::Zoom::Parser::BuiltIn->html_to_stream($$proto);
- }
- die "Don't know how to turn $proto (ref $ref) into a stream";
+ shift->_zconfig->stream_utils->stream_from_proto(@_)
}
sub _stream_concat {
- shift->_stream_from_array(@_)->flatten;
+ shift->_zconfig->stream_utils->stream_concat(@_)
}
sub set_attribute {
my $name = $evt->{name};
my $depth = 1;
my $_next = $content ? 'peek' : 'next';
- $stream = $filter->($stream) if $filter;
+ $stream = do { local $_ = $stream; $filter->($stream) } if $filter;
my $collector = $self->_stream_from_code(sub {
return unless $stream;
while (my ($evt) = $stream->$_next) {
my @between;
my $repeat_between = delete $options->{repeat_between};
if ($repeat_between) {
- require HTML::Zoom::SelectorParser;
- require HTML::Zoom::FilterStream;
- my $sp = HTML::Zoom::SelectorParser->new;
- my $filter = $self->collect({ into => \@between });
$options->{filter} = sub {
- HTML::Zoom::FilterStream->new({
- stream => $_[0],
- match => $sp->parse_selector($repeat_between),
- filter => $filter
- })
+ $_->select($repeat_between)->collect({ into => \@between })
};
}
my $repeater = sub {
_stream => $args->{stream},
_match => $args->{match},
_filter => $args->{filter},
+ _zconfig => $args->{zconfig},
},
$class
);
--- /dev/null
+package HTML::Zoom::MatchWithoutFilter;
+
+use strict;
+use warnings FATAL => 'all';
+
+sub construct {
+ bless({
+ zoom => $_[1], match => $_[2], fb => $_[3],
+ }, $_[0]);
+}
+
+sub DESTROY {}
+
+sub AUTOLOAD {
+ my $meth = our $AUTOLOAD;
+ $meth =~ s/.*:://;
+ my $self = shift;
+ if (my $cr = $self->{fb}->can($meth)) {
+ return $self->{zoom}->with_filter(
+ $self->{match}, $self->{fb}->$cr(@_)
+ );
+ }
+ die "Filter builder ${\$self->{fb}} does not provide action ${meth}";
+}
+
+1;
use strict;
use warnings FATAL => 'all';
-
-use HTML::Zoom::CodeStream;
+use base qw(HTML::Zoom::SubObject);
sub html_to_events {
- my ($class, $text) = @_;
+ my ($self, $text) = @_;
my @events;
_hacky_tag_parser($text => sub { push @events, $_[0] });
return \@events;
}
sub html_to_stream {
- my ($class, $text) = @_;
- return HTML::Zoom::CodeStream->from_array(@{$class->html_to_events($text)});
+ my ($self, $text) = @_;
+ return $self->_zconfig->stream_utils
+ ->stream_from_array(@{$self->html_to_events($text)});
}
sub _hacky_tag_parser {
use strict;
use warnings FATAL => 'all';
+sub new { bless({}, $_[0]) }
+
+sub with_zconfig { shift }
+
sub html_from_stream {
my ($class, $stream) = @_;
my $html;
use strict;
use warnings FATAL => 'all';
+use HTML::Zoom::MatchWithoutFilter;
+
+sub _zconfig { shift->{_zconfig} }
sub peek {
my ($self) = @_;
});
}
+sub with_filter {
+ my ($self, $selector, $filter) = @_;
+ my $match = $self->_parse_selector($selector);
+ $self->_zconfig->stream_utils->wrap_with_filter($self, $match, $filter);
+}
+
+sub select {
+ my ($self, $selector) = @_;
+ my $match = $self->_parse_selector($selector);
+ return HTML::Zoom::MatchWithoutFilter->construct(
+ $self, $match, $self->_zconfig->filter_builder,
+ );
+}
+
+sub _parse_selector {
+ my ($self, $selector) = @_;
+ return $selector if ref($selector); # already a match sub
+ $self->_zconfig->selector_parser->parse_selector($selector);
+}
+
1;
--- /dev/null
+package HTML::Zoom::StreamUtils;
+
+use strict;
+use warnings FATAL => 'all';
+use base qw(HTML::Zoom::SubObject);
+
+use HTML::Zoom::CodeStream;
+use HTML::Zoom::FilterStream;
+
+sub stream_from_code {
+ my ($self, $code) = @_;
+ HTML::Zoom::CodeStream->new({
+ code => $code,
+ zconfig => $self->_zconfig,
+ })
+}
+
+sub stream_from_array {
+ my $self = shift;
+ my @array = @_;
+ $self->stream_from_code(sub {
+ return unless @array;
+ return shift @array;
+ });
+}
+
+sub stream_concat {
+ shift->stream_from_array(@_)->flatten;
+}
+
+sub stream_from_proto {
+ my ($self, $proto) = @_;
+ my $ref = ref $proto;
+ if (not $ref) {
+ return $self->stream_from_array({
+ type => 'TEXT',
+ raw => $self->_zconfig->parser->html_escape($proto)
+ });
+ } elsif ($ref eq 'ARRAY') {
+ return $self->stream_from_array(@$proto);
+ } elsif ($ref eq 'CODE') {
+ return $proto->();
+ } elsif ($ref eq 'SCALAR') {
+ return $self->_zconfig->parser->html_to_stream($$proto);
+ }
+ die "Don't know how to turn $proto (ref $ref) into a stream";
+}
+
+sub wrap_with_filter {
+ my ($self, $stream, $match, $filter) = @_;
+ HTML::Zoom::FilterStream->new({
+ stream => $stream,
+ match => $match,
+ filter => $filter,
+ zconfig => $self->_zconfig,
+ })
+}
+
+1;
--- /dev/null
+package HTML::Zoom::SubObject;
+
+use strict;
+use warnings FATAL => 'all';
+use Scalar::Util ();
+
+sub new {
+ my ($class, $args) = @_;
+ ($args||={})->{zconfig} ||= do {
+ require HTML::Zoom::ZConfig;
+ HTML::Zoom::ZConfig->new
+ };
+ my $new = { _zconfig => $args->{zconfig} };
+ Scalar::Util::weaken($new->{_zconfig});
+ bless($new, $class)
+}
+
+sub _zconfig { shift->{_zconfig} }
+
+sub with_zconfig {
+ my ($self, $zconfig) = @_;
+ my $new = bless({ %$self, _zconfig => $zconfig }, ref($self));
+ Scalar::Util::weaken($new->{_zconfig});
+ $new
+}
+
+1;
--- /dev/null
+package HTML::Zoom::ZConfig;
+
+use strict;
+use warnings FATAL => 'all';
+
+my %DEFAULTS = (
+ parser => 'HTML::Zoom::Parser::BuiltIn',
+ producer => 'HTML::Zoom::Producer::BuiltIn',
+ filter_builder => 'HTML::Zoom::FilterBuilder',
+ selector_parser => 'HTML::Zoom::SelectorParser',
+ stream_utils => 'HTML::Zoom::StreamUtils',
+);
+
+my $ALL_DEFAULT;
+
+sub new {
+ my ($class, $args) = @_;
+ return $ALL_DEFAULT if $ALL_DEFAULT && !keys %{$args||{}};
+ my $new = {};
+ foreach my $arg_name (keys %DEFAULTS) {
+ $new->{$arg_name} = $args->{$arg_name} || $DEFAULTS{$arg_name};
+ if (ref($new->{$arg_name})) {
+ $new->{$arg_name} = $new->{$arg_name}->with_zconfig($new);
+ } else {
+ require(do { (my $m = $new->{$arg_name}) =~ s/::/\//g; "${m}.pm" });
+ $new->{$arg_name} = $new->{$arg_name}->new({ zconfig => $new });
+ }
+ }
+ $ALL_DEFAULT = $new if !keys %{$args||{}};
+ bless($new, $class);
+}
+
+sub parser { shift->{parser} }
+sub producer { shift->{producer} }
+sub filter_builder { shift->{filter_builder} }
+sub selector_parser { shift->{selector_parser} }
+sub stream_utils { shift->{stream_utils} }
+
+1;
--- /dev/null
+#!/usr/bin/env perl
+
+use strict;
+use warnings FATAL => 'all';
+
+my $from = do { local (@ARGV, $/) = ('lib/HTML/Zoom.pm'); <> };
+
+$from =~ s/.*^=head1 SYNOPSIS\n//sm;
+
+$from =~ s/^=head1.*//sm;
+
+my $code = join "\n", map { s/^ // ? ($_) : () } split "\n", $from;
+
+open my $syn_test, '>', 't/synopsis.t'
+ or die "Couldn't open t/synopsis.t - you screwed something up. Go fix it.\n";
+
+print $syn_test "use strict;
+use warnings FATAL => 'all';
+use Test::More qw(no_plan);
+
+$code;
+";
</body>
END
-sub src_stream { HTML::Zoom::Parser::BuiltIn->html_to_stream($tmpl); }
+sub src_stream { HTML::Zoom::Parser::BuiltIn->new->html_to_stream($tmpl); }
-sub html_sink { HTML::Zoom::Producer::BuiltIn->html_from_stream($_[0]) }
+sub html_sink { HTML::Zoom::Producer::BuiltIn->new->html_from_stream($_[0]) }
my $fb = HTML::Zoom::FilterBuilder->new;