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