UNPKG

11.4 kBJavaScriptView Raw
1'use strict';
2
3const os = require('os');
4const url = require('url');
5const path = require('path');
6const fs = require('fs-extra');
7const {chain, first, map} = require('lodash');
8const Bluebird = require('bluebird');
9const got = require('got');
10const debug = require('debug')('critical:file');
11const mime = require('mime-types');
12const slash = require('slash');
13const oust = require('oust');
14const chalk = require('chalk');
15const tempy = require('tempy');
16// Use patched vinyl to allow remote paths
17const File = require('./vinyl-remote');
18const gc = require('./gc');
19
20/**
21 * Fixup slashes in file paths for windows
22 * @param {string} str path
23 * @returns {string}
24 */
25function normalizePath(str) {
26 return process.platform === 'win32' ? slash(str) : str;
27}
28
29/**
30 * Helper function to rewrite the file paths relative to the stylesheet
31 * to be relative to the html file
32 * @param {File} html
33 * @param opts
34 * @returns {function}
35 */
36function replaceAssetPaths(html, opts) {
37 // Set dest path with fallback to html path
38 const destPath = opts.destFolder || (opts.dest && path.dirname(opts.dest)) || path.dirname(html.path);
39 const destPathResolved = path.resolve(destPath);
40 const baseResolved = path.resolve(opts.base);
41
42 /**
43 * The resulting function should get passed an vinyl object with the css file
44 */
45 return stylesheet => {
46 // Normalize relative paths
47 const css = stylesheet.contents.toString().replace(/url\(['"]?([^'"\\)]+)['"]?\)/g, (match, assetPath) => {
48 // Skip absolute paths, urls and data-uris
49 if (assetPath.startsWith('data:') || /(?:^\/)|(?::\/\/)/.test(assetPath)) {
50 return match;
51 }
52
53 // Create asset path relative to opts.base
54 const stylesheetPath = path.dirname(stylesheet.path);
55 const assetRelative = path.relative(baseResolved, path.resolve(path.join(stylesheetPath, assetPath)));
56
57 // Compute path prefix default relative to html
58 const pathPrefixDefault = path.relative(destPathResolved, baseResolved);
59
60 const pathPrefix = (typeof opts.pathPrefix === 'undefined') ? pathPrefixDefault : opts.pathPrefix;
61
62 return normalizePath(match.replace(assetPath, path.join(pathPrefix, assetRelative)));
63 });
64
65 stylesheet.contents = Buffer.from(css);
66 return stylesheet;
67 };
68}
69
70/**
71 * Get html path for penthouse
72 * Needs to be an absolute file:// url for local files to work on windows
73 * @param {object} opts Options passed to critical
74 * @param {File} file Vinyl file object of html file
75 * @returns {string}
76 */
77function getPenthouseUrl(opts, file) {
78 if (opts.src && isExternal(opts.src)) {
79 debug('Fetching remote html:', opts.src);
80 return opts.src;
81 }
82
83 let filepath = path.resolve(file.path);
84 if (!fs.existsSync(filepath)) {
85 filepath = path.resolve(file.history[0]);
86 }
87
88 debug('Fetching local html:', `file://${filepath}`);
89 return `file://${filepath}`;
90}
91
92/**
93 * Check wether a resource is external or not
94 * @param {string} href
95 * @returns {boolean}
96 */
97function isExternal(href) {
98 return /(^\/\/)|(:\/\/)/.test(href);
99}
100
101/**
102 * Generate temp file from request response object
103 * @param {Response} resp response
104 * @returns {Promise}
105 */
106function temp(resp) {
107 const contentType = resp.headers['content-type'];
108 return Promise.resolve()
109 .then(() => {
110 const filepath = tempy.file({extension: mime.extension(contentType)});
111 gc.addFile(filepath);
112 return fs.outputFile(filepath, resp.body).then(() => {
113 return filepath;
114 });
115 });
116}
117
118/**
119 * Token generated by concatenating username and password with `:` character within a base64 encoded string.
120 * @param {String} user User identifier.
121 * @param {String} pass Password.
122 * @returns {String} Base64 encoded authentication token.
123 */
124const token = (user, pass) => Buffer.from([user, pass].join(':')).toString('base64');
125
126/**
127 * Get external resource
128 * @param {string} uri
129 * @param {boolean} secure
130 * @param {object} opts Options passed to critical
131 * @returns {Promise}
132 */
133function requestAsync(uri, secure = true, opts = {}) {
134 const {user, pass, userAgent} = opts;
135 let resourceUrl = uri;
136 // Consider protocol-relative urls
137 if (/^\/\//.test(uri)) {
138 // eslint-disable-next-line node/no-deprecated-api
139 resourceUrl = url.resolve(`http${secure ? 's' : ''}://te.st`, uri);
140 }
141
142 debug(`Fetching resource: ${resourceUrl}`);
143 const options = {rejectUnauthorized: false, headers: {}};
144 if (user && pass) {
145 options.headers.Authorization = `Basic ${token(user, pass)}`;
146 }
147
148 if (userAgent) {
149 options.headers['User-Agent'] = userAgent;
150 }
151
152 return got(resourceUrl, options).catch(error => {
153 if (secure) {
154 debug(`${error.message} - trying again over http`);
155 return requestAsync(uri, false, opts);
156 }
157
158 debug(`${resourceUrl} failed: ${error.message}`);
159 return Promise.resolve(error); // eslint-disable-line promise/no-return-wrap
160 });
161}
162
163/**
164 * Get default base path based on options
165 * @param {object} opts Options passed to critical
166 * @returns {string}
167 */
168function guessBasePath(opts) {
169 if (opts.src && !isExternal(opts.src) && !isVinyl(opts.src)) {
170 return path.dirname(opts.src);
171 }
172
173 if (opts.src && isVinyl(opts.src)) {
174 return opts.src.dirname;
175 }
176
177 return process.cwd();
178}
179
180/**
181 * Wrapper for File.isVinyl to detect vinyl objects generated by gulp (vinyl < v0.5.6)
182 * @param {*} file
183 * @returns {string}
184 */
185function isVinyl(file) {
186 return File.isVinyl(file) ||
187 file instanceof File ||
188 (file && /function File\(/.test(file.constructor.toString()) && file.contents && file.path);
189}
190
191/**
192 * Returns a promise to a local file
193 * @param {string} filePath
194 * @param {object} opts Options passed to critical
195 * @returns {Promise}
196 */
197function assertLocal(filePath, opts = {}) {
198 if (!isExternal(filePath)) {
199 return new Bluebird(resolve => {
200 resolve(filePath);
201 });
202 }
203
204 return requestAsync(filePath, true, opts)
205 .then(response => temp(response));
206}
207
208/**
209 * Resolve path to file
210 * @param {File} htmlfile Vinyl file object of html file
211 * @param {object} opts Options passed to critical
212 * @returns {function}
213 */
214function resourcePath(htmlfile, opts) {
215 return filepath => {
216 if (isExternal(filepath)) {
217 debug('resourcePath - remote', filepath);
218 return filepath;
219 }
220
221 if (isExternal(htmlfile.history[0])) {
222 debug('resourcePath - remote', htmlfile.history[0]);
223 // eslint-disable-next-line node/no-deprecated-api
224 return url.resolve(htmlfile.history[0], filepath);
225 }
226
227 if (/(?:^\/)/.test(filepath)) {
228 return path.join(opts.base, filepath.split('?')[0]);
229 }
230
231 const folder = path.relative(opts.base, path.dirname(htmlfile.path));
232 if (folder) {
233 debug('resourcePath - folder', folder);
234 }
235
236 return path.join(path.dirname(htmlfile.path), filepath.split('?')[0]);
237 };
238}
239
240/**
241 * Compute a source path which fits to the directory structure
242 * so that relative links could be resolved
243 * @param {object} opts Options passed to critical
244 * @returns {string}
245 */
246function generateSourcePath(opts) {
247 const {html} = opts;
248
249 if (typeof opts.src !== 'undefined') {
250 return path.dirname(opts.src);
251 }
252
253 if (typeof opts.folder !== 'undefined') {
254 const folder = path.isAbsolute(opts.folder) ? opts.folder : path.join(opts.base, opts.folder);
255 opts.pathPrefix = path.relative(opts.folder, opts.base);
256 return folder;
257 }
258
259 if (!opts.pathPrefix) {
260 const links = oust(html, 'stylesheets');
261
262 debug('generateSourcePath - links', links);
263 // We can only determine a valid path by checking relative links
264 const relative = chain(links).omitBy(link => {
265 return link.startsWith('data:') || /(?:^\/)|(?::\/\/)/.test(link);
266 }).toArray().value();
267
268 debug('generateSourcePath - relative', relative);
269
270 if (relative.length === 0) {
271 process.stderr.write([
272 chalk.red('Warning:'),
273 'Missing html source path. Consider \'folder\' option.',
274 'https://goo.gl/PwvFVb',
275 os.EOL
276 ].join(' '));
277
278 opts.pathPrefix = '/';
279 return opts.base;
280 }
281
282 const dots = map(relative, link => {
283 const match = /^(\.\.\/)+/.exec(link);
284 return first(match);
285 });
286
287 opts.pathPrefix = chain(dots).sortBy('length').last().value() || '';
288 debug('generateSourcePath', opts.pathPrefix.replace(/\.\./g, '~'));
289 }
290
291 return path.join(opts.base, opts.pathPrefix.replace(/\.\./g, '~'));
292}
293
294/**
295 * Get vinyl object based on options
296 * could either be a html string or a local file.
297 * If opts.src already is a vinyl object it gets returnd without modifications
298 * @param {object} opts Options passed to critical
299 * @returns {promise} resolves to vinyl object
300 */
301function getVinylPromise(opts) {
302 if (!(opts.src || opts.html) || !opts.base) {
303 return Bluebird.reject(new Error('A valid source and base path are required.'));
304 }
305
306 if (isVinyl(opts.src)) {
307 return new Bluebird(resolve => {
308 resolve(opts.src);
309 });
310 }
311
312 const file = new File({
313 base: opts.base
314 });
315
316 if (opts.src && isExternal(opts.src)) {
317 file.remotePath = opts.src;
318 } else if (opts.src) {
319 file.path = opts.src;
320 }
321
322 if (opts.html) {
323 const folder = generateSourcePath(opts);
324 debug('hacky source path folder', folder);
325
326 // Html passed in directly -> create tmp file
327 return Promise.resolve()
328 .then(() => {
329 const filepath = tempy.file({extension: 'html'});
330 file.path = filepath;
331 file.path = path.join(folder, path.basename(filepath));
332 file.base = folder;
333 file.contents = Buffer.from(opts.html);
334 debug(file);
335 gc.addFile(filepath);
336 return fs.outputFile(filepath, file.contents).then(() => {
337 return file;
338 });
339 });
340 }
341
342 // Use src file provided, fetch content and return vinyl
343 return assertLocal(opts.src, opts)
344 .then(data => {
345 // Src can either be absolute or relative to opts.base
346 if (opts.src !== path.resolve(data) && !isExternal(opts.src)) {
347 file.path = path.join(opts.base, opts.src);
348 } else {
349 file.path = path.relative(process.cwd(), data);
350 }
351
352 return fs.readFile(file.path).then(contents => {
353 file.contents = contents;
354 return file;
355 });
356 });
357}
358
359exports.normalizePath = normalizePath;
360exports.isExternal = isExternal;
361exports.isVinyl = isVinyl;
362exports.replaceAssetPaths = replaceAssetPaths;
363exports.getPenthouseUrl = getPenthouseUrl;
364exports.guessBasePath = guessBasePath;
365exports.resourcePath = resourcePath;
366exports.assertLocal = assertLocal;
367exports.getVinylPromise = getVinylPromise;
368exports.token = token;