File Coverage

blib/lib/Test/HTML/Spelling.pm
Criterion Covered Total %
statement 4 6 66.6
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 6 8 75.0


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Test::HTML::Spelling - Test the spelling of HTML documents
4              
5             =begin readme
6              
7             =head1 REQUIREMENTS
8              
9             This module requires Perl v5.10 or newer and the following non-core
10             modules:
11              
12             =over
13              
14             =item L<Const::Fast>
15              
16             =item L<curry>
17              
18             =item L<HTML::Parser>
19              
20             =item L<Moose>
21              
22             =item L<MooseX::NonMoose>
23              
24             =item L<namespace::autoclean>
25              
26             =item L<Search::Tokenizer>
27              
28             =item L<Text::Aspell>
29              
30             =back
31              
32             The following modules are used for tests but are not needed to run
33             this module:
34              
35             =over
36              
37             =item L<File::Slurp>
38              
39             =item L<Test::Builder>
40              
41             =item L<Test::Pod::Spelling>
42              
43             =back
44              
45             =end readme
46              
47             =head1 SYNOPSIS
48              
49             use Test::More;
50             use Test::HTML::Spelling;
51              
52             use Test::WWW::Mechanize;
53              
54             my $sc = Test::HTML::Spelling->new(
55             ignore_classes => [qw( no-spellcheck )],
56             check_attributes => [qw( title alt )],
57             );
58              
59             $sc->speller->set_option('lang','en_GB');
60             $sc->speller->set_option('sug-mode','fast');
61              
62             my $mech = Test::WWW::Mechanize->new();
63              
64             $mech->get_ok('http://www.example.com/');
65              
66             $sc->spelling_ok($mech->content, "spelling");
67              
68             done_testing;
69              
70             =head1 DESCRIPTION
71              
72             This module parses an HTML document, and checks the spelling of the
73             text and some attributes (such as the C<title> and C<alt> attributes).
74              
75             It will not spellcheck the attributes or contents of elements
76             (including the contents of child elements) with the class
77             C<no-spellcheck>. For example, elements that contain user input, or
78             placenames that are unlikely to be in a dictionary (such as timezones)
79             should be in this class.
80              
81             It will fail when an HTML document if not well-formed.
82              
83             =cut
84              
85             package Test::HTML::Spelling;
86              
87 1     1   30989 use v5.10;
  1         4  
  1         78  
88              
89 1     1   529 use Moose;
  0            
  0            
90             use MooseX::NonMoose;
91              
92             extends 'Test::Builder::Module';
93              
94             use utf8;
95              
96             use curry;
97              
98             use Const::Fast;
99             use Encode;
100             use HTML::Parser;
101             use List::Util qw( reduce );
102             use Scalar::Util qw( looks_like_number );
103             use Search::Tokenizer;
104             use Text::Aspell;
105              
106             use version 0.77; our $VERSION = version->declare('v0.3.7');
107              
108             # A placeholder key for the default spellchecker
109              
110             const my $DEFAULT => '_';
111              
112             =for readme stop
113              
114             =head1 METHODS
115              
116             =cut
117              
118             =head2 ignore_classes
119              
120             This is an accessor method for the names of element classes that will
121             not be spellchecked. It is also a constructor parameter.
122              
123             It defaults to C<no-spellcheck>.
124              
125             =cut
126              
127             has 'ignore_classes' => (
128             is => 'rw',
129             isa => 'ArrayRef[Str]',
130             default => sub { [qw( no-spellcheck )] },
131             );
132              
133             =head2 check_attributes
134              
135             This is an accessor method for the names of element attributes that
136             will be spellchecked. It is also a constructor parameter.
137              
138             It defaults to C<title> and C<alt>.
139              
140             =cut
141              
142             has 'check_attributes' => (
143             is => 'rw',
144             isa => 'ArrayRef[Str]',
145             default => sub { [qw( title alt )] },
146             );
147              
148             has '_empty_elements' => (
149             is => 'rw',
150             isa => 'HashRef',
151             default => sub { return { map { $_ => 1 } (qw( area base basefont br col frame hr img input isindex link meta param )) } },
152             );
153              
154             =head2 ignore_words
155              
156             This is an accessor method for setting a hash of words that will be
157             ignored by the spellchecker. Use it to specify a custom dictionary,
158             e.g.
159              
160             use File::Slurp;
161              
162             my %dict = map { chomp($_); $_ => 1 } read_file('custom');
163              
164             $sc->ignore_words( \%dict );
165              
166             =cut
167              
168             has 'ignore_words' => (
169             is => 'rw',
170             isa => 'HashRef',
171             default => sub { { } },
172             );
173              
174             has 'tester' => (
175             is => 'ro',
176             lazy => 1,
177             default => sub {
178             my $self = shift;
179             return $self->builder;
180             },
181             );
182              
183             has 'tokenizer' => (
184             is => 'rw',
185             lazy => 1,
186             default => sub {
187              
188             my ($self) = @_;
189              
190             return Search::Tokenizer->new(
191              
192             regex => qr/\p{Word}+(?:[-'.]\p{Word}+)*/,
193             lower => 0,
194             stopwords => $self->ignore_words,
195              
196             );
197              
198             },
199             );
200              
201             has 'parser' => (
202             is => 'ro',
203             lazy => 1,
204             default => sub {
205             my ($self) = @_;
206              
207             return HTML::Parser->new(
208              
209             api_version => 3,
210              
211             ignore_elements => [qw( script style )],
212             empty_element_tags => 1,
213              
214             start_document_h => [ $self->curry::_start_document ],
215             start_h => [ $self->curry::_start_element, "tagname,attr,line,column" ],
216             end_h => [ $self->curry::_end_element, "tagname,line" ],
217             text_h => [ $self->curry::_text, "dtext,line,column" ],
218              
219             );
220              
221             },
222             );
223              
224             has '_spellers' => (
225             is => 'ro',
226             isa => 'HashRef',
227             lazy => 1,
228             default => sub {
229             my $speller = Text::Aspell->new();
230             my $self = { $DEFAULT => $speller, };
231             return $self;
232             },
233             );
234              
235             =head2 speller
236              
237             my $sc = $sc->speller($lang);
238              
239             This is an accessor that gives you access to a spellchecker for a
240             particular language (where C<$lang> is a two-letter ISO 639-1 language
241             code). If the language is omitted, it returns the default
242             spellchecker:
243              
244             $sc->speller->set_option('sug-mode','fast');
245              
246             Note that options set for the default spellchecker will not be set for
247             other spellcheckers. To ensure all spellcheckers have the same
248             options as the default, use something like the following:
249              
250             foreach my $lang (qw( en es fs )) {
251             $sc->speller($lang)->set_option('sug-mode',
252             $sc->speller->get_option('sug-mode')
253             )
254             }
255              
256             =cut
257              
258             sub speller {
259             my ($self, $lang) = @_;
260             $lang =~ tr/-/_/ if (defined $lang);
261              
262             if (my $speller = $self->_spellers->{ $lang // $DEFAULT }) {
263              
264             return $speller;
265              
266             } elsif ($lang eq $self->_spellers->{$DEFAULT}->get_option('lang')) {
267              
268             $speller = $self->_spellers->{$DEFAULT};
269              
270             # Extract non-regional ISO 639-1 language code
271              
272             if ($lang =~ /^([a-z]{2})[_-]/) {
273             if (defined $self->_spellers->{$1}) {
274             $speller = $self->_spellers->{$1};
275             } else {
276             $self->_spellers->{$1} = $speller;
277             }
278             }
279              
280             $self->_spellers->{$lang} = $speller;
281              
282             return $speller;
283              
284             } else {
285              
286             $speller = Text::Aspell->new();
287             $speller->set_option("lang", $lang);
288              
289             # Extract non-regional ISO 639-1 language code
290              
291             if ($lang =~ /^([a-z]{2})[_-]/) {
292             if (defined $self->_spellers->{$1}) {
293             $speller = $self->_spellers->{$1};
294             } else {
295             $self->_spellers->{$1} = $speller;
296             }
297             }
298              
299             $self->_spellers->{$lang} = $speller;
300              
301             return $speller;
302              
303             }
304             }
305              
306             =head2 langs
307              
308             my @langs = $sc->langs;
309              
310             Returns a list of languages (as two-letter ISO 639-1 codes) that there
311             are spellcheckers for.
312              
313             This can be checked I<after> testing a document to ensure that the
314             document does not contain markup in unexpected languages.
315              
316             =cut
317              
318             sub langs {
319             my ($self) = @_;
320             my @langs = grep { ! /[_]/ } (keys %{ $self->_spellers });
321             return @langs;
322             }
323              
324             has '_errors' => (
325             is => 'rw',
326             isa => 'Int',
327             default => 0,
328             );
329              
330             has '_context' => (
331             is => 'rw',
332             isa => 'ArrayRef[HashRef]',
333             default => sub { [ ] },
334             );
335              
336             sub _context_depth {
337             my ($self) = @_;
338             return scalar(@{$self->_context});
339             }
340              
341             sub _context_top {
342             my ($self) = @_;
343             return $self->_context->[0];
344             }
345              
346             sub _is_ignored_context {
347             my ($self) = @_;
348             if ($self->_context_depth) {
349             return $self->_context_top->{ignore};
350             } else {
351             return 0;
352             }
353             }
354              
355             sub _context_lang {
356             my ($self) = @_;
357             if ($self->_context_top) {
358             return $self->_context_top->{lang};
359             } else {
360             return $self->speller->get_option("lang");
361             }
362             }
363              
364             sub _push_context {
365             my ($self, $element, $lang, $ignore, $line) = @_;
366              
367             if ($self->_empty_elements->{$element}) {
368             return;
369             }
370              
371             unshift @{ $self->_context }, {
372             element => $element,
373             lang => $lang,
374             ignore => $ignore || $self->_is_ignored_context,
375             line => $line,
376             };
377             }
378              
379             sub _pop_context {
380             my ($self, $element, $line) = @_;
381              
382             if ($self->_empty_elements->{$element}) {
383             return;
384             }
385              
386             my $context = shift @{ $self->_context };
387             return $context;
388             }
389              
390             sub _start_document {
391             my ($self) = @_;
392             $self->_context([]);
393             $self->_errors(0);
394              
395             }
396              
397             sub _start_element {
398             my ($self, $tag, $attr, $line) = @_;
399              
400             $attr //= { };
401              
402             my %classes = map { $_ => 1 } split /\s+/, ($attr->{class} // "");
403              
404             my $state = $self->_is_ignored_context;
405              
406             my $ignore = reduce {
407             no warnings 'once';
408             $a || $b;
409             } ($state, map { $classes{$_} // 0 } @{ $self->ignore_classes } );
410              
411             my $lang = $attr->{lang} // $self->_context_lang;
412              
413             $self->_push_context($tag, $lang, $ignore, $line);
414              
415             unless ($ignore) {
416              
417             foreach my $name (@{ $self->check_attributes }) {
418             $self->_text($attr->{$name}, $line) if (exists $attr->{$name});
419             }
420             }
421             }
422              
423             sub _end_element {
424             my ($self, $tag, $line) = @_;
425              
426             if (my $context = $self->_pop_context($tag, $line)) {
427              
428             if ($tag ne $context->{element}) {
429             $self->tester->croak(sprintf("Expected element '%s' near input line %d", $context->{element}, $line // 0));
430             }
431              
432             my $lang = $context->{lang};
433             }
434              
435             }
436              
437             sub _text {
438             my ($self, $text, $line) = @_;
439              
440             unless ($self->_is_ignored_context) {
441              
442             my $speller = $self->speller( $self->_context_lang );
443             my $encoding = $speller->get_option('encoding');
444              
445             my $iterator = $self->tokenizer->($text);
446              
447             while (my $u_word = $iterator->()) {
448              
449             my $word = encode($encoding, $u_word);
450              
451             my $check = $speller->check($word) || looks_like_number($word) || $word =~ /^\d+(?:[-'._]\d+)*/;
452             unless ($check) {
453              
454             $self->_errors( 1 + $self->_errors );
455             $self->tester->diag("Unrecognized word: '${word}' at line ${line}");
456             }
457              
458             }
459              
460             }
461              
462             }
463              
464             =head2 check_spelling
465              
466             if ($sc->check_spelling( $content )) {
467             ..
468             }
469              
470             Check the spelling of a document, and return true if there are no
471             spelling errors.
472              
473             =cut
474              
475             sub check_spelling {
476             my ($self, $text) = @_;
477              
478             $self->_errors(0);
479             $self->parser->parse($text);
480             $self->parser->eof;
481              
482             if ($self->_errors) {
483             $self->tester->diag(
484             sprintf("Found %d spelling %s",
485             $self->_errors,
486             ($self->_errors == 1) ? "error" : "errors"));
487             }
488              
489             return ($self->_errors == 0);
490             }
491              
492             =head2 spelling_ok
493              
494             $sc->spelling_ok( $content, $message );
495              
496             Parses the HTML file and checks the spelling of the document text and
497             selected attributes.
498              
499             =cut
500              
501             sub spelling_ok {
502             my ($self, $text, $message) = @_;
503              
504             $self->tester->ok($self->check_spelling($text), $message);
505             }
506              
507             __PACKAGE__->meta->make_immutable;
508              
509             no Moose;
510              
511             =head1 KNOWN ISSUES
512              
513             =head2 Using Test::HTML::Spelling in a module
514              
515             Suppose you subclass a module like L<Test::WWW::Mechanize> and add a
516             C<spelling_ok> method that calls L</spelling_ok>. This will work
517             fine, except that any errors will be reported as coming from your
518             module, rather than the test scripts that call your method.
519              
520             To work around this, call the L</check_spelling> method from within
521             your module.
522              
523             =for readme continue
524              
525             =head1 SEE ALSO
526              
527             The following modules have similar functionality:
528              
529             =over 4
530              
531             =item L<Apache::AxKit::Language::SpellCheck>
532              
533             =back
534              
535             =head1 AUTHOR
536              
537             Robert Rothenberg, C<< <rrwo at cpan.org> >>
538              
539             =head2 Contributors and Acknowledgements
540              
541             =over
542              
543             =item * Rusty Conover
544              
545             =item * Murray Walker
546              
547             =item * Interactive Information, Ltd.
548              
549             =back
550              
551             =head1 LICENSE AND COPYRIGHT
552              
553             Copyright 2012-2014 Robert Rothenberg.
554              
555             This program is free software; you can redistribute it and/or modify it
556             under the terms of the the Artistic License (2.0). You may obtain a
557             copy of the full license at:
558              
559             L<http://www.perlfoundation.org/artistic_license_2_0>
560              
561             Any use, modification, and distribution of the Standard or Modified
562             Versions is governed by this Artistic License. By using, modifying or
563             distributing the Package, you accept this license. Do not use, modify,
564             or distribute the Package, if you do not accept this license.
565              
566             If your Modified Version has been derived from a Modified Version made
567             by someone other than you, you are nevertheless required to ensure that
568             your Modified Version complies with the requirements of this license.
569              
570             This license does not grant you the right to use any trademark, service
571             mark, tradename, or logo of the Copyright Holder.
572              
573             This license includes the non-exclusive, worldwide, free-of-charge
574             patent license to make, have made, use, offer to sell, sell, import and
575             otherwise transfer the Package with respect to any patent claims
576             licensable by the Copyright Holder that are necessarily infringed by the
577             Package. If you institute patent litigation (including a cross-claim or
578             counterclaim) against any party alleging that the Package constitutes
579             direct or contributory patent infringement, then this Artistic License
580             to you shall terminate on the date that such litigation is filed.
581              
582             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
583             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
584             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
585             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
586             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
587             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
588             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
589             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
590              
591              
592             =cut
593              
594             use namespace::autoclean;
595              
596             1; # End of Test::HTML::Spelling