├── __templates
├── partials
│ ├── empty-text.hbs
│ ├── preheader.hbs
│ └── content.hbs
└── index.tpl
├── .gitignore
├── gulpfile.js
├── gulptasks
├── core
│ ├── core-variant-list.js
│ ├── core-errors.js
│ ├── core-clean.js
│ ├── core-watch.js
│ ├── core-watch-notification.js
│ ├── core-paths.js
│ ├── core-templates.js
│ └── core-mjml.js
├── default.js
├── build.js
├── variants
│ ├── variants-build.js
│ └── variants-prepare.js
└── style
│ ├── style-inject.js
│ └── style-compile.js
├── .stylelintrc
├── __styles
└── source-stylesheet.scss
├── package.json
└── README.md
/__templates/partials/empty-text.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .*.yml
3 | __templates/variants
4 | dist
5 | styles
6 | *.log
7 |
--------------------------------------------------------------------------------
/__templates/partials/preheader.hbs:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TASKS_DIR = require( 'require-dir' )( './gulptasks', { recurse: true } )
4 |
--------------------------------------------------------------------------------
/gulptasks/core/core-variant-list.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const EMAIL_VARIANTS = {
4 | 'confirm-subscripton': {}
5 | , 'fragment': {}
6 | }
7 |
8 | module.exports = EMAIL_VARIANTS
9 |
--------------------------------------------------------------------------------
/__templates/partials/content.hbs:
--------------------------------------------------------------------------------
1 | {{_d.title}}
2 |
3 | Some content.
4 | The produced HTML can also host its own template variable:
5 | \{{anotherPostProductionVariable}} easily tag along with {{_d.staticContent}} prerendered variables.
6 |
7 |
--------------------------------------------------------------------------------
/gulptasks/default.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , SEQUENCE = require( 'gulp-sequence' ).use( GULP )
5 |
6 | let defaultGulpTask = () => SEQUENCE( 'build', 'core-watch' )()
7 |
8 | GULP.start( 'build')
9 |
10 | GULP.task(
11 | 'default'
12 | , defaultGulpTask
13 | )
14 | module.exports = defaultGulpTask
15 |
--------------------------------------------------------------------------------
/gulptasks/core/core-errors.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const GULP = require( 'gulp' )
4 | , GUTIL = require( 'gulp-util' )
5 |
6 | let onError = function( taskName, err ){
7 | GUTIL.log( GUTIL.colors.red( 'ERROR', taskName ), err);
8 | this.emit( 'end', new GUTIL.PluginError( taskName, err, { showStack: true } ) );
9 | }
10 |
11 | module.exports = onError
12 |
--------------------------------------------------------------------------------
/gulptasks/build.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , SEQUENCE = require( 'gulp-sequence' )
5 |
6 | let build = function(){
7 | return SEQUENCE(
8 | 'core-clean'
9 | , 'core-watch-notification'
10 | , 'variants-prepare'
11 | , 'style-compile'
12 | , 'style-inject'
13 | , 'variants-build'
14 | )()
15 | }
16 |
17 | GULP.task(
18 | 'build'
19 | , build
20 | )
21 | module.exports = build
22 |
--------------------------------------------------------------------------------
/gulptasks/core/core-clean.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , DEL = require( 'del' )
5 | , PATHS = require( './core-paths' )
6 |
7 | let cleanUp = function(){
8 | console.log( '\n\u001b[38;5;202mDeleting…\u001b[0m')
9 | return DEL([
10 | `${PATHS.dir.variants.variants}**/*`
11 | , `${PATHS.dir.variants.dist}*`
12 | , `${PATHS.dir.styles.dist}*`
13 | ])
14 | }
15 |
16 | GULP.task( 'core-clean', cleanUp )
17 |
18 | // module.exports = [ 'core']
19 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "block-no-empty": null,
4 | "color-no-invalid-hex": true,
5 | "declaration-colon-space-after": "always",
6 | "indentation": [
7 | 2,
8 | {
9 | "except": ["value"],
10 | "indentInsideParens": ["once-at-root-twice-in-block"]
11 | }
12 | ],
13 | "max-empty-lines": 2,
14 | "unit-whitelist": [
15 | "em"
16 | , "rem"
17 | , "%"
18 | , "px"
19 | , "s"
20 | , "vw"
21 | , "vh"
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/gulptasks/core/core-watch.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , PATHS = require( './core-paths' )
5 | , FILES_TO_WATCH_OUT = [
6 | `${PATHS.dir.styles.source}**/*+(${PATHS.file.lessStylesheets}|${PATHS.file.sassStylesheets})`
7 | , `${PATHS.dir.variants.source}**/*${PATHS.file.template}`
8 | ]
9 |
10 | let watchAll = () => GULP.watch( FILES_TO_WATCH_OUT, [ 'build' ], [ 'change' ] ) // https://github.com/floatdrop/gulp-watch#api
11 |
12 | GULP.task( 'core-watch', watchAll )
13 | module.export = watchAll
14 |
--------------------------------------------------------------------------------
/gulptasks/core/core-watch-notification.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , PATHS = require( './core-paths' )
5 | , FILES_TO_WATCH_OUT = [
6 | `${PATHS.dir.styles.source}**/*${PATHS.file.sourceStylesheet}`
7 | , `${PATHS.dir.variants.source}**/*${PATHS.file.template}`
8 | ]
9 |
10 | let watchNotification = function(){
11 | console.log( '\n\u001b[38;5;202;1m( ͡° ͜ʖ ͡°) WATCHED\n\u001b[0m')
12 | return true
13 | }
14 |
15 | GULP.task( 'core-watch-notification', watchNotification )
16 |
17 | module.export = watchNotification
18 |
--------------------------------------------------------------------------------
/__styles/source-stylesheet.scss:
--------------------------------------------------------------------------------
1 | mj-text, mj-list, mj-button, mj-table, .is-heading{
2 | font-family: 'Roboto Condensed', 'Roboto', sans-serif;
3 | color: #333;
4 | }
5 |
6 | .theme-forest{
7 | background-color: #2FDFAA;
8 | }
9 |
10 | .theme-tomato{
11 | background-color: tomato;
12 | }
13 |
14 | mj-section{
15 | full-width: "full-width";
16 | padding: 0;
17 | }
18 |
19 | mj-column{
20 | padding-left: 0;
21 | padding-right: 0;
22 | background-color: #f2f2f2;
23 | }
24 |
25 | mj-text{
26 | padding: 0;
27 | }
28 |
29 | .is-empty{
30 | padding: 0;
31 | font-size: 0;
32 | line-height: 0;
33 | }
34 |
--------------------------------------------------------------------------------
/gulptasks/core/core-paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const PATHS = {
4 | dir: {
5 | base: './'
6 | , variants: {
7 | source: './__templates/'
8 | , partials: './__templates/partials/'
9 | , variants: './__templates/variants/'
10 | , dist: './dist/'
11 | }
12 | , styles: {
13 | source: './__styles/'
14 | , dist: './styles/'
15 | }
16 | }
17 | , file: {
18 | template: '.+(tpl|hbs)'
19 | , variant: '.mjml'
20 | , lessStylesheets: '.less'
21 | , sassStylesheets: '.scss'
22 | , stylesheet: '.css'
23 | , production: '.html'
24 | }
25 | }
26 |
27 | module.exports = PATHS;
28 |
--------------------------------------------------------------------------------
/gulptasks/core/core-templates.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | let templates = function(){
4 | const TEMPLATE_DATA = [
5 | {
6 | name: 'email-A'
7 | , _d: {
8 | title: 'My title A'
9 | , preheader: 'A catchy headline that\'ll boost up your opens rate'
10 | , staticContent: 'any'
11 | , theme: 'forest'
12 | }
13 | }
14 | , {
15 | name: 'email-B'
16 | , _d: {
17 | title: 'Some random title B'
18 | , preheader: 'Preheader headline'
19 | , staticContent: 'all'
20 | , theme: 'tomato'
21 | }
22 | }
23 | ]
24 |
25 | return {
26 | data: TEMPLATE_DATA
27 | }
28 | }
29 |
30 | module.exports = templates();
31 |
--------------------------------------------------------------------------------
/__templates/index.tpl:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 | {{> content}}
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/gulptasks/variants/variants-build.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , MJML_ENGINE = require( 'mjml' )
5 | , MJML = require( 'gulp-mjml' )
6 | , HTML_CLEAN = require( 'gulp-htmlclean' )
7 | , HTML_MIN = require( 'gulp-htmlmin' )
8 | , RENAME = require( 'gulp-rename' )
9 | , PATHS = require( '../core/core-paths' )
10 | , ON_ERROR = require( '../core/core-errors' )
11 |
12 | let buildVariant = function(){
13 | console.log( '\n\u001b[38;5;120;1m> MJML \u001b[0m\u001b[38;5;115m Building variant…\u001b[0m')
14 |
15 | return GULP.src( `${PATHS.dir.variants.variants}**/*${PATHS.file.variant}` )
16 | .pipe( MJML( MJML_ENGINE ) )
17 | .pipe( HTML_CLEAN() )
18 | .pipe( HTML_MIN({
19 | maxLineLength: 960
20 | }) )
21 | .pipe( RENAME({
22 | extname: PATHS.file.production
23 | }) )
24 | .pipe( GULP.dest( PATHS.dir.variants.dist ) )
25 | }
26 |
27 | GULP.task( 'variants-build', buildVariant )
28 |
29 | module.exports = buildVariant
30 |
--------------------------------------------------------------------------------
/gulptasks/style/style-inject.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , SMOOSHER = require( 'gulp-smoosher' )
5 | , INLINE_CSS = require( 'gulp-inline-css' )
6 | , MJML_DICTIONARY = require( '../core/core-mjml' )
7 | , PATHS = require( '../core/core-paths' )
8 | , ON_ERROR = require( '../core/core-errors' )
9 |
10 |
11 | let injectStyleInVariants = function(){
12 | console.log( '\n\u001b[38;5;120;1m> INJECTION \u001b[0m\u001b[38;5;115m Embedding rules…\u001b[0m')
13 | let onInjectStyleError = ( err ) => ON_ERROR( 'email:inject-css', err )
14 | return GULP.src( `${PATHS.dir.variants.variants}*${PATHS.file.variant}` )
15 | .pipe( SMOOSHER() )
16 | .pipe( INLINE_CSS({
17 | applyWidthAttributes : true
18 | , preserveMediaQueries : true
19 | , removeStyleTags : true
20 | , applyAttributesTo : MJML_DICTIONARY
21 | }).on( 'error', onInjectStyleError ) )
22 | .pipe( GULP.dest( PATHS.dir.variants.variants ) )
23 | }
24 |
25 | GULP.task( 'style-inject', injectStyleInVariants )
26 |
27 | module.exports = injectStyleInVariants
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gulp-mjml-hbs-pipeline ",
3 | "version": "0.7.3",
4 | "description": "",
5 | "main": "gulpfile.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "daylon",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "del": "^3.0.0",
13 | "event-stream": "^4.0.0",
14 | "gulp": "^3.9.1",
15 | "gulp-autoprefixer": "^6.0.0",
16 | "gulp-compile-handlebars": "^0.6.1",
17 | "gulp-csso": "^3.0.0",
18 | "gulp-dest": "^0.2.3",
19 | "gulp-group-css-media-queries": "^1.1.0",
20 | "gulp-htmlclean": "^2.7.6",
21 | "gulp-htmlmin": "^5.0.0",
22 | "gulp-inline-css": "daylon/gulp-inline-css",
23 | "gulp-less": "^4.0.0",
24 | "gulp-mjml": "^3.0.0",
25 | "gulp-purifycss": "^0.2.0",
26 | "gulp-rename": "^1.2.2",
27 | "gulp-sass": "^4.0.0",
28 | "gulp-sequence": "^1.0.0",
29 | "gulp-smoosher": "0.0.9",
30 | "gulp-stylelint": "^8.0.0",
31 | "gulp-util": "^3.0.7",
32 | "mjml": "^4.0.2",
33 | "require-dir": "^1.0.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/gulptasks/variants/variants-prepare.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , EVENT_STREAM = require( 'event-stream' )
5 | , RENAME = require( 'gulp-rename' )
6 | , HANDLEBARS = require( 'gulp-compile-handlebars' )
7 | , PATHS = require( '../core/core-paths' )
8 | , TEMPLATES = require( '../core/core-templates' )
9 | , ON_ERROR = require( '../core/core-errors' )
10 |
11 |
12 | let prepareVariants = function(){
13 | let variants
14 | , wrapInIEConditional = ( options ) => ( options.fn ? ``: options )
15 | , tplOptions = {
16 | ignorePartials: true
17 | , batch: [ `${PATHS.dir.variants.partials}` ]
18 | , helpers: { wrapInIEConditional }
19 | }
20 | , renderEntry = function( _entry ){
21 | console.log( `\t> rendering ${_entry.name}…` )
22 |
23 | return GULP.src( `${PATHS.dir.variants.source}*${PATHS.file.template}` )
24 | .pipe( HANDLEBARS(
25 | _entry
26 | , tplOptions
27 | ))
28 | .pipe( RENAME({
29 | prefix: 'email-'
30 | , basename: _entry.name
31 | , extname: PATHS.file.variant
32 | }) )
33 | .pipe( GULP.dest( `${PATHS.dir.variants.variants}` ) )
34 | }
35 |
36 | console.log( '\n\u001b[38;5;120;1m> TPL \u001b[0m\u001b[38;5;115m Preparing all variants…\u001b[0m')
37 |
38 | variants = TEMPLATES.data.map( renderEntry )
39 | return EVENT_STREAM.merge.apply( null, variants )
40 | }
41 |
42 | GULP.task( 'variants-prepare', prepareVariants )
43 |
44 | module.exports = prepareVariants
45 |
--------------------------------------------------------------------------------
/gulptasks/style/style-compile.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const GULP = require( 'gulp' )
4 | , EVENT_STREAM = require( 'event-stream' )
5 | , SASS = require( 'gulp-sass' )
6 | , LESS = require( 'gulp-less' )
7 | , GROUP_MEDIA_QUERIES = require( 'gulp-group-css-media-queries' )
8 | , STYLE_LINT = require( 'gulp-stylelint' )
9 | , AUTOPREFIXER = require( 'gulp-autoprefixer' )
10 | , CSSO = require( 'gulp-csso' )
11 | , PURIFY = require( 'gulp-purifycss' )
12 | , PATHS = require( '../core/core-paths' )
13 | , ON_ERROR = require( '../core/core-errors' )
14 |
15 | let compileStylesheets = function(){
16 |
17 | let autoprefixerOptions = {
18 | browsers: [ 'last 3 versions' ]
19 | , cascade: false
20 | }
21 | , compileAllStylesheets = function(){
22 | let lessStream = GULP.src( `${PATHS.dir.styles.source}*${PATHS.file.lessStylesheets}` )
23 | .pipe( STYLE_LINT({
24 | reporters: [
25 | {formatter: 'string', console: true}
26 | ]
27 | , syntax: 'less'
28 | }) )
29 | .pipe( LESS() )
30 | let sassStream = GULP.src( `${PATHS.dir.styles.source}*${PATHS.file.sassStylesheets}` )
31 | .pipe( STYLE_LINT({
32 | reporters: [
33 | {formatter: 'string', console: true}
34 | ]
35 | , syntax: 'scss'
36 | }) )
37 | .pipe( SASS() )
38 | return EVENT_STREAM.merge.apply( null, [ lessStream, sassStream ] )
39 | }
40 |
41 | console.log( '\n\u001b[38;5;120;1m> STYLESHEET \u001b[0m\u001b[38;5;115m Compiling CSS\u001b[0m')
42 |
43 | return compileAllStylesheets()
44 | .pipe( GROUP_MEDIA_QUERIES() )
45 | .pipe( AUTOPREFIXER( autoprefixerOptions ) )
46 | .pipe( CSSO() )
47 | .pipe( PURIFY( [ `${PATHS.dir.variants.variants}*${PATHS.file.variant}` ] ) )
48 | .pipe( GULP.dest( `${PATHS.dir.styles.dist}` ) )
49 | }
50 |
51 | GULP.task( 'style-compile', compileStylesheets )
52 |
53 | module.exports = compileStylesheets
54 |
--------------------------------------------------------------------------------
/gulptasks/core/core-mjml.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const MJML_DICTIONARY = {
4 | 'table, tr, td, img': [
5 | 'width'
6 | , 'height'
7 | , 'align'
8 | ]
9 | , 'table, td': [ 'bgcolor' ]
10 | , 'table': [
11 | , 'cellpadding'
12 | , 'cellspacing'
13 | ]
14 | , 'td': [
15 | 'valign'
16 | ]
17 | , 'img': [
18 | 'border'
19 | ]
20 | , 'v\\:rect': [
21 | 'width'
22 | , 'height'
23 | , 'color'
24 | , 'strokecolor'
25 | , 'arcsize'
26 | ]
27 | , 'mj-body, mj-container, mj-section, mj-column, mj-button': [ 'background-color' ]
28 | , 'mj-container': [
29 | 'width'
30 | , 'font-size'
31 | , 'background-color'
32 | ]
33 | , 'mj-section': [
34 | 'full-width'
35 | , 'background-color'
36 | , 'background-url'
37 | , 'background-repeat'
38 | , 'background-size'
39 | , 'vertical-align'
40 | , 'text-align'
41 | , 'padding'
42 | , 'padding-top'
43 | , 'padding-bottom'
44 | , 'padding-left'
45 | , 'padding-right'
46 | ]
47 | , 'mj-column': [
48 | 'width'
49 | , 'vertical-align'
50 | , 'background-color'
51 | ]
52 | , 'mj-text': [
53 | 'color'
54 | , 'font-family'
55 | , 'font-size'
56 | , 'font-style'
57 | , 'font-weight'
58 | , 'line-height'
59 | , 'text-decoration'
60 | , 'align'
61 | , 'container-background-color'
62 | , 'padding'
63 | , 'padding-top'
64 | , 'padding-bottom'
65 | , 'padding-left'
66 | , 'padding-right'
67 | ]
68 | , 'mj-button': [
69 | 'container-background-color'
70 | , 'border-radius'
71 | , 'font-style'
72 | , 'font-size'
73 | , 'font-weight'
74 | , 'font-family'
75 | , 'color'
76 | , 'border'
77 | , 'text-decoration'
78 | , 'align'
79 | , 'vertical-align'
80 | , 'href'
81 | , 'padding'
82 | , 'padding-top'
83 | , 'padding-bottom'
84 | , 'padding-left'
85 | , 'padding-right'
86 | ]
87 | , 'mj-table': [
88 | 'color'
89 | , 'font-family'
90 | , 'font-size'
91 | , 'line-height'
92 | , 'container-background-color'
93 | , 'padding'
94 | , 'width'
95 | , 'table-layout'
96 | ]
97 | }
98 |
99 | module.exports = MJML_DICTIONARY
100 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # hbs-mjml-seed
2 |
3 | [](https://greenkeeper.io/)
4 | Gulp pipeline example dedicated to build HTML emails.
5 |
6 | ## Disclaimer
7 | _This is a mere prototype. Use it at your own risks.
8 | This was intended to provide a faster to build a complete set of emails with custom parts and styling._
9 |
10 | ## Usage
11 |
12 | ```sh
13 | $ git clone https://github.com/Daylon/hbs-mjml-seed.git
14 | $ cd hbs-mjml-seed
15 | $ gulp
16 | ```
17 |
18 | This pipeline use `MJML` for custom email markup, `Handlebars` for templating and `SASS` for styling. Please refer to their own documentation and/or repo for help:
19 | - [mjml.io](https://mjml.io/);
20 | - [HandlebarsJS](http://handlebarsjs.com/);
21 | - [Sass](http://sass-lang.com/).
22 |
23 | ## Let's keep MJML and CSS separated, shall we?
24 |
25 | ### Rationale
26 |
27 | The same way that making HTML for email can be tedious, having to tweak CSS rules attributes by hand can be cumbersome as well. For these reasons, I didn't want to regress from my previous setup _but_ MJML is pretty much the only solution (at the time of this writing) to provide a markup capable of removing noise from your HMTL templates.
28 |
29 | To achieve this, this pipeline leverages a fork from the fantastic lib `inline-css` by (and, in the process, `gulp-inline-css`; same author). [Here's the pull request, if you wish to weigh in](https://github.com/jonkemp/inline-css/pull/40). While this lib neatly converts class related css rules into style attributes, I tweak it up to support any custom rule, including _exotic_ ones (_e.g._ `full-width`).
30 |
31 | Interesting side-effect: VML style attributes (for, say, call-to-actions) can now be set, so you won't have to worry about having discrepancies between templates and style rules when modifying your call-to-actions.
32 |
33 | ### Can I change these definitions?
34 |
35 | Yes, you can. Open up `/gulptasks/core/core-mjml.js` and start adding properties. By default, any added rule will become an attribute.
36 |
37 | ## Templating
38 |
39 | ### Why using Handlebars
40 |
41 | Handlebars allows us to use a neat built-in feature that make it skip partials declaration on first pass.
42 |
43 | `\{{>partialName}}`
44 | ([Source](https://stackoverflow.com/questions/22249235/render-double-curly-brackets-inside-handlebars-partial))
45 |
46 | This way, a single handlebars template can produce multiple files, based on the first layer of variables, ready to be fed with real production data.
47 |
48 | ### Use case: theming
49 |
50 | #### First round —Gulp task
51 |
52 | Given a single `index.tpl`, we loop through an array of JSON data defined in `/gulptasks/core/core-templates.js`.
53 | Each index sets up what is static: pre-header, default unsuscribe link, invoke the correct partial, etc.
54 |
55 | #### Second pass —on server
56 |
57 | The produced new templates (one per theme) can now be used for a specific scenario or userbase sampling.
58 |
59 | ### Shortcomings
60 |
61 | **MJML** being a young project, some elements can still be buggy or lack optimizations; you may have to mix up both MJML and raw html.
62 |
--------------------------------------------------------------------------------