source: trip-planner-front/node_modules/critters/src/index2.js@ 59329aa

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

initial commit

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