Skip to content

Commit

Permalink
refactor: Vendor Critters so that 7c811ac can be reverted (#1780)
Browse files Browse the repository at this point in the history
  • Loading branch information
rschristian committed Jan 31, 2023
1 parent 31c7470 commit 3a9bbe4
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 19 deletions.
9 changes: 0 additions & 9 deletions .changeset/few-panthers-admire.md

This file was deleted.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"browserslist": "^4.20.3",
"console-clear": "^1.0.0",
"copy-webpack-plugin": "^9.1.0",
"critters": "^0.0.16",
"css-loader": "^6.6.0",
"css-minimizer-webpack-plugin": "3.4.1",
"dotenv": "^16.0.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ prog
'Path to prerendered routes config',
'prerender-urls.json'
)
.option('--inlineCss', 'Adds critical CSS to the prerendered HTML', true)
.option('-c, --config', 'Path to custom CLI config', 'preact.config.js')
.option('-v, --verbose', 'Verbose output', false)
.action(argv => exec(build(argv)));
Expand Down Expand Up @@ -80,6 +81,9 @@ prog
.action(() => exec(info()));

prog.parse(process.argv, {
alias: {
inlineCss: ['inline-css'],
},
unknown: arg => {
const cmd = process.argv[2];
error(
Expand Down
214 changes: 214 additions & 0 deletions packages/cli/src/lib/webpack/critters-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
* https://github.com/GoogleChromeLabs/critters/blob/main/packages/critters-webpack-plugin/src/index.js
*/

/**
* Critters does not (yet) support `html-webpack-plugin` v5, so we vendor it.
*/

const path = require('path');
const minimatch = require('minimatch');
const { sources } = require('webpack');
const Critters = require('critters');
const HtmlWebpackPlugin = require('html-webpack-plugin');

function tap(inst, hook, pluginName, async, callback) {
if (inst.hooks) {
const camel = hook.replace(/-([a-z])/g, (_s, i) => i.toUpperCase());
inst.hooks[camel][async ? 'tapAsync' : 'tap'](pluginName, callback);
} else {
inst.plugin(hook, callback);
}
}

// Used to annotate this plugin's hooks in Tappable invocations
const PLUGIN_NAME = 'critters-webpack-plugin';

/**
* Create a Critters plugin instance with the given options.
* @public
* @param {import('critters').Options} options Options to control how Critters inlines CSS. See https://github.com/GoogleChromeLabs/critters#usage
* @example
* // webpack.config.js
* module.exports = {
* plugins: [
* new Critters({
* // Outputs: <link rel="preload" onload="this.rel='stylesheet'">
* preload: 'swap',
*
* // Don't inline critical font-face rules, but preload the font URLs:
* preloadFonts: true
* })
* ]
* }
*/
module.exports = class CrittersWebpackPlugin extends Critters {
/**
* @param {import('critters').Options} options
*/
constructor(options) {
super(options);
}

/**
* Invoked by Webpack during plugin initialization
*/
apply(compiler) {
// hook into the compiler to get a Compilation instance...
tap(compiler, 'compilation', PLUGIN_NAME, false, compilation => {
this.options.path = compiler.options.output.path;
this.options.publicPath = compiler.options.output.publicPath;

const handleHtmlPluginData = (htmlPluginData, callback) => {
this.fs = compilation.outputFileSystem;
this.compilation = compilation;
this.process(htmlPluginData.html)
.then(html => {
callback(null, { html });
})
.catch(callback);
};

HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
PLUGIN_NAME,
handleHtmlPluginData
);
});
}

/**
* Given href, find the corresponding CSS asset
*/
async getCssAsset(href, style) {
const outputPath = this.options.path;
const publicPath = this.options.publicPath;

// CHECK - the output path
// path on disk (with output.publicPath removed)
let normalizedPath = href.replace(/^\//, '');
const pathPrefix = (publicPath || '').replace(/(^\/|\/$)/g, '') + '/';
if (normalizedPath.indexOf(pathPrefix) === 0) {
normalizedPath = normalizedPath
.substring(pathPrefix.length)
.replace(/^\//, '');
}
const filename = path.resolve(outputPath, normalizedPath);

// try to find a matching asset by filename in webpack's output (not yet written to disk)
const relativePath = path
.relative(outputPath, filename)
.replace(/^\.\//, '');
const asset = this.compilation.assets[relativePath]; // compilation.assets[relativePath];

// Attempt to read from assets, falling back to a disk read
let sheet = asset && asset.source();

if (!sheet) {
try {
sheet = await this.readFile(this.compilation, filename);
this.logger.warn(
`Stylesheet "${relativePath}" not found in assets, but a file was located on disk.${
this.options.pruneSource
? ' This means pruneSource will not be applied.'
: ''
}`
);
} catch (e) {
this.logger.warn(`Unable to locate stylesheet: ${relativePath}`);
return;
}
}

style.$$asset = asset;
style.$$assetName = relativePath;
// style.$$assets = this.compilation.assets;

return sheet;
}

checkInlineThreshold(link, style, sheet) {
const inlined = super.checkInlineThreshold(link, style, sheet);

if (inlined) {
const asset = style.$$asset;
if (asset) {
delete this.compilation.assets[style.$$assetName];
} else {
this.logger.warn(
` > ${style.$$name} was not found in assets. the resource may still be emitted but will be unreferenced.`
);
}
}

return inlined;
}

/**
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
*/
async embedAdditionalStylesheet(document) {
const styleSheetsIncluded = [];
(this.options.additionalStylesheets || []).forEach(cssFile => {
if (styleSheetsIncluded.includes(cssFile)) {
return;
}
styleSheetsIncluded.push(cssFile);
const webpackCssAssets = Object.keys(this.compilation.assets).filter(
file => minimatch(file, cssFile)
);
webpackCssAssets.map(asset => {
const style = document.createElement('style');
style.$$external = true;
style.textContent = this.compilation.assets[asset].source();
document.head.appendChild(style);
});
});
}

/**
* Prune the source CSS files
*/
pruneSource(style, before, sheetInverse) {
const isStyleInlined = super.pruneSource(style, before, sheetInverse);
const asset = style.$$asset;
const name = style.$$name;

if (asset) {
// if external stylesheet would be below minimum size, just inline everything
const minSize = this.options.minimumExternalSize;
if (minSize && sheetInverse.length < minSize) {
// delete the webpack asset:
delete this.compilation.assets[style.$$assetName];
return true;
}
this.compilation.assets[style.$$assetName] =
new sources.LineToLineMappedSource(
sheetInverse,
style.$$assetName,
before
);
} else {
this.logger.warn(
'pruneSource is enabled, but a style (' +
name +
') has no corresponding Webpack asset.'
);
}

return isStyleInlined;
}
};
12 changes: 12 additions & 0 deletions packages/cli/src/lib/webpack/webpack-client-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const CrittersPlugin = require('./critters-plugin.js');
const renderHTMLPlugin = require('./render-html-plugin');
const baseConfig = require('./webpack-base-config');
const { InjectManifest } = require('workbox-webpack-plugin');
Expand Down Expand Up @@ -189,6 +190,17 @@ function prodBuild(config) {
},
};

if (config.inlineCss) {
prodConfig.plugins.push(
new CrittersPlugin({
preload: 'media',
pruneSource: false,
logLevel: 'silent',
additionalStylesheets: ['route-*.css'],
})
);
}

if (config.analyze) {
prodConfig.plugins.push(new BundleAnalyzerPlugin());
}
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/tests/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ describe('preact build', () => {
).toBeUndefined();
});

it('--inlineCss', async () => {
let dir = await subject('minimal');

await buildFast(dir, { inlineCss: true });
let head = await getHead(dir);
expect(head).toMatch('<style>h1{color:red}</style>');

await buildFast(dir, { inlineCss: false });
head = await getOutputFile(dir, 'index.html');
expect(head).not.toMatch(/<style>[^<]*<\/style>/);
});

it('--config', async () => {
let dir = await subject('custom-webpack');

Expand Down Expand Up @@ -284,6 +296,16 @@ describe('preact build', () => {
expect(builtStylesheet).toMatch(/\.text__\w{5}{color:blue}/);
});

it('should inline critical CSS only', async () => {
let dir = await subject('css-inline');
await buildFast(dir);
const builtStylesheet = await getOutputFile(dir, /bundle\.\w{5}\.css$/);
const html = await getOutputFile(dir, 'index.html');

expect(builtStylesheet).toMatch('h1{color:red}div{background:tan}');
expect(html).toMatch('<style>h1{color:red}</style>');
});

// Issue #1411
it('should preserve side-effectful CSS imports even if package.json claims no side effects', async () => {
let dir = await subject('css-side-effect');
Expand Down
30 changes: 21 additions & 9 deletions packages/cli/tests/images/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ exports.default = {
'es-polyfills.js': 46419,

'favicon.ico': 15086,
'index.html': 1972,
'index.html': 3998,
'manifest.json': 455,
'preact_prerender_data.json': 11,

Expand Down Expand Up @@ -55,7 +55,11 @@ exports.prerender.heads.home = `
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href=\\"\\/assets\\/icons\\/apple-touch-icon\\.png\\">
<link rel="manifest" href="\\/manifest\\.json">
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\">
<style>html{padding:0}<\\/style>
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\" media=\\"print\\" onload=\\"this.media='all'\\">
<noscript>
<link rel=\\"stylesheet\\" href=\\"\\/bundle.\\w{5}.css\\">
</noscript>
<\\/head>
`;

Expand All @@ -68,7 +72,11 @@ exports.prerender.heads.route66 = `
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href=\\"\\/assets\\/icons\\/apple-touch-icon\\.png\\">
<link rel="manifest" href="\\/manifest\\.json">
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\">
<style>html{padding:0}<\\/style>
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\" media=\\"print\\" onload=\\"this.media='all'\\">
<noscript>
<link rel=\\"stylesheet\\" href=\\"\\/bundle.\\w{5}.css\\">
</noscript>
<\\/head>
`;

Expand All @@ -81,7 +89,11 @@ exports.prerender.heads.custom = `
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href=\\"\\/assets\\/icons\\/apple-touch-icon\\.png\\">
<link rel="manifest" href="\\/manifest\\.json">
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\">
<style>html{padding:0}<\\/style>
<link href=\\"/bundle.\\w{5}.css\\" rel=\\"stylesheet\\" media=\\"print\\" onload=\\"this.media='all'\\">
<noscript>
<link rel=\\"stylesheet\\" href=\\"\\/bundle.\\w{5}.css\\">
</noscript>
<\\/head>
`;

Expand Down Expand Up @@ -131,7 +143,7 @@ exports.prerender.htmlSafe = `
`;

exports.template = `
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
Expand All @@ -147,7 +159,7 @@ exports.template = `
`;

exports.publicPath = `
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
Expand All @@ -163,9 +175,9 @@ exports.publicPath = `
<h1>Public path test</h1>
<script type="__PREACT_CLI_DATA__">%7B%22prerenderData%22:%7B%22url%22:%22/%22%7D%7D</script>
<script type="module" src="/example-path/bundle.\\w{5}.js"></script>
<script nomodule src="/example-path/dom-polyfills.\\w{5}.js"></script>
<script nomodule src="/example-path/es-polyfills.js"></script>
<script nomodule defer="defer" src="/example-path/bundle.\\w{5}.legacy.js"></script>
<script nomodule="" src="/example-path/dom-polyfills.\\w{5}.js"></script>
<script nomodule="" src="/example-path/es-polyfills.js"></script>
<script nomodule="" defer="defer" src="/example-path/bundle.\\w{5}.legacy.js"></script>
</body>
</html>
`;
Loading

0 comments on commit 3a9bbe4

Please sign in to comment.