├── .gitignore ├── test ├── fixtures │ ├── unused │ │ ├── index.js │ │ └── index.html │ ├── raw │ │ ├── index.js │ │ └── index.html │ ├── basic │ │ ├── index.js │ │ └── index.html │ └── external │ │ ├── index.js │ │ ├── index.html │ │ └── style.css ├── standalone.test.js ├── __snapshots__ │ ├── standalone.test.js.snap │ └── index.test.js.snap ├── _helpers.js └── index.test.js ├── .editorconfig ├── CONTRIBUTING.md ├── src ├── css.js ├── dom.js └── index.js ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | dist 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /test/fixtures/unused/index.js: -------------------------------------------------------------------------------- 1 | console.log('empty file'); 2 | -------------------------------------------------------------------------------- /test/fixtures/raw/index.js: -------------------------------------------------------------------------------- 1 | import html from './index.html'; 2 | 3 | module.exports = html; 4 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.js: -------------------------------------------------------------------------------- 1 | document.body.appendChild(document.createTextNode('this counts as SSR')); 2 | -------------------------------------------------------------------------------- /test/fixtures/external/index.js: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | 3 | document.body.appendChild(document.createTextNode('this counts as SSR')); 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /test/fixtures/raw/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |Welcome to my styled page!
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/fixtures/external/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-left: 11em; 3 | font-family: "Times New Roman", times, serif; 4 | color: purple; 5 | background-color: #d8da3d; 6 | } 7 | ul.navbar { 8 | list-style-type: none; 9 | padding: 0; 10 | margin: 0; 11 | position: absolute; 12 | top: 2em; 13 | left: 1em; 14 | width: 9em; 15 | } 16 | h1 { 17 | font-family: helvetica, arial, sans-serif; 18 | } 19 | ul.navbar li { 20 | background: white; 21 | margin: 0.5em 0; 22 | padding: 0.3em; 23 | border-right: 1em solid black; 24 | } 25 | ul.navbar a { 26 | text-decoration: none; 27 | } 28 | a:link { 29 | color: blue; 30 | } 31 | a:visited { 32 | color: purple; 33 | } 34 | footer { 35 | margin-top: 1em; 36 | padding-top: 1em; 37 | border-top: thin dotted; 38 | } 39 | .extra-style { 40 | font-size: 200%; 41 | } 42 | 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over toWelcome to my styled page!
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /test/standalone.test.js: -------------------------------------------------------------------------------- 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 | 17 | import { compile, compileToHtml, readFile } from './_helpers'; 18 | 19 | function configure (config) { 20 | config.module.rules.push( 21 | { 22 | test: /\.css$/, 23 | loader: 'css-loader' 24 | }, 25 | { 26 | test: /\.html$/, 27 | loader: 'file-loader?name=[name].[ext]' 28 | } 29 | ); 30 | } 31 | 32 | test('webpack compilation', async () => { 33 | const info = await compile('fixtures/raw/index.js', configure); 34 | expect(info.assets).toHaveLength(2); 35 | expect(await readFile('fixtures/basic/dist/index.html')).toMatchSnapshot(); 36 | }); 37 | 38 | describe('Usage without html-webpack-plugin', () => { 39 | let output; 40 | beforeAll(async () => { 41 | output = await compileToHtml('raw', configure); 42 | }); 43 | 44 | it('should process the first html asset', () => { 45 | const { html, document } = output; 46 | expect(document.querySelectorAll('style')).toHaveLength(1); 47 | expect(document.getElementById('unused')).toBeNull(); 48 | expect(document.getElementById('used')).not.toBeNull(); 49 | expect(document.getElementById('used').textContent).toMatchSnapshot(); 50 | expect(html).toMatchSnapshot(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/__snapshots__/standalone.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Usage without html-webpack-plugin should process the first html asset 1`] = ` 4 | "h1 { 5 | color: green; 6 | }" 7 | `; 8 | 9 | exports[`Usage without html-webpack-plugin should process the first html asset 2`] = ` 10 | " 11 |Welcome to my styled page!
84 | 85 | 86 | 87 | " 88 | `; 89 | -------------------------------------------------------------------------------- /src/css.js: -------------------------------------------------------------------------------- 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 | 17 | import css from 'css'; 18 | 19 | /** 20 | * Parse a textual CSS Stylesheet into a Stylesheet instance. 21 | * Stylesheet is a mutable ReworkCSS AST with format similar to CSSOM. 22 | * @see https://github.com/reworkcss/css 23 | * @private 24 | * @param {String} stylesheet 25 | * @returns {css.Stylesheet} ast 26 | */ 27 | export function parseStylesheet (stylesheet) { 28 | return css.parse(stylesheet); 29 | } 30 | 31 | /** 32 | * Serialize a ReworkCSS Stylesheet to a String of CSS. 33 | * @private 34 | * @param {css.Stylesheet} ast A Stylesheet to serialize, such as one returned from `parseStylesheet()` 35 | * @param {Object} options Options to pass to `css.stringify()` 36 | * @param {Boolean} [options.compress] Compress CSS output (removes comments, whitespace, etc) 37 | */ 38 | export function serializeStylesheet (ast, options) { 39 | return css.stringify(ast, options); 40 | } 41 | 42 | /** 43 | * Recursively walk all rules in a stylesheet. 44 | * @private 45 | * @param {css.Rule} node A Stylesheet or Rule to descend into. 46 | * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node. 47 | */ 48 | export function walkStyleRules (node, iterator) { 49 | if (node.stylesheet) return walkStyleRules(node.stylesheet, iterator); 50 | 51 | node.rules = node.rules.filter(rule => { 52 | if (rule.rules) { 53 | walkStyleRules(rule, iterator); 54 | } 55 | return iterator(rule) !== false; 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "critters-webpack-plugin", 3 | "version": "1.0.0", 4 | "description": "Webpack plugin to inline critical CSS and lazy-load the rest.", 5 | "main": "dist/critters.js", 6 | "source": "src/index.js", 7 | "license": "Apache-2.0", 8 | "author": "The Chromium Authors", 9 | "contributors": [ 10 | { 11 | "name": "Jason Miller", 12 | "email": "developit@google.com" 13 | } 14 | ], 15 | "keywords": [ 16 | "critical css", 17 | "inline css", 18 | "critical", 19 | "critters", 20 | "webpack plugin", 21 | "performance" 22 | ], 23 | "repository": "GoogleChromeLabs/critters", 24 | "scripts": { 25 | "build": "microbundle -f cjs --no-compress --external all", 26 | "docs": "documentation readme -q --no-markdown-toc -a public -s Usage --sort-order alpha src", 27 | "test": "jest --coverage" 28 | }, 29 | "babel": { 30 | "presets": [ 31 | "env" 32 | ] 33 | }, 34 | "jest": { 35 | "testEnvironment": "jsdom", 36 | "coverageReporters": [ 37 | "text" 38 | ], 39 | "collectCoverageFrom": [ 40 | "src/**/*" 41 | ], 42 | "watchPathIgnorePatterns": [ 43 | "node_modules", 44 | "dist" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "babel-core": "^6.26.0", 49 | "babel-jest": "^22.4.3", 50 | "babel-preset-env": "^1.6.1", 51 | "css-loader": "^0.28.11", 52 | "documentation": "^6.3.2", 53 | "eslint": "^4.19.1", 54 | "eslint-config-standard": "^11.0.0", 55 | "eslint-plugin-import": "^2.11.0", 56 | "eslint-plugin-jest": "^21.15.1", 57 | "eslint-plugin-node": "^6.0.1", 58 | "eslint-plugin-promise": "^3.7.0", 59 | "eslint-plugin-standard": "^3.0.1", 60 | "file-loader": "^1.1.11", 61 | "html-webpack-plugin": "^3.2.0", 62 | "jest": "^22.4.3", 63 | "jsdom": "^11.9.0", 64 | "microbundle": "^0.4.4", 65 | "mini-css-extract-plugin": "^0.4.0", 66 | "webpack": "^4.6.0" 67 | }, 68 | "dependencies": { 69 | "css": "^2.2.1", 70 | "nwmatcher": "^1.4.4", 71 | "parse5": "^4.0.0", 72 | "pretty-bytes": "^4.0.2", 73 | "webpack-sources": "^1.1.0" 74 | }, 75 | "eslintConfig": { 76 | "extends": [ 77 | "standard", 78 | "plugin:jest/recommended" 79 | ], 80 | "rules": { 81 | "indent": [ 82 | 2, 83 | 2 84 | ], 85 | "semi": [ 86 | 2, 87 | "always" 88 | ], 89 | "prefer-const": 1 90 | }, 91 | "globals": { 92 | "document": 0, 93 | "DOMParser": 1 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/_helpers.js: -------------------------------------------------------------------------------- 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 | 17 | import { promisify } from 'util'; 18 | import fs from 'fs'; 19 | import path from 'path'; 20 | import webpack from 'webpack'; 21 | import Critters from '../src'; 22 | 23 | // parse a string into a JSDOM Document 24 | export const parseDom = html => new DOMParser().parseFromString(html, 'text/html'); 25 | 26 | // returns a promise resolving to the contents of a file 27 | export const readFile = file => promisify(fs.readFile)(path.resolve(__dirname, file), 'utf8'); 28 | 29 | // invoke webpack on a given entry module, optionally mutating the default configuration 30 | export function compile (entry, configDecorator) { 31 | return new Promise((resolve, reject) => { 32 | const context = path.dirname(path.resolve(__dirname, entry)); 33 | entry = path.basename(entry); 34 | let config = { 35 | context, 36 | entry: path.resolve(context, entry), 37 | output: { 38 | path: path.resolve(__dirname, path.resolve(context, 'dist')), 39 | filename: 'bundle.js', 40 | chunkFilename: '[name].chunk.js' 41 | }, 42 | resolveLoader: { 43 | modules: [path.resolve(__dirname, '../node_modules')] 44 | }, 45 | module: { 46 | rules: [] 47 | }, 48 | plugins: [] 49 | }; 50 | if (configDecorator) { 51 | config = configDecorator(config) || config; 52 | } 53 | webpack(config, (err, stats) => { 54 | if (err) return reject(err); 55 | const info = stats.toJson(); 56 | if (stats.hasErrors()) return reject(info.errors.join('\n')); 57 | resolve(info); 58 | }); 59 | }); 60 | } 61 | 62 | // invoke webpack via compile(), applying Critters to inline CSS and injecting `html` and `document` properties into the webpack build info. 63 | export async function compileToHtml (fixture, configDecorator, crittersOptions = {}) { 64 | const info = await compile(`fixtures/${fixture}/index.js`, config => { 65 | config = configDecorator(config) || config; 66 | config.plugins.push( 67 | new Critters({ 68 | compress: false, 69 | ...crittersOptions 70 | }) 71 | ); 72 | }); 73 | info.html = await readFile(`fixtures/${fixture}/dist/index.html`); 74 | info.document = parseDom(info.html); 75 | return info; 76 | } 77 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 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 | 17 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 18 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 19 | import { compile, compileToHtml, readFile } from './_helpers'; 20 | 21 | function configure (config) { 22 | config.module.rules.push({ 23 | test: /\.css$/, 24 | use: [ 25 | MiniCssExtractPlugin.loader, 26 | 'css-loader' 27 | ] 28 | }); 29 | 30 | config.plugins.push( 31 | new MiniCssExtractPlugin({ 32 | filename: '[name].css', 33 | chunkFilename: '[name].chunk.css' 34 | }), 35 | new HtmlWebpackPlugin({ 36 | filename: 'index.html', 37 | template: 'index.html', 38 | inject: true, 39 | compile: true 40 | }) 41 | ); 42 | } 43 | 44 | test('webpack compilation', async () => { 45 | const info = await compile('fixtures/basic/index.js', configure); 46 | expect(info.assets).toHaveLength(2); 47 | 48 | const html = await readFile('fixtures/basic/dist/index.html'); 49 | expect(html).toMatchSnapshot(); 50 | 51 | expect(html).toMatch(/\.extra-style/); 52 | }); 53 | 54 | describe('Inline 51 | 52 | 60 |Welcome to my styled page!
62 | 63 | 64 | 65 |