UNPKG

9.51 kBJavaScriptView Raw
1/*
2 * grunt-check-pages
3 * https://github.com/DavidAnson/grunt-check-pages
4 *
5 * Copyright (c) 2014 David Anson
6 * Licensed under the MIT license.
7 */
8
9'use strict';
10
11module.exports = function(grunt) {
12 // Imports
13 var url = require('url');
14 var request = require('superagent');
15 var cheerio = require('cheerio');
16 var sax = require('sax');
17
18 // Patch the Node.js version of superagent's Request which is missing 'use'
19 request.Request.prototype.use = function(fn) {
20 fn(this);
21 return this;
22 };
23
24 // Global variables
25 var userAgent = 'grunt-check-pages/' + require('../package.json').version;
26 var pendingCallbacks = [];
27 var issueCount = 0;
28
29 // Logs an error and increments the error count
30 function logError(message) {
31 grunt.log.error(message);
32 issueCount++;
33 }
34
35 // Set common request headers
36 function setCommonHeaders(req) {
37 // Set (or clear) user agent
38 if (userAgent) {
39 req.set('User-Agent', userAgent);
40 } else {
41 req.unset('User-Agent');
42 }
43 // Prevent caching so response time will be accurate
44 req
45 .set('Cache-Control', 'no-cache')
46 .set('Pragma', 'no-cache');
47 }
48
49 // Returns true if and only if the specified link is on the list to ignore
50 function isLinkIgnored(link, options) {
51 return options.linksToIgnore.some(function(isLinkIgnored) {
52 return (isLinkIgnored === link);
53 });
54 }
55
56 // Adds pending callbacks for all links matching <element attribute='*'/>
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 // Add to front of queue so it gets processed before the next page
66 pendingCallbacks.unshift(testLink(resolvedLink, options));
67 }
68 }
69 });
70 }
71
72 // Returns a callback to test the specified page
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 // Check the page's links for validity (i.e., HTTP HEAD returns OK)
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 // Check the page's structure for XHTML compliance
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 // Check the page's response time
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 // Check the page's cache headers
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)) { // Don't require ETag for responses that won't be cached
140 logError('Missing ETag header in response');
141 }
142 }
143 if (options.checkCompression) {
144
145 // Check that the page was compressed (superagent always sets Accept-Encoding to gzip/deflate)
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 // Returns a callback to test the specified link
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 // Retry HEAD request as GET to be sure
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 // Register the task with Grunt
193 grunt.registerMultiTask('checkPages', 'Checks various aspects of a web page for correctness.', function() {
194
195 // Check for unsupported use
196 if (this.files.length) {
197 grunt.fail.warn('checkPages task does not use files; remove the files parameter');
198 }
199
200 // Check for required options
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 // Check for and normalize optional options
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 // Queue callbacks for each page
235 options.pageUrls.forEach(function(page) {
236 pendingCallbacks.push(testPage(page, options));
237 });
238
239 // Queue 'done' callback
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 // Process the queue
249 var next = function() {
250 var callback = pendingCallbacks.shift();
251 callback(next);
252 };
253 next();
254 });
255};