diff --git a/History.md b/History.md index bf41b80..86d29dc 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,10 @@ +1.7.0 / 2015-11-03 +================== + +* Refactor to provide browser support at `juice/client` +* Add option `applyHeightAttributes` +* Bump dep `web-resource-inliner` + 1.6.0 / 2015-10-26 ================== diff --git a/README.md b/README.md index 587e115..4d6450d 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ Current CLI options: - `--css [filepath]` will load and inject CSS into `extraCss`. +### Running Juice in the Browser + +Attempting to Browserify `require('juice')` fails because portions of Juice and its dependencies interact with the file system using the standard `require('fs')`. However, you can `require('juice/client')` via Browserify which has support for `juiceDocument`, `inlineDocument`, and `inlineContent`, but not `juiceFile`, `juiceResources`, or `inlineExternal`. *Note that automated tests are not running in the browser yet.* ## Credits diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..2cdeaf4 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,320 @@ + "use strict"; + +var utils = require('./utils'); + +var juiceClient = function (html,options) { + var $ = utils.cheerio(html, { xmlMode: options && options.xmlMode}); + var doc = juiceDocument($,options); + + if (options && options.xmlMode){ + return doc.xml(); + } + else { + return utils.decodeEntities(doc.html()); + } +}; + +module.exports = juiceClient; + +juiceClient.ignoredPseudos = ['hover', 'active', 'focus', 'visited', 'link']; +juiceClient.widthElements = ['TABLE', 'TD', 'IMG']; +juiceClient.heightElements = ['TABLE', 'TD', 'IMG']; +juiceClient.tableElements = ['TABLE', 'TD', 'TH', 'TR', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'THEAD', 'TBODY', 'TFOOT']; +juiceClient.nonVisualElements = [ "HEAD", "TITLE", "BASE", "LINK", "STYLE", "META", "SCRIPT", "NOSCRIPT" ]; +juiceClient.styleToAttribute = { + 'background-color': 'bgcolor', + 'background-image': 'background', + 'text-align': 'align', + 'vertical-align': 'valign' +}; + +juiceClient.juiceDocument = juiceDocument; +juiceClient.inlineDocument = inlineDocument; +juiceClient.inlineContent = inlineContent; + +function inlineDocument($, css, options) { + + var rules = utils.parseCSS(css); + var editedElements = []; + + rules.forEach(handleRule); + editedElements.forEach(setStyleAttrs); + + if (options && options.inlinePseudoElements) { + editedElements.forEach(inlinePseudoElements); + } + + if (options && options.applyWidthAttributes) { + editedElements.forEach(function(el) { + setDimensionAttrs(el, 'width'); + }); + } + + if (options && options.applyHeightAttributes) { + editedElements.forEach(function(el) { + setDimensionAttrs(el, 'height'); + }); + } + + if (options && options.applyAttributesTableElements) { + editedElements.forEach(setAttributesOnTableElements); + } + + function handleRule(rule) { + var sel = rule[0]; + var style = rule[1]; + var selector = new utils.Selector(sel); + var parsedSelector = selector.parsed(); + var pseudoElementType = getPseudoElementType(parsedSelector); + + // skip rule if the selector has any pseudos which are ignored + for (var i = 0; i < parsedSelector.length; ++i) { + var subSel = parsedSelector[i]; + if (subSel.pseudos) { + for (var j = 0; j < subSel.pseudos.length; ++j) { + var subSelPseudo = subSel.pseudos[j]; + if (juiceClient.ignoredPseudos.indexOf(subSelPseudo.name) >= 0) { + return; + } + } + } + } + + if (pseudoElementType) { + var last = parsedSelector[parsedSelector.length - 1]; + var pseudos = last.pseudos; + last.pseudos = filterElementPseudos(last.pseudos); + sel = parsedSelector.toString(); + last.pseudos = pseudos; + } + + var els; + try { + els = $(sel); + } catch (err) { + // skip invalid selector + return; + } + + els.each(function () { + var el = this; + + if (el.name && juiceClient.nonVisualElements.indexOf(el.name.toUpperCase()) >= 0) { + return; + } + + if (pseudoElementType) { + var pseudoElPropName = "pseudo" + pseudoElementType; + var pseudoEl = el[pseudoElPropName]; + if (!pseudoEl) { + pseudoEl = el[pseudoElPropName] = $("").get(0); + pseudoEl.pseudoElementType = pseudoElementType; + pseudoEl.pseudoElementParent = el; + el[pseudoElPropName] = pseudoEl; + } + el = pseudoEl; + } + + if (!el.styleProps) { + el.styleProps = {}; + + // if the element has inline styles, fake selector with topmost specificity + if ($(el).attr('style')) { + var cssText = '* { ' + $(el).attr('style') + ' } '; + addProps(utils.parseCSS(cssText)[0][1], utils.styleSelector); + } + + // store reference to an element we need to compile style="" attr for + editedElements.push(el); + } + + // go through the properties + function addProps (style, selector) { + for (var i = 0, l = style.length; i < l; i++) { + var name = style[i]; + var value = style[name] + (options && options.preserveImportant && style._importants[name] ? ' !important' : ''); + var sel = style._importants[name] ? utils.importantSelector : selector; + var prop = new utils.Property(name, value, sel); + var existing = el.styleProps[name]; + + if (existing && existing.compare(prop) === prop || !existing) { + el.styleProps[name] = prop; + } + } + } + + addProps(style, selector); + }); + } + + function setStyleAttrs(el) { + var props = Object.keys(el.styleProps).map(function(key) { + return el.styleProps[key]; + }); + // sort properties by their originating selector's specificity so that + // props like "padding" and "padding-bottom" are resolved as expected. + props.sort(function(a, b) { + return a.selector.specificity().join("").localeCompare( + b.selector.specificity().join("")); + }); + var string = props + .filter(function(prop) { + // Content becomes the innerHTML of pseudo elements, not used as a + // style property + return prop.prop !== "content"; + }) + .map(function(prop) { + return prop.prop + ": " + prop.value.replace(/["]/g, "'") + ";"; + }) + .join(" "); + if (string) { + $(el).attr('style', string); + } + } + + function inlinePseudoElements(el) { + if (el.pseudoElementType && el.styleProps.content) { + $(el).html(parseContent(el.styleProps.content.value)); + var parent = el.pseudoElementParent; + if (el.pseudoElementType === "before") { + $(parent).prepend(el); + } + else { + $(parent).append(el); + } + } + } + + function setDimensionAttrs(el, dimension) { + var elName = el.name.toUpperCase(); + if (juiceClient[dimension + 'Elements'].indexOf(elName) > -1) { + for (var i in el.styleProps) { + if (el.styleProps[i].prop === dimension) { + if (el.styleProps[i].value.match(/px/)) { + var pxSize = el.styleProps[i].value.replace('px', ''); + $(el).attr(dimension, pxSize); + return; + } + if (juiceClient.tableElements.indexOf(elName) > -1 && el.styleProps[i].value.match(/\%/)) { + $(el).attr(dimension, el.styleProps[i].value); + return; + } + } + } + } + } + + function setAttributesOnTableElements(el) { + var elName = el.name.toUpperCase(), + styleProps = Object.keys(juiceClient.styleToAttribute); + + if (juiceClient.tableElements.indexOf(elName) > -1) { + for (var i in el.styleProps) { + if (styleProps.indexOf(el.styleProps[i].prop) > -1) { + $(el).attr(juiceClient.styleToAttribute[el.styleProps[i].prop], el.styleProps[i].value); + } + } + } + } +} + +function parseContent(content) { + if (content === "none" || content === "normal") { + return ""; + } + + // Naive parsing, assume well-formed value + content = content.slice(1, content.length - 1); + // Naive unescape, assume no unicode char codes + content = content.replace(/\\/g, ""); + return content; +} + +// Return "before" or "after" if the given selector is a pseudo element (e.g., +// a::after). +function getPseudoElementType(selector) { + if (selector.length === 0) { + return; + } + + var pseudos = selector[selector.length - 1].pseudos; + if (!pseudos) { + return; + } + + for (var i = 0; i < pseudos.length; i++) { + if (isPseudoElementName(pseudos[i])) { + return pseudos[i].name; + } + } +} + +function isPseudoElementName(pseudo) { + return pseudo.name === "before" || pseudo.name === "after"; +} + +function filterElementPseudos(pseudos) { + return pseudos.filter(function(pseudo) { + return !isPseudoElementName(pseudo); + }); +} + +function juiceDocument($, options) { + options = utils.getDefaultOptions(options); + var css = extractCssFromDocument($, options); + css += "\n" + options.extraCss; + inlineDocument($, css, options); + return $; +} + +function inlineContent(html, css, options) { + var $ = utils.cheerio(html, { xmlMode: options && options.xmlMode}); + inlineDocument($, css, options); + + if (options && options.xmlMode){ + return $.xml(); + } + else { + return utils.decodeEntities($.html()); + } +} + +function getStylesData($, options) { + var results = []; + var stylesList = $("style"); + var styleDataList, styleData, styleElement; + stylesList.each(function () { + styleElement = this; + styleDataList = styleElement.childNodes; + if (styleDataList.length !== 1) { + return; + } + styleData = styleDataList[0].data; + if ( options.applyStyleTags && styleElement.attribs['data-embed'] === undefined ) { + results.push( styleData ); + } + if ( options.removeStyleTags && styleElement.attribs['data-embed'] === undefined ) + { + var preservedText = utils.getPreservedText( styleElement.childNodes[0].nodeValue, { + mediaQueries: options.preserveMediaQueries, + fontFaces: options.preserveFontFaces + } ); + if ( preservedText ) + { + styleElement.childNodes[0].nodeValue = preservedText; + } + else + { + $(styleElement).remove(); + } + } + delete styleElement.attribs['data-embed']; + }); + return results; +} + +function extractCssFromDocument($, options) { + var results = getStylesData($, options); + var css = results.join("\n"); + return css; +} diff --git a/lib/juice.js b/lib/juice.js index 9d74e76..0c658b8 100644 --- a/lib/juice.js +++ b/lib/juice.js @@ -12,308 +12,57 @@ */ var utils = require('./utils'); -var Selector = require('./selector'); -var Property = require('./property'); var packageJson = require('../package'); var fs = require('fs'); var path = require('path'); -var styleSelector = new Selector('