inline.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. 'use strict';
  2. var utils = require('./utils');
  3. var numbers = require('./numbers');
  4. module.exports = function makeJuiceClient(juiceClient) {
  5. juiceClient.ignoredPseudos = ['hover', 'active', 'focus', 'visited', 'link'];
  6. juiceClient.widthElements = ['TABLE', 'TD', 'TH', 'IMG'];
  7. juiceClient.heightElements = ['TABLE', 'TD', 'TH', 'IMG'];
  8. juiceClient.tableElements = ['TABLE', 'TH', 'TR', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'THEAD', 'TBODY', 'TFOOT'];
  9. juiceClient.nonVisualElements = [ 'HEAD', 'TITLE', 'BASE', 'LINK', 'STYLE', 'META', 'SCRIPT', 'NOSCRIPT' ];
  10. juiceClient.styleToAttribute = {
  11. 'background-color': 'bgcolor',
  12. 'background-image': 'background',
  13. 'text-align': 'align',
  14. 'vertical-align': 'valign'
  15. };
  16. juiceClient.excludedProperties = [];
  17. juiceClient.juiceDocument = juiceDocument;
  18. juiceClient.inlineDocument = inlineDocument;
  19. function inlineDocument($, css, options) {
  20. options = options || {};
  21. var rules = utils.parseCSS(css);
  22. var editedElements = [];
  23. var styleAttributeName = 'style';
  24. var counters = {};
  25. if (options.styleAttributeName) {
  26. styleAttributeName = options.styleAttributeName;
  27. }
  28. rules.forEach(handleRule);
  29. editedElements.forEach(setStyleAttrs);
  30. if (options.inlinePseudoElements) {
  31. editedElements.forEach(inlinePseudoElements);
  32. }
  33. if (options.applyWidthAttributes) {
  34. editedElements.forEach(function(el) {
  35. setDimensionAttrs(el, 'width');
  36. });
  37. }
  38. if (options.applyHeightAttributes) {
  39. editedElements.forEach(function(el) {
  40. setDimensionAttrs(el, 'height');
  41. });
  42. }
  43. if (options.applyAttributesTableElements) {
  44. editedElements.forEach(setAttributesOnTableElements);
  45. }
  46. if (options.insertPreservedExtraCss && options.extraCss) {
  47. var preservedText = utils.getPreservedText(options.extraCss, {
  48. mediaQueries: options.preserveMediaQueries,
  49. fontFaces: options.preserveFontFaces,
  50. keyFrames: options.preserveKeyFrames
  51. });
  52. if (preservedText) {
  53. var $appendTo = null;
  54. if (options.insertPreservedExtraCss !== true) {
  55. $appendTo = $(options.insertPreservedExtraCss);
  56. } else {
  57. $appendTo = $('head');
  58. if (!$appendTo.length) { $appendTo = $('body'); }
  59. if (!$appendTo.length) { $appendTo = $.root(); }
  60. }
  61. $appendTo.first().append('<style>' + preservedText + '</style>');
  62. }
  63. }
  64. function handleRule(rule) {
  65. var sel = rule[0];
  66. var style = rule[1];
  67. var selector = new utils.Selector(sel);
  68. var parsedSelector = selector.parsed();
  69. if (!parsedSelector) {
  70. return;
  71. }
  72. var pseudoElementType = getPseudoElementType(parsedSelector);
  73. // skip rule if the selector has any pseudos which are ignored
  74. for (var i = 0; i < parsedSelector.length; ++i) {
  75. var subSel = parsedSelector[i];
  76. if (subSel.pseudos) {
  77. for (var j = 0; j < subSel.pseudos.length; ++j) {
  78. var subSelPseudo = subSel.pseudos[j];
  79. if (juiceClient.ignoredPseudos.indexOf(subSelPseudo.name) >= 0) {
  80. return;
  81. }
  82. }
  83. }
  84. }
  85. if (pseudoElementType) {
  86. var last = parsedSelector[parsedSelector.length - 1];
  87. var pseudos = last.pseudos;
  88. last.pseudos = filterElementPseudos(last.pseudos);
  89. sel = parsedSelector.toString();
  90. last.pseudos = pseudos;
  91. }
  92. var els;
  93. try {
  94. els = $(sel);
  95. } catch (err) {
  96. // skip invalid selector
  97. return;
  98. }
  99. els.each(function() {
  100. var el = this;
  101. if (el.name && juiceClient.nonVisualElements.indexOf(el.name.toUpperCase()) >= 0) {
  102. return;
  103. }
  104. if (pseudoElementType) {
  105. var pseudoElPropName = 'pseudo' + pseudoElementType;
  106. var pseudoEl = el[pseudoElPropName];
  107. if (!pseudoEl) {
  108. pseudoEl = el[pseudoElPropName] = $('<span />').get(0);
  109. pseudoEl.pseudoElementType = pseudoElementType;
  110. pseudoEl.pseudoElementParent = el;
  111. pseudoEl.counterProps = el.counterProps;
  112. el[pseudoElPropName] = pseudoEl;
  113. }
  114. el = pseudoEl;
  115. }
  116. if (!el.styleProps) {
  117. el.styleProps = {};
  118. // if the element has inline styles, fake selector with topmost specificity
  119. if ($(el).attr(styleAttributeName)) {
  120. var cssText = '* { ' + $(el).attr(styleAttributeName) + ' } ';
  121. addProps(utils.parseCSS(cssText)[0][1], new utils.Selector('<style>', true));
  122. }
  123. // store reference to an element we need to compile style="" attr for
  124. editedElements.push(el);
  125. }
  126. if (!el.counterProps) {
  127. el.counterProps = el.parent && el.parent.counterProps
  128. ? Object.create(el.parent.counterProps)
  129. : {};
  130. }
  131. function resetCounter(el, value) {
  132. var tokens = value.split(/\s+/);
  133. for (var j = 0; j < tokens.length; j++) {
  134. var counter = tokens[j];
  135. var resetval = parseInt(tokens[j+1], 10);
  136. isNaN(resetval)
  137. ? el.counterProps[counter] = counters[counter] = 0
  138. : el.counterProps[counter] = counters[tokens[j++]] = resetval;
  139. }
  140. }
  141. function incrementCounter(el, value) {
  142. var tokens = value.split(/\s+/);
  143. for (var j = 0; j < tokens.length; j++) {
  144. var counter = tokens[j];
  145. if (el.counterProps[counter] === undefined) {
  146. continue;
  147. }
  148. var incrval = parseInt(tokens[j+1], 10);
  149. isNaN(incrval)
  150. ? el.counterProps[counter] = counters[counter] += 1
  151. : el.counterProps[counter] = counters[tokens[j++]] += incrval;
  152. }
  153. }
  154. // go through the properties
  155. function addProps(style, selector) {
  156. for (var i = 0, l = style.length; i < l; i++) {
  157. if (style[i].type == 'property') {
  158. var name = style[i].name;
  159. var value = style[i].value;
  160. if (name === 'counter-reset') {
  161. resetCounter(el, value);
  162. }
  163. if (name === 'counter-increment') {
  164. incrementCounter(el, value);
  165. }
  166. var important = value.match(/!important$/) !== null;
  167. if (important && !options.preserveImportant) value = removeImportant(value);
  168. // adds line number and column number for the properties as "additionalPriority" to the
  169. // properties because in CSS the position directly affect the priority.
  170. var additionalPriority = [style[i].position.start.line, style[i].position.start.col];
  171. var prop = new utils.Property(name, value, selector, important ? 2 : 0, additionalPriority);
  172. var existing = el.styleProps[name];
  173. // if property name is not in the excluded properties array
  174. if (juiceClient.excludedProperties.indexOf(name) < 0) {
  175. if (existing && existing.compare(prop) === prop || !existing) {
  176. // deleting a property let us change the order (move it to the end in the setStyleAttrs loop)
  177. if (existing && existing.selector !== selector) {
  178. delete el.styleProps[name];
  179. } else if (existing) {
  180. // make "prop" a special composed property.
  181. prop.nextProp = existing;
  182. }
  183. el.styleProps[name] = prop;
  184. }
  185. }
  186. }
  187. }
  188. }
  189. addProps(style, selector);
  190. });
  191. }
  192. function setStyleAttrs(el) {
  193. var l = Object.keys(el.styleProps).length;
  194. var props = [];
  195. // Here we loop each property and make sure to "expand"
  196. // linked "nextProp" properties happening when the same property
  197. // is declared multiple times in the same selector.
  198. Object.keys(el.styleProps).forEach(function(key) {
  199. var np = el.styleProps[key];
  200. while (typeof np !== 'undefined') {
  201. props.push(np);
  202. np = np.nextProp;
  203. }
  204. });
  205. // sort properties by their originating selector's specificity so that
  206. // props like "padding" and "padding-bottom" are resolved as expected.
  207. props.sort(function(a, b) {
  208. return a.compareFunc(b);
  209. });
  210. var string = props
  211. .filter(function(prop) {
  212. // Content becomes the innerHTML of pseudo elements, not used as a
  213. // style property
  214. return prop.prop !== 'content';
  215. })
  216. .map(function(prop) {
  217. return prop.prop + ': ' + prop.value.replace(/["]/g, '\'') + ';';
  218. })
  219. .join(' ');
  220. if (string) {
  221. $(el).attr(styleAttributeName, string);
  222. }
  223. }
  224. function inlinePseudoElements(el) {
  225. if (el.pseudoElementType && el.styleProps.content) {
  226. var parsed = parseContent(el);
  227. if (parsed.img) {
  228. el.name = 'img';
  229. $(el).attr('src', parsed.img);
  230. } else {
  231. $(el).text(parsed);
  232. }
  233. var parent = el.pseudoElementParent;
  234. if (el.pseudoElementType === 'before') {
  235. $(parent).prepend(el);
  236. } else {
  237. $(parent).append(el);
  238. }
  239. }
  240. }
  241. function setDimensionAttrs(el, dimension) {
  242. if (!el.name) { return; }
  243. var elName = el.name.toUpperCase();
  244. if (juiceClient[dimension + 'Elements'].indexOf(elName) > -1) {
  245. for (var i in el.styleProps) {
  246. if (el.styleProps[i].prop === dimension) {
  247. var value = el.styleProps[i].value;
  248. if (options.preserveImportant) {
  249. value = removeImportant(value);
  250. }
  251. if (value.match(/px/)) {
  252. var pxSize = value.replace('px', '');
  253. $(el).attr(dimension, pxSize);
  254. return;
  255. }
  256. if (juiceClient.tableElements.indexOf(elName) > -1 && value.match(/\%/)) {
  257. $(el).attr(dimension, value);
  258. return;
  259. }
  260. }
  261. }
  262. }
  263. }
  264. function extractBackgroundUrl(value) {
  265. return value.indexOf('url(') !== 0
  266. ? value
  267. : value.replace(/^url\((["'])?([^"']+)\1\)$/, '$2');
  268. }
  269. function setAttributesOnTableElements(el) {
  270. if (!el.name) { return; }
  271. var elName = el.name.toUpperCase();
  272. var styleProps = Object.keys(juiceClient.styleToAttribute);
  273. if (juiceClient.tableElements.indexOf(elName) > -1) {
  274. for (var i in el.styleProps) {
  275. if (styleProps.indexOf(el.styleProps[i].prop) > -1) {
  276. var prop = juiceClient.styleToAttribute[el.styleProps[i].prop];
  277. var value = el.styleProps[i].value;
  278. if (options.preserveImportant) {
  279. value = removeImportant(value);
  280. }
  281. if (prop === 'background') {
  282. value = extractBackgroundUrl(value);
  283. }
  284. if (/(linear|radial)-gradient\(/i.test(value)) {
  285. continue;
  286. }
  287. $(el).attr(prop, value);
  288. }
  289. }
  290. }
  291. }
  292. }
  293. function removeImportant(value) {
  294. return value.replace(/\s*!important$/, '')
  295. }
  296. function findVariableValue(el, variable) {
  297. while (el) {
  298. if (variable in el.styleProps) {
  299. return el.styleProps[variable].value;
  300. }
  301. var el = el.pseudoElementParent || el.parent;
  302. }
  303. }
  304. function applyCounterStyle(counter, style) {
  305. switch (style) {
  306. case 'lower-roman':
  307. return numbers.romanize(counter).toLowerCase();
  308. case 'upper-roman':
  309. return numbers.romanize(counter);
  310. case 'lower-latin':
  311. case 'lower-alpha':
  312. return numbers.alphanumeric(counter).toLowerCase();
  313. case 'upper-latin':
  314. case 'upper-alpha':
  315. return numbers.alphanumeric(counter);
  316. // TODO support more counter styles
  317. default:
  318. return counter.toString();
  319. }
  320. }
  321. function parseContent(el) {
  322. var content = el.styleProps.content.value;
  323. if (content === 'none' || content === 'normal') {
  324. return '';
  325. }
  326. var imageUrlMatch = content.match(/^\s*url\s*\(\s*(.*?)\s*\)\s*$/i);
  327. if (imageUrlMatch) {
  328. var url = imageUrlMatch[1].replace(/^['"]|['"]$/g, '');
  329. return { img: url };
  330. }
  331. var parsed = [];
  332. var tokens = content.split(/['"]/);
  333. for (var i = 0; i < tokens.length; i++) {
  334. if (tokens[i] === '') continue;
  335. var varMatch = tokens[i].match(/var\s*\(\s*(.*?)\s*(,\s*(.*?)\s*)?\s*\)/i);
  336. if (varMatch) {
  337. var variable = findVariableValue(el, varMatch[1]) || varMatch[2];
  338. parsed.push(variable.replace(/^['"]|['"]$/g, ''));
  339. continue;
  340. }
  341. var counterMatch = tokens[i].match(/counter\s*\(\s*(.*?)\s*(,\s*(.*?)\s*)?\s*\)/i);
  342. if (counterMatch && counterMatch[1] in el.counterProps) {
  343. var counter = el.counterProps[counterMatch[1]];
  344. parsed.push(applyCounterStyle(counter, counterMatch[3]));
  345. continue;
  346. }
  347. var attrMatch = tokens[i].match(/attr\s*\(\s*(.*?)\s*\)/i);
  348. if (attrMatch) {
  349. var attr = attrMatch[1];
  350. parsed.push(el.pseudoElementParent
  351. ? el.pseudoElementParent.attribs[attr]
  352. : el.attribs[attr]
  353. );
  354. continue;
  355. }
  356. parsed.push(tokens[i]);
  357. }
  358. content = parsed.join('');
  359. // Naive unescape, assume no unicode char codes
  360. content = content.replace(/\\/g, '');
  361. return content;
  362. }
  363. // Return "before" or "after" if the given selector is a pseudo element (e.g.,
  364. // a::after).
  365. function getPseudoElementType(selector) {
  366. if (selector.length === 0) {
  367. return;
  368. }
  369. var pseudos = selector[selector.length - 1].pseudos;
  370. if (!pseudos) {
  371. return;
  372. }
  373. for (var i = 0; i < pseudos.length; i++) {
  374. if (isPseudoElementName(pseudos[i])) {
  375. return pseudos[i].name;
  376. }
  377. }
  378. }
  379. function isPseudoElementName(pseudo) {
  380. return pseudo.name === 'before' || pseudo.name === 'after';
  381. }
  382. function filterElementPseudos(pseudos) {
  383. return pseudos.filter(function(pseudo) {
  384. return !isPseudoElementName(pseudo);
  385. });
  386. }
  387. function juiceDocument($, options) {
  388. options = utils.getDefaultOptions(options);
  389. var css = extractCssFromDocument($, options);
  390. css += '\n' + options.extraCss;
  391. inlineDocument($, css, options);
  392. return $;
  393. }
  394. function getStylesData($, options) {
  395. var results = [];
  396. var stylesList = $('style');
  397. var styleDataList, styleData, styleElement;
  398. stylesList.each(function() {
  399. styleElement = this;
  400. // the API for Cheerio using parse5 (default) and htmlparser2 are slightly different
  401. // detect this by checking if .childNodes exist (as opposed to .children)
  402. var usingParse5 = !!styleElement.childNodes;
  403. styleDataList = usingParse5 ? styleElement.childNodes : styleElement.children;
  404. if (styleDataList.length !== 1) {
  405. if (options.removeStyleTags) {
  406. $(styleElement).remove();
  407. }
  408. return;
  409. }
  410. styleData = styleDataList[0].data;
  411. if (options.applyStyleTags && $(styleElement).attr('data-embed') === undefined) {
  412. results.push(styleData);
  413. }
  414. if (options.removeStyleTags && $(styleElement).attr('data-embed') === undefined) {
  415. var text = usingParse5 ? styleElement.childNodes[0].nodeValue : styleElement.children[0].data;
  416. var preservedText = utils.getPreservedText(text, {
  417. mediaQueries: options.preserveMediaQueries,
  418. fontFaces: options.preserveFontFaces,
  419. keyFrames: options.preserveKeyFrames,
  420. pseudos: options.preservePseudos
  421. }, juiceClient.ignoredPseudos);
  422. if (preservedText) {
  423. if (usingParse5) {
  424. styleElement.childNodes[0].nodeValue = preservedText;
  425. } else {
  426. styleElement.children[0].data = preservedText;
  427. }
  428. } else {
  429. $(styleElement).remove();
  430. }
  431. }
  432. $(styleElement).removeAttr('data-embed');
  433. });
  434. return results;
  435. }
  436. function extractCssFromDocument($, options) {
  437. var results = getStylesData($, options);
  438. var css = results.join('\n');
  439. return css;
  440. }
  441. return juiceClient;
  442. };