├── .gitignore ├── LICENSE.txt ├── README.md ├── build └── buildHtmlWithMockData.js ├── jest.config.js ├── package.json ├── src ├── assets │ ├── css.js │ ├── doggy.jpg │ ├── flower.jpg │ ├── head.css │ ├── inline.css │ ├── kitty.jpg │ ├── piggy.jpg │ ├── tigger.jpg │ └── trees.jpg ├── components │ ├── article.js │ ├── featuredSection.js │ ├── feedSection.js │ ├── footer.js │ ├── header.js │ ├── imageSection.js │ ├── navigation.js │ ├── sections.js │ └── tags.js ├── data │ └── mockData.json ├── email.js ├── renderer.js ├── util │ ├── index.js │ └── index.test.js └── views │ └── body.js ├── tests └── performance │ ├── function.js │ ├── function2.js │ └── renderer.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | node_modules 4 | .vscode 5 | .idea 6 | 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2020] [Tracey Holinka] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Email Renderer 2 | 3 | Create email friendly HTML using Vue.js, Vue Server Renderer(SSR) & MJML. 4 | 5 | ## Description 6 | 7 | Vue.js is just plain awesome so let's use it to build HTML email and bring some joy into HTML email development. 8 | 9 | Creating HTML email means coding for desktop and web based email clients that often don't support today's basic web standards. The solution is to code like it is 1999 and place everything in tables, but unlike 1999 your emails also need to be responsive for mobile devices. Many developers turn to email frameworks like MJML to solve email client compatibility issues for them. 10 | 11 | Let's make creating HTML emails even better by adding Vue.js to the mix. Vue.js brings data binding and easy component based development making creating HTML email painless. 12 | 13 | ## Videos 14 | 15 | - [HTML Email with Vue.js @ Global Vue Meetup](https://youtu.be/QuEEF-QvfmU) 16 | - [HTML Email with Vue.js @ VueDC Meetup](https://youtu.be/ZpanV5DQlbs) 17 | 18 | ## Requirements 19 | 20 | - Node v14.15+ 21 | - npm v6.4+ 22 | - yarn 1.22+ 23 | 24 | ## Project setup 25 | 26 | ``` bash 27 | yarn install 28 | ``` 29 | 30 | ## Create email using mock data 31 | 32 | ``` bash 33 | # ouputs to dist/buildHtmlWithMockData.js 34 | yarn dev 35 | ``` 36 | 37 | ## Run unit test 38 | 39 | ``` bash 40 | yarn test:unit 41 | ``` 42 | 43 | ## Test the performance of the renderer 44 | 45 | ``` bash 46 | node ./tests/performance/renderer.js 47 | ``` 48 | 49 | ## VSCode 50 | 51 | - Vetur 52 | - Vue Inline Template 53 | - Jest 54 | 55 | ## Documentation 56 | 57 | - [Vue 3](https://vuejs.org/v3/guide/) 58 | - [Vue SSR 3](https://github.com/vuejs/vue-next/tree/master/packages/server-renderer) 59 | - [mjml](https://mjml.io/documentation/) 60 | - [jest](https://jestjs.io/docs/en/api/) 61 | -------------------------------------------------------------------------------- /build/buildHtmlWithMockData.js: -------------------------------------------------------------------------------- 1 | const renderer = require('../src/renderer') 2 | const data = require('../src/data/mockData.json') 3 | 4 | renderer.renderHtml(data, { validationLevel: 'strict' }) 5 | .then(data => console.log(data)) 6 | .catch(error => console.log(error.message)) 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | rootDir: path.resolve(__dirname), 5 | moduleFileExtensions: ['js', 'json', 'vue'], 6 | moduleNameMapper: {'^@/(.*)$': '/src/$1'}, 7 | transform: {'^.+\\.js$': '/node_modules/babel-jest'}, 8 | testMatch: ['**/**/**/*.(test|spec).(js|jsx|ts|tsx)'], 9 | coverageDirectory: '/test/unit/coverage', 10 | collectCoverageFrom: ['!**/node_modules/**'] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuejs-email-renderer", 3 | "version": "2.0.4", 4 | "description": "Vue.js & MJML renderer for emails", 5 | "license": "MIT", 6 | "main": "src/renderer.js", 7 | "scripts": { 8 | "dev": "mkdir dist; node ./build/buildHtmlWithMockData.js > dist/htmlWithMockData.html", 9 | "test:unit": "jest '(^.*(test|spec).(js|jsx|ts|tsx))'" 10 | }, 11 | "dependencies": { 12 | "@vue/server-renderer": "^3.0.7", 13 | "mjml": "^4.9.0", 14 | "vue": "^3.0.7" 15 | }, 16 | "devDependencies": { 17 | "babel-jest": "^26.6.3", 18 | "jest": "^26.6.3" 19 | }, 20 | "engines": { 21 | "node": ">= 14.15", 22 | "npm": ">= 6.14" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/assets/css.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs') 2 | const { join } = require('path') 3 | 4 | exports.inlineCSS = readFileSync(join(__dirname, 'inline.css')) 5 | exports.headCSS = readFileSync(join(__dirname, 'head.css')) 6 | -------------------------------------------------------------------------------- /src/assets/doggy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/doggy.jpg -------------------------------------------------------------------------------- /src/assets/flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/flower.jpg -------------------------------------------------------------------------------- /src/assets/head.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | text-align: center; 3 | } 4 | @media(min-width: 480px) { 5 | .footer { 6 | text-align: left; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/inline.css: -------------------------------------------------------------------------------- 1 | /* 2 | Hover states are generally not supported so leaving links underline is a visual cue that the text is clickable. 3 | */ 4 | a { 5 | color: #0073ff; 6 | } 7 | h1 a, 8 | h2 a, 9 | h3 a { 10 | color: #292e31; 11 | } 12 | img { 13 | max-width: 100%; 14 | } 15 | .section-title { 16 | font-size: 26px; 17 | } 18 | .summary h2, 19 | .headline { 20 | font-size: 20px; 21 | } 22 | .body, 23 | .summary { 24 | font-size: 16px; 25 | line-height: 24px; 26 | } 27 | .footer a { 28 | color: #fff; 29 | } 30 | -------------------------------------------------------------------------------- /src/assets/kitty.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/kitty.jpg -------------------------------------------------------------------------------- /src/assets/piggy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/piggy.jpg -------------------------------------------------------------------------------- /src/assets/tigger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/tigger.jpg -------------------------------------------------------------------------------- /src/assets/trees.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/7c49dede2c4ece7fb086481177f1d5ce67335d5a/src/assets/trees.jpg -------------------------------------------------------------------------------- /src/components/article.js: -------------------------------------------------------------------------------- 1 | const article = { 2 | 3 | props: { 4 | article: { type: Object, required: true } 5 | }, 6 | 7 | template: ` 8 | 15 | 16 |

{{ article.headline }}

17 |

{{ article.summary }} Read more…

18 |
19 | ` 20 | } 21 | 22 | exports.article = article 23 | -------------------------------------------------------------------------------- /src/components/featuredSection.js: -------------------------------------------------------------------------------- 1 | const featuredSection = { 2 | 3 | props: { 4 | section: { type: Object, required: true } 5 | }, 6 | 7 | template: ` 8 | 9 | 10 | 11 |

{{ section.headline }}

12 |
13 | 20 | 21 |
22 | 23 | 24 | 25 | ` 26 | } 27 | 28 | exports.featuredSection = featuredSection 29 | -------------------------------------------------------------------------------- /src/components/feedSection.js: -------------------------------------------------------------------------------- 1 | const { article } = require('./article.js') 2 | 3 | const feedSection = { 4 | 5 | components: { 6 | Article: article 7 | }, 8 | 9 | props: { 10 | section: { type: Object, required: true } 11 | }, 12 | 13 | template: ` 14 | 15 | 16 | 17 |

{{ section.title }}

18 |
19 |
24 | 25 | 26 | ` 27 | } 28 | 29 | exports.feedSection = feedSection 30 | -------------------------------------------------------------------------------- /src/components/footer.js: -------------------------------------------------------------------------------- 1 | const footer = { 2 | template: ` 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ` 13 | } 14 | 15 | exports.footer = footer; 16 | -------------------------------------------------------------------------------- /src/components/header.js: -------------------------------------------------------------------------------- 1 | const header = { 2 | 3 | template: ` 4 | 5 | 6 | 13 | Faker News: The Real Fake News 14 | 15 | 16 | 17 | ` 18 | } 19 | 20 | exports.header = header 21 | -------------------------------------------------------------------------------- /src/components/imageSection.js: -------------------------------------------------------------------------------- 1 | const imageSection = { 2 | 3 | props: { 4 | section: { type: Object, required: true } 5 | }, 6 | 7 | // Add image width for Microsoft Office because it doesn't respect CSS width. 8 | template: ` 9 | 10 | 11 | 19 | 20 | 21 | ` 22 | } 23 | 24 | exports.imageSection = imageSection 25 | -------------------------------------------------------------------------------- /src/components/navigation.js: -------------------------------------------------------------------------------- 1 | const navigation = { 2 | 3 | props: { 4 | topics: { type: Array, default: true } 5 | }, 6 | 7 | // Mixing variables and text in attributes like href="#${topic.id}" throws errors. 8 | // The work around is to use a method or computed property. 9 | methods: { 10 | href(anchor) { 11 | return `#${anchor}` 12 | } 13 | }, 14 | 15 | // Anchor links are not supported on many mobile email clients. 16 | // Microsoft Outline doesn't play nice with css display so spans are used rather then an unordered list. 17 | template: ` 18 | 19 | 20 | 21 | Inside: {{ topic.title }}, 22 | 23 | 24 | 25 | ` 26 | } 27 | 28 | exports.navigation = navigation 29 | -------------------------------------------------------------------------------- /src/components/sections.js: -------------------------------------------------------------------------------- 1 | const { featuredSection } = require('./featuredSection.js') 2 | const { feedSection } = require('./feedSection.js') 3 | const { imageSection } = require('./imageSection.js') 4 | 5 | const sections = { 6 | 7 | components: { 8 | FeaturedSection: featuredSection, 9 | FeedSection: feedSection, 10 | ImageSection: imageSection, 11 | }, 12 | 13 | props: { 14 | section: { type: Object, required: true } 15 | }, 16 | 17 | // Dynamic components in a for loop 18 | // 25 | ` 26 | } 27 | 28 | exports.sections = sections 29 | -------------------------------------------------------------------------------- /src/components/tags.js: -------------------------------------------------------------------------------- 1 | const tags = { 2 | props: { 3 | tags: { type: Array, default: true } 4 | }, 5 | 6 | // Microsoft Outline doesn't play nice with css display so spans are used rather then an unordered list. 7 | template: ` 8 | 9 | 10 | 11 | Learn more about: 12 | {{ tag.name }}, 13 | 14 | 15 | 16 | ` 17 | } 18 | 19 | exports.tags = tags 20 | -------------------------------------------------------------------------------- /src/data/mockData.json: -------------------------------------------------------------------------------- 1 | { 2 | "sections": [ 3 | { 4 | "__typename": "FeaturedSection", 5 | "id": 0, 6 | "headline": "Headline for Featured Article ", 7 | "postedDate": "Fri, 12 Jun 2020 07:47:48 GMT", 8 | "permalink": "#", 9 | "tags": [ 10 | { 11 | "id": 6, 12 | "name": "Tag 2" 13 | } 14 | ], 15 | "author": { 16 | "id": 30, 17 | "name": "Faker News Author 3" 18 | }, 19 | "image": { 20 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/doggy.jpg", 21 | "alt": "Puppy lying on the ground." 22 | }, 23 | "body": "

tenetur repellat culpa est accusamus

Possimus deserunt et quia fuga. Blanditiis hic et officiis. Itaque autem numquam. Ea voluptas a unde laborum aut mollitia at non et.

Mollitia non nulla ab velit. Aut praesentium ipsam dolorum aut fugit provident. Est quibusdam consequatur. Impedit voluptatem hic est.

Odit nam mollitia. Et ut blanditiis. Est aut consectetur nulla consequuntur sed sequi veniam. Architecto dolor amet. Vel deleniti voluptate vero reprehenderit. Maiores animi a ducimus officia quia aut ab nihil.

Eius id optio saepe dolor velit. Ut perferendis possimus assumenda qui et maxime dicta. Iusto dolorem quod expedita.

Perspiciatis commodi est voluptatum ea. Sed nulla et cupiditate ut inventore animi eligendi. Eum dolorem corporis maxime deleniti recusandae molestiae. Dolorem delectus reprehenderit minus quas veritatis ex. Nobis quam qui amet quae autem est fugiat at et.

" 24 | }, 25 | { 26 | "__typename": "FeedSection", 27 | "id": 51, 28 | "title": "Some Interesting Stuff", 29 | "items": [ 30 | { 31 | "id": 10, 32 | "headline": "Headline for Faker News Article 1", 33 | "postedDate": "Fri, 12 Jun 2020 03:47:48 GMT", 34 | "permalink": "#", 35 | "tags": [ 36 | { 37 | "id": 21, 38 | "name": "Tag 1" 39 | }, 40 | { 41 | "id": 26, 42 | "name": "Tag 2" 43 | } 44 | ], 45 | "author": { 46 | "id": 30, 47 | "name": "Faker News Author 1" 48 | }, 49 | "summary": "Consequuntur voluptas minima numquam. Natus vel sit aspernatur consequatur autem esse veniam officiis ad. Error unde et eos autem. Exercitationem harum possimus labore. Velit dignissimos consectetur. Ipsum aut quae quibusdam omnis natus similique adipisci autem pariatur.", 50 | "image": { 51 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/piggy.jpg", 52 | "alt": "Close up of pig in field.", 53 | "display": true 54 | } 55 | }, 56 | { 57 | "id": 11, 58 | "headline": "Headline for Faker News Article 2", 59 | "postedDate": "Fri, 12 Jun 2020 12:48:07 GMT", 60 | "permalink": "#", 61 | "tags": [ 62 | { 63 | "id": 21, 64 | "name": "Tag 1" 65 | } 66 | ], 67 | "author": { 68 | "id": 30, 69 | "name": "Faker News Author 1" 70 | }, 71 | "summary": "Sed repudiandae labore nulla voluptas blanditiis placeat ex vitae. Optio non saepe culpa quo dolor aut consequatur. In repellendus itaque id sit est impedit eum corrupti est.", 72 | "image": { 73 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/cat.jpg", 74 | "alt": "Cat on top of fence.", 75 | "display": false 76 | } 77 | }, 78 | { 79 | "id": 12, 80 | "headline": "Headline for Faker News Article 3", 81 | "postedDate": "Fri, 12 Jun 2020 00:08:25 GMT", 82 | "permalink": "#", 83 | "tags": [ 84 | { 85 | "id": 28, 86 | "name": "Tag 3" 87 | }, 88 | { 89 | "id": 26, 90 | "name": "Tag 2" 91 | } 92 | ], 93 | "author": { 94 | "id": 30, 95 | "name": "Faker News Author 1" 96 | }, 97 | "summary": "Accusantium sit repellendus esse et et reiciendis sint. Dolorum quia deserunt et cum voluptates aliquid sed dignissimos. Similique distinctio recusandae. Deserunt quod sint quo sequi beatae facilis maxime voluptas. Qui perferendis facere dolorem asperiores.", 98 | "image": null 99 | }, 100 | { 101 | "id": 13, 102 | "headline": "Headline for Faker News Article 4", 103 | "postedDate": "Fri, 12 Jun 2020 02:01:34 GMT", 104 | "permalink": "#", 105 | "tags": [ 106 | { 107 | "id": 21, 108 | "name": "Tag 1" 109 | } 110 | ], 111 | "author": { 112 | "id": 31, 113 | "name": "Faker News Author 2" 114 | }, 115 | "summary": "Excepturi ipsum in explicabo. Dolorem laudantium omnis nihil. Quasi vitae mollitia nobis numquam explicabo. Ut atque et et perspiciatis illo sit ea facilis ad.", 116 | "image": null 117 | }, 118 | { 119 | "id": 14, 120 | "headline": "Headline for Faker News Article 5", 121 | "postedDate": "Thu, 11 Jun 2020 02:31:56 GMT", 122 | "permalink": "#", 123 | "tags": [ 124 | { 125 | "id": 28, 126 | "name": "Tag 3" 127 | }, 128 | { 129 | "id": 26, 130 | "name": "Tag 2" 131 | }, 132 | { 133 | "id": 24, 134 | "name": "Tag 4" 135 | } 136 | ], 137 | "author": { 138 | "id": 32, 139 | "name": "Faker News Author 3" 140 | }, 141 | "summary": "Provident eos ut officia. Quo sed rerum itaque sequi sed id consectetur. Consequatur magni qui enim quis ut ut quae et minima.", 142 | "image": null 143 | } 144 | ] 145 | }, 146 | { 147 | "__typename": "ImageSection", 148 | "id": 41, 149 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/flower.jpg", 150 | "alt": "A white flower on a blue sky." 151 | }, 152 | { 153 | "__typename": "FeedSection", 154 | "id": 52, 155 | "title": "Some Not So Interesting Stuff", 156 | "items": [ 157 | { 158 | "id": 15, 159 | "headline": "Headline for Faker News Article 6", 160 | "postedDate": "Thu, 11 Jun 2020 16:28:25 GMT", 161 | "permalink": "#", 162 | "tags": [], 163 | "author": { 164 | "id": 30, 165 | "name": "Faker News Author 1" 166 | }, 167 | "summary": "Occaecati architecto aut saepe ut velit. Harum deserunt quibusdam nobis ad expedita rem blanditiis. Facilis sapiente libero quia qui corporis. Blanditiis quo qui commodi. Et voluptate laborum id harum. Ut quia vero adipisci porro beatae eos recusandae quidem.", 168 | "image": { 169 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/tigger.jpg", 170 | "alt": "Tiger lying on stone wall.", 171 | "display": true 172 | } 173 | }, 174 | { 175 | "id": 16, 176 | "headline": "Headline for Faker News Article 7", 177 | "postedDate": "Thu, 11 Jun 2020 08:57:55 GMT", 178 | "permalink": "#", 179 | "tags": [ 180 | { 181 | "id": 21, 182 | "name": "Tag 1" 183 | }, 184 | { 185 | "id": 28, 186 | "name": "Tag 3" 187 | }, 188 | { 189 | "id": 24, 190 | "name": "Tag 4" 191 | } 192 | ], 193 | "author": { 194 | "id": 31, 195 | "name": "Faker News Author 2" 196 | }, 197 | "summary": "Magni sequi autem deserunt voluptatem ullam et magnam. Voluptatem omnis beatae fuga exercitationem repellendus et voluptatum. Hic repellat at assumenda. Et quia quis laudantium magni et ipsum. Consequuntur voluptatem deserunt. Maiores et ipsum aperiam.", 198 | "image": { 199 | "url": "https://raw.githubusercontent.com/TraceyHolinka/vuejs-email-renderer/master/src/assets/trees.jpg", 200 | "alt": "Trees with fall colored leaves along a path.", 201 | "display": false 202 | } 203 | }, 204 | { 205 | "id": 17, 206 | "headline": "Headline for Faker News Article 8", 207 | "postedDate": "Fri, 12 Jun 2020 02:48:11 GMT", 208 | "permalink": "#", 209 | "tags": [ 210 | { 211 | "id": 24, 212 | "name": "Tag 4" 213 | } 214 | ], 215 | "author": { 216 | "id": 31, 217 | "name": "Faker News Author 2" 218 | }, 219 | "summary": "Minus non omnis maiores aut quas placeat debitis impedit consequatur. Iste amet explicabo officia accusantium facere sint et. Est quia dolores a minima illum tenetur perspiciatis. Est qui blanditiis ut veritatis ut adipisci in. Minima mollitia incidunt quidem quasi excepturi.", 220 | "image": null 221 | }, 222 | { 223 | "id": 18, 224 | "headline": "Headline for Faker News Article 9", 225 | "postedDate": "Thu, 11 Jun 2020 04:50:39 GMT", 226 | "permalink": "#", 227 | "tags": [ 228 | { 229 | "id": 21, 230 | "name": "Tag 1" 231 | }, 232 | { 233 | "id": 24, 234 | "name": "Tag 4" 235 | } 236 | ], 237 | "author": { 238 | "id": 32, 239 | "name": "Faker News Author 3" 240 | }, 241 | "summary": "Nisi dicta temporibus quas et minus dolores. Rerum cumque consequatur cupiditate in vel aperiam mollitia non. Possimus eveniet illo nisi doloremque et sint adipisci aut aut. Reprehenderit quia necessitatibus et. Et dolores error velit illum aliquid exercitationem. Est voluptas maiores repellat et earum voluptatem veritatis sunt.", 242 | "image": null 243 | } 244 | ] 245 | } 246 | ] 247 | } -------------------------------------------------------------------------------- /src/email.js: -------------------------------------------------------------------------------- 1 | // Import CSS 2 | const css = require('./assets/css') 3 | 4 | // Unlike the the first version of Vue.js Email Renderer, here we export the component as an object 5 | // and then declare it in the component that is use it. 6 | const email = { 7 | 8 | // The default width for is 600px. 9 | template: ` 10 | 11 | 12 | Faker News: The Real Fake News 13 | Vue.js is just plain awesome, so let's use it to build HTML email and bring some joy into HTML email development. 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ${css.inlineCSS} 22 | 23 | 24 | ${css.headCSS} 25 | 26 | 27 | 28 | 29 | 30 | 31 | ` 32 | } 33 | 34 | exports.email = email; 35 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | const mjml2html = require('mjml') 2 | const { createSSRApp } = require('vue') 3 | const { renderToString } = require('@vue/server-renderer') 4 | 5 | const { email } = require('./email.js') 6 | const { body } = require('./views/body.js') 7 | 8 | exports.renderHtml = async function renderHtml(payload) { 9 | 10 | // Create an instance of Vue. 11 | const app = createSSRApp({ 12 | data() { 13 | return { 14 | sections: payload.sections 15 | } 16 | }, 17 | 18 | components: { 19 | Body: body, 20 | Email: email, 21 | }, 22 | 23 | template: ` 24 | 25 | 26 | 27 | ` 28 | }) 29 | 30 | // Tell Vue to recognize mjml components. See: https://v3.vuejs.org/api/application-config.html#iscustomelement 31 | app.config.isCustomElement = tag => tag === 'mjml' || tag.startsWith('mj-') 32 | 33 | // Render the Vue instance to a variable as a string 34 | let html = await renderToString(app) 35 | 36 | // Remove and add by the server renderer. 37 | html = html.replace('', '').replace('', '') 38 | 39 | // Let mjml do its magic 40 | return mjml2html(html).html 41 | } 42 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | exports.dedup = function dedup(items) { 2 | let uniqueItems = {} 3 | for (const item of items) { 4 | uniqueItems[item.id] = item 5 | } 6 | 7 | return Object.values(uniqueItems) 8 | } 9 | 10 | exports.sortAscending = function sortAscending(items) { 11 | return items.sort((a, b) => a.name.localeCompare(b.name)) 12 | } 13 | -------------------------------------------------------------------------------- /src/util/index.test.js: -------------------------------------------------------------------------------- 1 | const processors = require('./index') 2 | 3 | describe('processors', () => { 4 | 5 | const items = [ 6 | { 7 | id: 1, 8 | name: 'Word 1' 9 | }, 10 | { 11 | id: 8, 12 | name: 'Word 3' 13 | }, 14 | { 15 | id: 6, 16 | name: 'Word 2' 17 | }, 18 | { 19 | id: 6, 20 | name: 'Word 2' 21 | }, 22 | { 23 | id: 4, 24 | name: 'Word 4' 25 | }, 26 | 27 | { 28 | id: 1, 29 | name: 'Word 1' 30 | }, 31 | { 32 | id: 8, 33 | name: 'Word 3' 34 | }, 35 | { 36 | id: 4, 37 | name: 'Word 4' 38 | } 39 | ] 40 | 41 | const dedupedItems = [ 42 | { 43 | id: 1, 44 | name: 'Word 1' 45 | }, 46 | { 47 | id: 4, 48 | name: 'Word 4' 49 | }, 50 | { 51 | id: 6, 52 | name: 'Word 2' 53 | }, 54 | { 55 | id: 8, 56 | name: 'Word 3' 57 | } 58 | ] 59 | 60 | const sortedAscending = [ 61 | { 62 | id: 1, 63 | name: 'Word 1' 64 | }, 65 | { 66 | id: 6, 67 | name: 'Word 2' 68 | }, 69 | { 70 | id: 8, 71 | name: 'Word 3' 72 | }, 73 | { 74 | id: 4, 75 | name: 'Word 4' 76 | } 77 | ] 78 | 79 | it('dedups items', () => { 80 | expect(processors.dedup(items)).toEqual(dedupedItems) 81 | }) 82 | 83 | it('sorts items alphabetically', () => { 84 | expect(processors.sortAscending(dedupedItems)).toEqual(sortedAscending) 85 | }) 86 | 87 | }) -------------------------------------------------------------------------------- /src/views/body.js: -------------------------------------------------------------------------------- 1 | const util = require('../util') 2 | 3 | const { header } = require('../components/header.js') 4 | const { navigation } = require('../components/navigation.js') 5 | const { sections } = require('../components/sections.js') 6 | const { tags } = require('../components/tags.js') 7 | const { footer } = require('../components/footer.js') 8 | 9 | const body = { 10 | 11 | components: { 12 | Header: header, 13 | Navigation: navigation, 14 | Sections: sections, 15 | Tags: tags, 16 | Footer: footer 17 | }, 18 | 19 | props: { 20 | sections: { type: Array, required: true } 21 | }, 22 | 23 | computed: { 24 | articleTags(){ 25 | return this.sections 26 | .filter(section => section.__typename === "FeedSection") 27 | .flatMap(section => section.items) 28 | .flatMap(item => item.tags) 29 | }, 30 | tags(){ 31 | const tags = util.dedup(this.articleTags) 32 | return util.sortAscending(tags) 33 | }, 34 | topics(){ 35 | return this.sections.filter(section => section.__typename === "FeedSection") 36 | .map( item => ({ 37 | id: item.id, 38 | title: item.title 39 | })) 40 | } 41 | }, 42 | 43 | template: ` 44 | 45 | 46 |
47 | 48 | 53 | 54 | 55 |