Skip to content

Commit e43221c

Browse files
committed
refactor: Vendor Critters so that 7c811ac can be reverted (#1780)
1 parent dfcae75 commit e43221c

File tree

8 files changed

+331
-19
lines changed

8 files changed

+331
-19
lines changed

.changeset/few-panthers-admire.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"browserslist": "^4.20.3",
4949
"console-clear": "^1.0.0",
5050
"copy-webpack-plugin": "^9.1.0",
51+
"critters": "^0.0.16",
5152
"css-loader": "^6.6.0",
5253
"css-minimizer-webpack-plugin": "3.4.1",
5354
"dotenv": "^16.0.0",

packages/cli/src/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ prog
4242
'Path to prerendered routes config',
4343
'prerender-urls.json'
4444
)
45+
.option('--inlineCss', 'Adds critical CSS to the prerendered HTML', true)
4546
.option('-c, --config', 'Path to custom CLI config', 'preact.config.js')
4647
.option('-v, --verbose', 'Verbose output', false)
4748
.action(argv => exec(build(argv)));
@@ -80,6 +81,9 @@ prog
8081
.action(() => exec(info()));
8182

8283
prog.parse(process.argv, {
84+
alias: {
85+
inlineCss: ['inline-css'],
86+
},
8387
unknown: arg => {
8488
const cmd = process.argv[2];
8589
error(
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
* https://github.com/GoogleChromeLabs/critters/blob/main/packages/critters-webpack-plugin/src/index.js
17+
*/
18+
19+
/**
20+
* Critters does not (yet) support `html-webpack-plugin` v5, so we vendor it.
21+
*/
22+
23+
const path = require('path');
24+
const minimatch = require('minimatch');
25+
const { sources } = require('webpack');
26+
const Critters = require('critters');
27+
const HtmlWebpackPlugin = require('html-webpack-plugin');
28+
29+
function tap(inst, hook, pluginName, async, callback) {
30+
if (inst.hooks) {
31+
const camel = hook.replace(/-([a-z])/g, (_s, i) => i.toUpperCase());
32+
inst.hooks[camel][async ? 'tapAsync' : 'tap'](pluginName, callback);
33+
} else {
34+
inst.plugin(hook, callback);
35+
}
36+
}
37+
38+
// Used to annotate this plugin's hooks in Tappable invocations
39+
const PLUGIN_NAME = 'critters-webpack-plugin';
40+
41+
/**
42+
* Create a Critters plugin instance with the given options.
43+
* @public
44+
* @param {import('critters').Options} options Options to control how Critters inlines CSS. See https://github.com/GoogleChromeLabs/critters#usage
45+
* @example
46+
* // webpack.config.js
47+
* module.exports = {
48+
* plugins: [
49+
* new Critters({
50+
* // Outputs: <link rel="preload" onload="this.rel='stylesheet'">
51+
* preload: 'swap',
52+
*
53+
* // Don't inline critical font-face rules, but preload the font URLs:
54+
* preloadFonts: true
55+
* })
56+
* ]
57+
* }
58+
*/
59+
module.exports = class CrittersWebpackPlugin extends Critters {
60+
/**
61+
* @param {import('critters').Options} options
62+
*/
63+
constructor(options) {
64+
super(options);
65+
}
66+
67+
/**
68+
* Invoked by Webpack during plugin initialization
69+
*/
70+
apply(compiler) {
71+
// hook into the compiler to get a Compilation instance...
72+
tap(compiler, 'compilation', PLUGIN_NAME, false, compilation => {
73+
this.options.path = compiler.options.output.path;
74+
this.options.publicPath = compiler.options.output.publicPath;
75+
76+
const handleHtmlPluginData = (htmlPluginData, callback) => {
77+
this.fs = compilation.outputFileSystem;
78+
this.compilation = compilation;
79+
this.process(htmlPluginData.html)
80+
.then(html => {
81+
callback(null, { html });
82+
})
83+
.catch(callback);
84+
};
85+
86+
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
87+
PLUGIN_NAME,
88+
handleHtmlPluginData
89+
);
90+
});
91+
}
92+
93+
/**
94+
* Given href, find the corresponding CSS asset
95+
*/
96+
async getCssAsset(href, style) {
97+
const outputPath = this.options.path;
98+
const publicPath = this.options.publicPath;
99+
100+
// CHECK - the output path
101+
// path on disk (with output.publicPath removed)
102+
let normalizedPath = href.replace(/^\//, '');
103+
const pathPrefix = (publicPath || '').replace(/(^\/|\/$)/g, '') + '/';
104+
if (normalizedPath.indexOf(pathPrefix) === 0) {
105+
normalizedPath = normalizedPath
106+
.substring(pathPrefix.length)
107+
.replace(/^\//, '');
108+
}
109+
const filename = path.resolve(outputPath, normalizedPath);
110+
111+
// try to find a matching asset by filename in webpack's output (not yet written to disk)
112+
const relativePath = path
113+
.relative(outputPath, filename)
114+
.replace(/^\.\//, '');
115+
const asset = this.compilation.assets[relativePath]; // compilation.assets[relativePath];
116+
117+
// Attempt to read from assets, falling back to a disk read
118+
let sheet = asset && asset.source();
119+
120+
if (!sheet) {
121+
try {
122+
sheet = await this.readFile(this.compilation, filename);
123+
this.logger.warn(
124+
`Stylesheet "${relativePath}" not found in assets, but a file was located on disk.${
125+
this.options.pruneSource
126+
? ' This means pruneSource will not be applied.'
127+
: ''
128+
}`
129+
);
130+
} catch (e) {
131+
this.logger.warn(`Unable to locate stylesheet: ${relativePath}`);
132+
return;
133+
}
134+
}
135+
136+
style.$$asset = asset;
137+
style.$$assetName = relativePath;
138+
// style.$$assets = this.compilation.assets;
139+
140+
return sheet;
141+
}
142+
143+
checkInlineThreshold(link, style, sheet) {
144+
const inlined = super.checkInlineThreshold(link, style, sheet);
145+
146+
if (inlined) {
147+
const asset = style.$$asset;
148+
if (asset) {
149+
delete this.compilation.assets[style.$$assetName];
150+
} else {
151+
this.logger.warn(
152+
` > ${style.$$name} was not found in assets. the resource may still be emitted but will be unreferenced.`
153+
);
154+
}
155+
}
156+
157+
return inlined;
158+
}
159+
160+
/**
161+
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
162+
*/
163+
async embedAdditionalStylesheet(document) {
164+
const styleSheetsIncluded = [];
165+
(this.options.additionalStylesheets || []).forEach(cssFile => {
166+
if (styleSheetsIncluded.includes(cssFile)) {
167+
return;
168+
}
169+
styleSheetsIncluded.push(cssFile);
170+
const webpackCssAssets = Object.keys(this.compilation.assets).filter(
171+
file => minimatch(file, cssFile)
172+
);
173+
webpackCssAssets.map(asset => {
174+
const style = document.createElement('style');
175+
style.$$external = true;
176+
style.textContent = this.compilation.assets[asset].source();
177+
document.head.appendChild(style);
178+
});
179+
});
180+
}
181+
182+
/**
183+
* Prune the source CSS files
184+
*/
185+
pruneSource(style, before, sheetInverse) {
186+
const isStyleInlined = super.pruneSource(style, before, sheetInverse);
187+
const asset = style.$$asset;
188+
const name = style.$$name;
189+
190+
if (asset) {
191+
// if external stylesheet would be below minimum size, just inline everything
192+
const minSize = this.options.minimumExternalSize;
193+
if (minSize && sheetInverse.length < minSize) {
194+
// delete the webpack asset:
195+
delete this.compilation.assets[style.$$assetName];
196+
return true;
197+
}
198+
this.compilation.assets[style.$$assetName] =
199+
new sources.LineToLineMappedSource(
200+
sheetInverse,
201+
style.$$assetName,
202+
before
203+
);
204+
} else {
205+
this.logger.warn(
206+
'pruneSource is enabled, but a style (' +
207+
name +
208+
') has no corresponding Webpack asset.'
209+
);
210+
}
211+
212+
return isStyleInlined;
213+
}
214+
};

packages/cli/src/lib/webpack/webpack-client-config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
88
const TerserPlugin = require('terser-webpack-plugin');
99
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
1010
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
11+
const CrittersPlugin = require('./critters-plugin.js');
1112
const renderHTMLPlugin = require('./render-html-plugin');
1213
const baseConfig = require('./webpack-base-config');
1314
const { InjectManifest } = require('workbox-webpack-plugin');
@@ -189,6 +190,17 @@ function prodBuild(config) {
189190
},
190191
};
191192

193+
if (config.inlineCss) {
194+
prodConfig.plugins.push(
195+
new CrittersPlugin({
196+
preload: 'media',
197+
pruneSource: false,
198+
logLevel: 'silent',
199+
additionalStylesheets: ['route-*.css'],
200+
})
201+
);
202+
}
203+
192204
if (config.analyze) {
193205
prodConfig.plugins.push(new BundleAnalyzerPlugin());
194206
}

packages/cli/tests/build.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,18 @@ describe('preact build', () => {
231231
).toBeUndefined();
232232
});
233233

234+
it('--inlineCss', async () => {
235+
let dir = await subject('minimal');
236+
237+
await buildFast(dir, { inlineCss: true });
238+
let head = await getHead(dir);
239+
expect(head).toMatch('<style>h1{color:red}</style>');
240+
241+
await buildFast(dir, { inlineCss: false });
242+
head = await getOutputFile(dir, 'index.html');
243+
expect(head).not.toMatch(/<style>[^<]*<\/style>/);
244+
});
245+
234246
it('--config', async () => {
235247
let dir = await subject('custom-webpack');
236248

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

299+
it('should inline critical CSS only', async () => {
300+
let dir = await subject('css-inline');
301+
await buildFast(dir);
302+
const builtStylesheet = await getOutputFile(dir, /bundle\.\w{5}\.css$/);
303+
const html = await getOutputFile(dir, 'index.html');
304+
305+
expect(builtStylesheet).toMatch('h1{color:red}div{background:tan}');
306+
expect(html).toMatch('<style>h1{color:red}</style>');
307+
});
308+
287309
// Issue #1411
288310
it('should preserve side-effectful CSS imports even if package.json claims no side effects', async () => {
289311
let dir = await subject('css-side-effect');

packages/cli/tests/images/build.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ exports.default = {
2626
'es-polyfills.js': 42690,
2727

2828
'favicon.ico': 15086,
29-
'index.html': 1972,
29+
'index.html': 3998,
3030
'manifest.json': 455,
3131
'preact_prerender_data.json': 11,
3232

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

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

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

@@ -131,7 +143,7 @@ exports.prerender.htmlSafe = `
131143
`;
132144

133145
exports.template = `
134-
<!doctype html>
146+
<!DOCTYPE html>
135147
<html lang="en">
136148
<head>
137149
<meta charset="utf-8">
@@ -147,7 +159,7 @@ exports.template = `
147159
`;
148160

149161
exports.publicPath = `
150-
<!doctype html>
162+
<!DOCTYPE html>
151163
<html lang="en">
152164
<head>
153165
<meta charset="utf-8">
@@ -163,9 +175,9 @@ exports.publicPath = `
163175
<h1>Public path test</h1>
164176
<script type="__PREACT_CLI_DATA__">%7B%22preRenderData%22:%7B%22url%22:%22/%22%7D%7D</script>
165177
<script type="module" src="/example-path/bundle.\\w{5}.js"></script>
166-
<script nomodule src="/example-path/dom-polyfills.\\w{5}.js"></script>
167-
<script nomodule src="/example-path/es-polyfills.js"></script>
168-
<script nomodule defer="defer" src="/example-path/bundle.\\w{5}.legacy.js"></script>
178+
<script nomodule="" src="/example-path/dom-polyfills.\\w{5}.js"></script>
179+
<script nomodule="" src="/example-path/es-polyfills.js"></script>
180+
<script nomodule="" defer="defer" src="/example-path/bundle.\\w{5}.legacy.js"></script>
169181
</body>
170182
</html>
171183
`;

0 commit comments

Comments
 (0)