metalsmith-inline-critical-css
Version:
Metalsmith plugin to inspect HTML files, inline used selectors from specified CSS, and load the rest asynchronously.
163 lines • 7.61 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const multimatch_1 = __importDefault(require("multimatch"));
const invariant_1 = __importDefault(require("invariant"));
const purgecss_1 = __importDefault(require("purgecss"));
const purgecss_from_html_1 = __importDefault(require("purgecss-from-html"));
const gzip_size_1 = __importDefault(require("gzip-size"));
const cheerio_1 = __importDefault(require("cheerio"));
const debug_1 = __importDefault(require("debug"));
// Set up debug
const debug = debug_1.default('inline-critical-css-plugin');
exports.default = plugin;
/**
* Metalsmith plugin to inline critical css, and load the rest asynchronously.
*/
function plugin({ pattern, cssFile, cssPublicPath }) {
invariant_1.default(!!pattern, 'You must supply a pattern for the html files.');
invariant_1.default(!!cssFile && !Array.isArray(cssFile), 'You must supply a single css file to look for. Multiple files are not currently supported');
return function (files, metalsmith, done) {
debug('WILL: Read CSS file', cssFile);
const cssFilePath = path_1.default.resolve(cssFile);
const cssContent = fs_1.default.readFileSync(cssFilePath, { encoding: 'utf-8' });
debug('OK: Read CSS file');
// Read the LoadCSS file once
debug('WILL: Read LoadCSS contents');
const loadCssPreloadContent = getLoadCSSFallback();
debug('OK: Read LoadCSS contents');
// Loop over all the files, applying the transform if matching
Object.keys(files)
.filter(file => {
const matches = multimatch_1.default(file, pattern).length > 0;
if (matches) {
debug(`MATCH: ${file}`);
}
else {
debug(`NO MATCH: ${file}`);
}
return matches;
})
.forEach(function (file) {
debug(`WILL: Run for ${file}`);
// utf-8 decode read file contents
debug('WILL: Read html file contents');
let fileContent = files[file].contents;
if (!!fileContent && fileContent instanceof Buffer) {
fileContent = fileContent.toString('utf-8');
}
debug('OK: Read html file contents');
debug('WILL: get used CSS');
const usedCss = getUsedCss({
htmlContent: fileContent,
cssContent: cssContent,
});
debug('OK: get used CSS');
// NOTE: The gzip size will be even smaller when inlined into the document,
// because the classes are shared
debug(`Used CSS gzip-size (standalone): ${gzip_size_1.default.sync(usedCss)} B`);
debug('WILL: inject used CSS to file contents');
const htmlWithInline = inlineCriticalCss({
htmlContent: fileContent,
cssPublicPath: cssPublicPath,
criticalCssContent: usedCss,
loadCssPreloadContent,
});
debug('OK: inject used CSS to file contents');
debug('WILL: write to file contents');
files[file].contents = htmlWithInline;
debug('OK: write to file contents');
debug('OK: Critical CSS plugin run');
done();
});
};
}
/**
* Return only the css from cssContent that is used in htmlContent.
* Might have false negatives where JS interaction is concerned, but
* those should be minimal and in any case the full css should come
* in before that.
*/
const getUsedCss = ({ htmlContent, cssContent }) => {
const purgeCss = new purgecss_1.default({
content: [
{
raw: htmlContent,
extension: 'html',
},
],
css: [
{
raw: cssContent,
extension: 'css',
},
],
extractors: [
{
extractor: purgecss_from_html_1.default,
extensions: ['html'],
},
],
});
// The result of purgeCss.purge() is an array because of multiple files.
// We only have one file, so we take the first one.
const usedCss = purgeCss.purge()[0].css;
return usedCss;
};
/**
* Inline Critical CSS in HTML, by replacing <link rel="stylesheet">
* with a preload tag, adding the critical CSS as <style>, and using
* loadCSS as a polyfill.
*/
function inlineCriticalCss({ htmlContent, cssPublicPath, criticalCssContent, loadCssPreloadContent, }) {
// Set up new markup
const criticalStyleTag = `<style>${criticalCssContent}</style>`;
// Get a handle to html root
const $ = cheerio_1.default.load(htmlContent);
// Find the relevant link tag, under cssPublicPath
const $link = $('link[rel="stylesheet"]').filter(`[href="${cssPublicPath}"]`);
// If no relevant link tags found, return the original content
if ($link.length === 0) {
debug('Found no link tags with cssPublicPath of ' + cssPublicPath);
return htmlContent;
}
// NOTE: .html() returns '' for some reason, so we use toString() instead...
const linkStylesheet = $link.toString();
const noscriptFallback = `<noscript>${linkStylesheet}</noscript>`;
// Fallback for browsers that do not support link rel="preload"
// @see https://github.com/filamentgroup/loadCSS
const loadCssPreloadScript = `<script>${loadCssPreloadContent}</script>`;
/* Add the relevant markup to the page.
* <link rel="stylesheet"...> ->
* <style>.inlined-things{...}</style>
* <link rel="preload" href="path/to/mystylesheet.css" as="style" onload="this.rel='stylesheet'">
* <noscript><link rel="stylesheet" ...></noscript>
* <script>load-css-preload-polyfill</script>
*/
$link
// @ts-ignore
.attr({
rel: 'preload',
as: 'style',
// eslint-disable-next-line quotes
onload: `this.onload=null;this.rel='stylesheet'`,
})
.before(criticalStyleTag)
.after(loadCssPreloadScript)
.after(noscriptFallback);
const newHtml = $.html();
return newHtml;
}
/**
* Inlined cssrelpreload.min.js fallback
*/
function getLoadCSSFallback() {
return `
!function(t){"use strict";t.loadCSS||(t.loadCSS=function(){});var e=loadCSS.relpreload={};if(e.support=function(){var e;try{e=t.document.createElement("link").relList.supports("preload")}catch(t){e=!1}return function(){return e}}(),e.bindMediaToggle=function(t){function e(){t.media=a}var a=t.media||"all";t.addEventListener?t.addEventListener("load",e):t.attachEvent&&t.attachEvent("onload",e),setTimeout(function(){t.rel="stylesheet",t.media="only x"}),setTimeout(e,3e3)},e.poly=function(){if(!e.support())for(var a=t.document.getElementsByTagName("link"),n=0;n<a.length;n++){var o=a[n];"preload"!==o.rel||"style"!==o.getAttribute("as")||o.getAttribute("data-loadcss")||(o.setAttribute("data-loadcss",!0),e.bindMediaToggle(o))}},!e.support()){e.poly();var a=t.setInterval(e.poly,500);t.addEventListener?t.addEventListener("load",function(){e.poly(),t.clearInterval(a)}):t.attachEvent&&t.attachEvent("onload",function(){e.poly(),t.clearInterval(a)})}"undefined"!=typeof exports?exports.loadCSS=loadCSS:t.loadCSS=loadCSS}("undefined"!=typeof global?global:this);
`;
}
//# sourceMappingURL=index.js.map