@michaelheerklotz/dr-css-inliner
Version:
Puppeteer script to inline above-the-fold CSS for a webpage.
711 lines (589 loc) • 14 kB
JavaScript
var debug = {
time: new Date(),
loadTime: null,
processingTime: null,
requests: [],
stripped: [],
errors: [],
cssLength: 0
};
var path = require('path');
var fs = require("fs");
var rw = require("rw");
var process = require("process");
var puppeteer = require("puppeteer");
var args = [].slice.call(process.argv, 2), arg;
var html, url, fakeUrl;
var value;
var width = 1200;
var height = 0;
var matchMQ;
var required;
var prefetch;
var cssOnly;
var cssId;
var cssToken;
var exposeStylesheets;
var stripResources;
var localStorage;
var outputDebug;
var outputPath;
var diskCacheDir;
var userAgent;
var ignoreHttpsErrors = false;
var browserTimeout = 30000;
var browserTimeoutHandle = null;
var noSandbox;
var noGpu;
var connect = null;
var scriptPath = __dirname + "/extractCSS.js";
if (args.length < 1) {
try {
stdout(fs.readFileSync(path.resolve(__dirname, 'README.md'), 'utf8')
.match(/## Usage:([\s\S]*?)##### Examples:/i)[1]
.replace(/\n```\n/g,'')
.replace(/#### Options:/,'\nOptions:')
.replace(/node index.js/g, 'dr-css-inliner'));
} catch (e) {
stderr('Off-line `dr-css-inliner` help is not available!');
}
return;
}
while (args.length) {
arg = args.shift();
switch (arg) {
case "-f":
case "--fake-url":
value = (args.length) ? args.shift() : "";
if (value) {
if (!value.match(/(\/|\?.*|\.[^.\/]+)$/)) {
value += "/";
}
fakeUrl = value;
}
else {
stderr("Expected string for '--fake-url' option");
return;
}
break;
case "-w":
case "--width":
value = (args.length) ? args.shift() : "";
if (value.match(/^\d+$/)) {
width = parseInt(value);
}
else {
stderr("Expected numeric value for '--width' option");
return;
}
break;
case "-h":
case "--height":
value = (args.length) ? args.shift() : "";
if (value.match(/^\d+$/)) {
height = parseInt(value);
}
else {
stderr("Expected numeric value for '--height' option");
return;
}
break;
case "-m":
case "--match-media-queries":
matchMQ = true;
break;
case "-r":
case "--required-selectors":
value = (args.length) ? args.shift() : "";
if (value) {
value = parseString(value);
if (typeof value == "string") {
value = value.split(/\s*,\s*/).map(function (string) {
return "(?:" + string.replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1') + ")";
}).join("|");
value = [value];
}
required = value;
}
else {
stderr("Expected a string for '--required-selectors' option");
return;
}
break;
case "-e":
case "--expose-stylesheets":
value = (args.length) ? args.shift() : "";
if (value) {
exposeStylesheets = ((value.indexOf(".") > -1) ? "" : "var ") + value;
}
else {
stderr("Expected a string for '--expose-stylesheets' option");
return;
}
break;
case "-p":
case "--prefetch":
prefetch = true;
break;
case "-t":
case "--insertion-token":
value = (args.length) ? args.shift() : "";
if (value) {
cssToken = parseString(value);
}
else {
stderr("Expected a string for '--insertion-token' option");
return;
}
break;
case "-i":
case "--css-id":
value = (args.length) ? args.shift() : "";
if (value) {
cssId = value;
}
else {
stderr("Expected a string for '--css-id' option");
return;
}
break;
case "-s":
case "--strip-resources":
value = (args.length) ? args.shift() : "";
if (value) {
value = parseString(value);
if (typeof value == "string") {
value = [value];
}
value = value.map(function (string) {
return new RegExp(string, "i");
});
stripResources = value;
}
else {
stderr("Expected a string for '--strip-resources' option");
return;
}
break;
case "-l":
case "--local-storage":
value = (args.length) ? args.shift() : "";
if (value) {
localStorage = parseString(value);
}
else {
stderr("Expected a string for '--local-storage' option");
return;
}
break;
case "-c":
case "--css-only":
cssOnly = true;
break;
case "-o":
case "--output":
value = (args.length) ? args.shift() : "";
if (value) {
outputPath = value;
}
else {
stderr("Expected a string for '--output' option");
return;
}
break;
case "-d":
case "--debug":
outputDebug = true;
break;
case "-dcd":
case "--disk-cache-dir":
value = (args.length) ? args.shift() : "";
if (value) {
diskCacheDir = value;
}
else {
stderr("Expected a string for '--disk-cache-dir' option");
return;
}
break;
case "-u":
case "--user-agent":
value = (args.length) ? args.shift() : "";
if (value) {
userAgent = value;
}
else {
stderr("Expected a string for '--user-agent' option");
return;
}
break;
case "-ihe":
case "--ignore-https-errors":
ignoreHttpsErrors = true;
break;
case "-b":
case "--browser-timeout":
value = (args.length) ? args.shift() : "";
if (value.match(/^\d+$/)) {
browserTimeout = parseInt(value);
}
else {
stderr("Expected numeric value for '--browser-timeout' option");
return;
}
break;
case "-ns":
case "--no-sandbox":
noSandbox = true;
break;
case "-ngpu":
case "--no-gpu":
noGpu = true;
break;
case "--connect":
value = (args.length) ? args.shift() : "";
if (value.match(/^\d+$/)) {
connect = parseInt(value);
}
else {
stderr("Expected numeric value for '--connect' option");
return;
}
break;
default:
if (!url && !arg.match(/^--?[a-z]/)) {
url = arg;
}
else {
stderr("Unknown option");
return;
}
break;
}
}
(async () => {
var launchOptions = {
ignoreHTTPSErrors: ignoreHttpsErrors,
headless: 'new',
args: [
'--disable-web-security'
]
};
if (diskCacheDir) {
launchOptions.args.push('--disk-cache-dir=' + diskCacheDir);
}
if (noSandbox) {
launchOptions.args.push('--no-sandbox');
launchOptions.args.push('--disable-setuid-sandbox');
}
if (noGpu) {
launchOptions.args.push('--disable-gpu');
}
var browser;
var page;
async function closePuppeteer() {
if (browser) {
const pages = await browser.pages();
for (const page of pages) {
await page.close().catch((e) => {
outputError("PUPPETEER ERROR", e);
});
}
if (connect) {
await browser.disconnect();
} else {
await browser.close().catch((e) => {
outputError("PUPPETEER ERROR", e);
});
}
browser = null;
}
if (browserTimeoutHandle) {
clearTimeout(browserTimeoutHandle);
browserTimeoutHandle = null;
}
}
try {
if (connect) {
browser = await puppeteer.connect({
ignoreHTTPSErrors: ignoreHttpsErrors,
browserURL: "http://localhost:" + connect
});
} else {
browser = await puppeteer.launch(launchOptions);
}
page = await browser.newPage();
if (userAgent) {
await page.setUserAgent(userAgent);
}
await page.setViewport({
width: width,
height: height || 800
});
if (stripResources) {
await page.setRequestInterception(true);
var baseUrl = url || fakeUrl;
page.on("request", request => {
var _url = request.url();
if (_url.indexOf(baseUrl) > -1) {
_url = _url.slice(baseUrl.length);
}
if (outputDebug && !_url.match(/^data/) && debug.requests.indexOf(_url) < 0) {
debug.requests.push(_url);
}
if (stripResources) {
var i = 0;
var l = stripResources.length;
// /http:\/\/.+?\.(jpg|png|svg|gif)$/gi
while (i < l) {
if (stripResources[i++].test(_url)) {
if (outputDebug) {
debug.stripped.push(_url);
}
request.abort();
return;
}
}
}
request.continue();
});
}
page.on("pageerror", function(err) {
outputError("PAGE ERROR", err);
});
page.on("error", function (err) {
outputError("PAGE ERROR", err);
});
if (!(await loadPage())) {
await closePuppeteer();
}
if (!(await injectCssExtractor())) {
await closePuppeteer();
}
}
catch (e) {
outputError("EXCEPTION", e);
try {
await closePuppeteer();
} catch (err) {}
}
return;
async function loadPage() {
if (url) {
debug.loadTime = new Date();
await page.goto(url);
return true;
}
else {
if (!fakeUrl) {
stderr("Missing \"fake-url\" option");
return false;
}
if (fs.existsSync("/dev/stdin")) {
html = rw.readFileSync("/dev/stdin", "utf-8");
} else {
function streamToString (stream) {
const chunks = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
stream.on('error', (err) => reject(err));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
html = await streamToString(process.stdin);
}
debug.loadTime = new Date();
await page.setRequestInterception(true);
if (diskCacheDir) {
await page.setCacheEnabled(true);
}
page.on("request", req => {
if (req.url() == fakeUrl) {
req.respond({
body: html
});
return;
}
req.continue();
});
await page.goto(fakeUrl);
return true;
}
}
async function injectCssExtractor() {
if (!html) {
html = await page.evaluate(function () {
var xhr = new XMLHttpRequest();
var html;
xhr.open("get", window.location.href, false);
xhr.onload = function () {
html = xhr.responseText;
};
xhr.send();
return html;
});
}
if(html.indexOf("stylesheet") === -1) {
return false;
}
debug.loadTime = new Date() - debug.loadTime;
var options = {};
if (matchMQ) {
options.matchMQ = true;
}
if (required) {
options.required = required;
}
if (localStorage) {
await page.evaluate(function (data) {
var storage = window.localStorage;
if (storage) {
for (var key in data) {
storage.setItem(key, data[key]);
}
}
}, localStorage);
}
if (Object.keys(options).length) {
await page.evaluate(function (options) {
window.extractCSSOptions = options;
}, options);
}
if (!height) {
var _height = await page.evaluate(function () {
return document.body.scrollHeight;
});
await page.setViewport({
width: width,
height: _height
});
}
await page.on("console", async msg => {
if (msg.args().length !== 2) {
return;
}
if (await msg.args()[0].jsonValue() !== "_extractedcss") {
return;
}
let response = await msg.args()[1].jsonValue();
await cssExtractorCallback(response);
await closePuppeteer();
});
if (!fs.lstatSync(scriptPath).isFile()) {
stderr("Unable to locate script at: " + scriptPath);
return false;
}
await page.addScriptTag({path: scriptPath});
if (browserTimeout) {
browserTimeoutHandle = setTimeout(async function () {
await closePuppeteer();
stderr("Browser timeout");
}, browserTimeout);
}
return true;
}
async function cssExtractorCallback(response) {
if (!response.css) {
stderr("Browser did not return any CSS");
return;
}
if ("css" in response) {
var result;
if (cssOnly) {
result = response.css;
}
else {
result = inlineCSS(response.css)
if (!result) {
return;
}
}
if (outputDebug) {
debug.cssLength = response.css.length;
debug.time = new Date() - debug.time;
debug.processingTime = debug.time - debug.loadTime;
result += "\n<!--\n\t" + JSON.stringify(debug) + "\n-->";
}
if (outputPath) {
fs.writeFileSync(outputPath, result);
}
else {
stdout(result);
}
}
else {
stdout(response);
}
};
})();
function inlineCSS(css) {
if (!css) {
return html;
}
var tokenAtFirstStylesheet = !cssToken; // auto-insert css if no cssToken has been specified.
var insertToken = function (m) {
var string = "";
if (tokenAtFirstStylesheet) {
tokenAtFirstStylesheet = false;
var whitespace = m.match(/^[^<]+/);
string = ((whitespace) ? whitespace[0] : "") + cssToken;
}
return string;
};
var links = [];
var stylesheets = [];
if (!cssToken) {
cssToken = "<!-- inline CSS insertion token -->";
}
html = html.replace(/[ \t]*<link [^>]*rel=["']?stylesheet["'][^>]*\/>[ \t]*(?:\n|\r\n)?/g, function (m) {
links.push(m);
return insertToken(m);
});
stylesheets = links.map(function (link) {
var urlMatch = link.match(/href="([^"]+)"/);
var mediaMatch = link.match(/media="([^"]+)"/);
var url = urlMatch && urlMatch[1];
var media = mediaMatch && mediaMatch[1];
return { url: url, media: media };
});
var index = html.indexOf(cssToken);
var length = cssToken.length;
if (index == -1) {
stderr("token not found:\n" + cssToken);
return false;
}
var replacement = "<style " + ((cssId) ? "id=\"" + cssId + "\" " : "") + "media=\"screen\">\n\t\t\t" + css + "\n\t\t</style>\n";
if (exposeStylesheets) {
replacement += "\t\t<script>\n\t\t\t" + exposeStylesheets + " = [" + stylesheets.map(function (link) {
return "{href:\"" + link.url + "\", media:\"" + link.media + "\"}";
}).join(",") + "];\n\t\t</script>\n";
}
if (prefetch) {
replacement += stylesheets.map(function (link) {
return "\t\t<link rel=\"prefetch\" href=\"" + link.url + "\" />\n";
}).join("");
}
return html.slice(0, index) + replacement + html.slice(index + length);
}
function outputError(context, msg) {
var error = msg.stack ? msg.stack : msg;
debug.errors.push(error);
stderr(context + ": " + error);
}
function stdout(message) {
process.stdout.write(message + "\n");
}
function stderr(message) {
process.stderr.write(message + "\n");
}
function parseString(value) {
if (value.match(/^(["']).*\1$/)) {
value = JSON.parse(value);
}
if (typeof value == "string") {
if (value.match(/^\{.*\}$/) || value.match(/^\[.*\]$/)) {
value = JSON.parse(value);
}
}
return value;
}