source: trip-planner-front/node_modules/critters/src/index.js@ fa375fe

Last change on this file since fa375fe was 6a3a178, checked in by Ema <ema_spirova@…>, 3 years ago

initial commit

  • Property mode set to 100644
File size: 22.2 KB
Line 
1/**
2 * Copyright 2018 Google LLC
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17import path from 'path';
18import prettyBytes from 'pretty-bytes';
19import { createDocument, serializeDocument } from './dom';
20import {
21 parseStylesheet,
22 serializeStylesheet,
23 walkStyleRules,
24 walkStyleRulesWithReverseMirror,
25 markOnly,
26 applyMarkedSelectors
27} from './css';
28import { createLogger } from './util';
29
30/**
31 * The mechanism to use for lazy-loading stylesheets.
32 * Note: <kbd>JS</kbd> indicates a strategy requiring JavaScript (falls back to `<noscript>` unless disabled).
33 *
34 * - **default:** Move stylesheet links to the end of the document and insert preload meta tags in their place.
35 * - **"body":** Move all external stylesheet links to the end of the document.
36 * - **"media":** Load stylesheets asynchronously by adding `media="not x"` and removing once loaded. <kbd>JS</kbd>
37 * - **"swap":** Convert stylesheet links to preloads that swap to `rel="stylesheet"` once loaded ([details](https://www.filamentgroup.com/lab/load-css-simpler/#the-code)). <kbd>JS</kbd>
38 * - **"swap-high":** Use `<link rel="alternate stylesheet preload">` and swap to `rel="stylesheet"` once loaded ([details](http://filamentgroup.github.io/loadCSS/test/new-high.html)). <kbd>JS</kbd>
39 * - **"js":** Inject an asynchronous CSS loader similar to [LoadCSS](https://github.com/filamentgroup/loadCSS) and use it to load stylesheets. <kbd>JS</kbd>
40 * - **"js-lazy":** Like `"js"`, but the stylesheet is disabled until fully loaded.
41 * @typedef {(default|'body'|'media'|'swap'|'swap-high'|'js'|'js-lazy')} PreloadStrategy
42 * @public
43 */
44
45/**
46 * Controls which keyframes rules are inlined.
47 *
48 * - **"critical":** _(default)_ inline keyframes rules that are used by the critical CSS.
49 * - **"all":** Inline all keyframes rules.
50 * - **"none":** Remove all keyframes rules.
51 * @typedef {('critical'|'all'|'none')} KeyframeStrategy
52 * @private
53 * @property {String} keyframes Which {@link KeyframeStrategy keyframe strategy} to use (default: `critical`)_
54 */
55
56/**
57 * Controls log level of the plugin. Specifies the level the logger should use. A logger will
58 * not produce output for any log level beneath the specified level. Available levels and order
59 * are:
60 *
61 * - **"info"** _(default)_
62 * - **"warn"**
63 * - **"error"**
64 * - **"trace"**
65 * - **"debug"**
66 * - **"silent"**
67 * @typedef {('info'|'warn'|'error'|'trace'|'debug'|'silent')} LogLevel
68 * @public
69 */
70
71/**
72 * Custom logger interface:
73 * @typedef {object} Logger
74 * @public
75 * @property {function(String)} trace - Prints a trace message
76 * @property {function(String)} debug - Prints a debug message
77 * @property {function(String)} info - Prints an information message
78 * @property {function(String)} warn - Prints a warning message
79 * @property {function(String)} error - Prints an error message
80 */
81
82/**
83 * All optional. Pass them to `new Critters({ ... })`.
84 * @public
85 * @typedef Options
86 * @property {String} path Base path location of the CSS files _(default: `''`)_
87 * @property {String} publicPath Public path of the CSS resources. This prefix is removed from the href _(default: `''`)_
88 * @property {Boolean} external Inline styles from external stylesheets _(default: `true`)_
89 * @property {Number} inlineThreshold Inline external stylesheets smaller than a given size _(default: `0`)_
90 * @property {Number} minimumExternalSize If the non-critical external stylesheet would be below this size, just inline it _(default: `0`)_
91 * @property {Boolean} pruneSource Remove inlined rules from the external stylesheet _(default: `false`)_
92 * @property {Boolean} mergeStylesheets Merged inlined stylesheets into a single <style> tag _(default: `true`)_
93 * @property {String[]} additionalStylesheets Glob for matching other stylesheets to be used while looking for critical CSS _(default: ``)_.
94 * @property {String} preload Which {@link PreloadStrategy preload strategy} to use
95 * @property {Boolean} noscriptFallback Add `<noscript>` fallback to JS-based strategies
96 * @property {Boolean} inlineFonts Inline critical font-face rules _(default: `false`)_
97 * @property {Boolean} preloadFonts Preloads critical fonts _(default: `true`)_
98 * @property {Boolean} fonts Shorthand for setting `inlineFonts`+`preloadFonts`
99 * - Values:
100 * - `true` to inline critical font-face rules and preload the fonts
101 * - `false` to don't inline any font-face rules and don't preload fonts
102 * @property {String} keyframes Controls which keyframes rules are inlined.
103 * - Values:
104 * - `"critical"`: _(default)_ inline keyframes rules used by the critical CSS
105 * - `"all"` inline all keyframes rules
106 * - `"none"` remove all keyframes rules
107 * @property {Boolean} compress Compress resulting critical CSS _(default: `true`)_
108 * @property {String} logLevel Controls {@link LogLevel log level} of the plugin _(default: `"info"`)_
109 * @property {object} logger Provide a custom logger interface {@link Logger logger}
110 */
111
112export default class Critters {
113 /** @private */
114 constructor(options) {
115 this.options = Object.assign(
116 {
117 logLevel: 'info',
118 path: '',
119 publicPath: '',
120 reduceInlineStyles: true,
121 pruneSource: false,
122 additionalStylesheets: []
123 },
124 options || {}
125 );
126
127 this.urlFilter = this.options.filter;
128 if (this.urlFilter instanceof RegExp) {
129 this.urlFilter = this.urlFilter.test.bind(this.urlFilter);
130 }
131
132 this.logger = this.options.logger || createLogger(this.options.logLevel);
133 }
134
135 /**
136 * Read the contents of a file from the specified filesystem or disk
137 */
138 readFile(filename) {
139 const fs = this.fs;
140 return new Promise((resolve, reject) => {
141 const callback = (err, data) => {
142 if (err) reject(err);
143 else resolve(data);
144 };
145 if (fs && fs.readFile) {
146 fs.readFile(filename, callback);
147 } else {
148 require('fs').readFile(filename, 'utf8', callback);
149 }
150 });
151 }
152
153 /**
154 * Apply critical CSS processing to the html
155 */
156 async process(html) {
157 const start = process.hrtime.bigint();
158
159 // Parse the generated HTML in a DOM we can mutate
160 const document = createDocument(html);
161
162 if (this.options.additionalStylesheets.length > 0) {
163 this.embedAdditionalStylesheet(document);
164 }
165
166 // `external:false` skips processing of external sheets
167 if (this.options.external !== false) {
168 const externalSheets = [].slice.call(
169 document.querySelectorAll('link[rel="stylesheet"]')
170 );
171
172 await Promise.all(
173 externalSheets.map((link) => this.embedLinkedStylesheet(link, document))
174 );
175 }
176
177 // go through all the style tags in the document and reduce them to only critical CSS
178 const styles = this.getAffectedStyleTags(document);
179
180 await Promise.all(
181 styles.map((style) => this.processStyle(style, document))
182 );
183
184 if (this.options.mergeStylesheets !== false && styles.length !== 0) {
185 await this.mergeStylesheets(document);
186 }
187
188 // serialize the document back to HTML and we're done
189 const output = serializeDocument(document);
190 const end = process.hrtime.bigint();
191 this.logger.info('Time ' + parseFloat(end - start) / 1000000.0);
192 return output;
193 }
194
195 /**
196 * Get the style tags that need processing
197 */
198 getAffectedStyleTags(document) {
199 const styles = [].slice.call(document.querySelectorAll('style'));
200
201 // `inline:false` skips processing of inline stylesheets
202 if (this.options.reduceInlineStyles === false) {
203 return styles.filter((style) => style.$$external);
204 }
205 return styles;
206 }
207
208 async mergeStylesheets(document) {
209 const styles = this.getAffectedStyleTags(document);
210 if (styles.length === 0) {
211 this.logger.warn(
212 'Merging inline stylesheets into a single <style> tag skipped, no inline stylesheets to merge'
213 );
214 return;
215 }
216 const first = styles[0];
217 let sheet = first.textContent;
218
219 for (let i = 1; i < styles.length; i++) {
220 const node = styles[i];
221 sheet += node.textContent;
222 node.remove();
223 }
224
225 first.textContent = sheet;
226 }
227
228 /**
229 * Given href, find the corresponding CSS asset
230 */
231 async getCssAsset(href) {
232 const outputPath = this.options.path;
233 const publicPath = this.options.publicPath;
234
235 // CHECK - the output path
236 // path on disk (with output.publicPath removed)
237 let normalizedPath = href.replace(/^\//, '');
238 const pathPrefix = (publicPath || '').replace(/(^\/|\/$)/g, '') + '/';
239 if (normalizedPath.indexOf(pathPrefix) === 0) {
240 normalizedPath = normalizedPath
241 .substring(pathPrefix.length)
242 .replace(/^\//, '');
243 }
244 const filename = path.resolve(outputPath, normalizedPath);
245
246 let sheet;
247
248 try {
249 sheet = await this.readFile(filename);
250 } catch (e) {
251 this.logger.warn(`Unable to locate stylesheet: ${filename}`);
252 }
253
254 return sheet;
255 }
256
257 checkInlineThreshold(link, style, sheet) {
258 if (
259 this.options.inlineThreshold &&
260 sheet.length < this.options.inlineThreshold
261 ) {
262 const href = style.$$name;
263 style.$$reduce = false;
264 this.logger.info(
265 `\u001b[32mInlined all of ${href} (${sheet.length} was below the threshold of ${this.options.inlineThreshold})\u001b[39m`
266 );
267 link.remove();
268 return true;
269 }
270
271 return false;
272 }
273
274 /**
275 * Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
276 */
277 async embedAdditionalStylesheet(document) {
278 const styleSheetsIncluded = [];
279
280 const sources = await Promise.all(
281 this.options.additionalStylesheets.map((cssFile) => {
282 if (styleSheetsIncluded.includes(cssFile)) {
283 return;
284 }
285 styleSheetsIncluded.push(cssFile);
286 const style = document.createElement('style');
287 style.$$external = true;
288 return this.getCssAsset(cssFile, style).then((sheet) => [sheet, style]);
289 })
290 );
291
292 sources.forEach(([sheet, style]) => {
293 if (!sheet) return;
294 style.textContent = sheet;
295 document.head.appendChild(style);
296 });
297 }
298
299 /**
300 * Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`)
301 */
302 async embedLinkedStylesheet(link, document) {
303 const href = link.getAttribute('href');
304 const media = link.getAttribute('media');
305
306 const preloadMode = this.options.preload;
307
308 // skip filtered resources, or network resources if no filter is provided
309 if (this.urlFilter ? this.urlFilter(href) : !(href || '').match(/\.css$/)) {
310 return Promise.resolve();
311 }
312
313 // the reduced critical CSS gets injected into a new <style> tag
314 const style = document.createElement('style');
315 style.$$external = true;
316 const sheet = await this.getCssAsset(href, style);
317
318 if (!sheet) {
319 return;
320 }
321
322 style.textContent = sheet;
323 style.$$name = href;
324 style.$$links = [link];
325 link.parentNode.insertBefore(style, link);
326
327 if (this.checkInlineThreshold(link, style, sheet)) {
328 return;
329 }
330
331 // CSS loader is only injected for the first sheet, then this becomes an empty string
332 let cssLoaderPreamble =
333 "function $loadcss(u,m,l){(l=document.createElement('link')).rel='stylesheet';l.href=u;document.head.appendChild(l)}";
334 const lazy = preloadMode === 'js-lazy';
335 if (lazy) {
336 cssLoaderPreamble = cssLoaderPreamble.replace(
337 'l.href',
338 "l.media='print';l.onload=function(){l.media=m};l.href"
339 );
340 }
341
342 // Allow disabling any mutation of the stylesheet link:
343 if (preloadMode === false) return;
344
345 let noscriptFallback = false;
346
347 if (preloadMode === 'body') {
348 document.body.appendChild(link);
349 } else {
350 link.setAttribute('rel', 'preload');
351 link.setAttribute('as', 'style');
352 if (preloadMode === 'js' || preloadMode === 'js-lazy') {
353 const script = document.createElement('script');
354 const js = `${cssLoaderPreamble}$loadcss(${JSON.stringify(href)}${
355 lazy ? ',' + JSON.stringify(media || 'all') : ''
356 })`;
357 // script.appendChild(document.createTextNode(js));
358 script.textContent = js;
359 link.parentNode.insertBefore(script, link.nextSibling);
360 style.$$links.push(script);
361 cssLoaderPreamble = '';
362 noscriptFallback = true;
363 } else if (preloadMode === 'media') {
364 // @see https://github.com/filamentgroup/loadCSS/blob/af1106cfe0bf70147e22185afa7ead96c01dec48/src/loadCSS.js#L26
365 link.setAttribute('rel', 'stylesheet');
366 link.removeAttribute('as');
367 link.setAttribute('media', 'print');
368 link.setAttribute('onload', `this.media='${media || 'all'}'`);
369 noscriptFallback = true;
370 } else if (preloadMode === 'swap-high') {
371 // @see http://filamentgroup.github.io/loadCSS/test/new-high.html
372 link.setAttribute('rel', 'alternate stylesheet preload');
373 link.setAttribute('title', 'styles');
374 link.setAttribute('onload', `this.title='';this.rel='stylesheet'`);
375 noscriptFallback = true;
376 } else if (preloadMode === 'swap') {
377 link.setAttribute('onload', "this.rel='stylesheet'");
378 noscriptFallback = true;
379 } else {
380 const bodyLink = document.createElement('link');
381 bodyLink.setAttribute('rel', 'stylesheet');
382 if (media) bodyLink.setAttribute('media', media);
383 bodyLink.setAttribute('href', href);
384 document.body.appendChild(bodyLink);
385 style.$$links.push(bodyLink);
386 }
387 }
388
389 if (this.options.noscriptFallback !== false && noscriptFallback) {
390 const noscript = document.createElement('noscript');
391 const noscriptLink = document.createElement('link');
392 noscriptLink.setAttribute('rel', 'stylesheet');
393 noscriptLink.setAttribute('href', href);
394 if (media) noscriptLink.setAttribute('media', media);
395 noscript.appendChild(noscriptLink);
396 link.parentNode.insertBefore(noscript, link.nextSibling);
397 style.$$links.push(noscript);
398 }
399 }
400
401 /**
402 * Prune the source CSS files
403 */
404 pruneSource(style, before, sheetInverse) {
405 // if external stylesheet would be below minimum size, just inline everything
406 const minSize = this.options.minimumExternalSize;
407 const name = style.$$name;
408 if (minSize && sheetInverse.length < minSize) {
409 this.logger.info(
410 `\u001b[32mInlined all of ${name} (non-critical external stylesheet would have been ${sheetInverse.length}b, which was below the threshold of ${minSize})\u001b[39m`
411 );
412 style.textContent = before;
413 // remove any associated external resources/loaders:
414 if (style.$$links) {
415 for (const link of style.$$links) {
416 const parent = link.parentNode;
417 if (parent) parent.removeChild(link);
418 }
419 }
420
421 return true;
422 }
423
424 return false;
425 }
426
427 /**
428 * Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document.
429 */
430 async processStyle(style, document) {
431 if (style.$$reduce === false) return;
432
433 const name = style.$$name ? style.$$name.replace(/^\//, '') : 'inline CSS';
434 const options = this.options;
435 // const document = style.ownerDocument;
436 const head = document.querySelector('head');
437 let keyframesMode = options.keyframes || 'critical';
438 // we also accept a boolean value for options.keyframes
439 if (keyframesMode === true) keyframesMode = 'all';
440 if (keyframesMode === false) keyframesMode = 'none';
441
442 let sheet = style.textContent;
443
444 // store a reference to the previous serialized stylesheet for reporting stats
445 const before = sheet;
446
447 // Skip empty stylesheets
448 if (!sheet) return;
449
450 const ast = parseStylesheet(sheet);
451 const astInverse = options.pruneSource ? parseStylesheet(sheet) : null;
452
453 // a string to search for font names (very loose)
454 let criticalFonts = '';
455
456 const failedSelectors = [];
457
458 const criticalKeyframeNames = [];
459
460 // Walk all CSS rules, marking unused rules with `.$$remove=true` for removal in the second pass.
461 // This first pass is also used to collect font and keyframe usage used in the second pass.
462 walkStyleRules(
463 ast,
464 markOnly((rule) => {
465 if (rule.type === 'rule') {
466 // Filter the selector list down to only those match
467 rule.filterSelectors((sel) => {
468 // Strip pseudo-elements and pseudo-classes, since we only care that their associated elements exist.
469 // This means any selector for a pseudo-element or having a pseudo-class will be inlined if the rest of the selector matches.
470 if (sel === ':root' || sel.match(/^::?(before|after)$/)) {
471 return true;
472 }
473 sel = sel
474 .replace(/(?<!\\)::?[a-z-]+(?![a-z-(])/gi, '')
475 .replace(/::?not\(\s*\)/g, '')
476 .trim();
477 if (!sel) return false;
478
479 try {
480 return document.querySelector(sel) != null;
481 } catch (e) {
482 failedSelectors.push(sel + ' -> ' + e.message);
483 return false;
484 }
485 });
486 // If there are no matched selectors, remove the rule:
487 if (rule.selectors.length === 0) {
488 return false;
489 }
490
491 if (rule.declarations) {
492 for (let i = 0; i < rule.declarations.length; i++) {
493 const decl = rule.declarations[i];
494
495 // detect used fonts
496 if (decl.property && decl.property.match(/\bfont(-family)?\b/i)) {
497 criticalFonts += ' ' + decl.value;
498 }
499
500 // detect used keyframes
501 if (
502 decl.property === 'animation' ||
503 decl.property === 'animation-name'
504 ) {
505 // @todo: parse animation declarations and extract only the name. for now we'll do a lazy match.
506 const names = decl.value.split(/\s+/);
507 for (let j = 0; j < names.length; j++) {
508 const name = names[j].trim();
509 if (name) criticalKeyframeNames.push(name);
510 }
511 }
512 }
513 }
514 }
515
516 // keep font rules, they're handled in the second pass:
517 if (rule.type === 'font-face') return;
518
519 // If there are no remaining rules, remove the whole rule:
520 const rules = rule.rules && rule.rules.filter((rule) => !rule.$$remove);
521 return !rules || rules.length !== 0;
522 })
523 );
524
525 if (failedSelectors.length !== 0) {
526 this.logger.warn(
527 `${
528 failedSelectors.length
529 } rules skipped due to selector errors:\n ${failedSelectors.join(
530 '\n '
531 )}`
532 );
533 }
534
535 const shouldPreloadFonts =
536 options.fonts === true || options.preloadFonts === true;
537 const shouldInlineFonts =
538 options.fonts !== false && options.inlineFonts === true;
539
540 const preloadedFonts = [];
541 // Second pass, using data picked up from the first
542 walkStyleRulesWithReverseMirror(ast, astInverse, (rule) => {
543 // remove any rules marked in the first pass
544 if (rule.$$remove === true) return false;
545
546 applyMarkedSelectors(rule);
547
548 // prune @keyframes rules
549 if (rule.type === 'keyframes') {
550 if (keyframesMode === 'none') return false;
551 if (keyframesMode === 'all') return true;
552 return criticalKeyframeNames.indexOf(rule.name) !== -1;
553 }
554
555 // prune @font-face rules
556 if (rule.type === 'font-face') {
557 let family, src;
558 for (let i = 0; i < rule.declarations.length; i++) {
559 const decl = rule.declarations[i];
560 if (decl.property === 'src') {
561 // @todo parse this properly and generate multiple preloads with type="font/woff2" etc
562 src = (decl.value.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/) || [])[2];
563 } else if (decl.property === 'font-family') {
564 family = decl.value;
565 }
566 }
567
568 if (src && shouldPreloadFonts && preloadedFonts.indexOf(src) === -1) {
569 preloadedFonts.push(src);
570 const preload = document.createElement('link');
571 preload.setAttribute('rel', 'preload');
572 preload.setAttribute('as', 'font');
573 preload.setAttribute('crossorigin', 'anonymous');
574 preload.setAttribute('href', src.trim());
575 head.appendChild(preload);
576 }
577
578 // if we're missing info, if the font is unused, or if critical font inlining is disabled, remove the rule:
579 if (
580 !family ||
581 !src ||
582 criticalFonts.indexOf(family) === -1 ||
583 !shouldInlineFonts
584 ) {
585 return false;
586 }
587 }
588 });
589
590 sheet = serializeStylesheet(ast, {
591 compress: this.options.compress !== false
592 }).trim();
593
594 // If all rules were removed, get rid of the style element entirely
595 if (sheet.trim().length === 0) {
596 if (style.parentNode) {
597 style.remove();
598 }
599 return;
600 }
601
602 let afterText = '';
603 let styleInlinedCompletely = false;
604 if (options.pruneSource) {
605 const sheetInverse = serializeStylesheet(astInverse, {
606 compress: this.options.compress !== false
607 });
608
609 styleInlinedCompletely = this.pruneSource(style, before, sheetInverse);
610
611 if (styleInlinedCompletely) {
612 const percent = (sheetInverse.length / before.length) * 100;
613 afterText = `, reducing non-inlined size ${
614 percent | 0
615 }% to ${prettyBytes(sheetInverse.length)}`;
616 }
617 }
618
619 // replace the inline stylesheet with its critical'd counterpart
620 if (!styleInlinedCompletely) {
621 style.textContent = sheet;
622 }
623
624 // output stats
625 const percent = ((sheet.length / before.length) * 100) | 0;
626 this.logger.info(
627 '\u001b[32mInlined ' +
628 prettyBytes(sheet.length) +
629 ' (' +
630 percent +
631 '% of original ' +
632 prettyBytes(before.length) +
633 ') of ' +
634 name +
635 afterText +
636 '.\u001b[39m'
637 );
638 }
639}
Note: See TracBrowser for help on using the repository browser.