diff --git a/packages/heml-elements/package-lock.json b/packages/heml-elements/package-lock.json index e5a05ed..e223d93 100644 --- a/packages/heml-elements/package-lock.json +++ b/packages/heml-elements/package-lock.json @@ -1,6 +1,6 @@ { "name": "@heml/elements", - "version": "1.0.0", + "version": "1.1.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -21,6 +21,11 @@ "ms": "2.0.0" } }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, "follow-redirects": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.2.5.tgz", diff --git a/packages/heml-elements/package.json b/packages/heml-elements/package.json index 3862838..096d251 100644 --- a/packages/heml-elements/package.json +++ b/packages/heml-elements/package.json @@ -24,6 +24,7 @@ "@heml/styles": "^1.1.2", "@heml/utils": "^1.1.2", "axios": "^0.17.0", + "escape-string-regexp": "^1.0.5", "image-size": "^0.6.1", "is-absolute-url": "^2.1.0", "lodash": "^4.17.4" diff --git a/packages/heml-elements/src/Block.js b/packages/heml-elements/src/Block.js index d64a487..848a5d1 100644 --- a/packages/heml-elements/src/Block.js +++ b/packages/heml-elements/src/Block.js @@ -1,5 +1,4 @@ import HEML, { createElement, transforms, cssGroups, condition } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' const { trueHide, @@ -31,6 +30,10 @@ export default createElement('block', { '.block__cell': [ { '@pseudo': 'cell' }, height, background, box, padding, border, borderRadius, 'vertical-align' ] }, + css (Style) { + return + }, + render (attrs, contents) { attrs.class += ' block' return ( @@ -42,11 +45,6 @@ export default createElement('block', { {condition('mso | IE', ``)} - ) } diff --git a/packages/heml-elements/src/Body.js b/packages/heml-elements/src/Body.js index b38aa63..9d2c374 100644 --- a/packages/heml-elements/src/Body.js +++ b/packages/heml-elements/src/Body.js @@ -1,5 +1,4 @@ import HEML, { createElement, transforms, cssGroups } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' import Preview from './Preview' const { @@ -23,6 +22,19 @@ export default createElement('body', { '.preview': [ { 'background-color': transforms.convertProp('color') } ] }, + css (Style) { + return + }, + async render (attrs, contents) { attrs.class += ' body' @@ -35,16 +47,6 @@ export default createElement('body', {
{'  '.repeat(30)}
- ) } }) diff --git a/packages/heml-elements/src/Button.js b/packages/heml-elements/src/Button.js index 494be72..db0ee7d 100644 --- a/packages/heml-elements/src/Button.js +++ b/packages/heml-elements/src/Button.js @@ -1,6 +1,5 @@ import HEML, { createElement, transforms, cssGroups } from '@heml/utils' // eslint-disable-line no-unused-vars import { omit, pick } from 'lodash' -import Style from './Style' const { background, @@ -37,6 +36,19 @@ export default createElement('button', { { '@pseudo': 'text' }, 'color', 'text-decoration' ] }, + css (Style) { + return + }, + render (attrs, contents) { attrs.class += ' button' @@ -57,16 +69,6 @@ export default createElement('button', { - ) } }) diff --git a/packages/heml-elements/src/Column.js b/packages/heml-elements/src/Column.js index a8afb68..11e3364 100644 --- a/packages/heml-elements/src/Column.js +++ b/packages/heml-elements/src/Column.js @@ -1,5 +1,5 @@ import HEML, { createElement, transforms, cssGroups } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' +import { times } from 'lodash' const { background, @@ -20,6 +20,20 @@ export default createElement('column', { '.column': [ { '@pseudo': 'root' }, { display: transforms.trueHide(undefined, true) }, background, box, padding, border, borderRadius, 'vertical-align' ] }, + css (Style) { + return + }, + render (attrs, contents) { const small = parseInt(attrs.small, 10) const large = parseInt(attrs.large, 10) @@ -29,19 +43,9 @@ export default createElement('column', { delete attrs.large delete attrs.small - return ([ + return ( {contents.length === 0 ? ' ' : contents} - , - small === large ? '' : () - ]) + ) } }) diff --git a/packages/heml-elements/src/Container.js b/packages/heml-elements/src/Container.js index 8e807a8..c93fa62 100644 --- a/packages/heml-elements/src/Container.js +++ b/packages/heml-elements/src/Container.js @@ -1,5 +1,4 @@ import HEML, { createElement, transforms, cssGroups, condition } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' const { trueHide, @@ -31,6 +30,16 @@ export default createElement('container', { '.container__cell': [ { '@pseudo': 'cell' }, height, background, box, padding, border, borderRadius ] }, + css (Style) { + return + }, + render (attrs, contents) { attrs.class += ' container' return ( @@ -42,13 +51,6 @@ export default createElement('container', { {condition('mso | IE', ``)} - ) } diff --git a/packages/heml-elements/src/Head.js b/packages/heml-elements/src/Head.js index b9852b9..d6f7191 100644 --- a/packages/heml-elements/src/Head.js +++ b/packages/heml-elements/src/Head.js @@ -1,6 +1,5 @@ import HEML, { createElement } from '@heml/utils' // eslint-disable-line no-unused-vars import Subject from './Subject' -import Style from './Style' export default createElement('head', { unique: true, @@ -48,8 +47,6 @@ export default createElement('head', { `} {Subject.flush()} - {await Style.flush()} - {``} {/* drop in the contents */ contents} {/* https://litmus.com/community/discussions/151-mystery-solved-dpi-scaling-in-outlook-2007-2013 */ diff --git a/packages/heml-elements/src/Hr.js b/packages/heml-elements/src/Hr.js index e2dfa77..27787ff 100644 --- a/packages/heml-elements/src/Hr.js +++ b/packages/heml-elements/src/Hr.js @@ -1,5 +1,4 @@ import HEML, { createElement, transforms, cssGroups, condition } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' const { trueHide, @@ -31,6 +30,17 @@ export default createElement('hr', { '.hr__cell': [ { '@pseudo': 'cell' }, height, background, box, padding, border, borderRadius, 'vertical-align' ] }, + css (Style) { + return + }, + + render (attrs, contents) { attrs.class += ' hr' return ( @@ -42,13 +52,6 @@ export default createElement('hr', { {condition('mso | IE', ``)} - ) } diff --git a/packages/heml-elements/src/Img.js b/packages/heml-elements/src/Img.js index f8b4159..681dc53 100644 --- a/packages/heml-elements/src/Img.js +++ b/packages/heml-elements/src/Img.js @@ -1,5 +1,4 @@ import HEML, { createElement, transforms } from '@heml/utils' // eslint-disable-line no-unused-vars -import Style from './Style' import { omit, has } from 'lodash' import fs from 'fs-extra' import isAbsoluteUrl from 'is-absolute-url' @@ -18,6 +17,15 @@ export default createElement('img', { 'img': [ { '@pseudo': 'root' }, { display: transforms.trueHide() }, '@default' ] }, + css (Style) { + return + }, + async render (attrs, contents) { const isBlock = !attrs.inline @@ -28,14 +36,7 @@ export default createElement('img', { attrs.class += ` ${isBlock ? 'img__block' : 'img__inline'}` attrs.style = isBlock ? '' : 'display: inline-block;' - return ([ - , - ]) + return } }) diff --git a/packages/heml-elements/src/Style.js b/packages/heml-elements/src/Style.js index 439b474..4b9f138 100644 --- a/packages/heml-elements/src/Style.js +++ b/packages/heml-elements/src/Style.js @@ -1,124 +1,170 @@ -import HEML, { createMetaElement } from '@heml/utils' // eslint-disable-line no-unused-vars +import HEML, { createElement, createMetaElement } from '@heml/utils' // eslint-disable-line no-unused-vars import hemlstyles from '@heml/styles' -import { castArray, isEqual, uniqWith, sortBy } from 'lodash' +import escape from 'escape-string-regexp' +import { castArray, isEqual, uniqWith, compact, flatMap } from 'lodash' -const START_EMBED_CSS = `/*!***START:EMBED_CSS*****/` -const START_INLINE_CSS = `/*!***START:INLINE_CSS*****/` - -let styleMap -let options +const COMMENT_EMBED_CSS = `/*!***EMBED_CSS*****/` +const COMMENT_INLINE_CSS = `/*!***INLINE_CSS*****/` +const COMMENT_IGNORE = `/*!***IGNORE_CSS*****/` +const IGNORE_REGEX = new RegExp(`(${escape(COMMENT_IGNORE)})`) +const COMMENT_REGEX = new RegExp(`(${escape(COMMENT_EMBED_CSS)}|${escape(COMMENT_INLINE_CSS)})`) export default createMetaElement('style', { parent: [ 'head' ], - attrs: [ 'for', 'heml-embed' ], - defaultAttrs: { - 'heml-embed': false, - 'for': 'global' + attrs: [ 'heml-embed' ], + defaultAttrs: { 'heml-embed': false }, + + async preRender (globals) { + const { $ } = globals + const options = buildOptions(globals) + const elementStyles = gatherElementStyles(globals) + const ignoredElementStyles = elementStyles.filter(({ ignore }) => ignore) + /** user given style[heml-ignore] are ignored through the findNodes query */ + const $nodes = $.findNodes('style') + const styles = nodesToStyles($nodes) + + const { css: processedCss } = await hemlstyles(stylesToCss([ ...elementStyles, ...styles ]), options) + const fullCss = insertIgnoredStyles(processedCss, ignoredElementStyles) + const processedStyles = cssToStyles(fullCss) + + $nodes.forEach(($node) => $node.remove()) + $('head').append(stylesToTags(processedStyles)) }, - preRender ({ $, elements }) { - styleMap = new Map([ [ 'global', [] ] ]) - options = { - plugins: [], - elements: {}, - aliases: {} - } - - for (let element of elements) { - if (element.postcss) { - options.plugins = options.plugins.concat(castArray(element.postcss)) - } + render (attrs, contents) { + return true + } +}) - if (element.rules) { - options.elements[element.tagName] = element.rules - } +/** + * Generates the options object to be passed to hemlstyles + * @param {Object} globals + * @return {Object} options for hemlstyles + */ +function buildOptions ({ $, elements }) { + const options = { + plugins: [], + elements: {}, + aliases: {} + } - options.aliases[element.tagName] = $.findNodes(element.tagName) + for (let element of elements) { + if (element.postcss) { + options.plugins = [ options.plugins, ...castArray(element.postcss) ] } - }, - render (attrs, contents) { - if (!styleMap.get(attrs.for)) { - styleMap.set(attrs.for, []) + if (element.rules) { + options.elements[element.tagName] = element.rules } - styleMap.get(attrs.for).push({ - embed: !!attrs['heml-embed'], - ignore: !!attrs['heml-ignore'], - css: contents - }) - - return false - }, - - async flush () { - /** - * reverse the styles so they fall in an order that mirrors their position - * - they get rendered bottom to top - should be styled top to bottom - * - * the global styles should always be rendered last - */ - const globalStyles = styleMap.get('global') - styleMap.delete('global') - styleMap = new Map([...styleMap].reverse()) - styleMap.set('global', globalStyles) - - let ignoredCSS = [] - let fullCSS = '' - - /** combine the non-ignored css to be combined */ - for (let [ element, styles ] of styleMap) { - styles = uniqWith(styles, isEqual) - styles = element === 'global' ? styles : sortBy(styles, ['embed', 'css']) - - styles.forEach(({ ignore, embed, css }) => { - /** replace the ignored css with placeholders that will be swapped later */ - if (ignore) { - ignoredCSS.push({ embed, css }) - fullCSS += ignoreComment(ignoredCSS.length - 1) - } else if (embed) { - fullCSS += `${START_EMBED_CSS}${css}` - } else { - fullCSS += `${START_INLINE_CSS}${css}` - } - }) - } + options.aliases[element.tagName] = $.findNodes(element.tagName) + } - let { css: processedCss } = await hemlstyles(fullCSS, options) + return options +} - /** put the ignored css back in */ - ignoredCSS.forEach(({ embed, css }, index) => { - processedCss = processedCss.replace(ignoreComment(index), embed ? `${START_EMBED_CSS}${css}` : `${START_INLINE_CSS}${css}`) - }) +/** + * Gather all the style objects from the elements + * @param {Object} globals + * @return {Array[Object]} an Array of style objects + */ +function gatherElementStyles ({ elements }) { + return compact(flatMap(elements, (element) => { + if (!element.css) { return } + + return castArray(element.css(StyleObjectElement)).map(JSON.parse) + })) +} - /** split on the dividers and map it so each part starts with INLINE or EMBED */ - let processedCssParts = processedCss.split(/\/\*!\*\*\*START:/g).splice(1).map((css) => css.replace(/_CSS\*\*\*\*\*\//, '')) +const StyleObjectElement = createElement('style-object', (attrs, contents) => { + return JSON.stringify({ + embed: !!attrs['heml-embed'], + ignore: !!attrs['heml-ignore'], + css: contents + }) +}) - /** build the html */ - let html = '' - let lastType = null +/** + * Converts an array of $nodes into style objects + * scope = { embed: Boolean, css: String } + * @param {Array[Cheerio]} $nodes + * @return {Array[Object]} + */ +function nodesToStyles ($nodes) { + return $nodes.map(($node) => { + return { css: $node.html(), embed: $node.is('[heml-embed]') } + }) +} - for (let cssPart of processedCssParts) { - const css = cssPart.replace(/^(EMBED|INLINE)/, '') - const type = cssPart.startsWith('EMBED') ? 'EMBED' : 'INLINE' +/** + * Converts an array of style objects into a single string to be processed by hemlstyles + * @param {Array[Object]} styles + * @return {String} + */ +function stylesToCss (styles) { + styles = uniqWith(styles, isEqual) + + let lastEmbed + let fullCss = '' + for (let { embed, ignore, css } of styles) { + if (lastEmbed !== embed) { + lastEmbed = embed + fullCss += embed ? COMMENT_EMBED_CSS : COMMENT_INLINE_CSS + } - if (type === lastType) { - html += css - } else { - lastType = type - html += `${html === '' ? '' : ''}\n${css}\n` - } + if (ignore) { + fullCss += COMMENT_IGNORE + continue } - html += '' + fullCss += css + } - /** reset the styles and options */ - styleMap = options = null + return fullCss +} - return html +/** + * Inserts the CSS from the ignored styles into the CSS + * @param {String} css the css + * @param {Array[Object]} ignoredStyles an array of ignored styles + * @return {String} the full CSS + */ +function insertIgnoredStyles (css, ignoredStyles) { + for (const { css: ignoredCss } of ignoredStyles) { + css = css.replace(IGNORE_REGEX, ignoredCss) } -}) -function ignoreComment (index) { - return `/*!***IGNORE_${index}*****/` + return css +} + +/** + * Converts a css string into array of style objects + * @param {String} css a string of CSS + * @return {Array[Object]} styles + */ +function cssToStyles (css) { + const parts = compact(css.split(COMMENT_REGEX)) + + return parts.reduce((styles, value) => { + if (value === COMMENT_EMBED_CSS) { + return [ ...styles, { embed: true, css: '' } ] + } else if (value === COMMENT_INLINE_CSS) { + return [ ...styles, { embed: false, css: '' } ] + } else { + styles[styles.length - 1].css += value + + return styles + } + }, []).filter((style) => style.css.length > 0) +} + +/** + * Convert style objects into html tags + * @param {Array[Object]} styles + * @return {Array[String]} + */ +function stylesToTags (styles) { + return styles.map(({ embed, css }) => { + return `\n` + }) }