├── .gitignore ├── README.md ├── bin └── index.js ├── extractCSS.js ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | cache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | This repo is no longer maintained. 4 | 5 | If you still need what it provided you should take a look at this fork: 6 | 7 | https://github.com/MichaelRoosz/dr-css-inliner 8 | 9 | ---- 10 | 11 | 12 | dr-css-inliner 13 | ================ 14 | 15 | Puppeteer script to inline above-the-fold CSS on a webpage. 16 | 17 | Inlining CSS for above-the-fold content (and loading stylesheets in a non-blocking manner) will make pages render instantly. 18 | This script will help extract the CSS needed. 19 | 20 | As proposed by the Google Pagespeed team: 21 | [Optimizing the Critical Rendering Path for Instant Mobile Websites - Velocity SC - 2013](https://www.youtube.com/watch?v=YV1nKLWoARQ) 22 | 23 | ## How it works 24 | 25 | There are two ways of processing a webpage; loaded via the `url` argument or piped in through `stdin`. When using `stdin` it is required that you use the `--fake-url` option in conjunction. 26 | 27 | Once Puppeteer has loaded the page all stylesheets with no `media` set or with `media` set to `screen` or `all` are loaded again through XHR to avoid browser engine bias when parsing CSS. 28 | 29 | The CSS is inlined as per the supplied options and all stylesheets and style elements stripped from the webpage html. You can opt to expose the stripped stylesheets as an array in a script tag through the `-e` (`--expose-stylesheets`) option. 30 | 31 | ## Install 32 | 33 | ``` 34 | npm install dr-css-inliner -g 35 | ``` 36 | 37 | ## Usage: 38 | 39 | ``` 40 | dr-css-inliner [options] 41 | ``` 42 | 43 | #### Options: 44 | 45 | * `-o, --output [string]` - Write the output to a file. If omitted the output is written to `stdout`. 46 | * `-w, --width [value]` - Determines the width of the viewport. Defaults to 1200. 47 | * `-h, --height [value]` - Determines the above-the-fold height. Defaults to the actual document height. 48 | * `-m, --match-media-queries` - Omit media queries that don't match the defined width. 49 | * `-r, --required-selectors [string]` - Force inclusion of required selectors in the form of a comma-separated selector string or an array (as a JSON string) of regexp strings (remember to escape `.`, `[` and `]` etc). Defaults to no required selectors. 50 | * `-s, --strip-resources [string]` - Avoid loading resources (while extracting CSS) matching the string or array (as a JSON string) of strings turned into regexp pattern(s). Used to speed up execution of CSS inlining. Default is no stripping of resources. Warning: when stripping is used caching is not possible. 51 | * `-c, --css-only` - Output the raw required CSS without wrapping it in HTML. 52 | * `-e, --expose-stylesheets [string]` - A variable name (or property on a preexisting variable) to expose an array containing information about the stripped stylesheets in an inline script tag. 53 | * `-t, --insertion-token [string]` - A token (preferably an HTML comment) to control the exact insertion point of the inlined CSS. If omited default insertion is at the first encountered stylesheet. 54 | * `-i, --css-id [string]` - Determines the id attribute of the inline style tag. By default no id is added. 55 | * `-f, --fake-url [url]` - Defines a _fake_ url context. Required when piping in html through `stdin`. Default is null. 56 | * `-dcd, --disk-cache-dir [path]` - Redirect the chroumium cache folder to this path. 57 | * `-u, --user-agent [string]` - Set the user agent. 58 | * `-ihe, --ignore-https-errors` - Ignore HTTPS errors (for example invalid certificates). 59 | * `-b, --browser-timeout [value]` - Set the browser timeout in ms. Defaults to 30000 (30 seconds). 60 | * `-d, --debug` - Prints out an HTML comment in the bottom of the output that exposes some info: 61 | * `time` - The time in ms it took to run the script (not including the puppeteer process itself). 62 | * `loadTime` - The time in ms it took to load the webpage. 63 | * `processingTime` - The time in ms it took to process and return the CSS in the webpage. 64 | * `requests` - An array of urls of all requests made by the webpage. Useful for spotting resources to strip. 65 | * `stripped` - An array of urls of requests aborted by the `--strip-resources` option. 66 | * `errors` - An array of errors that ocurred on the page. 67 | * `cssLength` - The length of the inlined CSS in chars. 68 | 69 | ##### Examples: 70 | 71 | ###### CSS options 72 | 73 | Only inline the needed above-the-fold CSS for smaller devices: 74 | ``` 75 | dr-css-inliner http://www.mydomain.com/index.html -w 350 -h 480 -m -o index-mobile.html 76 | ``` 77 | 78 | Inline all needed CSS for the above-the-fold content on all devices (default 1200px and smaller): 79 | ``` 80 | dr-css-inliner http://www.mydomain.com/index.html -h 800 -o index-page-top.html 81 | ``` 82 | 83 | Inline all needed CSS for webpage: 84 | ``` 85 | dr-css-inliner http://www.mydomain.com/index.html -o index-full-page.html 86 | ``` 87 | 88 | Inline all needed CSS for webpage with extra required selectors: 89 | ``` 90 | dr-css-inliner http://www.mydomain.com/index.html -r ".foo > .bar, #myId" -o index-full-page.html 91 | ``` 92 | 93 | Inline all needed CSS for webpage with extra required regexp selector filters: 94 | ``` 95 | dr-css-inliner http://www.mydomain.com/index.html -r '["\\.foo > ", "\\.span-\\d+"]' -o index-full-page.html 96 | ``` 97 | 98 | ###### Output options 99 | 100 | The examples listed below use the following `index.css` and `index.html` samples (unless specified otherwise): 101 | 102 | index.css: 103 | 104 | ```css 105 | .foo { 106 | color: #BADA55; 107 | } 108 | .bar { 109 | color: goldenrod; 110 | } 111 | ``` 112 | 113 | index.html: 114 | 115 | ```html 116 | 117 | 118 | 119 | Foo 120 | 121 | 122 | 123 | 124 |

Inlining CSS is in

125 | 126 | 127 | ``` 128 | 129 | Doing: 130 | 131 | ``` 132 | dr-css-inliner index.html 133 | ``` 134 | 135 | ...would get you: 136 | 137 | ```html 138 | 139 | 140 | 141 | Foo 142 | 147 | 148 | 149 |

Inlining CSS is in

150 | 151 | 152 | ``` 153 | 154 | ###### Only output CSS 155 | 156 | `-c, --css-only` 157 | 158 | ``` 159 | dr-css-inliner index.html -c 160 | ``` 161 | 162 | ...would get you: 163 | 164 | ```css 165 | .foo { 166 | color: #BADA55; 167 | } 168 | ``` 169 | 170 | ###### Exposing the stripped stylesheets for later consumption 171 | 172 | `-e, --expose-stylesheets [string]` 173 | 174 | __Single global variable:__ 175 | 176 | ``` 177 | dr-css-inliner index.html -e stylesheets 178 | ``` 179 | 180 | ...would get you: 181 | 182 | ```html 183 | 184 | 185 | 186 | Foo 187 | 192 | 195 | 196 | 197 |

Inlining CSS is in

198 | 199 | 200 | ``` 201 | 202 | __Namespaced property:__ 203 | 204 | ``` 205 | dr-css-inliner index.html -e myNamespace.stylesheets 206 | ``` 207 | 208 | provided you had an `index.html` like: 209 | 210 | ```html 211 | 212 | 213 | 214 | Foo 215 | 218 | 219 | 220 | 221 | 222 |

Inlining CSS is in

223 | 224 | 225 | ``` 226 | 227 | ...would get you: 228 | 229 | ```html 230 | 231 | 232 | 233 | Foo 234 | 237 | 242 | 245 | 246 | 247 |

Inlining CSS is in

248 | 249 | 250 | ``` 251 | 252 | ###### Controlling where to insert the inlined CSS 253 | 254 | `-t, --insertion-token [string]` 255 | 256 | provided you had an `index.html` like: 257 | 258 | ```html 259 | 260 | 261 | 262 | Foo 263 | 264 | 267 | 268 | 269 | 270 | 271 |

Inlining CSS is in

272 | 273 | 274 | ``` 275 | 276 | ``` 277 | dr-css-inliner index.html -t "" 278 | ``` 279 | 280 | ...would get you: 281 | 282 | ```html 283 | 284 | 285 | 286 | Foo 287 | 292 | 295 | 296 | 297 |

Inlining CSS is in

298 | 299 | 300 | ``` 301 | 302 | ###### Avoid loading unneeded resources 303 | 304 | `-s, --strip-resources [string]` 305 | 306 | Doing: 307 | 308 | ``` 309 | dr-css-inliner index.html -s '["\\.(jpg|gif|png)$","webstat\\.js$"]' 310 | ``` 311 | 312 | ... would avoid loading images and a given web statistic script. 313 | 314 | ###### Debug info 315 | 316 | `-d, --debug` 317 | 318 | Doing: 319 | ``` 320 | dr-css-inliner index.html -d 321 | ``` 322 | 323 | ...would get you: 324 | 325 | ```html 326 | 327 | 328 | 329 | Foo 330 | 335 | 336 | 337 |

Inlining CSS is in

338 | 339 | 340 | 343 | ``` 344 | 345 | 346 | ###### Adding an id to the inlined style tag 347 | 348 | `-i, --css-id [string]` 349 | 350 | Doing: 351 | ``` 352 | dr-css-inliner index.html -i my-inline-css 353 | ``` 354 | 355 | ...would get you: 356 | 357 | ```html 358 | 359 | 360 | 361 | Foo 362 | 367 | 368 | 369 |

Inlining CSS is in

370 | 371 | 372 | ``` 373 | 374 | ###### Piping in HTML content through `stdin` 375 | 376 | `-f, --fake-url [string]` 377 | 378 | If you need to parse HTML that is not yet publicly available you can pipe it into `dr-css-inliner`. Below is a contrived example (in a real-world example imagine an httpfilter or similar in place of `cat`): 379 | 380 | ``` 381 | cat not-yet-public.html | dr-css-inliner -f http://www.mydomain.com/index.html 382 | ``` 383 | 384 | All loading of assets will be loaded relative to the _fake_ url - meaning they need to be available already. 385 | 386 | 387 | --- 388 | 389 | ## Changelog 390 | 391 | ### next 392 | 393 | * Ported to Puppeteer 394 | * Fixed first css selector being ignored when the css file starts with a @charset declaration. 395 | * `bin` config added to package.json. 396 | * `-ihe, --ignore-https-errors` option added. 397 | * `-b, --browser-timeout` option added. 398 | * `-dcd, --disk-cache-dir [path]` option added. 399 | * `-u, --user-agent [string]` option added. 400 | 401 | ### 0.6.0 402 | 403 | Features: 404 | 405 | * `-o, --output` option added. 406 | 407 | Changes: 408 | 409 | * Only errors in the inliner will halt execution. Remote script errors are ignored - but logged in `debug.errors`. 410 | * the `-x, --allow-cross-domain` option is deprecated and cross domain requests allowed by default. 411 | * `debug.requests` is now populated by default regardless of the `--strip-resources` option. 412 | 413 | ### 0.5.4 414 | 415 | `css-inliner` bin removed due to not working on unix. 416 | 417 | 418 | 419 | [![Analytics](https://ga-beacon.appspot.com/UA-8318361-2/drdk/dr-css-inliner)](https://github.com/igrigorik/ga-beacon) 420 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../'); 3 | -------------------------------------------------------------------------------- /extractCSS.js: -------------------------------------------------------------------------------- 1 | (function (global, doc) { 2 | 3 | var html = doc.documentElement; 4 | var width, height; 5 | var options = global.extractCSSOptions; 6 | var matchMQ, required; 7 | var stylesheets = []; 8 | var mediaStylesheets = []; 9 | var left; 10 | var isRunning; 11 | 12 | if (options) { 13 | if ("matchMQ" in options) { 14 | matchMQ = options.matchMQ; 15 | } 16 | if ("required" in options) { 17 | required = options.required; 18 | 19 | required = required.map(function (string) { 20 | return new RegExp(string, "i"); 21 | }); 22 | } 23 | } 24 | 25 | function init(force) { 26 | 27 | if (isRunning && !force) { 28 | return; 29 | } 30 | isRunning = true; 31 | 32 | var links = doc.querySelectorAll("link"); 33 | 34 | var cssLinksStillLoading = Array.prototype.slice.call(links).filter(function (link) { 35 | if (link.rel == "preload" && link.as == "style") { 36 | return true; 37 | } 38 | return false; 39 | }); 40 | 41 | if (cssLinksStillLoading.length > 0) { 42 | setTimeout(function () { 43 | init(true); 44 | }, 50); 45 | return; 46 | } 47 | 48 | width = html.offsetWidth; 49 | height = global.innerHeight; 50 | mediaStylesheets = Array.prototype.slice.call(doc.styleSheets).filter(function (stylesheet) { 51 | return (!stylesheet.media.length || stylesheet.media[0] == "screen" || stylesheet.media[0] == "all"); 52 | }); 53 | left = mediaStylesheets.slice(0); 54 | 55 | var base = global.location.href.replace(/\/[^/]+$/, "/"); 56 | var host = global.location.protocol + "//" + global.location.host; 57 | 58 | mediaStylesheets.forEach(function (stylesheet) { 59 | if (stylesheet.href) { 60 | fetchStylesheet(stylesheet.href, function (text) { 61 | var index = left.indexOf(stylesheet); 62 | if (index > -1) { 63 | left.splice(index, 1); 64 | } 65 | text = text.replace(/\/\*[\s\S]+?\*\//g, "").replace(/[\n\r]+/g, "").replace(/url\((["']|)(\.\.\/[^"'\(\)]+)\1\)/g, function (m, quote, url) { 66 | return "url(" + quote + pathRelativeToPage(base, stylesheet.href, url) + quote + ")"; 67 | }); 68 | text = text.replace(/@charset "[^"]*";/gm, ""); 69 | stylesheets.push(text); 70 | if (left.length === 0) { 71 | complete(); 72 | } 73 | }); 74 | } 75 | else { 76 | var index = left.indexOf(stylesheet); 77 | if (index > -1) { 78 | left.splice(index, 1); 79 | } 80 | if (left.length === 0) { 81 | complete(); 82 | } 83 | } 84 | }); 85 | 86 | function complete() { 87 | var elements = false; 88 | 89 | // if viewport height is forced 90 | // define elements to check seletors against 91 | if (html.offsetHeight != height) { 92 | elements = Array.prototype.slice.call(doc.getElementsByTagName("*")).filter(function (element) { 93 | return (element.getBoundingClientRect().top < height); 94 | }); 95 | } 96 | 97 | var CSS = stylesheets.map(function (css) { 98 | return outputRules(filterCSS(css, elements)); 99 | }).join(""); 100 | 101 | console.log('_extractedcss', {css: CSS }); 102 | 103 | isRunning = false; 104 | } 105 | 106 | } 107 | 108 | function outputRules(rules) { 109 | return rules.map(function (rule) { 110 | return rule.selectors.join(",") + "{" + rule.css + "}"; 111 | }).join(""); 112 | } 113 | 114 | function matchSelectors(selectors, elements) { 115 | 116 | return selectors.map(function (selector) { 117 | // strip comments 118 | return selector.replace(/\/\*[\s\S]+?\*\//g, "").replace(/(?:^\s+)|(?:\s+$)/g, ""); 119 | }).filter(function (selector) { 120 | 121 | if (!selector || selector.match(/@/)) { 122 | return false; 123 | } 124 | if (required) { 125 | var found = required.some(function (reg) { 126 | return reg.test(selector); 127 | }); 128 | if (found) { 129 | return true; 130 | } 131 | } 132 | if (selector.indexOf(":") > -1) { 133 | // strip pseudo-classes that may not be directly selectable or can change on userinteraction 134 | selector = selector.replace(/(?:::?)(?:after|before|link|visited|hover|active|focus|selection|checked|selected|optional|required|invalid|valid|in-range|read-only|read-write|target|(?:-[a-zA-Z-]+))\s*$/g, ""); 135 | } 136 | 137 | if (selector.length == 0) { 138 | return true; 139 | } 140 | 141 | var matches = []; 142 | 143 | try { 144 | 145 | matches = doc.querySelectorAll(selector); 146 | 147 | } 148 | catch(e){} 149 | 150 | var i = 0; 151 | var l = matches.length; 152 | 153 | if (l) { 154 | if (elements) { 155 | while (i < l) { 156 | if (elements.indexOf(matches[i++]) > -1) { 157 | return true; 158 | } 159 | } 160 | return false; 161 | } 162 | return true; 163 | } 164 | return false; 165 | }); 166 | } 167 | 168 | function filterCSS(css, elements) { 169 | 170 | var rules = parseRules(css), 171 | matchedRules = []; 172 | 173 | rules.forEach(function (rule) { 174 | var matchingSelectors = []; 175 | var atRuleMatch = rule.selectors[0].match(/^\s*(@[a-z\-]+)/); 176 | if (rule.selectors) { 177 | if (atRuleMatch) { 178 | 179 | switch (atRuleMatch[1]) { 180 | case "@font-face": 181 | matchingSelectors = ["@font-face"]; 182 | break; 183 | case "@media": 184 | var mq; 185 | if (matchMQ) { 186 | var widths = rule.selectors[0].match(/m(?:ax|in)-width:[^)]+/g); 187 | var pair; 188 | if (widths) { 189 | mq = {}; 190 | while (widths.length) { 191 | pair = widths.shift().split(/:\s?/); 192 | mq[pair[0]] = parseInt(pair[1]); 193 | } 194 | } 195 | } 196 | if (!matchMQ || !mq || ((!("min-width" in mq) || mq["min-width"] <= width) && (!("max-width" in mq) || mq["max-width"] >= width))) { 197 | var matchedSubRules = filterCSS(rule.css, elements); 198 | if (matchedSubRules.length) { 199 | matchingSelectors = rule.selectors; 200 | rule.css = outputRules(matchedSubRules); 201 | } 202 | } 203 | break; 204 | } 205 | 206 | } 207 | else { 208 | matchingSelectors = matchSelectors(rule.selectors, elements); 209 | } 210 | } 211 | if (matchingSelectors.length) { 212 | rule.selectors = matchingSelectors; 213 | matchedRules.push(rule); 214 | } 215 | }); 216 | 217 | return matchedRules; 218 | 219 | } 220 | 221 | function parseRules(css) { 222 | var matches = css.replace(/\n+/g, " ").match(/(?:[^{}]+\s*\{[^{}]+\})|(?:[^{}]+\{\s*(?:[^{}]+\{[^{}]+\})+\s*\})/g); 223 | var rules = []; 224 | if (matches) { 225 | matches.forEach(function (match) { 226 | var rule = parseRule(match); 227 | if (rule) { 228 | rules.push(rule); 229 | } 230 | }); 231 | } 232 | 233 | return rules; 234 | } 235 | 236 | function parseRule(rule) { 237 | var match = rule.match(/^\s*([^{}]+)\s*\{\s*((?:[^{}]+\{[^{}]+\})+|[^{}]+)\s*\}$/); 238 | return { 239 | selectors: match && match[1].split(/\s*,\s*/), 240 | css: match && match[2] 241 | }; 242 | } 243 | 244 | function pathRelativeToPage(basepath, csspath, sourcepath) { 245 | while (sourcepath.indexOf("../") === 0) { 246 | sourcepath = sourcepath.slice(3); 247 | csspath = csspath.replace(/\/[^/]+\/[^/]*$/, "/"); 248 | } 249 | var path = csspath + sourcepath; 250 | return (path.indexOf(basepath) === 0) ? path.slice(basepath.length) : path; 251 | } 252 | 253 | function fetchStylesheet(url, callback) { 254 | var xhr = new XMLHttpRequest(); 255 | 256 | xhr.open("GET", url, false); 257 | 258 | xhr.onload = function () { 259 | callback(xhr.responseText); 260 | }; 261 | 262 | xhr.send(null); 263 | 264 | return xhr; 265 | } 266 | 267 | if (doc.readyState != "complete") { 268 | global.addEventListener("load", function () { 269 | init(); 270 | }, false); 271 | } 272 | else { 273 | init(); 274 | } 275 | 276 | 277 | }(this, document)); 278 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | var debug = { 3 | time: new Date(), 4 | loadTime: null, 5 | processingTime: null, 6 | requests: [], 7 | stripped: [], 8 | errors: [], 9 | cssLength: 0 10 | }; 11 | 12 | var path = require('path'); 13 | var fs = require("fs"); 14 | var process = require("process"); 15 | var puppeteer = require("puppeteer"); 16 | 17 | var args = [].slice.call(process.argv, 2), arg; 18 | var html, url, fakeUrl; 19 | var value; 20 | var width = 1200; 21 | var height = 0; 22 | var matchMQ; 23 | var required; 24 | var prefetch; 25 | var cssOnly = false; 26 | var cssId; 27 | var cssToken; 28 | var exposeStylesheets; 29 | var stripResources; 30 | var localStorage; 31 | var outputDebug; 32 | var outputPath; 33 | var diskCacheDir; 34 | var userAgent; 35 | var ignoreHttpsErrors = false; 36 | var browserTimeout = 30000; 37 | var browserTimeoutHandle = null; 38 | var scriptPath = __dirname + "/extractCSS.js"; 39 | 40 | if (args.length < 1) { 41 | 42 | try { 43 | stdout(fs.readFileSync(path.resolve(__dirname, 'README.md'), 'utf8') 44 | .match(/## Usage:([\s\S]*?)##### Examples:/i)[1] 45 | .replace(/\n```\n/g,'') 46 | .replace(/#### Options:/,'\nOptions:') 47 | .replace(/node index.js/g, 'dr-css-inliner')); 48 | } catch (e) { 49 | stderr('Off-line `dr-css-inliner` help is not available!'); 50 | } 51 | 52 | return; 53 | } 54 | 55 | while (args.length) { 56 | arg = args.shift(); 57 | switch (arg) { 58 | 59 | case "-f": 60 | case "--fake-url": 61 | value = (args.length) ? args.shift() : ""; 62 | if (value) { 63 | if (!value.match(/(\/|\.[^./]+)$/)) { 64 | value += "/"; 65 | } 66 | fakeUrl = value; 67 | } 68 | else { 69 | stderr("Expected string for '--fake-url' option"); 70 | return; 71 | } 72 | break; 73 | 74 | case "-w": 75 | case "--width": 76 | value = (args.length) ? args.shift() : ""; 77 | if (value.match(/^\d+$/)) { 78 | width = parseInt(value); 79 | } 80 | else { 81 | stderr("Expected numeric value for '--width' option"); 82 | return; 83 | } 84 | break; 85 | 86 | case "-h": 87 | case "--height": 88 | value = (args.length) ? args.shift() : ""; 89 | if (value.match(/^\d+$/)) { 90 | height = parseInt(value); 91 | } 92 | else { 93 | stderr("Expected numeric value for '--height' option"); 94 | return; 95 | } 96 | break; 97 | 98 | case "-m": 99 | case "--match-media-queries": 100 | matchMQ = true; 101 | break; 102 | 103 | case "-r": 104 | case "--required-selectors": 105 | value = (args.length) ? args.shift() : ""; 106 | if (value) { 107 | value = parseString(value); 108 | if (typeof value == "string") { 109 | value = value.split(/\s*,\s*/).map(function (string) { 110 | return "(?:" + string.replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1') + ")"; 111 | }).join("|"); 112 | 113 | value = [value]; 114 | } 115 | 116 | required = value; 117 | } 118 | else { 119 | stderr("Expected a string for '--required-selectors' option"); 120 | return; 121 | } 122 | break; 123 | 124 | case "-e": 125 | case "--expose-stylesheets": 126 | value = (args.length) ? args.shift() : ""; 127 | if (value) { 128 | exposeStylesheets = ((value.indexOf(".") > -1) ? "" : "var ") + value; 129 | } 130 | else { 131 | stderr("Expected a string for '--expose-stylesheets' option"); 132 | return; 133 | } 134 | break; 135 | 136 | case "-p": 137 | case "--prefetch": 138 | prefetch = true; 139 | break; 140 | 141 | case "-t": 142 | case "--insertion-token": 143 | value = (args.length) ? args.shift() : ""; 144 | if (value) { 145 | cssToken = parseString(value); 146 | } 147 | else { 148 | stderr("Expected a string for '--insertion-token' option"); 149 | return; 150 | } 151 | break; 152 | 153 | case "-i": 154 | case "--css-id": 155 | value = (args.length) ? args.shift() : ""; 156 | if (value) { 157 | cssId = value; 158 | } 159 | else { 160 | stderr("Expected a string for '--css-id' option"); 161 | return; 162 | } 163 | break; 164 | 165 | case "-s": 166 | case "--strip-resources": 167 | value = (args.length) ? args.shift() : ""; 168 | if (value) { 169 | value = parseString(value); 170 | if (typeof value == "string") { 171 | value = [value]; 172 | } 173 | value = value.map(function (string) { 174 | return new RegExp(string, "i"); 175 | }); 176 | stripResources = value; 177 | } 178 | else { 179 | stderr("Expected a string for '--strip-resources' option"); 180 | return; 181 | } 182 | break; 183 | 184 | case "-l": 185 | case "--local-storage": 186 | value = (args.length) ? args.shift() : ""; 187 | if (value) { 188 | localStorage = parseString(value); 189 | } 190 | else { 191 | stderr("Expected a string for '--local-storage' option"); 192 | return; 193 | } 194 | break; 195 | 196 | case "-c": 197 | case "--css-only": 198 | cssOnly = true; 199 | break; 200 | 201 | case "-o": 202 | case "--output": 203 | value = (args.length) ? args.shift() : ""; 204 | if (value) { 205 | outputPath = value; 206 | } 207 | else { 208 | stderr("Expected a string for '--output' option"); 209 | return; 210 | } 211 | break; 212 | 213 | case "-d": 214 | case "--debug": 215 | outputDebug = true; 216 | break; 217 | 218 | case "-dcd": 219 | case "--disk-cache-dir": 220 | value = (args.length) ? args.shift() : ""; 221 | if (value) { 222 | diskCacheDir = value; 223 | } 224 | else { 225 | stderr("Expected a string for '--disk-cache-dir' option"); 226 | return; 227 | } 228 | break; 229 | 230 | case "-u": 231 | case "--user-agent": 232 | value = (args.length) ? args.shift() : ""; 233 | if (value) { 234 | userAgent = value; 235 | } 236 | else { 237 | stderr("Expected a string for '--user-agent' option"); 238 | return; 239 | } 240 | break; 241 | 242 | case "-ihe": 243 | case "--ignore-https-errors": 244 | ignoreHttpsErrors = true; 245 | break; 246 | 247 | case "-b": 248 | case "--browser-timeout": 249 | value = (args.length) ? args.shift() : ""; 250 | if (value.match(/^\d+$/)) { 251 | browserTimeout = parseInt(value); 252 | } 253 | else { 254 | stderr("Expected numeric value for '--browser-timeout' option"); 255 | return; 256 | } 257 | break; 258 | 259 | default: 260 | if (!url && !arg.match(/^--?[a-z]/)) { 261 | url = arg; 262 | } 263 | else { 264 | stderr("Unknown option"); 265 | return; 266 | } 267 | break; 268 | } 269 | 270 | } 271 | 272 | (async () => { 273 | 274 | var launchOptions = { 275 | ignoreHTTPSErrors: ignoreHttpsErrors, 276 | args: [ 277 | '--disable-web-security' 278 | ] 279 | }; 280 | 281 | if (diskCacheDir) { 282 | launchOptions.args.push('--disk-cache-dir=' + diskCacheDir); 283 | } 284 | 285 | var browser; 286 | var page; 287 | 288 | async function closePuppeteer() { 289 | 290 | if (page) { 291 | await page.close().catch((e) => { 292 | outputError("PUPPETEER ERROR", e); 293 | }); 294 | } 295 | 296 | if (browser) { 297 | await browser.close().catch((e) => { 298 | outputError("PUPPETEER ERROR", e); 299 | }); 300 | } 301 | 302 | if (browserTimeoutHandle) { 303 | clearTimeout(browserTimeoutHandle); 304 | } 305 | } 306 | 307 | try { 308 | browser = await puppeteer.launch(launchOptions); 309 | page = await browser.newPage(); 310 | 311 | if (userAgent) { 312 | await page.setUserAgent(userAgent); 313 | } 314 | 315 | await page.setViewport({ 316 | width: width, 317 | height: height || 800 318 | }); 319 | 320 | if (stripResources) { 321 | await page.setRequestInterception(true); 322 | 323 | var baseUrl = url || fakeUrl; 324 | 325 | page.on("request", request => { 326 | 327 | var _url = request.url(); 328 | 329 | if (_url.indexOf(baseUrl) > -1) { 330 | _url = _url.slice(baseUrl.length); 331 | } 332 | 333 | if (outputDebug && !_url.match(/^data/) && debug.requests.indexOf(_url) < 0) { 334 | debug.requests.push(_url); 335 | } 336 | 337 | if (stripResources) { 338 | var i = 0; 339 | var l = stripResources.length; 340 | // /http:\/\/.+?\.(jpg|png|svg|gif)$/gi 341 | while (i < l) { 342 | if (stripResources[i++].test(_url)) { 343 | if (outputDebug) { 344 | debug.stripped.push(_url); 345 | } 346 | request.abort(); 347 | return; 348 | } 349 | } 350 | } 351 | 352 | request.continue(); 353 | }); 354 | } 355 | 356 | page.on("pageerror", function(err) { 357 | outputError("PAGE ERROR", err); 358 | }); 359 | 360 | page.on("error", function (err) { 361 | outputError("PAGE ERROR", err); 362 | }); 363 | 364 | if(!await loadPage() || !await injectCssExtractor()) { 365 | await closePuppeteer(); 366 | } 367 | 368 | } 369 | catch (e) { 370 | outputError("EXCEPTION", e); 371 | try { 372 | await closePuppeteer(); 373 | } catch (err) {} 374 | } 375 | 376 | return; 377 | 378 | async function loadPage() { 379 | 380 | if (url) { 381 | 382 | debug.loadTime = new Date(); 383 | 384 | await page.goto(url); 385 | 386 | return true; 387 | } 388 | else { 389 | 390 | if (!fakeUrl) { 391 | stderr("Missing \"fake-url\" option"); 392 | return false; 393 | } 394 | 395 | html = fs.readFileSync(0, "utf-8"); 396 | 397 | debug.loadTime = new Date(); 398 | 399 | await page.setRequestInterception(true); 400 | 401 | page.once("request", req => { 402 | req.respond({ 403 | body: "Empty page
Empty page
" 404 | }); 405 | }); 406 | 407 | await page.goto(fakeUrl); 408 | 409 | if (!stripResources) { 410 | // disable request interception to allow caching 411 | await page.setRequestInterception(false); 412 | } 413 | 414 | if (diskCacheDir) { 415 | await page.setCacheEnabled(true); 416 | } 417 | 418 | await page.setContent(html); 419 | 420 | return true; 421 | } 422 | } 423 | 424 | async function injectCssExtractor() { 425 | 426 | if (!html) { 427 | html = await page.evaluate(function () { 428 | var xhr = new XMLHttpRequest(); 429 | var html; 430 | xhr.open("get", window.location.href, false); 431 | xhr.onload = function () { 432 | html = xhr.responseText; 433 | }; 434 | xhr.send(); 435 | return html; 436 | }); 437 | } 438 | 439 | if(html.indexOf("stylesheet") === -1) { 440 | return false; 441 | } 442 | 443 | debug.loadTime = new Date() - debug.loadTime; 444 | 445 | var options = {}; 446 | 447 | if (matchMQ) { 448 | options.matchMQ = true; 449 | } 450 | 451 | if (required) { 452 | options.required = required; 453 | } 454 | 455 | if (localStorage) { 456 | await page.evaluate(function (data) { 457 | var storage = window.localStorage; 458 | if (storage) { 459 | for (var key in data) { 460 | storage.setItem(key, data[key]); 461 | } 462 | } 463 | }, localStorage); 464 | } 465 | 466 | if (Object.keys(options).length) { 467 | await page.evaluate(function (options) { 468 | window.extractCSSOptions = options; 469 | }, options); 470 | } 471 | 472 | if (!height) { 473 | var _height = await page.evaluate(function () { 474 | return document.body.offsetHeight; 475 | }); 476 | await page.setViewport({ 477 | width: width, 478 | height: _height 479 | }); 480 | } 481 | 482 | await page.on("console", async msg => { 483 | 484 | if (msg.args().length !== 2) { 485 | return; 486 | } 487 | 488 | if (await msg.args()[0].jsonValue() !== "_extractedcss") { 489 | return; 490 | } 491 | 492 | let response = await msg.args()[1].jsonValue(); 493 | 494 | await closePuppeteer(); 495 | 496 | await cssExtractorCallback(response); 497 | }); 498 | 499 | if (!fs.lstatSync(scriptPath).isFile()) { 500 | stderr("Unable to locate script at: " + scriptPath); 501 | return false; 502 | } 503 | await page.addScriptTag({path: scriptPath}); 504 | 505 | if (browserTimeout) { 506 | browserTimeoutHandle = setTimeout(async function () { 507 | await closePuppeteer(); 508 | stderr("Browser timeout"); 509 | }, browserTimeout); 510 | } 511 | 512 | return true; 513 | } 514 | 515 | async function cssExtractorCallback(response) { 516 | 517 | if (!response.css) { 518 | stderr("Browser did not return any CSS"); 519 | return; 520 | } 521 | 522 | if ("css" in response) { 523 | var result; 524 | if (cssOnly) { 525 | result = response.css; 526 | } 527 | else { 528 | result = inlineCSS(response.css) 529 | if (!result) { 530 | return; 531 | } 532 | } 533 | if (outputDebug) { 534 | debug.cssLength = response.css.length; 535 | debug.time = new Date() - debug.time; 536 | debug.processingTime = debug.time - debug.loadTime; 537 | result += "\n"; 538 | } 539 | if (outputPath) { 540 | fs.writeFileSync(outputPath, result); 541 | } 542 | else { 543 | stdout(result); 544 | } 545 | } 546 | else { 547 | stdout(response); 548 | } 549 | }; 550 | 551 | })(); 552 | 553 | function inlineCSS(css) { 554 | 555 | if (!css) { 556 | return html; 557 | } 558 | 559 | var tokenAtFirstStylesheet = !cssToken; // auto-insert css if no cssToken has been specified. 560 | var insertToken = function (m) { 561 | var string = ""; 562 | if (tokenAtFirstStylesheet) { 563 | tokenAtFirstStylesheet = false; 564 | var whitespace = m.match(/^[^<]+/); 565 | string = ((whitespace) ? whitespace[0] : "") + cssToken; 566 | } 567 | return string; 568 | }; 569 | var links = []; 570 | var stylesheets = []; 571 | 572 | if (!cssToken) { 573 | cssToken = ""; 574 | } 575 | 576 | html = html.replace(/[ \t]*]*rel=["']?stylesheet["'][^>]*\/>[ \t]*(?:\n|\r\n)?/g, function (m) { 577 | links.push(m); 578 | return insertToken(m); 579 | }); 580 | 581 | stylesheets = links.map(function (link) { 582 | var urlMatch = link.match(/href="([^"]+)"/); 583 | var mediaMatch = link.match(/media="([^"]+)"/); 584 | var url = urlMatch && urlMatch[1]; 585 | var media = mediaMatch && mediaMatch[1]; 586 | 587 | return { url: url, media: media }; 588 | }); 589 | 590 | var index = html.indexOf(cssToken); 591 | var length = cssToken.length; 592 | 593 | if (index == -1) { 594 | stderr("token not found:\n" + cssToken); 595 | return false; 596 | } 597 | 598 | var replacement = "\n"; 599 | 600 | if (exposeStylesheets) { 601 | replacement += "\t\t\n"; 604 | } 605 | 606 | if (prefetch) { 607 | replacement += stylesheets.map(function (link) { 608 | return "\t\t\n"; 609 | }).join(""); 610 | } 611 | 612 | return html.slice(0, index) + replacement + html.slice(index + length); 613 | 614 | } 615 | 616 | function outputError(context, msg) { 617 | 618 | var error = msg.stack ? msg.stack : msg; 619 | 620 | debug.errors.push(error); 621 | stderr(context + ": " + error); 622 | } 623 | 624 | function stdout(message) { 625 | process.stdout.write(message + "\n"); 626 | } 627 | 628 | function stderr(message) { 629 | process.stderr.write(message + "\n"); 630 | } 631 | 632 | function parseString(value) { 633 | if (value.match(/^(["']).*\1$/)) { 634 | value = JSON.parse(value); 635 | } 636 | if (typeof value == "string") { 637 | if (value.match(/^\{.*\}$/) || value.match(/^\[.*\]$/)) { 638 | value = JSON.parse(value); 639 | } 640 | } 641 | return value; 642 | } 643 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dr-css-inliner", 3 | "version": "0.6.0", 4 | "description": "Puppeteer script to inline above-the-fold CSS for a webpage.", 5 | "main": "index.js", 6 | "bin": { 7 | "dr-css-inliner": "bin/index.js" 8 | }, 9 | "dependencies": { 10 | "puppeteer": "^2.0.0" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/drdk/dr-css-inliner.git" 15 | }, 16 | "keywords": [ 17 | "puppeteer", 18 | "above-the-fold", 19 | "css" 20 | ], 21 | "author": "rasmusfl0e", 22 | "license": "BSD-2-Clause", 23 | "bugs": { 24 | "url": "https://github.com/drdk/dr-css-inliner/issues" 25 | } 26 | } 27 | --------------------------------------------------------------------------------