Add support for inlining CSS stylesheets. Add tests. Update docs.

This commit is contained in:
Alexandre Gigliotti
2015-12-04 15:06:06 -08:00
parent 22ce5e3f25
commit c30350d011
16 changed files with 204 additions and 30 deletions

View File

@@ -11,9 +11,11 @@ The following HTML elements and CSS data types are inlined:
- Scripts - The source path is read and inlined.
- Images - The source path is replaced with a datauri.
- Linked CSS stylesheets - The stylesheet is read and inlined within a `<style>` element. Note that nested `@import`'s are also inlined.
- Linked LESS stylesheets - The LESS is compiled and the result is inlined within a `<style>` element. Note that `@imports` are processed as well.
- Linked LESS stylesheets - The LESS is compiled and the output is inlined within a `<style>` element. Note that `@import`'s are also inlined.
- Images - The source path is replaced with a datauri.
- CSS url data types - The reference path is replaced with a datauri. These can be used in linked stylesheets, style elements, and element style attributes.
@@ -32,7 +34,6 @@ Assuming ...
- `main.less`
```css
@import (less) 'main.css';
div { background-image: url('path/to/file'); }
```
@@ -51,6 +52,7 @@ var inline = require('inline-html');
co(function * () {
var html = `
<script src="main.js"></script>
<link rel="stylesheet" href="main.css"/>
<link rel="stylesheet/less" href="main.less"/>
<style> div { background-image: url('path/to/file'); } </style>
<div style="background-image: url('path/to/file');"></div>
@@ -59,13 +61,9 @@ co(function * () {
html = yield inline.html(html);
console.log(html);
/**
<script>
var a = 1;
</script>
<style>
@font-face { src: url('data:...'); }
div { background-image: url('data:...'); }
</style>
<script> var a = 1; </script>
<style> @font-face { src: url('data:...'); } </style>
<style> div { background-image: url('data:...'); } </style>
<style> div { background-image: url('data:...'); } </style>
<div style="background-image: url('data:...');"></div>
<img src="data:..."/>

View File

@@ -4,6 +4,7 @@ const fs = require('mz/fs');
const inlineCssUrl = require('./css-url');
const inlineImg = require('./img');
const inlineLess = require('./link-less');
const inlineLinkCss = require('./link-css');
const inlineScript = require('./script');
const R = require('ramda');
const Ru = require('@panosoft/ramda-utils');
@@ -19,7 +20,7 @@ var inline = {};
*/
inline.html = co.wrap(function * (html, options) {
options = Ru.defaults({
filename: null,
filename: '.',
less: {},
verbose: false
}, options || {});
@@ -34,6 +35,10 @@ inline.html = co.wrap(function * (html, options) {
$ = result.$;
files = R.concat(files, result.files);
result = yield inlineLinkCss($, filename, options);
$ = result.$;
files = R.concat(files, result.files);
result = inlineCssUrl($, filename, options);
$ = result.$;
files = R.concat(files, result.files);

70
lib/link-css.js Normal file
View File

@@ -0,0 +1,70 @@
const co = require('co');
const fs = require('mz/fs');
const isLocalPath = require('is-local-path');
const isTemplateExpression = require('./is-template-expression');
const path = require('path');
const postcss = require('postcss');
const postcssImport = require('postcss-import');
const postcssUrl = require('postcss-url');
const R = require('ramda');
const forEachIndexed = R.addIndex(R.forEach);
const render = R.curryN(2, co.wrap(function * (filename, href) {
var imports;
const processor = postcss()
.use(postcssImport({ async: true, onImport: files => imports = files }))
.use(postcssUrl({ url: 'rebase' }));
try {
const css = yield fs.readFile(href, 'utf8');
const result = yield processor.process(css, { from: href, to: filename });
const output = {css: result.css, imports};
return output;
}
catch (error) {
// process uses error.file = href
// import uses error.fileName
// readFile uses nothing => use filename
error.filename = error.file || error.fileName || filename;
throw error;
}
}));
/**
* Inline liked CSS stylesheets by replacing link elements with
* style elements that contain the css file contents.
* @param {Object} $
* Parsed HTML source to inline
* @param {String} [filename='.']
* Filename of the HTML document contained within $
* @return {Promise}
*/
const inlineLinkCss = co.wrap(function * ($, filename) {
// TODO consider: explicitly default filename = '.'?
// path.dirname(null || undefined) -> '.'
const basedir = path.dirname(filename);
var files = [];
try {
const links = $('link[rel="stylesheet"]')
.filter((index, link) => {
const href = $(link).attr('href');
return isLocalPath(href) && !isTemplateExpression(href);
})
.toArray();
const getHref = element => path.resolve(basedir, $(element).attr('href'));
const hrefs = R.map(getHref, links);
files = R.concat(files, hrefs);
const outputs = yield R.map(render(filename), hrefs);
const imports = R.flatten(R.map(R.prop('imports'), outputs));
files = R.concat(files, imports);
const styles = R.map(output => $('<style>').html(output.css), outputs);
const replaceLink = (link, index) => $(link).replaceWith(styles[index]);
forEachIndexed(replaceLink, links);
files = R.uniq(files);
return { $, files };
}
catch (error) {
if (!error.filename) error.filename = filename;
error.files = R.uniq(files);
throw error;
}
});
module.exports = inlineLinkCss;

View File

@@ -3,8 +3,8 @@
"version": "0.2.2",
"description": "Inline local assets referenced in an HTML document.",
"repository": "panosoft/inline-html",
"engines" : {
"node" : ">=4.0.0"
"engines": {
"node": ">=4.0.0"
},
"main": "lib/index.js",
"scripts": {
@@ -22,6 +22,7 @@
"less": "^2.5.1",
"mz": "^2.0.0",
"postcss": "^5.0.12",
"postcss-import": "^7.1.3",
"postcss-url": "^5.0.0",
"ramda": "^0.18.0",
"string": "^3.3.0"

View File

@@ -1 +1,2 @@
/* basic.css */
div { color: blue; }

View File

@@ -1 +1,2 @@
div { background-image: url('file.txt'); }
/* basic.less */
div { color: blue; }

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

@@ -0,0 +1 @@
@import 'basic.css';

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

@@ -0,0 +1 @@
@import 'missing.css';

View File

@@ -1 +1 @@
@import (less) 'missing.css';
@import 'missing.less';

1
test/fixtures/invalid-syntax.css vendored Normal file
View File

@@ -0,0 +1 @@
div {

1
test/fixtures/invalid-url.css vendored Normal file
View File

@@ -0,0 +1 @@
div { background-image: url('missing.png'); }

View File

@@ -1,3 +1 @@
div {
background-image: url('missing.png');
}
div { background-image: url('missing.png'); }

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

@@ -0,0 +1 @@
@import 'import.css';

1
test/fixtures/url.css vendored Normal file
View File

@@ -0,0 +1 @@
div { background-image: url('file.txt'); }

1
test/fixtures/url.less vendored Normal file
View File

@@ -0,0 +1 @@
div { background-image: url('file.txt'); }

View File

@@ -324,11 +324,100 @@ describe('inline-html', () => {
}));
});
describe('link-css', () => {
const filename = path.resolve(__dirname, 'index.html');
const link = (href) => `<link rel="stylesheet" href="${href}"/>`;
it('inline local href', () => co(function * () {
const href = path.resolve(__dirname, 'fixtures/basic.css');
const html = link(href);
const result = yield inline.html(html);
expect(result).to.match(/<style>[^]*basic.css[^]*<\/style>/);
}));
it('ignore remote href', () => co(function * () {
const html = link('http://test.com/main.less');
const result = yield inline.html(html);
expect(result).to.equal(html);
}));
it('ignore template expression href', () => co(function * () {
const html = link('{{href}}');
const result = yield inline.html(html);
expect(result).to.equal(html);
}));
it('inline local nested imports', () => co(function * () {
const href = path.resolve(__dirname, 'fixtures/nested-import.css');
const html = link(href);
const result = yield inline.html(html);
return expect(result).to.match(/<style>[^]*basic.css[^]*<\/style>/)
.and.not.match(/@import/);
}));
it('rebase urls relative to html filename', () => co(function * () {
const href = 'fixtures/url.css';
const html = link(href);
const result = yield inline.html(html, { filename });
expect(result).to.match(/<style>[^]*url\('data:.*,.*'\)[^]*<\/style>/);
}));
it('throw error when href invalid', () => co(function * () {
const invalid = 'fixtures/missing.css';
const invalidResolved = path.resolve(path.dirname(filename), invalid);
const html = link(invalid);
try {
yield inline.html(html, { filename });
throw new Error('No error thrown');
}
catch (error) {
expect(error).to.have.property('filename').that.equals(filename);
expect(error).to.have.property('files').that.contains(invalidResolved);
}
}));
it('throw error when import path invalid', () => co(function * () {
const invalid = 'fixtures/invalid-import.css';
const invalidResolved = path.resolve(path.dirname(filename), invalid);
const html = link(invalid);
try {
yield inline.html(html, { filename });
throw new Error('No error thrown');
}
catch (error) {
expect(error).to.have.property('filename').that.equals(invalidResolved);
expect(error).to.have.property('files').that.contains(invalidResolved);
}
}));
it('throw error when css syntax invalid', () => co(function * () {
const invalid = 'fixtures/invalid-syntax.css';
const invalidResolved = path.resolve(path.dirname(filename), invalid);
const html = link(invalid);
try {
yield inline.html(html, { filename });
throw new Error('No error thrown');
}
catch (error) {
expect(error).to.have.property('filename').that.equals(invalidResolved);
expect(error).to.have.property('files').that.contains(invalidResolved);
}
}));
it('include all local hrefs in error.files when error encountered', () => co(function * () {
const valid = 'fixtures/basic.css';
const invalid = 'fixtures/missing.css';
const validResolved = path.resolve(path.dirname(filename), valid);
const invalidResolved = path.resolve(path.dirname(filename), invalid);
const html = `${link(invalid)}${link(valid)}`;
try {
yield inline.html(html, { filename });
throw new Error('No error thrown');
}
catch (error) {
expect(error).to.have.property('filename').that.equals(filename);
expect(error).to.have.property('files').that.contains(validResolved);
expect(error).to.have.property('files').that.contains(invalidResolved);
}
}));
});
describe('link-less', () => {
it('inline local href', () => {
const filename = path.resolve(__dirname, 'fixtures/basic.less');
const html = `<link rel="stylesheet/less" href="${filename}"/>`;
return expect(inline.html(html)).to.eventually.match(/<style>[^]*<\/style>/);
return expect(inline.html(html)).to.eventually.match(/<style>[^]*basic.less[^]*<\/style>/);
});
it('ignore remote href', () => {
const html = `<link rel="stylesheet/less" href="http://test.com/main.less"/>`;
@@ -341,14 +430,14 @@ describe('inline-html', () => {
it('inline local nested imports', () => {
const filename = path.resolve(__dirname, 'fixtures/nested-import.less');
const html = `<link rel="stylesheet/less" href="${filename}"/>`;
return expect(inline.html(html)).to.eventually.match(/<style>[^]*<\/style>/)
return expect(inline.html(html)).to.eventually.match(/<style>[^]*basic.less[^]*basic.css[^]*<\/style>/)
.and.not.match(/@import/);
});
it('rebase urls relative to html filename', () => {
const filename = path.resolve(__dirname, 'index.html');
const href = 'fixtures/basic.less';
const href = 'fixtures/url.less';
const html = `<link rel="stylesheet/less" href="${href}"/>`;
return expect(inline.html(html, { filename })).to.eventually.match(/<style>[^]*<\/style>/);
return expect(inline.html(html, { filename })).to.eventually.match(/<style>[^]*url\('data:.*,.*'\)[^]*<\/style>/);
});
it('throw error when link href invalid', () => co(function * () {
const filename = path.resolve(__dirname, 'index.html');
@@ -392,20 +481,24 @@ describe('inline-html', () => {
expect(error).to.have.property('files').that.contains(lessFilename);
}
}));
it('throw error when less url invalid', () => co(function * () {
const filename = path.resolve(__dirname, 'fixtures/index.html');
const lessBasename = 'invalid-url.less';
const badUrl = path.resolve(path.dirname(filename), 'missing.png');
const html = `<link rel="stylesheet/less" href="${lessBasename}">`;
it('include all local hrefs in error.files when error encountered', () => co(function * () {
const filename = path.resolve(__dirname, 'index.html');
const valid = 'fixtures/basic.css';
const invalid = 'fixtures/missing.css';
const validResolved = path.resolve(path.dirname(filename), valid);
const invalidResolved = path.resolve(path.dirname(filename), invalid);
const html = `
<link rel="stylesheet/less" href="${invalid}">
<link rel="stylesheet/less" href="${valid}">
`;
try {
yield inline.html(html, {filename});
throw new Error('No error thrown');
}
catch (error) {
// expect error.filename to be html file, not less file, since images
// aren't inlined until after the compiled less has been inlined into the html.
expect(error).to.have.property('filename').that.equals(filename);
expect(error).to.have.property('files').that.contains(badUrl);
expect(error).to.have.property('files').that.contains(validResolved);
expect(error).to.have.property('files').that.contains(invalidResolved);
}
}));
});