File Coverage

blib/lib/CGI/Application/Plugin/PageLookup.pm
Criterion Covered Total %
statement 126 145 86.9
branch 43 60 71.6
condition 3 14 21.4
subroutine 19 22 86.3
pod 15 15 100.0
total 206 256 80.4


line stmt bran cond sub pod time code
1             package CGI::Application::Plugin::PageLookup;
2              
3 16     16   7838225 use warnings;
  16         73  
  16         863  
4 16     16   119 use strict;
  16         53  
  16         692  
5 16     16   27115 use CGI::Application::Plugin::Forward;
  16         181708  
  16         2389  
6 16     16   174 use Carp;
  16         39  
  16         1166  
7 16     16   97 use base qw(Exporter);
  16         35  
  16         1937  
8 16     16   102 use vars qw($VERSION @EXPORT_OK %EXPORT_TAGS);
  16         35  
  16         19676  
9             # Items to export into callers namespace by default. Note: do not export
10             # names by default without a very good reason. Use EXPORT_OK instead.
11             # Do not simply export all your public functions/methods/constants.
12             @EXPORT_OK = qw(
13             pagelookup_config
14             pagelookup_get_config
15             pagelookup_set_charset
16             pagelookup_prefix
17             pagelookup_sql
18             pagelookup
19             pagelookup_notfound
20             pagelookup_set_expiry
21             pagelookup_default_lang
22             pagelookup_404
23             pagelookup_msg_param
24             pagelookup_rm
25             xml_sitemap_rm
26             xml_sitemap_sql
27             xml_sitemap_base_url
28             );
29             %EXPORT_TAGS = (all => \@EXPORT_OK);
30              
31             =head1 NAME
32              
33             CGI::Application::Plugin::PageLookup - Database driven model framework for CGI::Application
34              
35             =head1 VERSION
36              
37             Version 1.8
38              
39             =cut
40              
41             our $VERSION = '1.8';
42              
43             =head1 DESCRIPTION
44              
45             A model component for CGI::Application built around a table that has one row for each
46             page and that provides support for multiple languages and the 'dot' notation in templates.
47              
48             =head1 SYNOPSIS
49              
50             package MyCGIApp base qw(CGI::Application);
51             use CGI::Application::Plugin::PageLookup qw(:all);
52              
53             # Anything but the simplest usage depends on "dot" notation.
54             use HTML::Template::Pluggable;
55             use HTML::Template::Plugin::Dot;
56              
57             sub cgiapp_init {
58             my $self = shift;
59              
60             # pagelookup depends CGI::Application::DBH;
61             $self->dbh_config(......); # whatever arguments are appropriate
62            
63             $self->html_tmpl_class('HTML::Template::Pluggable');
64              
65             $self->pagelookup_config(
66              
67             # prefix defaults to 'cgiapp_'.
68             prefix => 'mycgiapp_',
69              
70             # load smart dot-notation objects
71             objects =>
72             {
73             # Support for TMPL_LOOP
74             loop => 'CGI::Application::Plugin::PageLookup::Loop',
75              
76             # Decoupling external and internal representations of URLs
77             href => 'CGI::Application::Plugin::PageLookup::Href',
78              
79             # Page specific and site wide parameters
80             value => 'CGI::Application::Plugin::PageLookup::Value',
81              
82             # We have defined a MyCGIApp::method method
83             method => 'create_custom_object',
84              
85             # We can also handle CODE refs
86             callback => sub {
87             my $self = shift;
88             my $page_id = shift;
89             my $template = shift;
90             ........
91             }
92              
93             },
94              
95             # remove certain fields before sending the parameters to the template.
96             remove =>
97             [
98             'custom_col1',
99             'priority'
100             ],
101              
102             xml_sitemap_base_url => 'http://www.mytestsite.org'
103              
104             );
105              
106             }
107              
108             sub create_custom_object {
109             my $self = shift;
110             my $page_id = shift;
111             my $template = shift;
112             my $name = shift;
113             return ........... # smart object that can be used for dot notation
114             }
115              
116             sub setup {
117             my $self = shift;
118              
119             $self->run_modes({
120             'pagelookup' => 'pagelookup_rm',
121             'xml_sitemap' => 'xml_sitemap',
122             'extra_stuff' => 'extra_stuff'
123             });
124             ............
125             }
126              
127             sub extra_stuff {
128             my $self = shift;
129              
130             # do page lookup
131             my $template_obj = $self->pagelookup($page_id,
132             handle_notfound=>0, # force function to return undef if page not found
133             objects=> ....); # but override config for this run mode alone.
134              
135             return $self->notfound($page_id) unless $template_obj;
136              
137             # More custom stuff
138             $template_obj->param( .....);
139              
140             return $template_obj->output;
141            
142             }
143              
144             =head1 DATABASE
145              
146             Something like the following schema is assumed. In general each column on these tables
147             corresponds to a template parameter that needs to be on every page on the website and each row in the join
148             corresponds to a page on the website. The exact types are not required and can be changed
149             but these are the recommended values. The lang and internalId columns combined should be as unique as the pageId
150             column. They are used to link the different language versions of the same page and also the page with
151             nearby pages in the same language. The lang column is used to join the two pages. The lang and collation fields expect
152             to find some template structure like this:
153             -">
154             ...
155            
156             The priority, lastmod and changefreq columns are used in XML sitemaps as defined by http://www.sitemaps.org/protocol.php.
157             The changefreq field is also used in setting the expiry header. Since these fields are not expected to be in general usage,
158             by default they are deleted just before being sent to the template. The lineage and rank columns are used by menu/sitemap
159             functionality and together should be unique.
160              
161             =over
162              
163             =item Table: cgiapp_structure
164              
165             Field Type Null Key Default Extra
166             ------------ ------------------------------------------------------------------- ---- ---- ------- -----
167             internalId unsigned numeric(10,0) NO PRI NULL
168             template varchar(20) NO NULL
169             lastmod date NO NULL
170             changefreq enum('always','hourly','daily','weekly','monthly','yearly','never') NO NULL
171             priority decimal(3,3) YES NULL
172             lineage varchar(255) NO UNI NULL
173             rank unsigned numeric(10,0) NO UNI NULL
174              
175             =item Table: cgiapp_pages
176              
177             Field Type Null Key Default Extra
178             ------------ ------------------------------------------------------------------- ---- ---- ------- -----
179             pageId varchar(255) NO UNI NULL
180             lang varchar(2) NO PRI NULL
181             internalId unsigned numeric(10,0) NO PRI NULL
182              
183             + any custom columns that the web application might require.
184              
185             =item Table: cgiapp_lang
186              
187             Field Type Null Key Default Extra
188             ------------ ------------------------------------------------------------------- ---- ---- ------- -----
189             lang varchar(2) NO PRI NULL
190             collation varchar(2) NO NULL
191              
192             + any custom columns that the web application might require.
193              
194             =back
195              
196             =head1 EXPORT
197              
198             These functions can be optionally imported into the CGI::Application or related namespace.
199              
200             pagelookup_config
201             pagelookup_get_config
202             pagelookup_set_charset
203             pagelookup_prefix
204             pagelookup_sql
205             pagelookup
206             pagelookup_notfound
207             pagelookup_set_expiry
208             pagelookup_default_lang
209             pagelookup_404
210             pagelookup_msg_param
211             pagelookup_rm
212             xml_sitemap_rm
213             xml_sitemap_sql
214             xml_sitemap_base_url
215              
216             Use the tag :all to export all of them.
217              
218             =head1 FUNCTIONS
219              
220             =head2 pagelookup_config
221              
222             This function defines the default behaviour of the plugin, though this can be overridden for specific runmodes.
223             The possible arguments are as follows:
224              
225             =over
226              
227             =item prefix
228              
229             This sets the prefix used in the database schema. It defaults to 'cgiapp_'.
230              
231             =item handle_notfound
232              
233             If set (which it is by default), the pagelookup function will return
234             the results of calling pagelookup_notfound when a pagelookup fails. If not set
235             the runmode must handle page lookup failures itself which it will identify
236             because the pagelookup function will return undef.
237              
238             =item expiry
239              
240             If set (which it is by default), the pagelookup function will set the appropriate
241             expiry header based upon the changefreq column.
242              
243             =item remove
244              
245             This points to an array ref of fields that are not expected to be required by the template.
246             It defaults to template, pageId and internalId, changefreq.
247              
248             =item objects
249              
250             This points to a hash ref. Each key is a parameter name (upto the dot). The value
251             is something that defines a smart object as described in L.
252             The point about a smart object is that usually it defines an AUTOLOAD function so if the template
253             has and the pagelookup_config has mapped object to some
254             object $MySmartObject then the method $MySmartObject->getcarter() will be called. Alternatively
255             there may be no AUTOLOAD function but the smart object may have methods that take additional arguments.
256             This way the template can be much more decoupled from the structure of the database.
257              
258             There are three ways a smart object can be defined. Firstly if the value is a CODE ref,
259             then the ref is passed 1.) the reference to the CGI::Application object; 2.) the page id; 3.) the template,
260             4.) the parameter name 5.) any argument overrides. Otherwise if the CGI::Application has the value as a method, then the method is called with
261             the same arguments as above. Finally the value is assumed to be the name of a module and the new constructor
262             of the supposed module is called with the same arguments. A typical smart object might be coded as follows:
263              
264             package MySmartObject;
265              
266             sub new {
267             my $class = shift;
268             my $self = .....
269             ......
270             return bless $self, $class;
271             }
272              
273             # If you do not have this, then HTML::Template::Plugin::Dot will not know that you can!
274             # [Note really can is supposed to return a subroutine ref, but this works in this context.]
275             sub can { return 1; }
276              
277             # This is the function that actually produces the value to be inserted into the template.
278             sub AUTOLOAD {
279             my $self = shift;
280             my $method = $AUTOLOAD;
281             if ($method =~ s/^MySmartObject\:\:(.+)$/) {
282             $method = $1; # Now we have what is in the template.
283             }
284             else {
285             ....
286             }
287             .....
288             return $value;
289             }
290              
291             Note that the smart object does not have access to HASH ref because the data is changing at the point
292             it would be used and so is non-deterministic.
293              
294             =item charset
295              
296             This is a string defining the character encoding. This defaults to 'utf-8'.
297              
298             =item template_params
299              
300             This is a hashref containing additional parameters that are to be passed to the load_templ function.
301              
302             =item default_lang
303              
304             This is a two letter code and defaults to 'en'. It is used when creating a notfound page when a language
305             cannot otherwise be guessed.
306              
307             =item status_404
308              
309             This is the internal id corresponding to the not found page.
310              
311             =item msg_param
312              
313             This is the parameter used to store error messages.
314              
315             =item xml_sitemap_base_url
316              
317             This is the url for the whole site. It is mandatory to set this if you want XML sitemaps (which you should).
318              
319             =back
320              
321             =cut
322              
323             sub pagelookup_config {
324 63     63 1 16065822 my $self = shift;
325 63         260 my %args = @_;
326              
327 63 50       377 croak "Calling pagelookup_config after the pagelookup has already been configured" if defined $self->{__cgi_application_plugin_pagelookup};
328              
329 63 100       492 $args{prefix} = "cgiapp_" unless exists $args{prefix};
330 63 50       10517 $args{handle_notfound} = 1 unless exists $args{handle_notfound};
331 63 50       294 $args{expiry} = 1 unless exists $args{expiry};
332 63 50       756 $args{remove} = ['template', 'pageId', 'internalId', 'changefreq'] unless exists $args{remove};
333 63 100       226 $args{objects} = {} unless exists $args{objects};
334 63 100       235 $args{template_params} = {} unless exists $args{template_params};
335 63 50       258 $args{default_lang} = 'en' unless exists $args{default_lang};
336 63 100       288 $args{status_404} = '404' unless exists $args{status_404};
337 63 100       248 $args{msg_param} = 'pagelookup_message' unless exists $args{msg_param};
338 63 50       219 $args{charset} = 'utf-8' unless exists $args{charset};
339              
340 63         167 $self->{__cgi_application_plugin_pagelookup} = \%args;
341              
342 63         223 $self->pagelookup_set_charset();
343 63         254 return;
344             }
345              
346             =head2 pagelookup_get_config
347              
348             Returns config including any overrides passed in as arguments.
349              
350             =cut
351              
352             sub pagelookup_get_config {
353 400     400 1 522 my $self = shift;
354 400         474 my %args = (%{$self->{__cgi_application_plugin_pagelookup}}, @_);
  400         2948  
355 400         3597 return %args;
356             }
357              
358             =head2 pagelookup_set_charset
359              
360             This function sets the character set based upon the config.
361              
362             =cut
363              
364             sub pagelookup_set_charset {
365 63     63 1 125 my $self = shift;
366 63         208 my %args = $self->pagelookup_get_config(@_);
367 63         879 $self->header_props(-encoding=>$args{charset},-charset=>$args{charset});
368 63         2726 return;
369             }
370              
371             =head2 pagelookup_prefix
372              
373             This function returns the prefix that is used on the database for all the tables.
374             The prefix can of course be overridden.
375              
376             =cut
377              
378             sub pagelookup_prefix {
379 247     247 1 322 my $self = shift;
380 247         554 my %args = $self->pagelookup_get_config(@_);
381 247         1258 return $args{prefix};
382             }
383              
384             =head2 pagelookup_sql
385              
386             This function returns the SQL that is used to lookup a specific page.
387             It takes a single argument which is usually expected to be a pageId.
388             This may also be taken in the form of a HASH ref having two fields: internalId and lang.
389              
390             =cut
391              
392             sub pagelookup_sql {
393 58     58 1 121 my $self = shift;
394 58         382 my $page_id = shift;
395 58         198 my $prefix = $self->pagelookup_prefix(@_);
396 58 100       222 if (ref($page_id) eq "HASH") {
397 10 50       38 croak "internalId expected" unless exists $page_id->{internalId};
398 10 50       36 croak "lang expected" unless exists $page_id->{lang};
399 10         104 return "SELECT s.template, s.changefreq, p.*, l.* FROM ${prefix}pages p, ${prefix}lang l, ${prefix}structure s WHERE p.lang = l.lang AND p.lang = '$page_id->{lang}' AND p.internalId = s.internalId AND p.internalId = $page_id->{internalId}";
400             }
401 48         587 return "SELECT s.template, s.changefreq, p.*, l.* FROM ${prefix}pages p, ${prefix}lang l, ${prefix}structure s WHERE p.lang = l.lang AND p.pageId = '$page_id' AND p.internalId = s.internalId";
402             }
403              
404             =head2 pagelookup
405              
406             This is the function that does the heavy lifting. It takes a page id and optionally some
407             arguments overriding the default config. Then the sequence of events is as follows:
408             1.) Lookup up the various parameters from the database.
409             2.) If this fails then exit either handling or just returning undef according to instructions.
410             3.) Load the template object.
411             4.) Set the expiry header unless instructed not to.
412             5.) Load the smart objects that are mentioned in the template.
413             6.) Remove unwanted parameters.
414             7.) Put the parameters into the template object.
415             8.) Return the now partially or completely filled template object.
416              
417             The page id may also be taken in the form of a HASH ref having two fields: internalId and lang.
418              
419             =cut
420              
421             sub pagelookup {
422 58     58 1 114 my $self = shift;
423 58         111 my $page_id = shift;
424 58         111 my @inargs = @_;
425 58         173 my %args = $self->pagelookup_get_config(@inargs);
426 58         384 my $dbh = $self->dbh();
427 58   33     9224 my $sth = $dbh->prepare($self->pagelookup_sql($page_id, @inargs)) || croak $dbh->errstr;
428 58 50       22306 $sth->execute || croak $dbh->errstr;
429 58         3551 my $hash_ref = $sth->fetchrow_hashref;
430              
431             # check if page was found
432 58 100       339 unless ($hash_ref) {
433 8 50       157 croak $dbh->errstr if $dbh->err;
434 8         49 $sth->finish;
435              
436 8 50       35 if ($args{handle_notfound}) {
437 8         37 return $self->pagelookup_notfound($page_id, @inargs);
438             }
439              
440 0         0 return undef;
441             }
442              
443 50 100       190 $page_id = $hash_ref->{pageId} if ref($page_id) eq "HASH";
444              
445             # Load the template
446 50         162 my $template = $self->load_tmpl($hash_ref->{template}, %{$args{template_params}});
  50         410  
447              
448             # Get a list of smart objects mentioned in the template
449 50         92707 my %smart_objects_actually_used = ();
450 50         352 foreach my $o ($template->query()) {
451 289 100       1865 if ($o =~ /^([a-zA-Z_]\w+)\./) {
452 105         301 $smart_objects_actually_used{$1} = 1;
453             }
454             }
455              
456             # Set the expiry headers
457 50 50       410 $self->pagelookup_set_expiry($hash_ref, @inargs) if $args{expiry};
458              
459             # create the smart objects
460 50         83 foreach my $okey (keys %{$args{objects}}) {
  50         205  
461 75 100       209 next unless $smart_objects_actually_used{$okey};
462 66         199 my $ovalue = $args{objects}->{$okey};
463 66         91 my $object = undef;
464              
465 66 100       618 if (ref($ovalue) eq "CODE") {
    100          
466 10         41 $object = &$ovalue($self, $page_id, $template, $okey, @inargs);
467             }
468             elsif ($self->can($ovalue)) {
469 9         45 $object = $self->$ovalue($page_id, $template, $okey, @inargs);
470             }
471             else {
472 16     16   17705 use UNIVERSAL::require;
  16         43254  
  16         355  
473 47         90 $object = eval {
474 47         343 $ovalue->require;
475 47         1664 return $ovalue->new($self, $page_id, $template, $okey, @inargs);
476             };
477 47 50       473 croak "Could not create smart object: $okey: $@" if $@;
478             }
479              
480 66 50       1217 $hash_ref->{$okey} = $object if $object;
481              
482             }
483              
484             # remove unwanted parameters
485 50         110 foreach my $remove (@{$args{remove}}) {
  50         148  
486 250         497 delete $hash_ref->{$remove};
487             }
488              
489             # we cannot think of anything else to stop us from inserting the parameters into the template
490 50         344 $template->param(%$hash_ref);
491 50         21651 return $template;
492             }
493              
494             =head2 pagelookup_rm
495              
496             This function is a generic run mode. It takes a page id and tries to do everything else.
497             Of course most of the work is done by pagelookup.
498              
499             =cut
500              
501             sub pagelookup_rm {
502 50     50 1 68798 my $self = shift;
503 50   50     205 my $page_id = $self->param('pageid') || return $self->forward($self->start_mode());
504 50         924 my $template = $self->pagelookup($page_id);
505 50 50       185 croak "no template returned: $page_id" unless $template;
506 50         229 return $template->output;
507             }
508              
509             =head2 xml_sitemap_rm
510              
511             This method is intended to be installed as a sitemap. Since the format is fixed, it is self-contained and does not load
512             templates from files. Note if a page as a null priority then it is not put in the sitemap.
513             For this function to work it is necessary to set the base BASE_URL parameter.
514              
515             =cut
516              
517             sub xml_sitemap_rm {
518 0     0 1 0 my $self = shift;
519 0         0 my $dbh = $self->dbh();
520 0         0 my $base_url = $self->xml_sitemap_base_url();
521 0         0 my $sql = $self->xml_sitemap_sql();
522 0   0     0 my $sth = $dbh->prepare($sql) || croak $dbh->errstr;
523 0 0       0 $sth->execute or croak $dbh->errstr;
524 0         0 my $hash_ref = $sth->fetchall_arrayref({});
525 0         0 my $template =<<"EOS"
526            
527            
528            
529            
530             $base_url
531            
532            
533            
534            
535            
536            
537             EOS
538             ;
539 0         0 my $t = $self->load_tmpl(\$template);
540 0         0 $t->param(urls=>$hash_ref);
541 0         0 $self->header_add( -type => 'text/xml', -charset=>'utf-8' );
542 0         0 return $t->output;
543             }
544              
545             =head2 pagelookup_notfound
546              
547             This function takes a page id which has failed a page lookup and tries to find the best fitting
548             404 page. First of all it attempts to find the correct by language by assuming that if the first three
549             characters of the page id consists of two characters followed by a '/'. If this matches then the first
550             two characters are taken to be the language. If that fails then the language is taken to be $self->pagelookup_default_lang.
551             Then the relevant 404 page is looked up by language and internal id. The internalId is taken to be $self->pagelookup_404 .
552             Of course it is assumed that this page lookup cannot fail. The header 404 status is added
553             to the header and the original page id is inserted into the $self->pagelookup_msg_param parameter.
554             If this logic does not match your URL structure you can omit exporting this function or turn notfound handling off
555             and implement your own logic.
556              
557             =cut
558              
559             sub pagelookup_notfound {
560 8     8 1 21 my $self = shift;
561 8         21 my $page_id = shift;
562 8         22 my @inargs = @_;
563 8         30 my %args = $self->pagelookup_get_config(@inargs);
564              
565             # Best guess at language
566 8         45 my $lang = $self->pagelookup_default_lang(@inargs);
567 8 100       62 if ($page_id =~ /^(\w\w)\//) {
568 6         22 $lang = $1;
569             }
570              
571 8   33     36 my $template = $self->pagelookup({lang=>$lang, internalId => $self->pagelookup_404}, handle_notfound=>0) || croak "failed to construct 'not found' page";
572 8         59 $template->param( $self->pagelookup_msg_param(@inargs) => $page_id);
573 8         663 $self->header_add( -status => 404 );
574 8         535 return $template;
575              
576             }
577              
578             =head2 pagelookup_set_expiry
579              
580             This function sets the expiry header based upon the hash_ref.
581              
582             =cut
583              
584             sub pagelookup_set_expiry {
585 50     50 1 91 my $self = shift;
586 50         94 my $hash_ref = shift;
587 50 100       211 my $changefreq = $hash_ref->{changefreq} or return;
588 36         419 my %mapping = (always=>'-1d', hourly=>'+1h', daily=>'+1d', weekly=>'+7d', monthly=>'+1M', yearly=>'+1y', never=>'+3y');
589 36         280 $self->header_add(-expires=>$mapping{$changefreq});
590 36         1793 return;
591             }
592              
593             =head2 pagelookup_default_lang
594              
595             This returns the default language code.
596              
597             =cut
598              
599             sub pagelookup_default_lang {
600 8     8 1 19 my $self = shift;
601 8         26 my %args = $self->pagelookup_get_config(@_);
602 8         47 return $args{default_lang};
603             }
604              
605             =head2 pagelookup_404
606              
607             This returns the core id used by 404 pages.
608              
609             =cut
610              
611             sub pagelookup_404 {
612 8     8 1 15 my $self = shift;
613 8         26 my %args = $self->pagelookup_get_config(@_);
614 8         99 return $args{status_404};
615             }
616              
617             =head2 pagelookup_msg_param
618              
619             This returns the parameter that pagelookup uses for inserting error messages.
620              
621             =cut
622              
623             sub pagelookup_msg_param {
624 8     8 1 21 my $self = shift;
625 8         30 my %args = $self->pagelookup_get_config(@_);
626 8         71 return $args{msg_param};
627             }
628              
629             =head2 xml_sitemap_sql
630              
631             This returns the SQL used to get the XML sitemap data.
632              
633             =cut
634              
635             sub xml_sitemap_sql {
636 0     0 1   my $self = shift;
637 0           my $prefix = $self->pagelookup_prefix(@_);
638 0           return "SELECT pageId, lastmod, changefreq, priority FROM ${prefix}pages p, ${prefix}structure s WHERE priority IS NOT NULL AND p.internalId = s.internalId ORDER BY priority DESC";
639             }
640              
641             =head2 xml_sitemap_base_url
642              
643             This returns the base url used in XML sitemaps.
644              
645             =cut
646              
647             sub xml_sitemap_base_url {
648 0     0 1   my $self = shift;
649 0           my %args = $self->pagelookup_get_config(@_);
650 0   0       return $args{xml_sitemap_base_url} || croak "no xml sitemp base url set";
651             }
652              
653              
654             =head1 AUTHOR
655              
656             Nicholas Bamber, C<< >>
657              
658             =head1 BUGS
659              
660             Currently errors are not trapped early enough and hence error messages are less informative than they might be.
661              
662             Also we are working on validating the code against more L drivers. Currently mysql and SQLite are known to work.
663             It is known to be incompatible with postgres, which should be fixed in the next release. This may entail schema changes.
664             It is also known to be in incompatible with L, apparently on account of a join across three tables.
665             The SQL is not ANSI standard and that is one possible change. Another approach may be to make the schema configurable.
666              
667             Please report any bugs or feature requests to C, or through
668             the web interface at L. I will be notified, and then you'll
669             automatically be notified of progress on your bug as I make changes.
670              
671             =head1 SUPPORT
672              
673             You can find documentation for this module with the perldoc command.
674              
675             perldoc CGI::Application::Plugin::PageLookup
676              
677              
678             You can also look for information at:
679              
680             =over 4
681              
682             =item * RT: CPAN's request tracker
683              
684             L
685              
686             =item * AnnoCPAN: Annotated CPAN documentation
687              
688             L
689              
690             =item * CPAN Ratings
691              
692             L
693              
694             =item * Search CPAN
695              
696             L
697              
698             =back
699              
700              
701             =head1 ACKNOWLEDGEMENTS
702              
703             Thanks to JavaFan for suggesting the use of L. Thanks to Philippe Bruhat
704             for help with getting Test::Database to work more smoothly.
705              
706             =head1 COPYRIGHT & LICENSE
707              
708             Copyright 2009 Nicholas Bamber.
709              
710             This program is free software; you can redistribute it and/or modify it
711             under the terms of either: the GNU General Public License as published
712             by the Free Software Foundation; or the Artistic License.
713              
714             See http://dev.perl.org/licenses/ for more information.
715              
716              
717             =cut
718              
719             1; # End of CGI::Application::Plugin::PageLookup