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

Last change on this file since 8d391a1 was e29cc2e, checked in by Ema <ema_spirova@…>, 3 years ago

primeNG components

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