Merge pull request #51 from remy/feature/refactor

Full refactor across to cheerio
This commit is contained in:
Remy Sharp 2015-07-27 22:25:20 +01:00
commit 1eb1238c94
36 changed files with 913 additions and 716 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "node-compress"]
path = node-compress
url = git://github.com/kkaefer/node-compress.git

8
.jscsrc Normal file
View File

@ -0,0 +1,8 @@
{
"preset": "node-style-guide",
"requireCapitalizedComments": null,
"requireSpacesInAnonymousFunctionExpression": {
"beforeOpeningCurlyBrace": true
},
"excludeFiles": ["node_modules/**"]
}

16
.jshintrc Normal file
View File

@ -0,0 +1,16 @@
{
"browser": true,
"camelcase": true,
"curly": true,
"devel": true,
"eqeqeq": true,
"forin": true,
"indent": 2,
"noarg": true,
"node": true,
"quotmark": "single",
"undef": true,
"strict": false,
"unused": true
}

16
.travis.yml Normal file
View File

@ -0,0 +1,16 @@
language: node_js
node_js:
- "0.12"
- "0.11"
- "0.10"
- "0.8"
before_install:
- npm install -g npm
before_script:
- npm install
notifications:
email: false
sudo: false
cache:
directories:
- node_modules

View File

@ -14,13 +14,8 @@ Turns your web page to a single HTML file with everything inlined - perfect for
Check out a working copy of the source code with [Git](http://git-scm.com), or install `inliner` via [npm](http://npmjs.org) (the recommended way). The latter will also install `inliner` into the system's `bin` path.
$ npm install inliner -g
Or
$ git clone https://github.com/remy/inliner.git
`inliner` uses a `package.json` to describe the dependancies, and if you install via a github clone, ensure you run `npm install` from the `inliner` directory to install the dependancies (or manually install [jsdom](https://github.com/tmpvar/jsdom "tmpvar/jsdom - GitHub") and [uglify-js](https://github.com/mishoo/UglifyJS "mishoo/UglifyJS - GitHub")).
$ npm install -g inliner
## Usage
@ -40,16 +35,16 @@ To use inline inside your own script:
// compressed and inlined HTML page
console.log(html);
});
Or:
var inliner = new Inliner('http://remysharp.com');
inliner.on('progress', function (event) {
console.error(event);
}).on('end', function (html) {
// compressed and inlined HTML page
console.log(html);
console.log(html);
});
Note that if you include the inliner script via a git submodule, it requires jsdom & uglifyjs to be installed via `npm install jsdom uglify-js`, otherwise you should be good to run.
@ -71,4 +66,3 @@ Once you've inlined the crap out of the page, add the `manifest="self.appcache"`
## Limitations / Caveats
- Whitespace compression might get a little heavy handed - all whitespace is collapsed from n spaces to one space.

View File

@ -1,108 +0,0 @@
#!/usr/bin/env node
var path = require('path'),
fs = require('fs'),
querystring = require('querystring'),
util = require('util');
fs.realpath(__filename, function(error, script) {
var servedir, root, port;
if (error) throw error;
var Inliner = require(path.join(path.dirname(script), '../inliner')),
program = require('commander');
program
.version(Inliner.version)
.usage('[options] http://yoursite.com')
.option('-v, --verbose', 'echo on STDERR the progress of inlining')
.option('-n, --nocompress', "don't compress CSS or HTML - useful for debugging")
.option('-i, --images', "don't encode images - keeps files size small, but more requests")
//.option('-s, --share', "create jsbin.com url for HTML page")
//.option('-h, --help', "help - you're looking at it");
program.on('--help', function () {
console.log(' Examples:');
console.log('');
console.log(' $ inliner -v http://twitter.com > twitter.html');
console.log(' $ inliner -ni http://twitter.com > twitter.html');
console.log('');
console.log(' For more details see http://github.com/remy/inliner/');
console.log('');
});
program.parse(process.argv);
var options = Inliner.defaults(),
verbose = false,
share = false;
if (program.nocompress) {
options.compressCSS = false;
options.collapseWhitespace = false;
}
if (program.share) {
share = true;
// because JS Bin is a damn sight easier to debug when it's not all on one line
options.compressCSS = false;
options.collapseWhitespace = false;
}
options.images = !program.images;
verbose = program.verbose;
if (program.args.length == 0) {
// ripped out of commander.js - should really be process.usage()
process.stdout.write(program.helpInformation());
program.emit('--help');
process.exit(0);
}
var url = program.args[0];
if (fs.existsSync(url)) {
// then it's a file
} else if (url.indexOf('http') !== 0) {
url = 'http://' + url;
}
var inliner = new Inliner(url, options, function (html) {
if (share) {
// post to jsbin
var data = querystring.stringify({
html: html,
javascript: '',
format: 'plain',
method: 'save'
});
// note: when making a POST request using node, for PHP to pick it up, the content-type is crutial - I never knew that :(
var request = Inliner.makeRequest('http://jsbin.com/save', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': data.length, 'X-Requested-With' : 'XMLHttpRequest' }
});
request.on('response', function (res) {
var body = '';
res.on('data', function (chunk) {
body += chunk;
});
res.on('end', function () {
util.print(body);
});
});
request.write(data);
request.end();
} else {
// using util.print because console.log evalutes sprintf commands - which we don't want to do
util.print(html);
}
});
if (verbose) {
inliner.on('progress', function (event) {
console.error(event);
});
}
});

92
cli/index.js Executable file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env node
var alias = {
V: 'version',
h: 'help',
d: 'debug',
v: 'verbose',
i: 'images',
n: 'nocompress',
};
var argv = process.argv.slice(2).reduce(function reduce(acc, arg) {
if (arg.indexOf('-') === 0) {
arg = arg.slice(1);
if (alias[arg] !== undefined) {
acc[alias[arg]] = true;
} else if (arg.indexOf('-') === 0) {
acc[arg.slice(1)] = true;
} else {
acc[arg] = true;
}
} else {
acc._.push(arg);
}
return acc;
}, { _: [] });
if (argv.debug) {
require('debug').enable('inliner');
}
// checks for available update and returns an instance
var updateNotifier = require('update-notifier');
var pkg = require(__dirname + '/../package.json');
var notifier = updateNotifier({ pkg: pkg });
if (notifier.update) {
// notify using the built-in convenience method
notifier.notify();
}
var Inliner = require('../');
var url = argv._.shift();
var argvKeys = Object.keys(argv).filter(function filter(item) {
return item !== '_';
});
if (!url && argvKeys.length === 0 || argv.help) {
// show USAGE!
console.log(require('fs').readFileSync(__dirname + '/../docs/usage.txt', 'utf8'));
process.exit(0);
}
if (argv.version) {
console.log(Inliner.version);
}
var options = Inliner.defaults();
if (argv.nocompress) {
options.compressCSS = false;
options.collapseWhitespace = false;
}
options.images = !argv.images;
var inliner = new Inliner(url, argv, function result(error, html) {
if (error) {
var message = Inliner.errors[error.code] || error.message;
console.error(message);
if (argv.debug) {
console.error(error.stack);
}
process.exit(1);
}
console.log(html);
});
if (argv.verbose) {
inliner.on('progress', function progress(event) {
console.error(event);
});
inliner.on('jobs', function jobs(event) {
console.error(event);
});
}

19
docs/usage.txt Normal file
View File

@ -0,0 +1,19 @@
Usage:
$ inliner [options] url-or-filename
Options:
-h, --help output usage information
-V, --version output the version number
-v, --verbose echo on STDERR the progress of inlining
-n, --nocompress don't compress CSS or HTML - useful for debugging
-i, --images don't encode images - keeps files size small, but more requests
Examples:
$ inliner -v https://twitter.com > twitter.html
$ inliner -ni local-file.html > local-file.min.html
For more details see http://github.com/remy/inliner/

View File

@ -1,566 +0,0 @@
var URL = require('url'),
util = require('util'),
jsmin = require('./jsmin'),
events = require('events'),
Buffer = require('buffer').Buffer,
fs = require('fs'),
path = require('path'),
jsdom = require('jsdom'),
uglifyjs = require('uglify-js'),
jsp = uglifyjs.parser,
pro = uglifyjs.uglify,
compress = null, // import only when required - might make the command line tool easier to use
http = {
http: require('http'),
https: require('https')
};
function compressCSS(css) {
return css
.replace(/\s+/g, ' ')
.replace(/:\s+/g, ':')
.replace(/\/\*.*?\*\//g, '')
.replace(/\} /g, '}')
.replace(/ \{/g, '{')
// .replace(/\{ /g, '{')
.replace(/; /g, ';')
.replace(/\n+/g, '');
}
function removeComments(element) {
if (!element || !element.childNodes) return;
var nodes = element.childNodes,
i = nodes.length;
while (i--) {
if (nodes[i].nodeName === '#comment' && nodes[i].nodeValue.indexOf('[') !== 0) {
element.removeChild(nodes[i]);
}
removeComments(nodes[i]);
}
}
function Inliner(url, options, callback) {
var root = url,
inliner = this;
this.requestCache = {};
this.requestCachePending = {};
// inherit EventEmitter so that we can send events with progress
events.EventEmitter.call(this);
if (typeof options == 'function') {
callback = options;
options = Inliner.defaults();
} else if (options === undefined) {
options = Inliner.defaults();
}
inliner.options = options;
inliner.total = 1;
inliner.todo = 1;
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
inliner.on('error', function (data) {
console.error(data + ' :: ' + url);
});
inliner.get(url, function (html) {
inliner.todo--;
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
// workaround for https://github.com/tmpvar/jsdom/issues/172
// need to remove empty script tags as it casues jsdom to skip the env callback
html = html.replace(/<\script(:? type=['"|].*?['"|])><\/script>/ig, '');
if (!html) {
inliner.emit('end', '');
callback && callback('');
} else {
// BIG ASS PROTECTIVE TRY/CATCH - mostly because of this: https://github.com/tmpvar/jsdom/issues/319
try {
jsdom.env(html, [
'http://code.jquery.com/jquery.min.js'
], {
url: url
}, function(errors, window) {
// remove jQuery that was included with jsdom
window.$('script:last').remove();
var todo = { scripts: true, images: inliner.options.images, links: true, styles: true },
assets = {
scripts: window.$('script'),
images: window.$('img').filter(function(){ return this.src.indexOf('data:') == -1; }),
links: window.$('link[rel=stylesheet]'),
styles: window.$('style')
},
breakdown = {},
images = {};
inliner.total = 1;
for (var key in todo) {
if (todo[key] === true && assets[key]) {
breakdown[key] = assets[key].length;
inliner.total += assets[key].length;
inliner.todo += assets[key].length;
} else {
assets[key] = [];
}
}
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
function finished() {
var items = 0,
html = '';
for (var key in breakdown) {
items += breakdown[key];
}
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
if (items === 0) {
// manually remove the comments
var els = removeComments(window.document.documentElement);
// collapse the white space
if (inliner.options.collapseWhitespace) {
// TODO put white space helper back in
window.$('pre').html(function (i, html) {
return html.replace(/\n/g, '~~nl~~'); //.replace(/\s/g, '~~s~~');
});
window.$('textarea').val(function (i, v) {
return v.replace(/\n/g, '~~nl~~').replace(/\s/g, '~~s~~');
});
html = window.document.innerHTML;
html = html.replace(/\s+/g, ' ').replace(/~~nl~~/g, '\n').replace(/~~s~~/g, ' ');
} else {
html = window.document.innerHTML;
}
html = '<!DOCTYPE html>' + html;
callback && callback(html);
inliner.emit('end', html);
} else if (items < 0) {
console.log('something went wrong on finish');
console.dir(breakdown);
}
}
todo.images && assets.images.each(function () {
var img = this,
resolvedURL = URL.resolve(url, img.src);
inliner.get(resolvedURL, { encode: true }, function (dataurl) {
if (dataurl) images[img.src] = dataurl;
img.src = dataurl;
breakdown.images--;
inliner.todo--;
finished();
});
});
todo.styles && assets.styles.each(function () {
var style = this;
inliner.getImportCSS(root, this.innerHTML, function (css, url) {
inliner.getImagesFromCSS(url, css, function (css) {
if (inliner.options.compressCSS) inliner.emit('progress', 'compress inline css');
window.$(style).text(css);
breakdown.styles--;
inliner.todo--;
finished();
});
});
});
todo.links && assets.links.each(function () {
var link = this,
linkURL = URL.resolve(url, link.href);
inliner.get(linkURL, function (css) {
inliner.getImagesFromCSS(linkURL, css, function (css) {
inliner.getImportCSS(linkURL, css, function (css) {
if (inliner.options.compressCSS) inliner.emit('progress', 'compress ' + linkURL);
breakdown.links--;
inliner.todo--;
var style = '',
media = link.getAttribute('media');
if (media) {
style = '<style>@media ' + media + '{' + css + '}</style>';
} else {
style = '<style>' + css + '</style>';
}
window.$(link).replaceWith(style);
finished();
});
});
});
});
function scriptsFinished() {
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
if (breakdown.scripts == 0) {
// now compress the source JavaScript
assets.scripts.each(function () {
if (this.innerHTML.trim().length == 0) {
// this is an empty script, so throw it away
inliner.todo--;
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
return;
}
var $script = window.$(this),
src = $script.attr('src'),
// note: not using .innerHTML as this coerses & => &amp;
orig_code = this.firstChild.nodeValue
.replace(/<\/script>/gi, '<\\/script>'),
final_code = '';
// only remove the src if we have a script body
if (orig_code) {
$script.removeAttr('src');
}
// don't compress already minified code
if(!(/\bmin\b/).test(src) && !(/google-analytics/).test(src)) {
inliner.todo++;
inliner.total++;
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
try {
var ast = jsp.parse(orig_code); // parse code and get the initial AST
ast = pro.ast_mangle(ast); // get a new AST with mangled names
ast = pro.ast_squeeze(ast); // get an AST with compression optimizations
final_code = pro.gen_code(ast);
// some protection against putting script tags in the body
window.$(this).text(final_code).append('\n');
if (src) {
inliner.emit('progress', 'compress ' + URL.resolve(root, src));
} else {
inliner.emit('progress', 'compress inline script');
}
} catch (e) {
// console.error(orig_code.indexOf('script>script'));
// window.$(this).html(jsmin('', orig_code, 2));
console.error('exception on ', src);
console.error('exception in ' + src + ': ' + e.message);
console.error('>>>>>> ' + orig_code.split('\n')[e.line - 1]);
}
inliner.todo--;
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
} else if (orig_code) {
window.$(this).text(orig_code);
// this.innerText = orig_code;
}
});
finished();
}
}
// basically this is the jQuery instance we tacked on to the request,
// but we're just being extra sure before we do zap it out
todo.scripts && assets.scripts.each(function () {
var $script = window.$(this),
scriptURL = URL.resolve(url, (this.src||"").toString());
if (!this.src || scriptURL.indexOf('google-analytics.com') !== -1) { // ignore google
breakdown.scripts--;
inliner.todo--;
scriptsFinished();
} else if (this.src) {
inliner.get(scriptURL, { not: 'text/html' }, function (data) {
// catches an exception that was being thrown, but script escaping wasn't being caught
if (data) $script.text(data.replace(/<\/script>/gi, '<\\/script>')); //.replace(/\/\/.*$\n/g, ''));
// $script.before('<!-- ' + scriptURL + ' -->');
breakdown.scripts--;
inliner.todo--;
scriptsFinished();
});
}
});
// edge case - if there's no images, nor scripts, nor links - we call finished manually
if (assets.links.length == 0 &&
assets.styles.length == 0 &&
assets.images.length == 0 &&
assets.scripts.length == 0) {
finished();
}
/** Inliner jobs:
* 1. get all inline images and base64 encode
* 2. get all external style sheets and move to inline
* 3. get all image references in CSS and base64 encode and replace urls
* 4. get all external scripts and move to inline
* 5. compress JavaScript
* 6. compress CSS & support media queries
* 7. compress HTML (/>\s+</g, '> <');
*
* FUTURE ITEMS:
* - support for @import
* - javascript validation - i.e. not throwing errors
*/
});
} catch (e) {
inliner.emit('error', 'Fatal error parsing HTML - exiting');
process.exit(1);
}
}
});
}
util.inherits(Inliner, events.EventEmitter);
Inliner.version = Inliner.prototype.version = JSON.parse(require('fs').readFileSync(__dirname + '/package.json').toString()).version;
Inliner.prototype.get = function (url, options, callback) {
// support no options being passed in
if (typeof options == 'function') {
callback = options;
options = {};
}
// if we've cached this request in the past, just return the cached content
if (this.requestCache[url] !== undefined) {
this.emit('progress', 'cached ' + url);
return callback && callback(this.requestCache[url]);
} else if (this.requestCachePending[url] !== undefined) {
this.requestCachePending[url].push(callback);
return true;
} else {
this.requestCachePending[url] = [callback];
}
var inliner = this;
// TODO remove the sync
if (fs.existsSync(url)) {
// then we're dealing with a file
fs.readFile(url, 'utf8', function (err, body) {
inliner.requestCache[url] = body;
inliner.requestCachePending[url].forEach(function (callback, i) {
if (i == 0 && body) {
inliner.emit('progress', (options.encode ? 'encode' : 'get') + ' ' + url);
} else if (body) {
inliner.emit('progress', 'cached ' + url);
}
callback && callback(body);
});
});
return;
}
// otherwis continue and create a new web request
var request = makeRequest(url),
body = '';
// this tends to occur when we can't connect to the url - i.e. target is down
// note that the main inliner app handles sending the error back to the client
request.on('error', function (error) {
console.error(error.message, url);
callback && callback('');
});
request.on('response', function (res) {
var gunzip;
// if we get a gzip header, then first we attempt to load the node-compress library
// ...which annoyingly isn't supported anymore (maybe I should fork it...).
// once loaded, we set up event listeners to handle data coming in and do a little
// dance with the response object - which I'll explain... ==>
if (res.headers['content-encoding'] == 'gzip') {
console.error('loading gzip library');
if (compress === null) {
try {
compress = require('./node-compress/lib/compress/');
} catch (e) {
console.error(url + ' sent gzipped header\nFailed to load node-compress - see http://github.com/remy/inliner for install directions. \nexiting');
process.exit();
}
}
gunzip = new compress.GunzipStream();
// the data event is triggered by writing to the gunzip object when the response
// receives data (yeah, further down).
// once all the data has finished (triggerd by the response end event), we undefine
// the gunzip object and re-trigger the response end event. By this point, we've
// decompressed the gzipped content, and it's human readable again.
gunzip.on('data', function (chunk) {
body += chunk;
}).on('end', function () {
gunzip = undefined;
res.emit('end');
});
}
res.on('data', function (chunk) {
// only process data if we have a 200 ok
if (res.statusCode == 200) {
if (gunzip) {
gunzip.write(chunk);
} else {
body += chunk;
}
}
});
// required if we're going to base64 encode the content later on
if (options.encode) {
res.setEncoding('binary');
}
res.on('end', function () {
if (gunzip) {
gunzip.end();
return;
}
if (res.statusCode !== 200) {
inliner.emit('progress', 'get ' + res.statusCode + ' on ' + url);
body = ''; // ?
callback && callback(body);
} else if (res.headers['location']) {
return inliner.get(res.headers['location'], options, callback);
} else {
if (options && options.not) {
if (res.headers['content-type'].indexOf(options.not) !== -1) {
body = '';
}
}
if (options.encode && res.statusCode == 200) {
body = 'data:' + res.headers['content-type'] + ';base64,' + new Buffer(body, 'binary').toString('base64');
}
inliner.requestCache[url] = body;
inliner.requestCachePending[url].forEach(function (callback, i) {
if (i == 0 && body && res.statusCode == 200) {
inliner.emit('progress', (options.encode ? 'encode' : 'get') + ' ' + url);
} else if (body) {
inliner.emit('progress', 'cached ' + url);
}
callback && callback(body);
});
}
});
}).end();
};
Inliner.prototype.getImagesFromCSS = function (rooturl, rawCSS, callback) {
if (this.options.images === false) {
callback && callback(rawCSS);
return;
}
var inliner = this,
images = {},
urlMatch = /url\((?:['"]*)(?!['"]*data:)(.*?)(?:['"]*)\)/g,
singleURLMatch = /url\(\s*(?:['"]*)(?!['"]*data:)(.*?)(?:['"]*)\s*\)/,
matches = rawCSS.match(urlMatch),
imageCount = matches === null ? 0 : matches.length; // TODO check!
inliner.total += imageCount;
inliner.todo += imageCount;
function checkFinished() {
inliner.emit('jobs', (inliner.total - inliner.todo) + '/' + inliner.total);
if (imageCount < 0) {
console.log('something went wrong :-S');
} else if (imageCount == 0) {
callback(rawCSS.replace(urlMatch, function (m, url) {
return 'url(' + images[url] + ')';
}));
}
}
if (imageCount) {
matches.forEach(function (url) {
url = url.match(singleURLMatch)[1];
var resolvedURL = URL.resolve(rooturl, url);
if (images[url] === undefined) {
inliner.get(resolvedURL, { encode: true }, function (dataurl) {
imageCount--;
inliner.todo--;
if (images[url] === undefined) images[url] = dataurl;
checkFinished();
});
} else {
imageCount--;
inliner.todo--;
checkFinished();
}
});
} else {
callback(rawCSS);
}
};
Inliner.prototype.getImportCSS = function (rooturl, css, callback) {
// if (typeof css == 'function') {
// callback = css;
// rooturl = '';
// }
var position = css.indexOf('@import'),
inliner = this;
if (position !== -1) {
var match = css.match(/@import\s*(.*)/);
if (match !== null && match.length) {
var url = match[1].replace(/url/, '').replace(/['}"]/g, '').replace(/;/, '').trim().split(' '); // clean up
// if url has a length > 1, then we have media types to target
var resolvedURL = URL.resolve(rooturl, url[0]);
inliner.get(resolvedURL, function (importedCSS) {
inliner.emit('progress', 'import ' + resolvedURL);
if (url.length > 1) {
url.shift();
importedCSS = '@media ' + url.join(' ') + '{' + importedCSS + '}';
}
css = css.replace(match[0], importedCSS);
inliner.getImportCSS(rooturl, css, callback);
});
}
} else {
if (inliner.options.compressCSS) css = compressCSS(css);
callback(css, rooturl);
}
};
Inliner.defaults = function () { return { compressCSS: true, collapseWhitespace: true, images: true }; };
var makeRequest = Inliner.makeRequest = function (url, extraOptions) {
var oURL = URL.parse(url),
options = {
host: oURL.hostname,
port: oURL.port === undefined ? (oURL.protocol+'').indexOf('https') === 0 ? 443 : 80 : oURL.port,
path: (oURL.pathname || '/') + (oURL.search || ''), // note 0.5.0pre doesn't fill pathname if missing
method: 'GET'
};
for (var key in extraOptions) {
options[key] = extraOptions[key];
}
return http[oURL.protocol.slice(0, -1) || 'http'].request(options);
};
module.exports = Inliner;
if (!module.parent) {
// if this module isn't being included in a larger app, defer to the
// bin/inliner for the help options
require('./bin/inliner');
}

16
jquery.min.js vendored

File diff suppressed because one or more lines are too long

90
lib/css.js Normal file
View File

@ -0,0 +1,90 @@
var match = /url\((?:['"]*)(?!['"]*data:)(.*?)(?:['"]*)\)/g;
module.exports = {
getImages: getImages,
getImports: getImports,
};
var Promise = require('es6-promise').Promise; // jshint ignore:line
var debug = require('debug')('inliner');
function getImages(root, css) {
var inliner = this;
var singleURLMatch = /url\(\s*(?:['"]*)(?!['"]*data:)(.*?)(?:['"]*)\s*\)/;
var matches = css.match(match) || [];
var images = matches.map(function eachURL(url) {
var source = url.match(singleURLMatch)[1];
return {
source: source,
resolved: inliner.resolve(root, source),
};
});
debug('adding %s CSS assets', images.length);
inliner.jobs.add('images', images.length);
return Promise.all(images.map(function map(url) {
return inliner.image(url.resolved).then(function then(dataURL) {
inliner.jobs.done.images();
css = css.replace(new RegExp(url.source, 'g'), function replace() {
return dataURL;
});
return css;
});
})).then(function then() {
return css;
});
}
function getImports(root, css) {
// change to a string in case the CSS is a buffer, which is the case
// when we're reading off the local file system
if (typeof css !== 'string') {
css = css.toString();
}
var position = css.indexOf('@import');
var inliner = this;
if (position !== -1) {
inliner.jobs.add('link', 1);
var match = (css.match(/@import\s*(.*)/) || [null, ''])[1];
var url = match.replace(/url/, '')
.replace(/['}"()]/g, '')
.replace(/;/, '')
.trim()
.split(' '); // clean up
// if url has a length > 1, then we have media types to target
var resolvedURL = inliner.resolve(root, url[0]);
return inliner.get(resolvedURL).then(function then(res) {
var importedCSS = res.body;
inliner.jobs.done.links();
inliner.emit('progress', 'import ' + resolvedURL);
if (url.length > 1) {
url.shift();
importedCSS = '@media ' + url.join(' ') + '{' + importedCSS + '}';
}
css = css.replace('@import ' + match, importedCSS);
return getImports.call(inliner, root, css);
});
} else {
if (inliner.options.compressCSS) {
inliner.emit('progress', 'compress css');
css = compress(css);
}
return Promise.resolve(css);
}
}
function compress(css) {
return css
.replace(/\s+/g, ' ')
.replace(/:\s+/g, ':')
.replace(/\/\*.*?\*\//g, '')
.replace(/\} /g, '}')
.replace(/ \{/g, '{')
// .replace(/\{ /g, '{')
.replace(/; /g, ';')
.replace(/\n+/g, '');
}

3
lib/errors.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
ENOTFOUND: 'The URL could not be loaded, possibly a typo, or no internet connection',
};

23
lib/find-assets.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = findAssets;
var cheerio = require('cheerio');
var debug = require('debug')('inliner');
function findAssets(html) {
var $ = cheerio.load(html);
debug('loaded DOM');
var images = $('img');
var links = $('link[rel=stylesheet]');
var styles = $('style');
var scripts = $('script');
return {
$: $,
images: images,
links: links,
styles: styles,
scripts: scripts,
};
}

34
lib/get.js Normal file
View File

@ -0,0 +1,34 @@
var request = require('request');
var assign = require('lodash.assign');
var debug = require('debug')('inliner');
var cache = {};
module.exports = function get(url, options) {
debug('request %s', url);
var settings = assign({}, options, {
followRedirect: true,
});
if (cache[url]) {
return Promise.resolve(cache[url]);
}
return new Promise(function promise(resolve, reject) {
request(url, settings, function response(error, res, body) {
if (error) {
debug('request failed: %s', error.message);
return reject(error);
}
debug('response: %s %s', res.statusCode, url);
if (!error && res.statusCode === 200) {
cache[url] = { headers: res.headers, body: body };
resolve(cache[url]);
} else {
return reject(new Error(res.statusCode));
}
});
});
};

15
lib/image.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = image;
var debug = require('debug')('inliner');
function image(url) {
this.emit('progress', 'get image ' + url);
return this.get(url, { encoding: 'binary' }).then(function then(res) {
debug('image loaded: %s', url);
var buffer = new Buffer(res.body, 'binary').toString('base64');
return 'data:' + res.headers['content-type'] + ';base64,' + buffer;
}).catch(function errorHandle(error) {
debug('image %s failed to load', url, error);
throw error;
});
}

363
lib/index.js Normal file
View File

@ -0,0 +1,363 @@
module.exports = Inliner;
var debug = require('debug')('inliner');
var events = require('events');
var path = require('path');
var util = require('util');
var fs = require('then-fs');
var mime = require('mime');
var assign = require('lodash.assign');
var forEach = require('lodash.foreach');
var Promise = require('es6-promise').Promise; // jshint ignore:line
var request = require('./get');
var findAssets = require('./find-assets');
var version = require(path.resolve(__dirname, '..', 'package.json')).version;
function Inliner(url, options, callback) {
var inliner = this;
events.EventEmitter.call(this);
if (typeof options === 'function') {
callback = options;
options = {};
}
if (!options) {
options = {};
}
this.url = url;
this.callback = function wrapper(error, res) {
// noop the callback once it's fired
inliner.callback = function noop() {
inliner.emit('error', 'callback fired again');
};
callback(error, res);
};
this.options = assign({}, options, Inliner.defaults());
this.jobs = {
total: 0,
todo: 0,
breakdown: {
html: 0,
js: 0,
links: 0,
styles: 0,
images: 0,
},
add: this.addJob.bind(this),
done: {
html: this.completeJob.bind(this, 'html'),
js: this.completeJob.bind(this, 'js'),
images: this.completeJob.bind(this, 'images'),
links: this.completeJob.bind(this, 'links'),
styles: this.completeJob.bind(this, 'styles'),
},
};
this.isFile = false;
this.on('error', function localErrorHandler(event) {
inliner.callback(event);
});
// this allows the user code to get the object back before
// it starts firing events
if (this.url) {
if (typeof setImmediate === 'undefined') {
global.setImmediate = function setImmediatePolyfill(fn) {
// :-/
setTimeout(fn, 0);
}
}
global.setImmediate(this.main.bind(this));
}
return this;
}
util.inherits(Inliner, events.EventEmitter);
Inliner.prototype.updateTodo = updateTodo;
Inliner.prototype.addJob = addJob;
Inliner.prototype.completeJob = completeJob;
Inliner.prototype.version = version;
Inliner.prototype.cssImages = require('./css').getImages;
Inliner.prototype.cssImports = require('./css').getImports;
Inliner.prototype.image = require('./image');
Inliner.prototype.uglify = require('./javascript');
Inliner.prototype.resolve = resolve;
Inliner.prototype.removeComments = removeComments;
Inliner.prototype.get = get;
Inliner.prototype.main = main;
// static properties and methods
Inliner.version = version;
Inliner.errors = require('./errors');
Inliner.defaults = function() {
return {
images: true,
compressCSS: true,
collapseWhitespace: true,
};
};
// main thread of functionality that does all the inlining
function main() {
var inliner = this;
var url = this.url;
fs.exists(url)
.then(function exists(isFile) {
if (!isFile) {
throw new Error();
}
debug('inlining file');
inliner.isFile = true;
return url;
})
.catch(function isUrl() {
// check for protocol on URL
if (url.indexOf('http') !== 0) {
url = 'http://' + url;
}
inliner.url = url;
debug('inlining url');
return url;
})
.then(inliner.get.bind(this))
.then(inliner.jobs.add('html'))
.then(function processHTML(res) {
inliner.jobs.done.html();
debug('processing HTML');
var todo = findAssets(res.body);
var $ = todo.$;
delete todo.$;
forEach(todo, function forEach(todo, key) {
if (key === 'images' && !inliner.options.images) {
// skip images if the user doesn't want them
delete todo.images;
debug('skipping images');
return;
}
if (!todo.length) {
return;
}
inliner.jobs.add(key, todo.length);
});
var promises = [];
if (todo.images.length) {
var imagePromises = todo.images.map(function images(i, image) {
var url = inliner.resolve(inliner.url, $(image).attr('src'));
return inliner.image(url).then(function then(dataURL) {
$(image).attr('src', dataURL);
}).then(inliner.jobs.done.images);
}).get();
[].push.apply(promises, imagePromises);
}
if (todo.links.length) {
debug('start %s links', todo.links.length);
var linkPromises = todo.links.map(function links(i, link) {
var url = $(link).attr('href');
if (url.indexOf('http') !== 0) {
url = inliner.resolve(inliner.url, url);
}
inliner.emit('progress', 'processing external css ' + url);
return inliner.get(url).then(function then(res) {
var css = res.body;
inliner.jobs.done.links();
return inliner.cssImports(url, css).then(inliner.cssImages.bind(inliner, url));
}).then(function then(css) {
$(link).replaceWith('<style>' + css + '</style>');
});
});
[].push.apply(promises, linkPromises);
}
if (todo.styles.length) {
debug('start %s styles', todo.styles.length);
var stylePromises = todo.styles.map(function links(i, style) {
var css = $(style).html();
inliner.emit('progress', 'processing inline css');
return inliner.cssImports(url, css)
.then(inliner.cssImages.bind(inliner, url))
.then(function then(css) {
$(style).html(css);
});
});
[].push.apply(promises, stylePromises);
}
if (todo.scripts.length) {
debug('start %s scripts', todo.scripts.length);
var scriptPromises = todo.scripts.map(function links(i, script) {
var $script = $(script);
var src = $script.attr('src');
var source = $(script).html();
var promise;
// ext script
if (src) {
$script.removeAttr('src');
if (src.indexOf('.min.') !== -1) {
inliner.emit('progress', 'skipping minified script ' + src);
// ignore scripts with .min. in them - i.e. avoid minify
// scripts that are already minifed
return;
} else if (src.indexOf('google-analytics') !== -1) {
inliner.emit('progress', 'skipping analytics script');
// ignore analytics
return;
}
var url = src;
if (url.indexOf('http') !== 0) {
url = inliner.resolve(inliner.url, url);
}
promise = inliner.get(url);
} else {
inliner.emit('progress', 'processing inline script');
promise = Promise.resolve({
body: source,
});
}
return promise.then(inliner.uglify.bind(inliner)).then(function then(res) {
debug('uglify: %s', res);
$script.html(res);
});
});
[].push.apply(promises, scriptPromises);
}
return Promise.all(promises).then(function then() {
var html = '';
inliner.removeComments($(':root')[0], $);
// collapse the white space
if (inliner.options.collapseWhitespace) {
// TODO put white space helper back in
$('pre').html(function tidyPre(i, html) {
return html.replace(/\n/g, '~~nl~~');
});
$('textarea').val(function tidyTextarea(i, v) {
return v.replace(/\n/g, '~~nl~~').replace(/\s/g, '~~s~~');
});
html = $.html()
.replace(/\s+/g, ' ')
.replace(/~~nl~~/g, '\n')
.replace(/~~s~~/g, ' ');
} else {
html = $.html();
}
return html;
});
})
.then(function then(html) {
inliner.callback(null, html);
})
.catch(function errHandler(error) {
debug('fail', error.stack);
inliner.callback(error);
inliner.emit('error', error);
});
}
function get(url) {
this.emit('progress', 'loading ' + url);
if (this.isFile && url.indexOf('http') !== 0) {
debug('inliner.get file: %s', url);
return fs.readFile(url).then(function read(body) {
return {
body: body,
headers: {
'content-type': mime.lookup(url),
},
};
});
} else {
debug('inliner.get url: %s', url);
return request(url);
}
}
function removeComments(element, $) {
if (!element || !element.childNodes) {
return;
}
var nodes = element.childNodes;
var i = nodes.length;
while (i--) {
if (nodes[i].type === 'comment' && nodes[i].nodeValue.indexOf('[') !== 0) {
$(nodes[i]).remove();
}
removeComments(nodes[i], $);
}
}
function resolve(from, to) {
if (!to) {
to = from;
from = this.url;
}
if (!this.isFile || from.indexOf('http') === 0) {
return require('url').resolve(from, to);
} else {
var path = require('path');
var base = path.dirname(from);
return path.resolve(base, to);
}
}
function updateTodo() {
this.jobs.todo = this.jobs.breakdown.html +
this.jobs.breakdown.js +
this.jobs.breakdown.links +
this.jobs.breakdown.styles +
this.jobs.breakdown.images +
0;
this.emit('jobs', (this.jobs.total - this.jobs.todo) + '/' + this.jobs.total);
}
function addJob(type) {
var n = typeof arguments[1] === 'number' ? arguments[1] : 1;
this.jobs.breakdown[type] += n;
this.jobs.total += n;
this.updateTodo();
debug('%s: %s', type, n);
// this allows me to include addJob as part of a promise chain
return arguments[1];
}
function completeJob(type) {
this.jobs.breakdown[type]--;
this.updateTodo();
// this allows me to include addJob as part of a promise chain
return arguments[1];
}

13
lib/javascript.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = uglify;
var UglifyJS = require('uglify-js');
function uglify(source) {
this.emit('progress', 'compressing javascript');
if (source.body) {
source = source.body;
}
return UglifyJS.minify(source, {
fromString: true,
}).code;
}

@ -1 +0,0 @@
Subproject commit 39b5c39140f8ad088503de66546a15c55c2b951f

View File

@ -1,24 +1,42 @@
{
"name": "inliner",
"version": "0.1.14",
"version": "1.0.0",
"description": "Utility to inline images, CSS and JavaScript for a web page - useful for mobile sites",
"homepage": "http://github.com/remy/inliner",
"main": "inliner",
"keywords": ["mobile", "inline", "production", "build", "minify"],
"author": {
"name": "Remy Sharp",
"web": "http://github.com/remy"
"main": "lib/index.js",
"scripts": {
"test": "tape test/*.test.js --cov | tap-spec"
},
"keywords": [
"mobile",
"inline",
"production",
"build",
"minify"
],
"author": "Remy Sharp",
"dependencies": {
"jsdom": "0.6.5",
"uglify-js": "1.2.2",
"commander": "0.5.1"
"cheerio": "^0.19.0",
"debug": "^2.2.0",
"es6-promise": "^2.3.0",
"lodash.assign": "^3.2.0",
"lodash.foreach": "^3.0.3",
"mime": "^1.3.4",
"request": "^2.60.0",
"then-fs": "^2.0.0",
"uglify-js": "^2.4.24",
"update-notifier": "^0.5.0"
},
"repository": {
"type": "git",
"url": "git://github.com/remy/inliner.git"
},
"license": "MIT",
"bin": {
"inliner": "bin/inliner"
"inliner": "cli/index.js"
},
"devDependencies": {
"tap-spec": "^4.0.2",
"tape": "^4.0.1"
}
}

1
test/fixtures/css-ext.result.html vendored Normal file

File diff suppressed because one or more lines are too long

13
test/fixtures/css-ext.src.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>External script + css</title>
<link href="https://code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.css" rel="stylesheet" type="text/css" />
<script src="https://code.jquery.com/jquery-1.11.3.js"></script>
<script src="https://code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.js"></script>
</head>
<body>
</body>
</html>

1
test/fixtures/css-import.result.html vendored Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>inline style</title> <style> p{ font-size:10px;} p{ font-size:10px;} p{ font-size:10px;} @media screen and orientation:landscape{p{ font-size:10px;}}</style> </head> <body> </body> </html>

20
test/fixtures/css-import.src.html vendored Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>inline style</title>
<style>
/* one */
@import url('import.css');
/* two */
@import url("import.css");
/* three */
@import 'import.css';
/* four */
@import url('import.css') screen and (orientation:landscape);
</style>
</head>
<body>
</body>
</html>

1
test/fixtures/image-css.result.html vendored Normal file

File diff suppressed because one or more lines are too long

17
test/fixtures/image-css.src.html vendored Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>css image</title>
<style>
#image {
background: url(image.jpg) no-repeat;
height: 400px;
width: 400px;
}
</style>
</head>
<body>
<div id="image"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

10
test/fixtures/image-inline.src.html vendored Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>inline image</title>
</head>
<body>
<img src="image.jpg">
</body>
</html>

BIN
test/fixtures/image.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

3
test/fixtures/import.css vendored Normal file
View File

@ -0,0 +1,3 @@
p {
font-size: 10px;
}

File diff suppressed because one or more lines are too long

35
test/fixtures/kitchen-sink.src.html vendored Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>full combo</title>
<link href="import.css" rel="stylesheet">
<style>
@import url("import.css");
body {
font-family: sans-serif;
}
#icon {
height: 48px;
width: 48px;
background: url(image.jpg) no-repeat;
background-size: cover;
}
</style>
</head>
<body>
<script>function doit(window) {
var foo = 'remy';
var bar = window.bar = 'sharp';
return foo + bar.split('').reverse().join('');
}
console.log(doit(window));
</script>
<script>console.log('Hello world');</script>
<img src="image.jpg" title="Remy Sharp">
<div id="icon"></div>
</body>
</html>

1
test/fixtures/script-ext.result.html vendored Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>External script</title> <script></script> </head> <body> </body> </html>

11
test/fixtures/script-ext.src.html vendored Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>External script</title>
<script src="https://cdn.rawgit.com/zloirock/core-js/master/client/shim.min.js"></script>
</head>
<body>
</body>
</html>

View File

@ -0,0 +1 @@
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>inline script</title> </head> <body> <script>function doit(o){var r="remy",e=o.bar="sharp";return r+e.split("").reverse().join("")}console.log(doit(window));</script> <script>console.log("Hello world");</script> </body> </html>

18
test/fixtures/script-inline.src.html vendored Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>inline script</title>
</head>
<body>
<script>function doit(window) {
var foo = 'remy';
var bar = window.bar = 'sharp';
return foo + bar.split('').reverse().join('');
}
console.log(doit(window));
</script>
<script>console.log('Hello world');</script>
</body>
</html>

53
test/index.test.js Normal file
View File

@ -0,0 +1,53 @@
'use strict';
var test = require('tape');
var Promise = require('es6-promise').Promise; // jshint ignore:line
var fs = require('then-fs');
var path = require('path');
test('inliner core functions', function coreTests(t) {
var Inliner = require('../');
t.equal(typeof Inliner, 'function', 'Inliner is a function');
t.equal(Inliner.version, require('../package.json').version);
var inliner = new Inliner();
t.ok(inliner, 'inline is instantiated');
t.end();
});
test('inliner fixtures', function fixtureTests(t) {
var Inliner = require('../');
var files = fs.readdirSync(path.resolve(__dirname, 'fixtures'));
var results = [];
files = files.filter(function filter(file) {
return file.indexOf('.src.') !== -1;
}).filter(function filter(file) {
// helps to diganose a single file
// return file.indexOf('image-css.src.html') === 0;
return file;
}).map(function map(file) {
file = path.resolve(__dirname, 'fixtures', file);
results.push(fs.readFile(file.replace('.src.', '.result.'), 'utf8'));
return file;
});
t.plan(files.length);
Promise.all(results).then(function then(results) {
files.map(function map(file, i) {
new Inliner(file, function callback(error, html) {
var basename = path.basename(file);
if (error) {
t.fail(error.message + ' @ ' + basename);
console.log(error.stack);
}
t.equal(html.trim(), results[i].trim(), basename + ' matches');
});
});
}).catch(function errHandler(error) {
t.fail(error.message);
console.log(error.stack);
t.bailout();
});
});