1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 | 'use strict';
|
10 |
|
11 | module.exports = function(grunt) {
|
12 |
|
13 | var url = require('url');
|
14 | var request = require('superagent');
|
15 | var cheerio = require('cheerio');
|
16 | var sax = require('sax');
|
17 |
|
18 |
|
19 | request.Request.prototype.use = function(fn) {
|
20 | fn(this);
|
21 | return this;
|
22 | };
|
23 |
|
24 |
|
25 | var userAgent = 'grunt-check-pages/' + require('../package.json').version;
|
26 | var pendingCallbacks = [];
|
27 | var issueCount = 0;
|
28 |
|
29 |
|
30 | function logError(message) {
|
31 | grunt.log.error(message);
|
32 | issueCount++;
|
33 | }
|
34 |
|
35 |
|
36 | function setCommonHeaders(req) {
|
37 |
|
38 | if (userAgent) {
|
39 | req.set('User-Agent', userAgent);
|
40 | } else {
|
41 | req.unset('User-Agent');
|
42 | }
|
43 |
|
44 | req
|
45 | .set('Cache-Control', 'no-cache')
|
46 | .set('Pragma', 'no-cache');
|
47 | }
|
48 |
|
49 |
|
50 | function isLinkIgnored(link, options) {
|
51 | return options.linksToIgnore.some(function(isLinkIgnored) {
|
52 | return (isLinkIgnored === link);
|
53 | });
|
54 | }
|
55 |
|
56 |
|
57 | function addLinks($, element, attribute, base, options) {
|
58 | var baseHostname = url.parse(base).hostname;
|
59 | $(element).each(function() {
|
60 | var link = $(this).attr(attribute);
|
61 | if (link) {
|
62 | var resolvedLink = url.resolve(base, link);
|
63 | if((!options.onlySameDomainLinks || (url.parse(resolvedLink).hostname === baseHostname)) &&
|
64 | !isLinkIgnored(resolvedLink, options)) {
|
65 |
|
66 | pendingCallbacks.unshift(testLink(resolvedLink, options));
|
67 | }
|
68 | }
|
69 | });
|
70 | }
|
71 |
|
72 |
|
73 | function testPage(page, options) {
|
74 | return function (callback) {
|
75 | var start = Date.now();
|
76 | var req = request
|
77 | .get(page)
|
78 | .use(setCommonHeaders)
|
79 | .buffer(true)
|
80 | .end(function(err, res) {
|
81 | var elapsed = Date.now() - start;
|
82 | if (err) {
|
83 | logError('Page error (' + err.message + '): ' + page + ' (' + elapsed + 'ms)');
|
84 | req.abort();
|
85 | } else if (!res.ok) {
|
86 | logError('Bad page (' + res.status + '): ' + page + ' (' + elapsed + 'ms)');
|
87 | } else {
|
88 | grunt.log.ok('Page: ' + page + ' (' + elapsed + 'ms)');
|
89 | if (options.checkLinks) {
|
90 |
|
91 |
|
92 | var $ = cheerio.load(res.text);
|
93 | addLinks($, 'a', 'href', page, options);
|
94 | addLinks($, 'area', 'href', page, options);
|
95 | addLinks($, 'audio', 'src', page, options);
|
96 | addLinks($, 'embed', 'src', page, options);
|
97 | addLinks($, 'iframe', 'src', page, options);
|
98 | addLinks($, 'img', 'src', page, options);
|
99 | addLinks($, 'input', 'src', page, options);
|
100 | addLinks($, 'link', 'href', page, options);
|
101 | addLinks($, 'object', 'data', page, options);
|
102 | addLinks($, 'script', 'src', page, options);
|
103 | addLinks($, 'source', 'src', page, options);
|
104 | addLinks($, 'track', 'src', page, options);
|
105 | addLinks($, 'video', 'src', page, options);
|
106 | }
|
107 | if (options.checkXhtml) {
|
108 |
|
109 |
|
110 | var parser = sax.parser(true);
|
111 | parser.onerror = function(error) {
|
112 | logError(error.message.replace(/\n/g, ', '));
|
113 | };
|
114 | parser.write(res.text);
|
115 | }
|
116 | if (options.maxResponseTime) {
|
117 |
|
118 |
|
119 | if (options.maxResponseTime < elapsed) {
|
120 | logError('Page response took more than ' + options.maxResponseTime + 'ms to complete');
|
121 | }
|
122 | }
|
123 | if (options.checkCaching) {
|
124 |
|
125 |
|
126 | var cacheControl = res.headers['cache-control'];
|
127 | if (cacheControl) {
|
128 | if (!/max-age|max-stale|min-fresh|must-revalidate|no-cache|no-store|no-transform|only-if-cached|private|proxy-revalidate|public|s-maxage/.test(cacheControl)) {
|
129 | logError('Invalid Cache-Control header in response: ' + cacheControl);
|
130 | }
|
131 | } else {
|
132 | logError('Missing Cache-Control header in response');
|
133 | }
|
134 | var etag = res.headers.etag;
|
135 | if (etag) {
|
136 | if (!/^(W\/)?\"[^\"]*\"$/.test(etag)) {
|
137 | logError('Invalid ETag header in response: ' + etag);
|
138 | }
|
139 | } else if (!cacheControl || !/no-cache|max-age=0/.test(cacheControl)) {
|
140 | logError('Missing ETag header in response');
|
141 | }
|
142 | }
|
143 | if (options.checkCompression) {
|
144 |
|
145 |
|
146 | var contentEncoding = res.headers['content-encoding'];
|
147 | if (contentEncoding) {
|
148 | if (!/^(deflate|gzip)$/.test(contentEncoding)) {
|
149 | logError('Invalid Content-Encoding header in response: ' + contentEncoding);
|
150 | }
|
151 | } else {
|
152 | logError('Missing Content-Encoding header in response');
|
153 | }
|
154 | }
|
155 | }
|
156 | callback();
|
157 | });
|
158 | };
|
159 | }
|
160 |
|
161 |
|
162 | function testLink(link, options, retryWithGet) {
|
163 | return function (callback) {
|
164 | var start = Date.now();
|
165 | var req = request
|
166 | [retryWithGet ? 'get' : 'head'](link)
|
167 | .use(setCommonHeaders)
|
168 | .buffer(false)
|
169 | .end(function(err, res) {
|
170 | var elapsed = Date.now() - start;
|
171 | if (!err && !res.ok && !retryWithGet) {
|
172 |
|
173 | testLink(link, options, true)(callback);
|
174 | } else {
|
175 | if (err) {
|
176 | logError('Link error (' + err.message + '): ' + link + ' (' + elapsed + 'ms)');
|
177 | req.abort();
|
178 | } else if (!res.ok) {
|
179 | logError('Bad link (' + res.status + '): ' + link + ' (' + elapsed + 'ms)');
|
180 | } else {
|
181 | grunt.log.ok('Link: ' + link + ' (' + elapsed + 'ms)');
|
182 | }
|
183 | callback();
|
184 | }
|
185 | });
|
186 | if (options.disallowRedirect) {
|
187 | req.redirects(0);
|
188 | }
|
189 | };
|
190 | }
|
191 |
|
192 |
|
193 | grunt.registerMultiTask('checkPages', 'Checks various aspects of a web page for correctness.', function() {
|
194 |
|
195 |
|
196 | if (this.files.length) {
|
197 | grunt.fail.warn('checkPages task does not use files; remove the files parameter');
|
198 | }
|
199 |
|
200 |
|
201 | var options = this.options();
|
202 | if (!options.pageUrls) {
|
203 | grunt.fail.warn('pageUrls option is not present; it should be an array of URLs');
|
204 | } else if (!Array.isArray(options.pageUrls)) {
|
205 | grunt.fail.warn('pageUrls option is invalid; it should be an array of URLs');
|
206 | }
|
207 |
|
208 |
|
209 | options.checkLinks = !!options.checkLinks;
|
210 | options.onlySameDomainLinks = !!options.onlySameDomainLinks;
|
211 | options.disallowRedirect = !!options.disallowRedirect;
|
212 | options.linksToIgnore = options.linksToIgnore || [];
|
213 | if (!Array.isArray(options.linksToIgnore)) {
|
214 | grunt.fail.warn('linksToIgnore option is invalid; it should be an array');
|
215 | }
|
216 | options.checkXhtml = !!options.checkXhtml;
|
217 | options.checkCaching = !!options.checkCaching;
|
218 | options.checkCompression = !!options.checkCompression;
|
219 | if (options.maxResponseTime && (typeof(options.maxResponseTime) !== 'number' || (options.maxResponseTime <= 0))) {
|
220 | grunt.fail.warn('maxResponseTime option is invalid; it should be a positive number');
|
221 | }
|
222 | if (options.userAgent !== undefined) {
|
223 | if (options.userAgent) {
|
224 | if (typeof(options.userAgent) === 'string') {
|
225 | userAgent = options.userAgent;
|
226 | } else {
|
227 | grunt.fail.warn('userAgent option is invalid; it should be a string or null');
|
228 | }
|
229 | } else {
|
230 | userAgent = null;
|
231 | }
|
232 | }
|
233 |
|
234 |
|
235 | options.pageUrls.forEach(function(page) {
|
236 | pendingCallbacks.push(testPage(page, options));
|
237 | });
|
238 |
|
239 |
|
240 | var done = this.async();
|
241 | pendingCallbacks.push(function() {
|
242 | if (issueCount) {
|
243 | grunt.fail.warn(issueCount + ' issue' + (1 < issueCount ? 's' : '') + ', see above');
|
244 | }
|
245 | done();
|
246 | });
|
247 |
|
248 |
|
249 | var next = function() {
|
250 | var callback = pendingCallbacks.shift();
|
251 | callback(next);
|
252 | };
|
253 | next();
|
254 | });
|
255 | };
|