File Coverage

blib/lib/XML/Atom/SimpleFeed.pm
Criterion Covered Total %
statement 143 170 84.1
branch 48 86 55.8
condition 13 25 52.0
subroutine 31 34 91.1
pod 4 25 16.0
total 239 340 70.2


line stmt bran cond sub pod time code
1 6     6   3593 use 5.008001; # no good Unicode support? you lose
  6         54  
2 6     6   32 use strict;
  6         12  
  6         120  
3 6     6   29 use warnings;
  6         12  
  6         455  
4              
5             package XML::Atom::SimpleFeed;
6              
7             our $VERSION = '0.904';
8              
9 6     6   48 use Carp;
  6         15  
  6         668  
10 6     6   3629 use Encode ();
  6         67142  
  6         149  
11 6     6   3111 use POSIX ();
  6         52513  
  6         16802  
12              
13             my @XML_ENC = 'us-ascii'; # use array because local($myvar) error but local($myvar[0]) OK
14             # and use a lexical because not a public interface
15              
16             sub ATOM_NS () { 'http://www.w3.org/2005/Atom' }
17             sub XHTML_NS () { 'http://www.w3.org/1999/xhtml' }
18 7     7 0 28 sub PREAMBLE () { qq(\n) }
19             sub W3C_DATETIME () { '%Y-%m-%dT%H:%M:%S' }
20             sub DEFAULT_GENERATOR () { {
21 4   50 4 0 250 uri => 'https://metacpan.org/pod/' . __PACKAGE__,
22             version => __PACKAGE__->VERSION || 'git',
23             name => __PACKAGE__,
24             } }
25              
26             ####################################################################
27             # superminimal XML writer
28             #
29              
30 2     2 0 2045 sub xml_encoding { local $XML_ENC[0] = shift; &{(shift)} }
  2         3  
  2         6  
31              
32             my %XML_ESC = (
33             "\xA" => ' ',
34             "\xD" => ' ',
35             '"' => '"',
36             '&' => '&',
37             "'" => ''',
38             '<' => '<',
39             '>' => '>',
40             );
41              
42 63     63 0 205 sub xml_cref { Encode::encode $XML_ENC[0], $_[0], Encode::HTMLCREF }
43              
44             sub xml_escape {
45 40     40 0 219 $_[0] =~ s{ ( [<>&'"] ) }{ $XML_ESC{ $1 } }gex;
  13         53  
46 40         85 &xml_cref;
47             }
48              
49             sub xml_attr_escape {
50 22     22 0 685 $_[0] =~ s{ ( [\x0A\x0D<>&'"] ) }{ $XML_ESC{ $1 } }gex;
  14         48  
51 22         46 &xml_cref;
52             }
53              
54             sub xml_cdata_flatten {
55 1     1 0 4 for ( $_[0] ) {
56 1         2 my $cdata_content;
57 1         21 s{}{ xml_escape $cdata_content = $1 }gse;
  1         9  
58 1 50       53 croak 'Incomplete CDATA section' if -1 < index $_, '
59 1         4 return $_;
60             }
61             }
62              
63 1     1 0 566 sub xml_string { xml_cref xml_cdata_flatten $_[ 0 ] }
64              
65             sub xml_tag {
66 57     57 0 3084 my $name = shift;
67 57         94 my $attr = '';
68 57 100       139 if( ref $name eq 'ARRAY' ) {
69 25         41 my $i = 1;
70 25         60 while( $i < @$name ) {
71 20         63 $attr .= ' ' . $name->[ $i ] . '="' . xml_attr_escape( $name->[ $i + 1 ] ) . '"';
72 20         844 $i += 2;
73             }
74 25         52 $name = $name->[ 0 ];
75             }
76 57 100       376 @_ ? join( '', "<$name$attr>", @_, "" ) : "<$name$attr/>";
77             }
78              
79             ####################################################################
80             # misc utility functions
81             #
82              
83             sub natural_enum {
84 0     0 0 0 my @and;
85 0 0       0 unshift @and, pop @_ if @_;
86 0 0       0 unshift @and, join ', ', @_ if @_;
87 0         0 join ' and ', @and;
88             }
89              
90             sub permalink {
91 3     3 0 6 my ( $link_arg ) = ( @_ );
92 3 100 33     23 if( ref $link_arg ne 'HASH' ) {
    50          
93 2         10 return $link_arg;
94             }
95             elsif( not exists $link_arg->{ rel } or $link_arg->{ rel } eq 'alternate' ) {
96 0         0 return $link_arg->{ href };
97             }
98 1         4 return;
99             }
100              
101             ####################################################################
102             # actual implementation of RFC 4287
103             #
104              
105             sub simple_construct {
106 8     8 0 20 my ( $name, $content ) = @_;
107 8         18 xml_tag $name, xml_escape $content;
108             }
109              
110             sub date_construct {
111 11     11 0 10874 my ( $name, $dt ) = @_;
112 11         24 eval { $dt = $dt->epoch }; # convert to epoch to avoid dealing with everyone's TZ crap
  11         156  
113 11 100       559 $dt = POSIX::strftime( W3C_DATETIME . 'Z', gmtime $dt ) unless $dt =~ /[^0-9]/;
114 11         57 xml_tag $name, xml_escape $dt;
115             }
116              
117             sub person_construct {
118 3     3 0 91 my ( $name, $arg ) = @_;
119              
120 3 50       35 my $prop = 'HASH' ne ref $arg ? { name => $arg } : $arg;
121              
122 3 50       15 croak "name required for $name element" if not exists $prop->{ name };
123              
124             return xml_tag $name => (
125 3         10 map { xml_tag $_ => xml_escape $prop->{ $_ } }
126 3         10 grep { exists $prop->{ $_ } }
  9         23  
127             qw( name email uri )
128             );
129             }
130              
131             sub text_construct {
132 9     9 0 22 my ( $name, $arg ) = @_;
133              
134 9         16 my ( $type, $content );
135              
136 9 50       30 if( ref $arg eq 'HASH' ) {
137             # FIXME doesn't support @src attribute for $name eq 'content' yet
138              
139 0 0       0 $type = exists $arg->{ type } ? $arg->{ type } : 'html';
140              
141 0 0       0 croak "content required for $name element" unless exists $arg->{ content };
142              
143             # a lof of the effort that follows is to omit the type attribute whenever possible
144             #
145 0 0 0     0 if( $type eq 'xhtml' ) {
    0          
146 0         0 $content = xml_string $arg->{ content };
147              
148 0 0       0 if( $content !~ /
149 0         0 $type = 'text';
150 0         0 $content =~ s/[\n\t]+/ /g;
151             }
152             else {
153 0         0 $content = xml_tag [ div => xmlns => XHTML_NS ], $content;
154             }
155             }
156             elsif( $type eq 'html' or $type eq 'text' ) {
157 0         0 $content = xml_escape $arg->{ content };
158             }
159             else {
160 0 0       0 croak "type '$type' not allowed in $name element"
161             if $name ne 'content';
162              
163             # FIXME non-XML/text media types must be base64 encoded!
164 0         0 $content = xml_string $arg->{ content };
165             }
166             }
167             else {
168 9         19 $type = 'html';
169 9         25 $content = xml_escape $arg;
170             }
171              
172 9 100 66     2663 if( $type eq 'html' and $content !~ /&/ ) {
173 8         17 $type = 'text';
174 8         19 $content =~ s/[\n\t]+/ /g;
175             }
176              
177 9 100       41 return xml_tag [ $name => $type ne 'text' ? ( type => $type ) : () ], $content;
178             }
179              
180             sub link_element {
181 3     3 0 8 my ( $name, $arg ) = @_;
182              
183             # omit atom:link/@rel value when possible
184             delete $arg->{'rel'}
185             if 'HASH' eq ref $arg
186             and exists $arg->{'rel'}
187 3 50 66     15 and 'alternate' eq $arg->{'rel'};
      33        
188              
189             my @attr = 'HASH' eq ref $arg
190 3 100       11 ? do {
191 1 50       13 croak "href required for link element" if not exists $arg->{'href'};
192 1         7 map { $_ => $arg->{ $_ } } grep exists $arg->{ $_ }, qw( href rel type title hreflang length );
  2         7  
193             }
194             : ( href => $arg );
195              
196             # croak "link '$attr[1]' is not a valid URI"
197             # if $attr[1] XXX TODO
198              
199 3         10 xml_tag [ link => @attr ];
200             }
201              
202             sub category_element {
203 2     2 0 5 my ( $name, $arg ) = @_;
204              
205             my @attr = 'HASH' eq ref $arg
206 2 50       7 ? do {
207 0 0       0 croak "term required for category element" if not exists $arg->{'term'};
208 0         0 map { $_ => $arg->{ $_ } } grep exists $arg->{ $_ }, qw( term scheme label );
  0         0  
209             }
210             : ( term => $arg );
211              
212 2         7 xml_tag [ category => @attr ];
213             }
214              
215             sub generator_element {
216 7     7 0 18 my ( $name, $arg ) = @_;
217 7 100       37 if( ref $arg eq 'HASH' ) {
    100          
218 2 50       7 croak 'name required for generator element' if not exists $arg->{ name };
219 2         15 my $content = delete $arg->{ name };
220 2         21 xml_tag [ generator => map +( $_ => $arg->{ $_ } ), grep exists $arg->{ $_ }, qw( uri version ) ], xml_escape( $content );
221             }
222             elsif( defined $arg ) {
223 4         11 xml_tag generator => xml_escape( $arg );
224             }
225 1         3 else { '' }
226             }
227              
228             # tag makers are called with the name of the tag they're supposed to handle as the first parameter
229             my %make_tag = (
230             icon => \&simple_construct,
231             id => \&simple_construct,
232             logo => \&simple_construct,
233             published => \&date_construct,
234             updated => \&date_construct,
235             author => \&person_construct,
236             contributor => \&person_construct,
237             title => \&text_construct,
238             subtitle => \&text_construct,
239             rights => \&text_construct,
240             summary => \&text_construct,
241             content => \&text_construct,
242             link => \&link_element,
243             category => \&category_element,
244             generator => \&generator_element,
245             );
246              
247             sub container_content {
248 8     8 0 40 my ( $name, %arg ) = @_;
249              
250             my ( $elements, $required, $optional, $singular, $deprecation, $callback ) =
251 8         32 @arg{ qw( elements required optional singular deprecate callback ) };
252              
253 8         17 my ( $content, %permission, %count, $permalink );
254              
255 8         54 undef @permission{ @$required, @$optional }; # populate
256              
257 8         67 while( my ( $elem, $arg ) = splice @$elements, 0, 2 ) {
258 36 50       126 if( exists $permission{ $elem } ) {
259 36         104 $content .= $make_tag{ $elem }->( $elem, $arg );
260 36         96 ++$count{ $elem };
261             }
262             else {
263 0         0 croak "Unknown element $elem";
264             }
265              
266 36 100 100     94 if( $elem eq 'link' and defined ( my $alt = permalink $arg ) ) {
267 2 50       9 $permalink = $alt unless $count{ 'alternate link' }++;
268             }
269              
270 36 100       85 if( exists $callback->{ $elem } ) { $callback->{ $elem }->( $arg ) }
  13         31  
271              
272 36 100       120 if( not @$elements ) { # end of input?
273             # we would normally fall off the bottom of the loop now;
274             # before that happens, it's time to defaultify stuff and
275             # put it in the input so we will keep going for a little longer
276 11 50 33     35 if( not $count{ id } and defined $permalink ) {
277 0         0 carp 'Falling back to alternate link as id';
278 0         0 push @$elements, id => $permalink;
279             }
280 11 100       51 if( not $count{ updated } ) {
281 3         12 push @$elements, updated => $arg{ default_upd };
282             }
283             }
284             }
285              
286 8         19 my @error;
287              
288 8         18 my @missing = grep { not exists $count{ $_ } } @$required;
  24         58  
289 8   100     18 my @toomany = grep { ( $count{ $_ } || 0 ) > 1 } 'alternate link', @$singular;
  69         263  
290              
291 8 50       23 push @error, 'requires at least one ' . natural_enum( @missing ) . ' element' if @missing;
292 8 50       25 push @error, 'must have no more than one ' . natural_enum( @toomany ) . ' element' if @toomany;
293              
294 8 50       26 croak $name, ' ', join ' and ', @error if @error;
295              
296 8         48 return $content;
297             }
298              
299             ####################################################################
300             # implementation of published interface and rest of RFC 4287
301             #
302              
303             sub XML::Atom::SimpleFeed::new {
304 7     7 1 2013 my $self = bless { xml_encoding => $XML_ENC[0] }, shift;
305              
306 7 100       27 if ( my @i = grep { '-encoding' eq $_[$_] } grep { not $_ % 2 } 0 .. $#_ ) {
  28         73  
  56         109  
307 2 50       6 croak 'multiple encodings requested' if @i > 1;
308 2         7 ( undef, my $encoding ) = splice @_, $i[0], 2;
309 2         4 $self->{ xml_encoding } = $encoding;
310             }
311              
312 7 50       35 @_ ? $self->feed( @_ ) : $self;
313             }
314              
315             sub XML::Atom::SimpleFeed::feed {
316 7     7 0 13 my $self = shift;
317              
318 7         15 my $have_generator;
319              
320 7         25 local $XML_ENC[0] = $self->{ xml_encoding };
321             $self->{ meta } = container_content feed => (
322             elements => \@_,
323             required => [ qw( id title updated ) ],
324             optional => [ qw( author category contributor generator icon logo link rights subtitle ) ],
325             singular => [ qw( generator icon logo id rights subtitle title updated ) ],
326             callback => {
327 1     1   2 author => sub { $self->{ have_default_author } = 1 },
328 7     7   19 updated => sub { $self->{ global_updated } = $_[ 0 ] },
329 5     5   10 generator => sub { $have_generator = 1 },
330             },
331 7         92 default_upd => time,
332             );
333              
334 7 100       58 $self->{ meta } .= $make_tag{ generator }->( generator => DEFAULT_GENERATOR )
335             unless $have_generator;
336              
337 7         30 return $self;
338             }
339              
340             sub XML::Atom::SimpleFeed::add_entry {
341 1     1 1 8 my $self = shift;
342              
343 1         4 my @required = qw( id title updated );
344 1         3 my @optional = qw( category content contributor link published rights summary );
345              
346 1 50       2 push @{ $self->{ have_default_author } ? \@optional : \@required }, 'author';
  1         5  
347              
348             # FIXME
349             #
350             # o atom:entry elements that contain no child atom:content element
351             # MUST contain at least one atom:link element with a rel attribute
352             # value of "alternate".
353             #
354             # o atom:entry elements MUST contain an atom:summary element in either
355             # of the following cases:
356             # * the atom:entry contains an atom:content that has a "src"
357             # attribute (and is thus empty).
358             # * the atom:entry contains content that is encoded in Base64;
359             # i.e., the "type" attribute of atom:content is a MIME media type
360             # [MIMEREG], but is not an XML media type [RFC3023], does not
361             # begin with "text/", and does not end with "/xml" or "+xml".
362              
363 1         2 local $XML_ENC[0] = $self->{ xml_encoding };
364 1         6 push @{ $self->{ entries } }, xml_tag entry => container_content entry => (
365             elements => \@_,
366             required => \@required,
367             optional => \@optional,
368             singular => [ qw( content id published rights summary ) ],
369             default_upd => $self->{ global_updated },
370 1         2 );
371              
372 1         5 return $self;
373             }
374              
375             sub XML::Atom::SimpleFeed::as_string {
376 7     7 1 19 my $self = shift;
377 7         17 local $XML_ENC[0] = $self->{ xml_encoding };
378 7         21 PREAMBLE . xml_tag [ feed => xmlns => ATOM_NS ], $self->{ meta }, @{ $self->{ entries } };
  7         23  
379             }
380              
381             sub XML::Atom::SimpleFeed::print {
382 0     0 1   my $self = shift;
383 0           my ( $handle ) = @_;
384 0           local $, = local $\ = '';
385 0 0         defined $handle ? print $handle $self->as_string : print $self->as_string;
386             }
387              
388 0     0 0   sub XML::Atom::SimpleFeed::save_file { croak q{no longer supported, use 'print' instead and pass in a filehandle} }
389              
390             !!'Funky and proud of it.';
391              
392             __END__