diff --git a/README.md b/README.md index de9d8e0..d496d1f 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,7 @@ Inline local assets referenced in an HTML document. [![npm version](https://img.shields.io/npm/v/inline-html.svg)](https://www.npmjs.com/package/inline-html) -[![npm license](https://img.shields.io/npm/l/inline-html.svg)](https://www.npmjs.com/package/inline-html) [![Travis](https://img.shields.io/travis/panosoft/inline-html.svg)](https://travis-ci.org/panosoft/inline-html) -[![David](https://img.shields.io/david/panosoft/inline-html.svg)](https://david-dm.org/panosoft/inline-html) -[![npm downloads](https://img.shields.io/npm/dm/inline-html.svg)](https://www.npmjs.com/package/inline-html) This library parses HTML, embeds the contents of local assets that are referenced within that HTML, and returns a new inlined HTML string. diff --git a/lib/css-url.js b/lib/css-url.js new file mode 100644 index 0000000..f0756f3 --- /dev/null +++ b/lib/css-url.js @@ -0,0 +1,133 @@ +const datauri = require('datauri'); +const isLocalPath = require('is-local-path'); +const isTemplateExpression = require('./is-template-expression'); +const path = require('path'); +const postcss = require('postcss'); +const postcssUrl = require('postcss-url'); +const R = require('ramda'); +const string = require('string'); +const url = require('url'); + +const collapseWhitespace = str => string(str).collapseWhitespace().toString(); +const forEachIndexed = R.addIndex(R.forEach); +const format = (path) => url.format(path); +const parse = (path) => url.parse(path); +const resolve = path.resolve; + +const augmentError = (error, filename, files) => { + if (!error.filename) error.filename = filename; + error.files = R.uniq(R.concat(files, error.files || [])); + return error; +}; + +/** + * Returns url path without query string and hash if present. + * + * @param path + * + * @returns path + */ +const cleanUrl = R.pipe( + parse, + R.pick(['protocol', 'host', 'pathname']), + format, + decodeURI +); +/** + * Convert local url data type paths to datauris. + * + * @param css + * @param filename + * @returns {{css: (css|any), files: Array}} + */ +const inlineUrl = R.curry((filename, css) => { + const basePath = path.dirname(filename); + var files = []; + const inline = url => { + try { + if (isLocalPath(url) && !isTemplateExpression(url)) { + url = cleanUrl(url); + url = resolve(basePath, url); + files = R.append(url, files); + url = datauri(url); + } + return url; + } + catch (error) { throw augmentError(error, filename, files); } + }; + css = postcss() + .use(postcssUrl({ url: inline })) + .process(css) + .css; + files = R.uniq(files); + return { css, files }; +}); + +const inlineStyles = ($, filename) => { + var files = []; + try { + const styles = $('style') + .toArray(); + + const contents = R.map(style => { + const css = $(style).html(); + const result = inlineUrl(filename, css); + files = R.concat(files, result.files); + return result.css; + }, styles); + + forEachIndexed((style, index) => $(style).html(contents[index]), styles); + + return { $, files }; + } + catch (error) { throw augmentError(error, filename, files); } +}; + +const prefix = 'selector {'; +const suffix = '}'; +const matchStyle = new RegExp(`^${prefix}\\s*(.*)\\s*${suffix}$`); +const wrap = style => `${prefix}${style}${suffix}`; +const unwrap = rule => rule.replace(matchStyle, '$1'); + +const inlineStyleAttributes = ($, filename) => { + var files = []; + try { + const elements = $('*') + .filter('[style]') + .toArray(); + + const styles = R.map(element => { + var style = $(element).attr('style'); + const rule = wrap(style); + const result = inlineUrl(filename, rule); + files = R.concat(files, result.files); + style = R.pipe( collapseWhitespace, unwrap )(result.css); + return style; + }, elements); + + forEachIndexed((element, index) => $(element).attr('style', styles[index]), elements); + + return { $, files }; + } + catch (error) { throw augmentError(error, filename, files); } +}; + +const inlineCssUrl = function ($, filename) { + var files = []; + try { + var result; + result = inlineStyles($, filename); + $ = result.$; + files = R.concat(files, result.files); + + result = inlineStyleAttributes($, filename); + $ = result.$; + files = R.concat(files, result.files); + + files = R.uniq(files); + return { $, files }; + } + catch (error) { throw augmentError(error, filename, files); } +}; + +module.exports = inlineCssUrl; diff --git a/lib/img.js b/lib/img.js new file mode 100644 index 0000000..eca6438 --- /dev/null +++ b/lib/img.js @@ -0,0 +1,48 @@ +const co = require('co'); +const datauri = require('datauri').promises; +const isLocalPath = require('is-local-path'); +const isTemplateExpression = require('./is-template-expression'); +const path = require('path'); +const R = require('ramda'); + +const forEachIndexed = R.addIndex(R.forEach); +const resolve = R.curry((a,b) => path.resolve(a,b)); + +/** + * Inline sourced image files + * + * @param {Object} $ + * Parsed HTML source to inline + * @param {String} filename + * Filename used to resolve relative sources being inlined + */ +const inlineImg = co.wrap(function * ($, filename) { + var files; + const basedir = path.dirname(filename); + const getAttr = R.curry((attr, element) => $(element).attr(attr)); + const setAttr = R.curry((attr, element, value) => $(element).attr(attr, value)); + const getFilename = R.pipe(getAttr('src'), resolve(basedir)); + try { + const images = $('img') + .filter((index, element) => { + const source = $(element).attr('src'); + return isLocalPath(source) && !isTemplateExpression(source); + }) + .toArray(); + + const filenames = R.map(getFilename, images); + files = R.uniq(filenames); + const uris = yield R.map(datauri, filenames); + + forEachIndexed((image, index) => setAttr('src', image, uris[index]), images); + + return { $, files }; + } + catch (error) { + error.filename = filename; + error.files = files; + throw error; + } +}); + +module.exports = inlineImg; diff --git a/lib/index.js b/lib/index.js index 5e61b48..412ed87 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,12 +1,13 @@ -var co = require('co'); -var fs = require('mz/fs'); -var inlineStyle = require('./inline-style'); -var inlineImg = require('./inline-img'); -var inlineLess = require('./inline-less'); -var R = require('ramda'); -var Ru = require('@panosoft/ramda-utils'); +const cheerio = require('cheerio'); +const co = require('co'); +const fs = require('mz/fs'); +const inlineCssUrl = require('./css-url'); +const inlineImg = require('./img'); +const inlineLess = require('./link-less'); +const R = require('ramda'); +const Ru = require('@panosoft/ramda-utils'); -var inlineHtml = {}; +var inline = {}; /** * Embed referenced local assets within and HTML file. * @@ -15,44 +16,46 @@ var inlineHtml = {}; * * @return {Promise} */ -inlineHtml.html = co.wrap(function * (html, options) { +inline.html = co.wrap(function * (html, options) { options = Ru.defaults({ filename: null, less: {}, verbose: false }, options || {}); - var filename = options.filename; - // Embed assets + const filename = options.filename; var files = [filename]; try { - var lessResult = yield inlineLess(html, filename, options.less); - html = lessResult.html; - files = R.concat(files, lessResult.files); + var $ = cheerio.load(html, {decodeEntities: false}); - var styleResult = inlineStyle(html, filename); - html = styleResult.html; - files = R.concat(files, styleResult.files); + var result; + result = yield inlineLess($, filename, options); + $ = result.$; + files = R.concat(files, result.files); - var imgResult = inlineImg(html, filename); - html = imgResult.html; - files = R.concat(files, imgResult.files); + result = inlineCssUrl($, filename); + $ = result.$; + files = R.concat(files, result.files); + + result = yield inlineImg($, filename); + $ = result.$; + files = R.concat(files, result.files); + + html = $.xml(); + files = R.uniq(files); + return (options.verbose ? { html, files } : html); } catch (error) { if (!error.filename) error.filename = filename; error.files = R.uniq(R.concat(files, error.files || [])); throw error; } - - files = R.uniq(files); - var result = { html, files }; - return (options.verbose ? result : result.html); }); -inlineHtml.file = co.wrap(function * (filename, options) { - var html = yield fs.readFile(filename, 'utf8'); +inline.file = co.wrap(function * (filename, options) { + const html = yield fs.readFile(filename, 'utf8'); options = R.merge(options || {}, {filename}); - return yield inlineHtml.html(html, options); + return yield inline.html(html, options); }); -module.exports = inlineHtml; +module.exports = inline; diff --git a/lib/inline-css-url.js b/lib/inline-css-url.js deleted file mode 100644 index 0dad98f..0000000 --- a/lib/inline-css-url.js +++ /dev/null @@ -1,61 +0,0 @@ -var datauri = require('datauri'); -var isLocalPath = require('is-local-path'); -var isTemplateExpression = require('./is-template-expression'); -var path = require('path'); -var postcss = require('postcss'); -var postcssUrl = require('postcss-url'); -var R = require('ramda'); -var url = require('url'); - -/** - * Returns url path without query string and hash if present. - * - * @param path - * - * @returns path - */ -var clean = function (path) { - path = url.parse(path); - path = R.pick(['protocol', 'host', 'pathname'], path); - path = url.format(path); - path = decodeURI(path); - return path; -}; -/** - * Convert local url data type paths to datauris. - * - * @param css - * @param filename - * @returns {{css: (css|any), files: Array}} - */ -var inlineUrl = function (css, filename) { - var files = []; - var basePath = path.dirname(filename); - var result = postcss() - .use(postcssUrl({ - url: function (urlPath) { - if (isLocalPath(urlPath) && !isTemplateExpression(urlPath)) { - try { - urlPath = clean(urlPath); - urlPath = path.resolve(basePath, urlPath); - files = R.append(urlPath, files); - urlPath = datauri(urlPath); - } - catch (error) { - error.filename = filename; - error.files = R.uniq(files); - throw error; - } - } - return urlPath; - } - })) - .process(css); - files = R.uniq(files); - return { - css: result.css, - files - }; -}; - -module.exports = inlineUrl; diff --git a/lib/inline-img.js b/lib/inline-img.js deleted file mode 100644 index 419ce47..0000000 --- a/lib/inline-img.js +++ /dev/null @@ -1,37 +0,0 @@ -var cheerio = require('cheerio'); -var datauri = require('datauri'); -var isLocalPath = require('is-local-path'); -var isTemplateExpression = require('./is-template-expression'); -var path = require('path'); -var R = require('ramda'); - -var inline = function (html, filename) { - var files = []; - var basedir = path.dirname(filename); - var $ = cheerio.load(html, {decodeEntities: false}); - var $images = $('img').filter((index, element) => { - var path = $(element).attr('src'); - return isLocalPath(path) && !isTemplateExpression(path); - }); - try { - $images.each((index, element) => { - var source = $(element).attr('src'); - var filename = path.resolve(basedir, source); - files = R.append(filename, files); - var uri = datauri(filename); - $(element).attr('src', uri); - }); - } - catch (error) { - error.filename = filename; - error.files = R.uniq(files); - throw error; - } - files = R.uniq(files); - return { - html: $.xml(), - files - }; -}; - -module.exports = inline; diff --git a/lib/inline-less.js b/lib/inline-less.js deleted file mode 100644 index cad44d9..0000000 --- a/lib/inline-less.js +++ /dev/null @@ -1,67 +0,0 @@ -var co = require('co'); -var cheerio = require('cheerio'); -var fs = require('mz/fs'); -var isLocalPath = require('is-local-path'); -var less = require('less'); -var path = require('path'); -var R = require('ramda'); -var Ru = require('@panosoft/ramda-utils'); - -var render = co.wrap(function * (filename, options) { - options = R.merge(options || {}, { filename }); - var contents = yield fs.readFile(filename, 'utf8'); - return yield less.render(contents, options); -}); -/** - * @param {String} html - * HTML source to inline - * @param {String} filename - * Filename to apply to the HTML source being inlined - * @param {Object} options - * LESS compiler options - */ -var inlineLess = co.wrap(function * (html, filename, options) { - options = Ru.defaults({ - relativeUrls: true - }, options || {}); - var basedir = path.dirname(filename); - - // get links - var $ = cheerio.load(html, {decodeEntities: false}); - var $links = $('link[rel="stylesheet/less"]') - .filter((index, element) => isLocalPath($(element).attr('href'))); - - // render LESS stylesheets - var files = []; - var outputs = []; - try { - $links.each((index, element) => { - var href = $(element).attr('href'); - var filename = path.resolve(basedir, href); - files = R.append(filename, files); - outputs = R.append(render(filename, options), outputs); - }); - outputs = yield outputs; - } - catch (error) { - if (!error.filename) error.filename = filename; - error.files = R.uniq(files); - throw error; - } - - // include imported filenames in files array - files = R.concat(files, R.flatten(R.map(output => output.imports, outputs))); - files = R.uniq(files); - - // replace links - $links.each((index, element) => { - var style = $('`; + 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(filename); + } + }); + }); + it('throw error when html style url invalid', () => { + return co(function * () { + var filename = path.resolve(__dirname, 'index.html'); + var url = 'missing.png'; + var resolvedUrl = path.resolve(path.dirname(filename), url); + var html = ``; + 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(resolvedUrl); + } + }); + }); + it('throw error when style attribute syntax invalid', () => { return co(function * () { var filename = path.resolve(__dirname, 'index.html'); var html = `
`; @@ -234,33 +286,69 @@ describe('inline-html', () => { } }); }); - it('throw error when html style syntax invalid', () => { + it('include valid and invalid paths in error.files when html style url invalid', () => { return co(function * () { var filename = path.resolve(__dirname, 'index.html'); - var html = ``; + var validUrl = 'fixtures/file.txt'; + var invalidUrl = 'missing.png'; + var resolvedInvalidUrl = path.resolve(path.dirname(filename), invalidUrl); + var resolvedValidUrl = path.resolve(path.dirname(filename), validUrl); + var html = ` + + + `; 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(filename); + expect(error).to.have.property('files').that.contains(resolvedValidUrl); + expect(error).to.have.property('files').that.contains(resolvedInvalidUrl); } }); }); - it('throw error when html style url invalid', () => { + it('include valid and invalid paths in error.files when html attribute url invalid', () => { return co(function * () { var filename = path.resolve(__dirname, 'index.html'); - var url = 'missing.png'; - var resolvedUrl = path.resolve(path.dirname(filename), url); - var html = ``; + var validUrl = 'fixtures/file.txt'; + var invalidUrl = 'missing.png'; + var resolvedInvalidUrl = path.resolve(path.dirname(filename), invalidUrl); + var resolvedValidUrl = path.resolve(path.dirname(filename), validUrl); + var html = ` +
+
+ `; 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(resolvedUrl); + expect(error).to.have.property('files').that.contains(resolvedValidUrl); + expect(error).to.have.property('files').that.contains(resolvedInvalidUrl); + } + }); + }); + it('include valid and invalid paths in error.files when style element valid and html attribute invalid', () => { + return co(function * () { + var filename = path.resolve(__dirname, 'index.html'); + var validUrl = 'fixtures/file.txt'; + var invalidUrl = 'missing.png'; + var resolvedInvalidUrl = path.resolve(path.dirname(filename), invalidUrl); + var resolvedValidUrl = path.resolve(path.dirname(filename), validUrl); + var html = ` + +
+ `; + 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(resolvedValidUrl); + expect(error).to.have.property('files').that.contains(resolvedInvalidUrl); } }); }); @@ -317,7 +405,6 @@ describe('inline-html', () => { return co(function * () { var filename = path.resolve(__dirname, 'fixtures/index.html'); var lessBasename = 'invalidUrl.less'; - var lessFilename = path.resolve(path.dirname(filename), lessBasename); var badUrl = path.resolve(path.dirname(filename), 'missing.png'); var html = ``; try { @@ -333,5 +420,4 @@ describe('inline-html', () => { }); }); }); - });