├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── beautify_options.go ├── beautify_options_test.go ├── docker-compose.yml ├── error.go ├── go.mod ├── go.sum ├── html_minifier_options.go ├── html_minifier_options_test.go ├── id.go ├── js ├── build.sh ├── package-lock.json ├── package.json ├── shims │ └── uglify-js.js ├── src │ ├── index.js │ ├── lib.js │ └── server.js └── webpack.config.js ├── juice_options.go ├── juice_options_test.go ├── mjml.go ├── mjml_benchmark_test.go ├── mjml_test.go ├── node-test-server └── server ├── options.go ├── options_test.go ├── pool.go ├── testdata ├── black-friday.html ├── black-friday.mjml ├── one-page.html ├── one-page.mjml ├── reactivation-email.html ├── reactivation-email.mjml ├── real-estate.html ├── real-estate.mjml ├── recast.html ├── recast.mjml ├── receipt-email.html └── receipt-email.mjml └── wasm └── mjml.wasm.br /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | name: Tests 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | go: 12 | - version: "1.22" 13 | report: true 14 | - version: "1.21" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install code climate 18 | if: matrix.go.report == true && github.ref == 'refs/heads/main' 19 | run: | 20 | wget -O /tmp/cc-test-reporter https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 21 | chmod +x /tmp/cc-test-reporter 22 | /tmp/cc-test-reporter before-build 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | - name: Run tests 26 | env: 27 | GO_VERSION: ${{ matrix.go.version }} 28 | run: | 29 | docker compose run test 30 | echo $? > /tmp/GO_EXIT_CODE 31 | - name: Send results to code climate 32 | if: matrix.go.report == true && github.ref == 'refs/heads/main' 33 | env: 34 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 35 | run: | 36 | /tmp/cc-test-reporter after-build --prefix github.com/$GITHUB_REPOSITORY --exit-code `cat /tmp/GO_EXIT_CODE` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | js/node_modules 2 | c.out -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mjml-go 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/Boostport/mjml-go.svg)](https://pkg.go.dev/github.com/Boostport/mjml-go) 3 | [![Tests Status](https://github.com/Boostport/mjml-go/workflows/Tests/badge.svg)](https://github.com/Boostport/mjml-go) 4 | [![Test Coverage](https://api.codeclimate.com/v1/badges/cbb1efa66cb148be5cb8/test_coverage)](https://codeclimate.com/github/Boostport/mjml-go/test_coverage) 5 | 6 | Compile [MJML](https://mjml.io/) into HTML directly in your Go application! 7 | 8 | ## Why? 9 | [MJML](https://github.com/mjmlio/mjml) is a JavaScript library. In order to use it with other languages, 10 | the usual approach is to wrap the library in a Node.js HTTP server and provide an endpoint through which 11 | applications not written in JavaScript can make HTTP requests to compile MJML into HTML. 12 | 13 | This approach poses some challenges, for example, if MJML is upgraded to a new major version in 14 | the deployed Node.js servers, applications calling these servers will need to be upgraded in a synchronized 15 | manner to avoid incompatibilities. In addition, running these extra servers introduces extra moving parts 16 | and the network into the mix. 17 | 18 | This is why we built `mjml-go` and created an idiomatic Go API to compile MJML into HTML directly in Go applications that 19 | can be deployed as a single Go binary. 20 | 21 | ## How? 22 | We wrote a [simple JavaScript wrapper](js/src) that wraps around the MJML library by accepting input and returning output 23 | using JSON. This wrapper is then bundled using webpack and compiled into a WebAssembly module using Suborbital's [Javy fork](https://github.com/suborbital/javy), 24 | a Javascript to WebAssembly compiler. The WebAssembly module is then compressed using Brotli to yield a 10x reduction in 25 | file size. 26 | 27 | During runtime, the module is decompressed and loaded into a [Wazero](https://github.com/tetratelabs/wazero) runtime 28 | on application start up to accept input in order to compile MJML into HTML. 29 | 30 | ### Workers 31 | As WebAssembly modules compiled using Javy are not thread-safe and cannot be called concurrently, the library maintains 32 | a pool of 1 to 10 instances to perform compilations. Idle instances are automatically destroyed and will be re-created when 33 | they are needed. This means that the library is thread-safe and you can use it concurrently in multiple goroutines. 34 | 35 | ## Example 36 | ```go 37 | func main() { 38 | 39 | input := `Hello World` 40 | 41 | output, err := mjml.ToHTML(context.Background(), input, mjml.WithMinify(true)) 42 | 43 | var mjmlError mjml.Error 44 | 45 | if errors.As(err, &mjmlError){ 46 | fmt.Println(mjmlError.Message) 47 | fmt.Println(mjmlError.Details) 48 | } 49 | 50 | fmt.Println(output) 51 | } 52 | ``` 53 | 54 | ## Options 55 | The library provides a complete list of options to customize the MJML compilation process including options for 56 | `html-minifier`, `js-beautify` and `juice`. 57 | 58 | These are all exposed via an idiomatic Go API and a complete list can be found in the [Go documentation](https://pkg.go.dev/github.com/Boostport/mjml-go). 59 | 60 | ### Defaults 61 | If beautify and minify are enabled, but no options were passed in, the library defaults to using the same defaults 62 | as the MJML CLI application: 63 | 64 | For minify: 65 | 66 | | option | value | 67 | |-------------------------|---------| 68 | | `CaseSensitive` | `true` | 69 | | `CollapseWhitespace` | `true` | 70 | | `MinifyCSS` | `false` | 71 | | `RemoveEmptyAttributes` | `true` | 72 | 73 | For beautify: 74 | 75 | | option | value | 76 | |----------------------------|---------| 77 | | `EndWithNewline` | `true` | 78 | | `IndentSize` | `2` | 79 | | `PreserveNewlines` | `false` | 80 | | `WrapAttributesIndentSize` | `2` | 81 | 82 | ## Limitations 83 | The WebAssembly module is not able to access the filesystem, so `` tags are ignored. The solution is to 84 | flatten your templates during development and pass the flattened templates to `mjml.ToHTML()`. 85 | 86 | This [example](https://github.com/mjmlio/mjml/issues/2465#issuecomment-1109515536) provides a good starting point to 87 | create a Node.js script to do this: 88 | ```javascript 89 | import mjml2html from 'mjml' // load default component 90 | import components from 'mjml-core/lib/components.js' 91 | import Parser from 'mjml-parser-xml' 92 | import jsonToXML from 'mjml-core/lib/helpers/jsonToXML.js' 93 | 94 | const xml = `...` 95 | 96 | const mjml = Parser(xml, { 97 | components, 98 | filePath: '.', 99 | actualPath: '.' 100 | }) 101 | 102 | console.log(JSON.stringify(mjml)) 103 | console.log(jsonToXML(mjml)) 104 | ``` 105 | 106 | ## Differences from the MJML JavaScript library 107 | - Beautify and minify will be removed from the library in [MJML5](https://github.com/mjmlio/mjml/pull/2204) and will be 108 | moved into the MJML CLI. Therefore, to prepare for this move, the [wrapper](js/src) imports `html-minifier` 109 | and `js-beautify` directly to support minifying and beautifying the output. 110 | - In the current implementation of mjml, it is not possible to customize the output of `js-beautify`. In this library, 111 | we have exposed those options. 112 | 113 | ## Benchmarks 114 | We are benchmarking against a very [minimal Node.js server](js/src/server.js) serving a single API endpoint. 115 | ``` 116 | goos: linux 117 | goarch: amd64 118 | pkg: github.com/Boostport/mjml-go 119 | cpu: 12th Gen Intel(R) Core(TM) i7-12700F 120 | BenchmarkNodeJS/black-friday-20 534 2303756 ns/op 121 | BenchmarkNodeJS/one-page-20 248 4691666 ns/op 122 | BenchmarkNodeJS/reactivation-email-20 176 6462409 ns/op 123 | BenchmarkNodeJS/real-estate-20 136 8432862 ns/op 124 | BenchmarkNodeJS/recast-20 162 7306482 ns/op 125 | BenchmarkNodeJS/receipt-email-20 301 3974783 ns/op 126 | BenchmarkMJMLGo/black-friday-20 25 44522946 ns/op 127 | BenchmarkMJMLGo/one-page-20 10 102439103 ns/op 128 | BenchmarkMJMLGo/reactivation-email-20 13 89384155 ns/op 129 | BenchmarkMJMLGo/real-estate-20 6 196078406 ns/op 130 | BenchmarkMJMLGo/recast-20 7 158731099 ns/op 131 | BenchmarkMJMLGo/receipt-email-20 13 88048332 ns/op 132 | PASS 133 | ok github.com/Boostport/mjml-go 28.476s 134 | ``` 135 | 136 | In its current state the Node.js implementation is significantly faster than `mjml-go`. However, with improvements to 137 | Wazero (in particular [tetratelabs/wazero#618](https://github.com/tetratelabs/wazero/issues/618) and [tetratelabs/wazero#179](https://github.com/tetratelabs/wazero/issues/179)), 138 | module instantiation times should see great improvement, reducing worker spin-up times and improving the compilation performance. 139 | 140 | Also, we should see improvements from Javy improve these numbers as well. 141 | 142 | ## Development 143 | 144 | ### Run tests 145 | You can run tests using docker by running `docker compose run test` from the root of the repository. 146 | 147 | ### Run benchmarks 148 | From the root of the repository, run `go test -bench=. ./...`. Alternatively, you can run them in a docker container: 149 | `docker compose run benchmark` 150 | 151 | ### Compile WebAssembly module and build Node.js test server 152 | Run `docker compose run build-js` from the root of the repository. 153 | 154 | ## Other languages 155 | Since the MJML library is compiled into a WebAssembly module, it should be relatively easy to take the compiled module and 156 | drop it into languages with WebAssembly environments. 157 | 158 | If you've created a library for another language, please let us know, so that we can add it to this list! -------------------------------------------------------------------------------- /beautify_options.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | type BeautifyBraceStyle string 4 | 5 | const ( 6 | BeautifyBraceStyleCollapsePreserveInline BeautifyBraceStyle = "collapse-preserve-inline" 7 | BeautifyBraceStyleCollapse BeautifyBraceStyle = "collapse" 8 | BeautifyBraceStyleExpand BeautifyBraceStyle = "expand" 9 | BeautifyBraceStyleEndExpand BeautifyBraceStyle = "end-expand" 10 | BeautifyBraceStyleNone BeautifyBraceStyle = "none" 11 | ) 12 | 13 | type BeautifyIndentScripts string 14 | 15 | const ( 16 | BeautifyIndentScriptsKeep BeautifyIndentScripts = "keep" 17 | BeautifyIndentScriptsSeparate BeautifyIndentScripts = "separate" 18 | BeautifyIndentScriptsNormal BeautifyIndentScripts = "normal" 19 | ) 20 | 21 | type BeautifyWrapAttributes string 22 | 23 | const ( 24 | BeautifyWrapAttributesAuto BeautifyWrapAttributes = "auto" 25 | BeautifyWrapAttributesForce BeautifyWrapAttributes = "force" 26 | BeautifyWrapAttributesForceAligned BeautifyWrapAttributes = "force-aligned" 27 | BeautifyWrapAttributesForceExpandMultiline BeautifyWrapAttributes = "force-expand-multiline" 28 | BeautifyWrapAttributesAlignedMultiple BeautifyWrapAttributes = "aligned-multiple" 29 | BeautifyWrapAttributesPreserve BeautifyWrapAttributes = "preserve" 30 | BeautifyWrapAttributesPreserveAligned BeautifyWrapAttributes = "preserved-aligned" 31 | ) 32 | 33 | type BeautifyTemplating string 34 | 35 | const ( 36 | BeautifyTemplatingAuto BeautifyTemplating = "auto" 37 | BeautifyTemplatingNone BeautifyTemplating = "none" 38 | BeautifyTemplatingDjango BeautifyTemplating = "django" 39 | BeautifyTemplatingERB BeautifyTemplating = "erb" 40 | BeautifyTemplatingHandlebars BeautifyTemplating = "handlebars" 41 | BeautifyTemplatingPHP BeautifyTemplating = "php" 42 | BeautifyTemplatingSmarty BeautifyTemplating = "smarty" 43 | ) 44 | 45 | // BeautifyOptions is used to construct Beautify options to be passed to the MJML compiler 46 | // Detailed explanations of the options are here: https://github.com/beautify-web/js-beautify#css--html 47 | type BeautifyOptions interface { 48 | IndentSize(uint) BeautifyOptions 49 | IndentChar(string) BeautifyOptions 50 | IndentWithTabs(bool) BeautifyOptions 51 | Eol(string) BeautifyOptions 52 | EndWithNewline(bool) BeautifyOptions 53 | PreserveNewlines(bool) BeautifyOptions 54 | MaxPreserveNewlines(uint) BeautifyOptions 55 | IndentInnerHtml(bool) BeautifyOptions 56 | BraceStyle(BeautifyBraceStyle) BeautifyOptions 57 | IndentScripts(BeautifyIndentScripts) BeautifyOptions 58 | WrapLineLength(uint) BeautifyOptions 59 | WrapAttributes(BeautifyWrapAttributes) BeautifyOptions 60 | WrapAttributesIndentSize(uint) BeautifyOptions 61 | Inline([]string) BeautifyOptions 62 | Unformatted([]string) BeautifyOptions 63 | ContentUnformatted([]string) BeautifyOptions 64 | ExtraLiners([]string) BeautifyOptions 65 | UnformattedContentDelimiter(string) BeautifyOptions 66 | IndentEmptyLines(bool) BeautifyOptions 67 | Templating([]BeautifyTemplating) BeautifyOptions 68 | } 69 | 70 | type beautifyOptions struct { 71 | data map[string]interface{} 72 | } 73 | 74 | func (o *beautifyOptions) IndentSize(indentSize uint) BeautifyOptions { 75 | ret := *o 76 | ret.data["indent_size"] = indentSize 77 | return &ret 78 | } 79 | 80 | func (o *beautifyOptions) IndentChar(character string) BeautifyOptions { 81 | ret := *o 82 | ret.data["indent_char"] = character 83 | return &ret 84 | } 85 | 86 | func (o *beautifyOptions) IndentWithTabs(b bool) BeautifyOptions { 87 | ret := *o 88 | ret.data["indent_with_tabs"] = b 89 | return &ret 90 | } 91 | 92 | func (o *beautifyOptions) Eol(string string) BeautifyOptions { 93 | ret := *o 94 | ret.data["eol"] = string 95 | return &ret 96 | } 97 | 98 | func (o *beautifyOptions) EndWithNewline(b bool) BeautifyOptions { 99 | ret := *o 100 | ret.data["end_with_newline"] = b 101 | return &ret 102 | } 103 | 104 | func (o *beautifyOptions) PreserveNewlines(b bool) BeautifyOptions { 105 | ret := *o 106 | ret.data["preserve_newlines"] = b 107 | return &ret 108 | } 109 | 110 | func (o *beautifyOptions) MaxPreserveNewlines(max uint) BeautifyOptions { 111 | ret := *o 112 | ret.data["max_preserve_newlines"] = max 113 | return &ret 114 | } 115 | 116 | func (o *beautifyOptions) IndentInnerHtml(b bool) BeautifyOptions { 117 | ret := *o 118 | ret.data["indent_inner_html"] = b 119 | return &ret 120 | } 121 | 122 | func (o *beautifyOptions) BraceStyle(braceStyle BeautifyBraceStyle) BeautifyOptions { 123 | ret := *o 124 | ret.data["brace_style"] = braceStyle 125 | return &ret 126 | } 127 | 128 | func (o *beautifyOptions) IndentScripts(indentScripts BeautifyIndentScripts) BeautifyOptions { 129 | ret := *o 130 | ret.data["indent_scripts"] = indentScripts 131 | return &ret 132 | } 133 | 134 | func (o *beautifyOptions) WrapLineLength(lineLength uint) BeautifyOptions { 135 | ret := *o 136 | ret.data["wrap_line_length"] = lineLength 137 | return &ret 138 | } 139 | 140 | func (o *beautifyOptions) WrapAttributes(wrapAttributes BeautifyWrapAttributes) BeautifyOptions { 141 | ret := *o 142 | ret.data["wrap_attributes"] = wrapAttributes 143 | return &ret 144 | } 145 | 146 | func (o *beautifyOptions) WrapAttributesIndentSize(indentSize uint) BeautifyOptions { 147 | ret := *o 148 | ret.data["wrap_attributes_indent_size"] = indentSize 149 | return &ret 150 | } 151 | 152 | func (o *beautifyOptions) Inline(tags []string) BeautifyOptions { 153 | ret := *o 154 | ret.data["inline"] = tags 155 | return &ret 156 | } 157 | 158 | func (o *beautifyOptions) Unformatted(tags []string) BeautifyOptions { 159 | ret := *o 160 | ret.data["unformatted"] = tags 161 | return &ret 162 | } 163 | 164 | func (o *beautifyOptions) ContentUnformatted(tags []string) BeautifyOptions { 165 | ret := *o 166 | ret.data["content_unformatted"] = tags 167 | return &ret 168 | } 169 | 170 | func (o *beautifyOptions) ExtraLiners(tags []string) BeautifyOptions { 171 | ret := *o 172 | ret.data["extra_liners"] = tags 173 | return &ret 174 | } 175 | 176 | func (o *beautifyOptions) UnformattedContentDelimiter(string string) BeautifyOptions { 177 | ret := *o 178 | ret.data["unformatted_content_delimiter"] = string 179 | return &ret 180 | } 181 | 182 | func (o *beautifyOptions) IndentEmptyLines(b bool) BeautifyOptions { 183 | ret := *o 184 | ret.data["indent_empty_lines"] = b 185 | return &ret 186 | } 187 | 188 | func (o *beautifyOptions) Templating(templating []BeautifyTemplating) BeautifyOptions { 189 | ret := *o 190 | ret.data["templating"] = templating 191 | return &ret 192 | } 193 | 194 | func NewBeautifyOptions() BeautifyOptions { 195 | return &beautifyOptions{ 196 | data: map[string]interface{}{}, 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /beautify_options_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestBeautifyOptions(t *testing.T) { 9 | 10 | options := NewBeautifyOptions(). 11 | IndentSize(2). 12 | IndentChar(" "). 13 | IndentWithTabs(true). 14 | Eol("\n"). 15 | EndWithNewline(true). 16 | PreserveNewlines(true). 17 | MaxPreserveNewlines(10). 18 | IndentInnerHtml(true). 19 | BraceStyle(BeautifyBraceStyleCollapse). 20 | IndentScripts(BeautifyIndentScriptsSeparate). 21 | WrapLineLength(1). 22 | WrapAttributes(BeautifyWrapAttributesAuto). 23 | WrapAttributesIndentSize(10). 24 | Inline([]string{"a", "span"}). 25 | Unformatted([]string{"a"}). 26 | ContentUnformatted([]string{"pre"}). 27 | ExtraLiners([]string{"head", "body"}). 28 | UnformattedContentDelimiter(" "). 29 | IndentEmptyLines(true). 30 | Templating([]BeautifyTemplating{BeautifyTemplatingAuto}) 31 | 32 | expected := map[string]interface{}{ 33 | "indent_size": uint(2), 34 | "indent_char": " ", 35 | "indent_with_tabs": true, 36 | "eol": "\n", 37 | "end_with_newline": true, 38 | "preserve_newlines": true, 39 | "max_preserve_newlines": uint(10), 40 | "indent_inner_html": true, 41 | "brace_style": BeautifyBraceStyleCollapse, 42 | "indent_scripts": BeautifyIndentScriptsSeparate, 43 | "wrap_line_length": uint(1), 44 | "wrap_attributes": BeautifyWrapAttributesAuto, 45 | "wrap_attributes_indent_size": uint(10), 46 | "inline": []string{"a", "span"}, 47 | "unformatted": []string{"a"}, 48 | "content_unformatted": []string{"pre"}, 49 | "extra_liners": []string{"head", "body"}, 50 | "unformatted_content_delimiter": " ", 51 | "indent_empty_lines": true, 52 | "templating": []BeautifyTemplating{BeautifyTemplatingAuto}, 53 | } 54 | 55 | beautifierOptions, ok := options.(*beautifyOptions) 56 | 57 | if !ok { 58 | t.Fatal("Options is not a *beautifierOptions") 59 | } 60 | 61 | if !reflect.DeepEqual(beautifierOptions.data, expected) { 62 | t.Error("BeautifierOptions does not match expected data") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | build-js: 3 | image: suborbital/builder-js:v0.6.0 4 | command: ./build.sh 5 | working_dir: /source/js 6 | volumes: 7 | - .:/source 8 | 9 | test: 10 | image: golang:${GO_VERSION:-1.22} 11 | working_dir: /source 12 | command: go test -coverprofile c.out -v ./... 13 | volumes: 14 | - .:/source 15 | - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache 16 | 17 | benchmark: 18 | image: golang:${GO_VERSION:-1.22} 19 | working_dir: /source 20 | command: go test -bench=. ./... 21 | volumes: 22 | - .:/source 23 | - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Error struct { 9 | Message string `json:"message"` 10 | Details []struct { 11 | Line int `json:"line"` 12 | Message string `json:"message"` 13 | TagName string `json:"tagName"` 14 | } `json:"details"` 15 | } 16 | 17 | func (e Error) Error() string { 18 | 19 | var sb strings.Builder 20 | 21 | sb.WriteString(e.Message) 22 | 23 | numDetails := len(e.Details) 24 | 25 | if numDetails > 0 { 26 | sb.WriteString(":\n") 27 | } 28 | 29 | for i, detail := range e.Details { 30 | sb.WriteString(fmt.Sprintf("- Line %d of (%s) - %s", detail.Line, detail.TagName, detail.Message)) 31 | 32 | if i != numDetails-1 { 33 | sb.WriteString("\n") 34 | } 35 | } 36 | 37 | return sb.String() 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Boostport/mjml-go 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.0 6 | 7 | require ( 8 | github.com/andybalholm/brotli v1.1.0 9 | github.com/jackc/puddle/v2 v2.2.1 10 | github.com/tetratelabs/wazero v1.8.0 11 | ) 12 | 13 | require golang.org/x/sync v0.3.0 // indirect 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 2 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 6 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 10 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 11 | github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g= 12 | github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= 13 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 14 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /html_minifier_options.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | type HTMLMinifierQuoteCharacter string 4 | 5 | const ( 6 | HTMLMinifierSingleQuote HTMLMinifierQuoteCharacter = "'" 7 | HTMLMinifierDoubleQuote HTMLMinifierQuoteCharacter = "\"" 8 | ) 9 | 10 | // HTMLMinifierOptions is used to construct HTMLMinifier options 11 | // Detailed explanations of the options are here: https://github.com/kangax/html-minifier#options-quick-reference 12 | type HTMLMinifierOptions interface { 13 | CaseSensitive(bool) HTMLMinifierOptions 14 | CollapseBooleanAttributes(bool) HTMLMinifierOptions 15 | CollapseInlineTagWhitespace(bool) HTMLMinifierOptions 16 | CollapseWhitespace(bool) HTMLMinifierOptions 17 | ConservativeCollapse(bool) HTMLMinifierOptions 18 | ContinueOnParseError(bool) HTMLMinifierOptions 19 | CustomAttrAssign([]string) HTMLMinifierOptions 20 | CustomAttrCollapse(string) HTMLMinifierOptions 21 | CustomAttrSurround([]string) HTMLMinifierOptions 22 | DecodeEntities(bool) HTMLMinifierOptions 23 | HTML5(bool) HTMLMinifierOptions 24 | IgnoreCustomComments([]string) HTMLMinifierOptions 25 | IgnoreCustomFragments([]string) HTMLMinifierOptions 26 | IncludeAutoGeneratedTags(bool) HTMLMinifierOptions 27 | KeepClosingSlash(bool) HTMLMinifierOptions 28 | MaxLineLength(lineLength uint) HTMLMinifierOptions 29 | MinifyCSS(bool) HTMLMinifierOptions 30 | MinifyURLs(bool) HTMLMinifierOptions 31 | PreserveLineBreaks(bool) HTMLMinifierOptions 32 | PreventAttributesEscaping(bool) HTMLMinifierOptions 33 | ProcessConditionalComments(bool) HTMLMinifierOptions 34 | ProcessScripts([]string) HTMLMinifierOptions 35 | QuoteCharacter(character HTMLMinifierQuoteCharacter) HTMLMinifierOptions 36 | RemoveAttributeQuotes(bool) HTMLMinifierOptions 37 | RemoveComments(bool) HTMLMinifierOptions 38 | RemoveEmptyAttributes(bool) HTMLMinifierOptions 39 | RemoveEmptyElements(bool) HTMLMinifierOptions 40 | RemoveOptionalTags(bool) HTMLMinifierOptions 41 | RemoveRedundantAttributes(bool) HTMLMinifierOptions 42 | RemoveScriptTypeAttributes(bool) HTMLMinifierOptions 43 | RemoveStyleLinkTypeAttributes(bool) HTMLMinifierOptions 44 | RemoveTagWhitespace(bool) HTMLMinifierOptions 45 | SortAttributes(bool) HTMLMinifierOptions 46 | SortClassName(bool) HTMLMinifierOptions 47 | TrimCustomFragments(bool) HTMLMinifierOptions 48 | UseShortDoctype(bool) HTMLMinifierOptions 49 | } 50 | 51 | type htmlMinifierOptions struct { 52 | data map[string]interface{} 53 | } 54 | 55 | func (o *htmlMinifierOptions) CaseSensitive(b bool) HTMLMinifierOptions { 56 | ret := *o 57 | ret.data["caseSensitive"] = b 58 | return &ret 59 | } 60 | 61 | func (o *htmlMinifierOptions) CollapseBooleanAttributes(b bool) HTMLMinifierOptions { 62 | ret := *o 63 | ret.data["collapseBooleanAttributes"] = b 64 | return &ret 65 | } 66 | 67 | func (o *htmlMinifierOptions) CollapseInlineTagWhitespace(b bool) HTMLMinifierOptions { 68 | ret := *o 69 | ret.data["collapseInlineTagWhitespace"] = b 70 | return &ret 71 | } 72 | 73 | func (o *htmlMinifierOptions) CollapseWhitespace(b bool) HTMLMinifierOptions { 74 | ret := *o 75 | ret.data["collapseWhitespace"] = b 76 | return &ret 77 | } 78 | 79 | func (o *htmlMinifierOptions) ConservativeCollapse(b bool) HTMLMinifierOptions { 80 | ret := *o 81 | ret.data["conservativeCollapse"] = b 82 | return &ret 83 | } 84 | 85 | func (o *htmlMinifierOptions) ContinueOnParseError(b bool) HTMLMinifierOptions { 86 | ret := *o 87 | ret.data["continueOnParseError"] = b 88 | return &ret 89 | } 90 | 91 | func (o *htmlMinifierOptions) CustomAttrAssign(regexes []string) HTMLMinifierOptions { 92 | ret := *o 93 | ret.data["customAttrAssign"] = regexes 94 | return &ret 95 | } 96 | 97 | func (o *htmlMinifierOptions) CustomAttrCollapse(regex string) HTMLMinifierOptions { 98 | ret := *o 99 | ret.data["customAttrCollapse"] = regex 100 | return &ret 101 | } 102 | 103 | func (o *htmlMinifierOptions) CustomAttrSurround(regexes []string) HTMLMinifierOptions { 104 | ret := *o 105 | ret.data["customAttrSurround"] = regexes 106 | return &ret 107 | } 108 | 109 | func (o *htmlMinifierOptions) DecodeEntities(b bool) HTMLMinifierOptions { 110 | ret := *o 111 | ret.data["decodeEntities"] = b 112 | return &ret 113 | } 114 | 115 | func (o *htmlMinifierOptions) HTML5(b bool) HTMLMinifierOptions { 116 | ret := *o 117 | ret.data["html5"] = b 118 | return &ret 119 | } 120 | 121 | func (o *htmlMinifierOptions) IgnoreCustomComments(regexes []string) HTMLMinifierOptions { 122 | ret := *o 123 | ret.data["ignoreCustomComments"] = regexes 124 | return &ret 125 | } 126 | 127 | func (o *htmlMinifierOptions) IgnoreCustomFragments(regexes []string) HTMLMinifierOptions { 128 | ret := *o 129 | ret.data["ignoreCustomFragments"] = regexes 130 | return &ret 131 | } 132 | 133 | func (o *htmlMinifierOptions) IncludeAutoGeneratedTags(b bool) HTMLMinifierOptions { 134 | ret := *o 135 | ret.data["includeAutoGeneratedTags"] = b 136 | return &ret 137 | } 138 | 139 | func (o *htmlMinifierOptions) KeepClosingSlash(b bool) HTMLMinifierOptions { 140 | ret := *o 141 | ret.data["keepClosingSlash"] = b 142 | return &ret 143 | } 144 | 145 | func (o *htmlMinifierOptions) MaxLineLength(lineLength uint) HTMLMinifierOptions { 146 | ret := *o 147 | ret.data["maxLineLength"] = lineLength 148 | return &ret 149 | } 150 | 151 | func (o *htmlMinifierOptions) MinifyCSS(b bool) HTMLMinifierOptions { 152 | ret := *o 153 | ret.data["minifyCSS"] = b 154 | return &ret 155 | } 156 | 157 | func (o *htmlMinifierOptions) MinifyURLs(b bool) HTMLMinifierOptions { 158 | ret := *o 159 | ret.data["minifyURLs"] = b 160 | return &ret 161 | } 162 | 163 | func (o *htmlMinifierOptions) PreserveLineBreaks(b bool) HTMLMinifierOptions { 164 | ret := *o 165 | ret.data["preserveLineBreaks"] = b 166 | return &ret 167 | } 168 | 169 | func (o *htmlMinifierOptions) PreventAttributesEscaping(b bool) HTMLMinifierOptions { 170 | ret := *o 171 | ret.data["preventAttributesEscaping"] = b 172 | return &ret 173 | } 174 | 175 | func (o *htmlMinifierOptions) ProcessConditionalComments(b bool) HTMLMinifierOptions { 176 | ret := *o 177 | ret.data["processConditionalComments"] = b 178 | return &ret 179 | } 180 | 181 | func (o *htmlMinifierOptions) ProcessScripts(scriptTypes []string) HTMLMinifierOptions { 182 | ret := *o 183 | ret.data["processScripts"] = scriptTypes 184 | return &ret 185 | } 186 | 187 | func (o *htmlMinifierOptions) QuoteCharacter(quoteCharacter HTMLMinifierQuoteCharacter) HTMLMinifierOptions { 188 | ret := *o 189 | ret.data["quoteCharacter"] = quoteCharacter 190 | return &ret 191 | } 192 | 193 | func (o *htmlMinifierOptions) RemoveAttributeQuotes(b bool) HTMLMinifierOptions { 194 | ret := *o 195 | ret.data["removeAttributeQuotes"] = b 196 | return &ret 197 | } 198 | 199 | func (o *htmlMinifierOptions) RemoveComments(b bool) HTMLMinifierOptions { 200 | ret := *o 201 | ret.data["removeComments"] = b 202 | return &ret 203 | } 204 | 205 | func (o *htmlMinifierOptions) RemoveEmptyAttributes(b bool) HTMLMinifierOptions { 206 | ret := *o 207 | ret.data["removeEmptyAttributes"] = b 208 | return &ret 209 | } 210 | 211 | func (o *htmlMinifierOptions) RemoveEmptyElements(b bool) HTMLMinifierOptions { 212 | ret := *o 213 | ret.data["removeEmptyElements"] = b 214 | return &ret 215 | } 216 | 217 | func (o *htmlMinifierOptions) RemoveOptionalTags(b bool) HTMLMinifierOptions { 218 | ret := *o 219 | ret.data["removeOptionalTags"] = b 220 | return &ret 221 | } 222 | 223 | func (o *htmlMinifierOptions) RemoveRedundantAttributes(b bool) HTMLMinifierOptions { 224 | ret := *o 225 | ret.data["removeRedundantAttributes"] = b 226 | return &ret 227 | } 228 | 229 | func (o *htmlMinifierOptions) RemoveScriptTypeAttributes(b bool) HTMLMinifierOptions { 230 | ret := *o 231 | ret.data["removeScriptTypeAttributes"] = b 232 | return &ret 233 | } 234 | 235 | func (o *htmlMinifierOptions) RemoveStyleLinkTypeAttributes(b bool) HTMLMinifierOptions { 236 | ret := *o 237 | ret.data["removeStyleLinkTypeAttributes"] = b 238 | return &ret 239 | } 240 | 241 | func (o *htmlMinifierOptions) RemoveTagWhitespace(b bool) HTMLMinifierOptions { 242 | ret := *o 243 | ret.data["removeTagWhitespace"] = b 244 | return &ret 245 | } 246 | 247 | func (o *htmlMinifierOptions) SortAttributes(b bool) HTMLMinifierOptions { 248 | ret := *o 249 | ret.data["sortAttributes"] = b 250 | return &ret 251 | } 252 | 253 | func (o *htmlMinifierOptions) SortClassName(b bool) HTMLMinifierOptions { 254 | ret := *o 255 | ret.data["sortClassName"] = b 256 | return &ret 257 | } 258 | 259 | func (o *htmlMinifierOptions) TrimCustomFragments(b bool) HTMLMinifierOptions { 260 | ret := *o 261 | ret.data["trimCustomFragments"] = b 262 | return &ret 263 | } 264 | 265 | func (o *htmlMinifierOptions) UseShortDoctype(b bool) HTMLMinifierOptions { 266 | ret := *o 267 | ret.data["useShortDoctype"] = b 268 | return &ret 269 | } 270 | 271 | func NewHTMLMinifierOptions() HTMLMinifierOptions { 272 | return &htmlMinifierOptions{ 273 | data: map[string]interface{}{}, 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /html_minifier_options_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestHTMLMiniferOptions(t *testing.T) { 9 | 10 | options := NewHTMLMinifierOptions(). 11 | CaseSensitive(true). 12 | CollapseBooleanAttributes(true). 13 | CollapseInlineTagWhitespace(true). 14 | CollapseWhitespace(true). 15 | ConservativeCollapse(true). 16 | ContinueOnParseError(true). 17 | CustomAttrAssign([]string{"
"}). 18 | CustomAttrCollapse("ng-class"). 19 | CustomAttrSurround([]string{""}). 20 | DecodeEntities(true). 21 | HTML5(true). 22 | IgnoreCustomComments([]string{"^!"}). 23 | IgnoreCustomFragments([]string{"<%[\\s\\S]*?%>"}). 24 | IncludeAutoGeneratedTags(true). 25 | KeepClosingSlash(true). 26 | MaxLineLength(50). 27 | MinifyCSS(true). 28 | MinifyURLs(true). 29 | PreserveLineBreaks(true). 30 | PreventAttributesEscaping(true). 31 | ProcessConditionalComments(true). 32 | ProcessScripts([]string{"text/ng-template"}). 33 | QuoteCharacter(HTMLMinifierDoubleQuote). 34 | RemoveAttributeQuotes(true). 35 | RemoveComments(true). 36 | RemoveEmptyAttributes(true). 37 | RemoveEmptyElements(true). 38 | RemoveOptionalTags(true). 39 | RemoveRedundantAttributes(true). 40 | RemoveScriptTypeAttributes(true). 41 | RemoveStyleLinkTypeAttributes(true). 42 | RemoveTagWhitespace(true). 43 | SortAttributes(true). 44 | SortClassName(true). 45 | TrimCustomFragments(true). 46 | UseShortDoctype(true) 47 | 48 | expected := map[string]interface{}{ 49 | "caseSensitive": true, 50 | "collapseBooleanAttributes": true, 51 | "collapseInlineTagWhitespace": true, 52 | "collapseWhitespace": true, 53 | "conservativeCollapse": true, 54 | "continueOnParseError": true, 55 | "customAttrAssign": []string{"
"}, 56 | "customAttrCollapse": "ng-class", 57 | "customAttrSurround": []string{""}, 58 | "decodeEntities": true, 59 | "html5": true, 60 | "ignoreCustomComments": []string{"^!"}, 61 | "ignoreCustomFragments": []string{"<%[\\s\\S]*?%>"}, 62 | "includeAutoGeneratedTags": true, 63 | "keepClosingSlash": true, 64 | "maxLineLength": uint(50), 65 | "minifyCSS": true, 66 | "minifyURLs": true, 67 | "preserveLineBreaks": true, 68 | "preventAttributesEscaping": true, 69 | "processConditionalComments": true, 70 | "processScripts": []string{"text/ng-template"}, 71 | "quoteCharacter": HTMLMinifierDoubleQuote, 72 | "removeAttributeQuotes": true, 73 | "removeComments": true, 74 | "removeEmptyAttributes": true, 75 | "removeEmptyElements": true, 76 | "removeOptionalTags": true, 77 | "removeRedundantAttributes": true, 78 | "removeScriptTypeAttributes": true, 79 | "removeStyleLinkTypeAttributes": true, 80 | "removeTagWhitespace": true, 81 | "sortAttributes": true, 82 | "sortClassName": true, 83 | "trimCustomFragments": true, 84 | "useShortDoctype": true, 85 | } 86 | 87 | htmlMinifierOptions, ok := options.(*htmlMinifierOptions) 88 | 89 | if !ok { 90 | t.Fatal("Options is not a *htmlMinifierOptions") 91 | } 92 | 93 | if !reflect.DeepEqual(htmlMinifierOptions.data, expected) { 94 | t.Error("HTMLMinifierOptions does not match expected data") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /id.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | "math/big" 8 | ) 9 | 10 | func randomIdentifier() (int32, error) { 11 | // generate a random number between 0 and the largest possible int32 12 | num, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt32)) 13 | if err != nil { 14 | return -1, fmt.Errorf("failed to generate random int: %w", err) 15 | } 16 | 17 | return int32(num.Int64()), nil 18 | } 19 | -------------------------------------------------------------------------------- /js/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | apt -qq update && apt -qq install brotli 4 | echo "==> Bundling wrapper" 5 | npm config set update-notifier false 6 | npm install 7 | npx --no-update-notifier webpack 8 | echo "==> Compiling to wasm" 9 | javy -o /tmp/mjml.wasm /tmp/mjml.js 10 | echo "==> Compressing wasm" 11 | brotli -f -o ../wasm/mjml.wasm.br /tmp/mjml.wasm 12 | echo "==> Pkging test server" 13 | npx pkg -o ../node-test-server/server --compress brotli --targets node18-linux-x64 /tmp/server.js 14 | echo "==> Done!" -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mjml-wasm", 3 | "private": true, 4 | "dependencies": { 5 | "@suborbital/runnable": "^0.15.1", 6 | "fastestsmallesttextencoderdecoder-encodeinto": "^1.0.22", 7 | "global": "^4.4.0", 8 | "html-minifier": "^4.0.0", 9 | "js-beautify": "^1.14.8", 10 | "mjml-browser": "^4.14.1", 11 | "process": "^0.11.10" 12 | }, 13 | "devDependencies": { 14 | "lodash.merge": "^4.6.2", 15 | "pkg": "^5.8.1", 16 | "prettier": "^3.0.0", 17 | "webpack": "^5.88.1", 18 | "webpack-cli": "^5.1.4" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /js/shims/uglify-js.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /js/src/index.js: -------------------------------------------------------------------------------- 1 | import "fastestsmallesttextencoderdecoder-encodeinto/EncoderDecoderTogether.min.js"; 2 | import { compile } from "./lib"; 3 | 4 | import { setup, runnable } from "@suborbital/runnable"; 5 | 6 | const decoder = new TextDecoder(); 7 | 8 | function run_e(payload, ident) { 9 | // Imports will be injected by the runtime 10 | setup(this.imports, ident); 11 | 12 | const input = decoder.decode(payload); 13 | 14 | let encodedJSON; 15 | 16 | try { 17 | const decodedJSON = JSON.parse(input); 18 | const result = compile(decodedJSON); 19 | encodedJSON = JSON.stringify(result); 20 | } catch (err) { 21 | encodedJSON = JSON.stringify({ 22 | error: { 23 | message: err.message, 24 | }, 25 | }); 26 | } 27 | 28 | runnable.returnResult(encodedJSON); 29 | } 30 | 31 | export { run_e }; 32 | -------------------------------------------------------------------------------- /js/src/lib.js: -------------------------------------------------------------------------------- 1 | import mjml2html from "mjml-browser"; 2 | import htmlMinifier from "html-minifier"; 3 | import jsBeautify from "js-beautify"; 4 | 5 | const defaultMinifyOptions = { 6 | caseSensitive: true, 7 | collapseWhitespace: true, 8 | minifyCSS: false, 9 | removeEmptyAttributes: true, 10 | }; 11 | 12 | const defaultBeautifyOptions = { 13 | end_with_newline: true, 14 | indent_size: 2, 15 | max_preserve_newline: 0, 16 | preserve_newlines: false, 17 | wrap_attributes_indent_size: 2, 18 | }; 19 | 20 | export function compile(input) { 21 | if (!input.mjml) { 22 | return { 23 | error: { 24 | message: "input is missing mjml property", 25 | }, 26 | }; 27 | } 28 | 29 | let options = {}; 30 | 31 | if (input.options) { 32 | options = omit(input.options, "beautify", "minify", "minifyOptions"); 33 | } 34 | 35 | let output; 36 | 37 | try { 38 | output = mjml2html(input.mjml, options); 39 | 40 | if (input.options && input.options.beautify) { 41 | if (input.options.beautifyOptions == null) { 42 | input.options.beautifyOptions = {}; 43 | } 44 | 45 | output.html = jsBeautify.html(output.html, { 46 | ...defaultBeautifyOptions, 47 | ...input.options.beautifyOptions, 48 | }); 49 | } 50 | 51 | if (input.options && input.options.minify) { 52 | if (input.options.minifyOptions == null) { 53 | input.options.minifyOptions = {}; 54 | } 55 | 56 | // Convert array of regex strings to RegExp objects 57 | const regexArrays = [ 58 | "customAttrAssign", 59 | "customAttrSurround", 60 | "ignoreCustomComments", 61 | "ignoreCustomFragments", 62 | ]; 63 | 64 | regexArrays.forEach(function (key) { 65 | if ( 66 | input.options.minifyOptions && 67 | input.options.minifyOptions.hasOwnProperty(key) 68 | ) { 69 | input.options.minifyOptions[key] = parseStringRegExpArray( 70 | input.options.minifyOptions[key] 71 | ); 72 | } 73 | }); 74 | 75 | // Convert regex string to RegExp object 76 | if ( 77 | input.options.minifyOptions && 78 | input.options.minifyOptions.customAttrCollapse 79 | ) { 80 | input.options.minifyOptions.customAttrCollapse = parseRegExp( 81 | input.options.minifyOptions.customAttrCollapse 82 | ); 83 | } 84 | 85 | output.html = htmlMinifier.minify(output.html, { 86 | ...defaultMinifyOptions, 87 | ...input.options.minifyOptions, 88 | }); 89 | } 90 | } catch (err) { 91 | return { 92 | error: { 93 | message: err.message, 94 | }, 95 | }; 96 | } 97 | 98 | let result = { 99 | html: output.html, 100 | }; 101 | 102 | if (output.errors.length > 0) { 103 | result.error = { 104 | message: "MJML compilation error", 105 | details: output.errors.map((err) => ({ 106 | line: err.line, 107 | message: err.message, 108 | tagName: err.tagName, 109 | })), 110 | }; 111 | } 112 | 113 | return result; 114 | } 115 | 116 | function omit(obj, ...props) { 117 | const result = { ...obj }; 118 | 119 | props.forEach(function (prop) { 120 | delete result[prop]; 121 | }); 122 | 123 | return result; 124 | } 125 | 126 | function parseRegExp(value) { 127 | if (value) { 128 | return new RegExp(value.replace(/^\/(.*)\/$/, "$1")); 129 | } 130 | } 131 | 132 | function parseStringRegExpArray(value) { 133 | if (!Array.isArray(value)) { 134 | value = [value]; 135 | } 136 | 137 | return value && value.map(parseRegExp); 138 | } 139 | -------------------------------------------------------------------------------- /js/src/server.js: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import { compile } from "./lib"; 3 | 4 | http 5 | .createServer(async (request, response) => { 6 | let result; 7 | 8 | if (request.method !== "POST") { 9 | result = { 10 | error: { 11 | message: "Only POST requests are accepted", 12 | }, 13 | }; 14 | } else { 15 | const buffers = []; 16 | for await (const chunk of request) { 17 | buffers.push(chunk); 18 | } 19 | const data = Buffer.concat(buffers).toString(); 20 | 21 | try { 22 | const input = JSON.parse(data); 23 | result = compile(input); 24 | } catch (e) { 25 | result = { 26 | error: { 27 | message: e.message, 28 | }, 29 | }; 30 | } 31 | } 32 | 33 | const encoded = JSON.stringify(result); 34 | 35 | response.writeHead(200, { "Content-Type": "application/json" }); 36 | response.end(encoded, "utf-8"); 37 | }) 38 | .listen(8888); 39 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const merge = require("lodash.merge"); 4 | 5 | const config = { 6 | mode: "production", 7 | target: "es2019", 8 | optimization: { 9 | sideEffects: true, 10 | }, 11 | context: path.resolve(__dirname, "src"), 12 | resolve: { 13 | fallback: { 14 | fs: false, 15 | https: false, 16 | http: false, 17 | os: false, 18 | path: false, 19 | url: false, 20 | }, 21 | alias: { 22 | "uglify-js": path.resolve(__dirname, "shims/uglify-js"), // We need to do this because uglify-js can't be built with webpack 23 | }, 24 | }, 25 | output: { 26 | globalObject: "this", 27 | path: "/tmp", 28 | libraryTarget: "umd", 29 | library: "Suborbital", 30 | chunkFormat: "array-push", 31 | }, 32 | plugins: [ 33 | new webpack.ProvidePlugin({ 34 | process: "process/browser", 35 | window: "global/window", 36 | }), 37 | ], 38 | performance: { 39 | hints: false, 40 | }, 41 | }; 42 | 43 | const wasmConfig = merge({}, config, { 44 | entry: "./index.js", 45 | output: { 46 | filename: "mjml.js", 47 | }, 48 | }); 49 | 50 | const testServerConfig = merge({}, config, { 51 | target: "node18", 52 | entry: "./server.js", 53 | output: { 54 | filename: "server.js", 55 | }, 56 | }); 57 | 58 | module.exports = [wasmConfig, testServerConfig]; 59 | -------------------------------------------------------------------------------- /juice_options.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | // JuiceOptions is used to construct Juice options to be passed to the MJML compiler 4 | // Detailed explanations of the options are here: https://github.com/Automattic/juice#options 5 | type JuiceOptions interface { 6 | ApplyAttributesTableElements(bool) JuiceOptions 7 | ApplyHeightAttributes(bool) JuiceOptions 8 | ApplyStyleTags(bool) JuiceOptions 9 | ApplyWidthAttributes(bool) JuiceOptions 10 | ExtraCss(string) JuiceOptions 11 | InsertPreservedExtraCss(bool) JuiceOptions 12 | InlinePseudoElements(bool) JuiceOptions 13 | PreserveFontFaces(bool) JuiceOptions 14 | PreserveImportant(bool) JuiceOptions 15 | PreserveMediaQueries(bool) JuiceOptions 16 | PreserveKeyFrames(bool) JuiceOptions 17 | PreservePseudos(bool) JuiceOptions 18 | RemoveStyleTags(bool) JuiceOptions 19 | XmlMode(bool) JuiceOptions 20 | } 21 | 22 | type juiceOptions struct { 23 | data map[string]interface{} 24 | } 25 | 26 | func (o *juiceOptions) ApplyAttributesTableElements(b bool) JuiceOptions { 27 | ret := *o 28 | ret.data["applyAttributesTableElements"] = b 29 | return &ret 30 | } 31 | 32 | func (o *juiceOptions) ApplyHeightAttributes(b bool) JuiceOptions { 33 | ret := *o 34 | ret.data["applyHeightAttributes"] = b 35 | return &ret 36 | } 37 | 38 | func (o *juiceOptions) ApplyStyleTags(b bool) JuiceOptions { 39 | ret := *o 40 | ret.data["applyStyleTags"] = b 41 | return &ret 42 | } 43 | 44 | func (o *juiceOptions) ApplyWidthAttributes(b bool) JuiceOptions { 45 | ret := *o 46 | ret.data["applyWidthAttributes"] = b 47 | return &ret 48 | } 49 | 50 | func (o *juiceOptions) ExtraCss(s string) JuiceOptions { 51 | ret := *o 52 | ret.data["extraCss"] = s 53 | return &ret 54 | } 55 | 56 | func (o *juiceOptions) InsertPreservedExtraCss(b bool) JuiceOptions { 57 | ret := *o 58 | ret.data["insertPreservedExtraCss"] = b 59 | return &ret 60 | } 61 | 62 | func (o *juiceOptions) InlinePseudoElements(b bool) JuiceOptions { 63 | ret := *o 64 | ret.data["inlinePseudoElements"] = b 65 | return &ret 66 | } 67 | 68 | func (o *juiceOptions) PreserveFontFaces(b bool) JuiceOptions { 69 | ret := *o 70 | ret.data["preserveFontFaces"] = b 71 | return &ret 72 | } 73 | 74 | func (o *juiceOptions) PreserveImportant(b bool) JuiceOptions { 75 | ret := *o 76 | ret.data["preserveImportant"] = b 77 | return &ret 78 | } 79 | 80 | func (o *juiceOptions) PreserveMediaQueries(b bool) JuiceOptions { 81 | ret := *o 82 | ret.data["preserveMediaQueries"] = b 83 | return &ret 84 | } 85 | 86 | func (o *juiceOptions) PreserveKeyFrames(b bool) JuiceOptions { 87 | ret := *o 88 | ret.data["preserveKeyFrames"] = b 89 | return &ret 90 | } 91 | 92 | func (o *juiceOptions) PreservePseudos(b bool) JuiceOptions { 93 | ret := *o 94 | ret.data["preservePseudos"] = b 95 | return &ret 96 | } 97 | 98 | func (o *juiceOptions) RemoveStyleTags(b bool) JuiceOptions { 99 | ret := *o 100 | ret.data["removeStyleTags"] = b 101 | return &ret 102 | } 103 | 104 | func (o *juiceOptions) XmlMode(b bool) JuiceOptions { 105 | ret := *o 106 | ret.data["xmlMode"] = b 107 | return &ret 108 | } 109 | 110 | func NewJuiceOptions() JuiceOptions { 111 | return &juiceOptions{ 112 | data: map[string]interface{}{}, 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /juice_options_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestJuiceOptions(t *testing.T) { 9 | 10 | options := NewJuiceOptions(). 11 | ApplyAttributesTableElements(true). 12 | ApplyHeightAttributes(true). 13 | ApplyStyleTags(true). 14 | ApplyWidthAttributes(true). 15 | ExtraCss("somestring"). 16 | InsertPreservedExtraCss(true). 17 | InlinePseudoElements(true). 18 | PreserveFontFaces(true). 19 | PreserveImportant(true). 20 | PreserveMediaQueries(true). 21 | PreserveKeyFrames(true). 22 | PreservePseudos(true). 23 | RemoveStyleTags(true). 24 | XmlMode(true) 25 | 26 | expected := map[string]interface{}{ 27 | "applyAttributesTableElements": true, 28 | "applyHeightAttributes": true, 29 | "applyStyleTags": true, 30 | "applyWidthAttributes": true, 31 | "extraCss": "somestring", 32 | "insertPreservedExtraCss": true, 33 | "inlinePseudoElements": true, 34 | "preserveFontFaces": true, 35 | "preserveImportant": true, 36 | "preserveMediaQueries": true, 37 | "preserveKeyFrames": true, 38 | "preservePseudos": true, 39 | "removeStyleTags": true, 40 | "xmlMode": true, 41 | } 42 | 43 | juiceOptions, ok := options.(*juiceOptions) 44 | 45 | if !ok { 46 | t.Fatal("Options is not a *juiceOptions") 47 | } 48 | 49 | if !reflect.DeepEqual(juiceOptions.data, expected) { 50 | t.Error("JuiceOptions does not match expected data") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mjml.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "sync" 12 | "time" 13 | 14 | "github.com/andybalholm/brotli" 15 | "github.com/jackc/puddle/v2" 16 | "github.com/tetratelabs/wazero" 17 | "github.com/tetratelabs/wazero/api" 18 | "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" 19 | ) 20 | 21 | //go:embed wasm/mjml.wasm.br 22 | var wasm []byte 23 | 24 | var ( 25 | runtime wazero.Runtime 26 | compiled wazero.CompiledModule 27 | results *sync.Map 28 | resourcePool *puddle.Pool[api.Module] 29 | ) 30 | 31 | func init() { 32 | ctx := context.Background() 33 | 34 | results = &sync.Map{} 35 | 36 | br := brotli.NewReader(bytes.NewReader(wasm)) 37 | decompressed, err := io.ReadAll(br) 38 | 39 | if err != nil { 40 | panic(fmt.Sprintf("Error decompressing wasm file: %s", err)) 41 | } 42 | 43 | runtime = wazero.NewRuntime(ctx) // TODO: this should be closed 44 | 45 | if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { 46 | panic(fmt.Sprintf("Error instantiating wasi snapshot preview 1: %s", err)) 47 | } 48 | 49 | err = registerHostFunctions(ctx, runtime) 50 | 51 | if err != nil { 52 | panic(fmt.Sprintf("Error registering host functions: %s", err)) 53 | } 54 | 55 | compiled, err = runtime.CompileModule(ctx, decompressed) 56 | 57 | if err != nil { 58 | panic(fmt.Sprintf("Error compiling wasm module: %s", err)) 59 | } 60 | 61 | resourcePool, err = newResourcePool(10) 62 | 63 | if err != nil { 64 | panic(fmt.Sprintf("Error creating resource pool: %s", err)) 65 | } 66 | 67 | go periodicallyRemoveIdleResources(resourcePool) 68 | } 69 | 70 | func SetMaxWorkers(maxSize int32) error { 71 | oldPool := resourcePool 72 | 73 | newPool, err := newResourcePool(maxSize) 74 | 75 | if err != nil { 76 | return fmt.Errorf("error creating new resource pool: %w", err) 77 | } 78 | 79 | resourcePool = newPool 80 | oldPool.Close() 81 | 82 | return nil 83 | } 84 | 85 | type jsonResult struct { 86 | HTML string `json:"html"` 87 | Error *Error `json:"error,omitempty"` 88 | } 89 | 90 | // ToHTML converts a string containing mjml to HTML while using any of the optionally provided options 91 | func ToHTML(ctx context.Context, mjml string, toHTMLOptions ...ToHTMLOption) (string, error) { 92 | data := map[string]interface{}{ 93 | "mjml": mjml, 94 | } 95 | 96 | o := options{ 97 | data: map[string]interface{}{}, 98 | } 99 | 100 | for _, opt := range toHTMLOptions { 101 | opt(o) 102 | } 103 | 104 | if len(o.data) > 0 { 105 | data["options"] = o.data 106 | } 107 | 108 | inputBytes := bytes.NewBuffer([]byte{}) 109 | 110 | encoder := json.NewEncoder(inputBytes) 111 | encoder.SetEscapeHTML(false) 112 | 113 | err := encoder.Encode(data) 114 | 115 | if err != nil { 116 | return "", fmt.Errorf("error encoding input data: %w", err) 117 | } 118 | 119 | jsonInput := inputBytes.String() 120 | jsonInputLen := uint64(len(jsonInput)) 121 | 122 | var ( 123 | module *puddle.Resource[api.Module] 124 | tries int 125 | ) 126 | 127 | for { 128 | tries++ 129 | 130 | var err error 131 | 132 | module, err = resourcePool.Acquire(ctx) 133 | 134 | if err != nil { 135 | 136 | if tries >= 30 { 137 | return "", fmt.Errorf("unable to accquire wasm module after 30 tries: %w", err) 138 | } 139 | 140 | if err == puddle.ErrClosedPool { 141 | time.Sleep(1 * time.Millisecond) 142 | continue 143 | } 144 | 145 | return "", fmt.Errorf("error accquiring wasm module: %w", err) 146 | } 147 | 148 | break 149 | } 150 | 151 | defer module.Release() 152 | 153 | mod, ok := module.Value().(api.Module) 154 | 155 | if !ok { 156 | return "", errors.New("pool resource is not an api.Module") 157 | } 158 | 159 | deallocate := mod.ExportedFunction("deallocate") 160 | allocate := mod.ExportedFunction("allocate") 161 | run := mod.ExportedFunction("run_e") 162 | memory := mod.Memory() 163 | 164 | allocation, err := allocate.Call(ctx, jsonInputLen) 165 | 166 | if err != nil { 167 | return "", fmt.Errorf("error allocating memory: %w", err) 168 | } 169 | 170 | if len(allocation) != 1 { 171 | return "", errors.New("invalid input pointer allocated") 172 | } 173 | 174 | inputPtr := allocation[0] 175 | 176 | defer deallocate.Call(ctx, inputPtr) 177 | 178 | if !memory.Write(uint32(inputPtr), []byte(jsonInput)) { 179 | return "", fmt.Errorf("error writing input to memory: %w", err) 180 | } 181 | 182 | ident, err := randomIdentifier() 183 | 184 | if err != nil { 185 | return "", fmt.Errorf("error generating identifier: %w", err) 186 | } 187 | 188 | resultCh := make(chan []byte, 1) 189 | 190 | results.Store(ident, resultCh) 191 | 192 | defer results.Delete(ident) 193 | 194 | _, err = run.Call(ctx, inputPtr, jsonInputLen, uint64(ident)) 195 | 196 | if err != nil { 197 | return "", fmt.Errorf("error calling run: %w", err) 198 | } 199 | 200 | result := <-resultCh 201 | 202 | res := jsonResult{} 203 | 204 | err = json.Unmarshal(result, &res) 205 | 206 | if err != nil { 207 | return "", fmt.Errorf("error decoding result json: %w", err) 208 | } 209 | 210 | if res.Error != nil { 211 | return "", *res.Error 212 | } 213 | 214 | return res.HTML, nil 215 | } 216 | 217 | func registerHostFunctions(ctx context.Context, r wazero.Runtime) error { 218 | _, err := r.NewHostModuleBuilder("env"). 219 | NewFunctionBuilder(). 220 | WithFunc(returnResult). 221 | WithParameterNames("ptr", "len", "ident"). 222 | Export("return_result"). 223 | NewFunctionBuilder(). 224 | WithFunc(func(_ uint32, _ uint32, _ uint32) uint32 { 225 | panic("get_static_file is unimplemented") 226 | }). 227 | Export("get_static_file"). 228 | NewFunctionBuilder(). 229 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32, _ uint32) uint32 { 230 | panic("request_set_field is unimplemented") 231 | }). 232 | Export("request_set_field"). 233 | NewFunctionBuilder(). 234 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32) { 235 | panic("resp_set_header is unimplemented") 236 | }). 237 | Export("resp_set_header"). 238 | NewFunctionBuilder(). 239 | WithFunc(func(_ uint32, _ uint32, _ uint32) uint32 { 240 | panic("cache_get is unimplemented") 241 | }). 242 | Export("cache_get"). 243 | NewFunctionBuilder(). 244 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32) uint32 { 245 | panic("add_ffi_var is unimplemented") 246 | }). 247 | Export("add_ffi_var"). 248 | NewFunctionBuilder(). 249 | WithFunc(func(_ uint32, _ uint32) uint32 { 250 | panic("get_ffi_result is unimplemented") 251 | }). 252 | Export("get_ffi_result"). 253 | NewFunctionBuilder(). 254 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32) { 255 | panic("return_error is unimplemented") 256 | }). 257 | Export("return_error"). 258 | NewFunctionBuilder(). 259 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32, _ uint32) uint32 { 260 | panic("fetch_url is unimplemented") 261 | }). 262 | Export("fetch_url"). 263 | NewFunctionBuilder(). 264 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32) uint32 { 265 | panic("graphql_query is unimplemented") 266 | }). 267 | Export("graphql_query"). 268 | NewFunctionBuilder(). 269 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32) uint32 { 270 | panic("db_exec is unimplemented") 271 | }). 272 | Export("db_exec"). 273 | NewFunctionBuilder(). 274 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32, _ uint32, _ uint32) uint32 { 275 | panic("cache_set is unimplemented") 276 | }). 277 | Export("cache_set"). 278 | NewFunctionBuilder(). 279 | WithFunc(func(_ uint32, _ uint32, _ uint32, _ uint32) uint32 { 280 | panic("request_get_field is unimplemented") 281 | }). 282 | Export("request_get_field"). 283 | NewFunctionBuilder(). 284 | WithFunc(func(ctx context.Context, m api.Module, ptr uint32, size uint32, level uint32, ident uint32) { 285 | panic("log_msg is unimplemented") 286 | }). 287 | Export("log_msg"). 288 | Instantiate(ctx) 289 | 290 | return err 291 | } 292 | 293 | // returnResult is defined with a reflective signature instead of 294 | // api.GoModuleFunc because it isn't called frequently. 295 | func returnResult(ctx context.Context, m api.Module, ptr uint32, len uint32, ident uint32) { 296 | if ch, ok := results.Load(int32(ident)); ok { 297 | result, ok := m.Memory().Read(ptr, len) 298 | 299 | resultCh, isResultCh := ch.(chan []byte) 300 | 301 | if ok && isResultCh { 302 | resultCh <- result 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /mjml_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | type testTemplate struct { 17 | file string 18 | input string 19 | output string 20 | } 21 | 22 | func getTestTemplates() ([]testTemplate, error) { 23 | 24 | var result []testTemplate 25 | 26 | files := []string{ 27 | "black-friday", 28 | "one-page", 29 | "reactivation-email", 30 | "real-estate", 31 | "recast", 32 | "receipt-email", 33 | } 34 | 35 | for _, file := range files { 36 | input, err := os.ReadFile("testdata/" + file + ".mjml") 37 | 38 | if err != nil { 39 | return result, fmt.Errorf("error reading input %s.mjml: %w", file, err) 40 | } 41 | 42 | output, err := os.ReadFile("testdata/" + file + ".html") 43 | 44 | if err != nil { 45 | return result, fmt.Errorf("error reading output %s.mjml: %w", file, err) 46 | } 47 | 48 | result = append(result, testTemplate{ 49 | file: file, 50 | input: string(input), 51 | output: string(output), 52 | }) 53 | } 54 | 55 | return result, nil 56 | } 57 | 58 | func BenchmarkNodeJS(b *testing.B) { 59 | 60 | serverBin := "node-test-server/server" 61 | 62 | cmd := exec.Command(serverBin) 63 | 64 | err := cmd.Start() 65 | 66 | if err != nil { 67 | b.Fatalf("Error starting Node.js reference server: %s", err) 68 | } 69 | 70 | defer cmd.Process.Kill() 71 | 72 | nodeAddr := "http://localhost:8888" 73 | 74 | client := &http.Client{} 75 | 76 | var tries int 77 | 78 | for { 79 | _, err := client.Get(nodeAddr) 80 | 81 | if err == nil { 82 | break 83 | } 84 | 85 | if tries > 5 { 86 | b.Fatalf("Timed out while waiting for Node.js reference server to be available: %s", err) 87 | } 88 | 89 | time.Sleep(1 * time.Second) 90 | 91 | tries++ 92 | } 93 | 94 | testCases, err := getTestTemplates() 95 | 96 | if err != nil { 97 | b.Fatalf("Error getting test cases: %s", err) 98 | } 99 | 100 | type request struct { 101 | MJML string `json:"mjml"` 102 | Options map[string]string `json:"options"` 103 | } 104 | 105 | for _, testCase := range testCases { 106 | 107 | b.Run(testCase.file, func(b *testing.B) { 108 | b.ResetTimer() 109 | for i := 0; i < b.N; i++ { 110 | r := request{ 111 | MJML: testCase.input, 112 | Options: map[string]string{ 113 | "validationLevel": "skip", 114 | }, 115 | } 116 | jsonRequest, err := json.Marshal(r) 117 | 118 | if err != nil { 119 | b.Fatalf("Error marshaling json request: %s", err) 120 | } 121 | 122 | result, err := client.Post(nodeAddr, "application/json", bytes.NewReader(jsonRequest)) 123 | 124 | if err != nil { 125 | b.Fatalf("Error posting request to Node.js server: %s", err) 126 | } 127 | 128 | var decoded jsonResult 129 | 130 | body, err := io.ReadAll(result.Body) 131 | 132 | if err != nil { 133 | b.Fatalf("Error reading response body: %s", err) 134 | } 135 | 136 | err = json.Unmarshal(body, &decoded) 137 | 138 | if err != nil { 139 | b.Fatalf("Error decoding response; %s", err) 140 | } 141 | 142 | if decoded.Error != nil { 143 | b.Fatalf("Error converting input to HTML: %s", err) 144 | } 145 | 146 | if decoded.HTML != testCase.output { 147 | b.Fatalf("Output for input (%s.mjml) does not match expected response", testCase.file) 148 | } 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func BenchmarkMJMLGo(b *testing.B) { 155 | testCases, err := getTestTemplates() 156 | 157 | if err != nil { 158 | b.Fatalf("Error getting test cases: %s", err) 159 | } 160 | 161 | if err != nil { 162 | b.Fatalf("Error setting max workers: %s", err) 163 | } 164 | 165 | for _, testCase := range testCases { 166 | b.Run(testCase.file, func(b *testing.B) { 167 | b.ResetTimer() 168 | for i := 0; i < b.N; i++ { 169 | 170 | result, err := ToHTML(context.Background(), testCase.input, WithValidationLevel(Skip)) 171 | 172 | if err != nil { 173 | b.Fatalf("Error converting input to HTML: %s", err) 174 | } 175 | 176 | if result != testCase.output { 177 | b.Fatalf("Output for input (%s.mjml) does not match expected response", testCase.file) 178 | } 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /mjml_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "fmt" 7 | "math/big" 8 | "os" 9 | "sync" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestToHTML(t *testing.T) { 15 | 16 | files := []string{ 17 | "black-friday", 18 | "one-page", 19 | "reactivation-email", 20 | "real-estate", 21 | "recast", 22 | "receipt-email", 23 | } 24 | 25 | for _, file := range files { 26 | contents, err := os.ReadFile("testdata/" + file + ".mjml") 27 | 28 | if err != nil { 29 | t.Fatalf("Error reading file: %s", file) 30 | } 31 | 32 | result, err := ToHTML(context.Background(), string(contents), WithValidationLevel(Skip)) // Skip validation because the templates are not fully mjml4-compliant 33 | 34 | if err != nil { 35 | t.Errorf("Error converting mjml file (%s) to html: %s", file, err) 36 | } 37 | 38 | expected, err := os.ReadFile("testdata/" + file + ".html") 39 | 40 | if err != nil { 41 | t.Fatalf("Error reading expected html: %s", err) 42 | } 43 | 44 | if result != string(expected) { 45 | t.Errorf("Compiled HTML for %s does not match expected html", file) 46 | } 47 | } 48 | } 49 | 50 | func TestConcurrency(t *testing.T) { 51 | 52 | files := []string{ 53 | "black-friday", 54 | "one-page", 55 | "reactivation-email", 56 | "real-estate", 57 | "recast", 58 | "receipt-email", 59 | } 60 | 61 | type testCase struct { 62 | file string 63 | input string 64 | expected string 65 | } 66 | var testCases []testCase 67 | 68 | for _, file := range files { 69 | input, err := os.ReadFile("testdata/" + file + ".mjml") 70 | 71 | if err != nil { 72 | t.Fatalf("Error reading input test data (%s.mjml): %s", file, err) 73 | } 74 | 75 | expected, err := os.ReadFile("testdata/" + file + ".html") 76 | 77 | if err != nil { 78 | t.Fatalf("Error reading expected test data (%s.html): %s", file, err) 79 | } 80 | 81 | testCases = append(testCases, testCase{ 82 | file: file, 83 | input: string(input), 84 | expected: string(expected), 85 | }) 86 | } 87 | 88 | numTestCases := big.NewInt(int64(len(testCases))) 89 | 90 | var wg sync.WaitGroup 91 | 92 | numGoRoutines := 200 93 | 94 | errs := make(chan error, numGoRoutines) 95 | 96 | for i := 0; i < numGoRoutines; i++ { 97 | 98 | wg.Add(1) 99 | 100 | go func(run int) { 101 | 102 | defer wg.Done() 103 | 104 | testCaseIndex, err := rand.Int(rand.Reader, numTestCases) 105 | 106 | if err != nil { 107 | errs <- fmt.Errorf("error selecting test case for run %d: %w", run, err) 108 | return 109 | } 110 | 111 | testCase := testCases[testCaseIndex.Int64()] 112 | 113 | result, err := ToHTML(context.Background(), testCase.input, WithValidationLevel(Skip)) 114 | 115 | if err != nil { 116 | errs <- fmt.Errorf("error converting input to HTML for run %d using %s.mjml as input: %w", run, testCase.file, err) 117 | return 118 | } 119 | 120 | if result != testCase.expected { 121 | errs <- fmt.Errorf("html result does not match expected result for run %d", run) 122 | return 123 | } 124 | }(i) 125 | } 126 | 127 | wg.Wait() 128 | close(errs) 129 | 130 | for err := range errs { 131 | if err != nil { 132 | t.Errorf("Error running ToHTML concurrently: %s", err.Error()) 133 | } 134 | } 135 | } 136 | 137 | func TestSetMaxWorkers(t *testing.T) { 138 | files := []string{ 139 | "black-friday", 140 | "one-page", 141 | "reactivation-email", 142 | "real-estate", 143 | "recast", 144 | "receipt-email", 145 | } 146 | 147 | type testCase struct { 148 | file string 149 | input string 150 | expected string 151 | } 152 | var testCases []testCase 153 | 154 | for _, file := range files { 155 | input, err := os.ReadFile("testdata/" + file + ".mjml") 156 | 157 | if err != nil { 158 | t.Fatalf("Error reading input test data (%s.mjml): %s", file, err) 159 | } 160 | 161 | expected, err := os.ReadFile("testdata/" + file + ".html") 162 | 163 | if err != nil { 164 | t.Fatalf("Error reading expected test data (%s.html): %s", file, err) 165 | } 166 | 167 | testCases = append(testCases, testCase{ 168 | file: file, 169 | input: string(input), 170 | expected: string(expected), 171 | }) 172 | } 173 | 174 | numTestCases := big.NewInt(int64(len(testCases))) 175 | 176 | var wg sync.WaitGroup 177 | 178 | numGoRoutines := 200 179 | 180 | errs := make(chan error, numGoRoutines) 181 | 182 | for i := 0; i < numGoRoutines; i++ { 183 | 184 | wg.Add(1) 185 | 186 | go func(run int) { 187 | 188 | defer wg.Done() 189 | 190 | testCaseIndex, err := rand.Int(rand.Reader, numTestCases) 191 | 192 | if err != nil { 193 | errs <- fmt.Errorf("error selecting test case for run %d: %w", run, err) 194 | return 195 | } 196 | 197 | testCase := testCases[testCaseIndex.Int64()] 198 | 199 | result, err := ToHTML(context.Background(), testCase.input, WithValidationLevel(Skip)) 200 | 201 | if err != nil { 202 | errs <- fmt.Errorf("error converting input to HTML for run %d using %s.mjml as input: %w", run, testCase.file, err) 203 | return 204 | } 205 | 206 | if result != testCase.expected { 207 | errs <- fmt.Errorf("html result does not match expected result for run %d", run) 208 | return 209 | } 210 | }(i) 211 | } 212 | 213 | // SetMaxWorkers randomly 214 | for i := 0; i < 10; i++ { 215 | 216 | wg.Add(1) 217 | 218 | delay, err := rand.Int(rand.Reader, big.NewInt(int64(50))) 219 | 220 | if err != nil { 221 | t.Fatalf("Error generating random delay: %s", err) 222 | } 223 | 224 | duration := time.Duration(delay.Int64()) * time.Millisecond 225 | 226 | time.Sleep(duration) 227 | 228 | go func() { 229 | 230 | defer wg.Done() 231 | 232 | randWorkers, err := rand.Int(rand.Reader, big.NewInt(int64(199))) 233 | 234 | if err != nil { 235 | errs <- fmt.Errorf("error generating random number of workers: %w", err) 236 | return 237 | } 238 | 239 | numWorkers := randWorkers.Int64() + 1 // Generated number needs to be between 1 and 200 240 | 241 | err = SetMaxWorkers(int32(numWorkers)) 242 | 243 | if err != nil { 244 | errs <- fmt.Errorf("error setting max workers: %w", err) 245 | return 246 | } 247 | }() 248 | 249 | } 250 | 251 | wg.Wait() 252 | close(errs) 253 | 254 | for err := range errs { 255 | if err != nil { 256 | t.Errorf("Error running ToHTML concurrently: %s", err.Error()) 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /node-test-server/server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Boostport/mjml-go/923658d220fd920a102ef8ee42d21eb3ccbff4dc/node-test-server/server -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import "fmt" 4 | 5 | type ValidationLevel string 6 | 7 | const ( 8 | Strict ValidationLevel = "strict" 9 | Soft ValidationLevel = "soft" 10 | Skip ValidationLevel = "skip" 11 | ) 12 | 13 | type JuiceTag struct { 14 | Start string `json:"start"` 15 | End string `json:"end"` 16 | } 17 | 18 | type options struct { 19 | data map[string]interface{} 20 | } 21 | 22 | type Fonts map[string]string 23 | 24 | // ToHTMLOption provides options to customize the compilation process 25 | // Detailed explanations of each option is available here: https://github.com/mjmlio/mjml#inside-nodejs 26 | type ToHTMLOption func(options) 27 | 28 | func WithBeautify(beautify bool) ToHTMLOption { 29 | return func(o options) { 30 | o.data["beautify"] = beautify 31 | } 32 | } 33 | 34 | func WithBeautifyOptions(bOptions BeautifyOptions) ToHTMLOption { 35 | beautifyOptions, ok := bOptions.(*beautifyOptions) 36 | 37 | if !ok { 38 | panic(fmt.Errorf("unsupported BeautifyOptions implementation: %#v", beautifyOptions)) 39 | } 40 | 41 | return func(o options) { 42 | o.data["beautifyOptions"] = beautifyOptions.data 43 | } 44 | } 45 | 46 | func WithFonts(fonts Fonts) ToHTMLOption { 47 | return func(o options) { 48 | o.data["fonts"] = fonts 49 | } 50 | } 51 | 52 | func WithJuiceOptions(jOptions JuiceOptions) ToHTMLOption { 53 | juiceOptions, ok := jOptions.(*juiceOptions) 54 | 55 | if !ok { 56 | panic(fmt.Errorf("unsupported JuiceOptions implementation: %#v", juiceOptions)) 57 | } 58 | 59 | return func(o options) { 60 | o.data["juiceOptions"] = juiceOptions.data 61 | } 62 | } 63 | 64 | func WithJuicePreserveTags(preserveTags map[string]JuiceTag) ToHTMLOption { 65 | return func(o options) { 66 | o.data["juicePreserveTags"] = preserveTags 67 | } 68 | } 69 | 70 | func WithKeepComments(keepComments bool) ToHTMLOption { 71 | return func(o options) { 72 | o.data["keepComments"] = keepComments 73 | } 74 | } 75 | 76 | func WithMinify(minify bool) ToHTMLOption { 77 | return func(o options) { 78 | o.data["minify"] = minify 79 | } 80 | } 81 | 82 | func WithMinifyOptions(minifyOptions HTMLMinifierOptions) ToHTMLOption { 83 | htmlMinifierOptions, ok := minifyOptions.(*htmlMinifierOptions) 84 | 85 | if !ok { 86 | panic(fmt.Errorf("unsupported HTMLMinifierOptions implementation: %#v", htmlMinifierOptions)) 87 | } 88 | 89 | return func(o options) { 90 | o.data["minifyOptions"] = htmlMinifierOptions.data 91 | } 92 | } 93 | 94 | func WithPreprocessors(preprocessors []string) ToHTMLOption { 95 | return func(o options) { 96 | o.data["preprocessors"] = preprocessors 97 | } 98 | } 99 | 100 | func WithValidationLevel(validationLevel ValidationLevel) ToHTMLOption { 101 | return func(o options) { 102 | o.data["validationLevel"] = validationLevel 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestOptions(t *testing.T) { 9 | 10 | beautifyOptions := NewBeautifyOptions().IndentEmptyLines(true).WrapLineLength(10) 11 | juiceOptions := NewJuiceOptions().PreserveKeyFrames(true).PreservePseudos(true) 12 | htmlMinifierOptions := NewHTMLMinifierOptions().HTML5(true).MinifyURLs(true) 13 | 14 | o := options{data: map[string]interface{}{}} 15 | 16 | optionFunctions := []ToHTMLOption{ 17 | WithBeautify(true), 18 | WithBeautifyOptions(beautifyOptions), 19 | WithFonts(Fonts{"test": "test"}), 20 | WithJuiceOptions(juiceOptions), 21 | WithJuicePreserveTags( 22 | map[string]JuiceTag{ 23 | "myTag": { 24 | Start: "<#", 25 | End: " xml"}), 33 | WithValidationLevel(Strict), 34 | } 35 | 36 | for _, f := range optionFunctions { 37 | f(o) 38 | } 39 | 40 | expected := map[string]interface{}{ 41 | "beautify": true, 42 | "beautifyOptions": map[string]interface{}{ 43 | "indent_empty_lines": true, 44 | "wrap_line_length": uint(10), 45 | }, 46 | "fonts": Fonts{"test": "test"}, 47 | "juiceOptions": map[string]interface{}{ 48 | "preserveKeyFrames": true, 49 | "preservePseudos": true, 50 | }, 51 | "juicePreserveTags": map[string]JuiceTag{ 52 | "myTag": { 53 | Start: "<#", 54 | End: " xml"}, 64 | "validationLevel": Strict, 65 | } 66 | 67 | if !reflect.DeepEqual(o.data, expected) { 68 | t.Error("Options does not match expected data") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pool.go: -------------------------------------------------------------------------------- 1 | package mjml 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/jackc/puddle/v2" 10 | "github.com/tetratelabs/wazero" 11 | "github.com/tetratelabs/wazero/api" 12 | ) 13 | 14 | func constructor(ctx context.Context) (api.Module, error) { 15 | 16 | id, err := randomIdentifier() 17 | 18 | if err != nil { 19 | return nil, fmt.Errorf("error generating random id for wasm module: %w", err) 20 | } 21 | 22 | idStr := strconv.Itoa(int(id)) 23 | 24 | module, err := runtime.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName(idStr)) 25 | 26 | if err != nil { 27 | return nil, fmt.Errorf("error instantiating wasm module: %w", err) 28 | } 29 | 30 | return module, nil 31 | } 32 | 33 | func destructor(module api.Module) { 34 | _ = module.Close(context.Background()) // Not possible to deal with this error 35 | } 36 | 37 | func newResourcePool(maxSize int32) (*puddle.Pool[api.Module], error) { 38 | //pool := puddle.NewPool(constructor, destructor, maxSize) 39 | pool, err := puddle.NewPool(&puddle.Config[api.Module]{Constructor: constructor, Destructor: destructor, MaxSize: maxSize}) 40 | 41 | if err != nil { 42 | return pool, fmt.Errorf("error creating resource pool: %w", err) 43 | } 44 | 45 | err = pool.CreateResource(context.Background()) 46 | 47 | if err != nil { 48 | return pool, fmt.Errorf("error prewarming resource pool: %w", err) 49 | } 50 | 51 | return pool, nil 52 | } 53 | 54 | func periodicallyRemoveIdleResources(pool *puddle.Pool[api.Module]) { 55 | 56 | duration := 2 * time.Second 57 | ticker := time.NewTicker(duration) 58 | 59 | for { 60 | select { 61 | case <-ticker.C: 62 | stats := pool.Stat() 63 | 64 | if stats.TotalResources() <= 1 { 65 | continue 66 | } 67 | 68 | idleResources := pool.AcquireAllIdle() 69 | numIdleResources := len(idleResources) 70 | 71 | if numIdleResources <= 0 { 72 | continue 73 | } 74 | 75 | max := int(stats.TotalResources()) 76 | 77 | amountToKill := numIdleResources 78 | 79 | if numIdleResources >= max { 80 | amountToKill = numIdleResources - 1 81 | } 82 | 83 | for i := 0; i < numIdleResources; i++ { 84 | if i >= amountToKill { 85 | idleResources[i].Release() 86 | } else { 87 | idleResources[i].Destroy() 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /testdata/black-friday.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 27 | 32 | 33 | 34 | 39 | 42 | 43 | 44 | 45 | 46 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
63 | 64 | 65 | 66 | 67 | 68 |
69 | 70 | 73 | 74 | 75 | 132 | 133 | 134 |
78 | 79 | 80 |
83 | 84 | 87 | 88 | 89 | 90 | 111 | 112 | 113 | 114 | 123 | 124 | 125 | 126 |
93 | 94 | 97 | 98 | 99 | 106 | 107 | 108 |
100 | 101 | 104 | 105 |
109 | 110 |
117 | 118 |

WOMEN       |       MEN       |       KIDS

121 | 122 |
127 | 128 |
129 | 130 | 131 |
135 | 136 |
137 | 138 | 139 | 140 | 141 | 142 |
143 | 144 | 147 | 148 | 149 | 194 | 195 | 196 |
152 | 153 | 154 |
157 | 158 | 161 | 162 | 163 | 164 | 185 | 186 | 187 | 188 |
167 | 168 | 171 | 172 | 173 | 180 | 181 | 182 |
174 | 175 | 178 | 179 |
183 | 184 |
189 | 190 |
191 | 192 | 193 |
197 | 198 |
199 | 200 | 201 | 202 | 203 | 204 |
205 | 206 | 209 | 210 | 211 | 256 | 257 | 258 |
214 | 215 | 216 |
219 | 220 | 223 | 224 | 225 | 226 | 235 | 236 | 237 | 238 | 247 | 248 | 249 | 250 |
229 | 230 |

Black Friday

233 | 234 |
241 | 242 |

Take an  extra 50% off
Use code SALEONSALE* at checkout

245 | 246 |
251 | 252 |
253 | 254 | 255 |
259 | 260 |
261 | 262 | 263 | 264 | 265 | 266 |
267 | 268 | 271 | 272 | 273 | 332 | 333 | 334 |
276 | 277 | 278 |
281 | 282 | 285 | 286 | 287 | 288 | 311 | 312 | 313 | 314 | 323 | 324 | 325 | 326 |
291 | 292 | 295 | 296 | 297 | 306 | 307 | 308 |
300 |

303 | Shop Now 304 |

305 |
309 | 310 |
317 | 318 |

* Offer valid on Allura purchases on 17/29/11 at 11:59 pm. No price adjustments on previous 
purchases, offer limited to stock. Cannot be combined with any offer or promotion other than free.

321 | 322 |
327 | 328 |
329 | 330 | 331 |
335 | 336 |
337 | 338 | 339 | 340 | 341 | 342 |
343 | 344 | 345 | 346 | -------------------------------------------------------------------------------- /testdata/black-friday.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

WOMEN       |       MEN       |       KIDS

8 |
9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |

Black Friday

20 |
21 | 22 |

Take an  extra 50% off
Use code SALEONSALE* at checkout

23 |
24 |
25 |
26 | 27 | 28 | Shop Now 29 | 30 |

* Offer valid on Allura purchases on 17/29/11 at 11:59 pm. No price adjustments on previous 
purchases, offer limited to stock. Cannot be combined with any offer or promotion other than free.

31 |
32 |
33 |
34 |
35 |
-------------------------------------------------------------------------------- /testdata/one-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 27 | 32 | 33 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | 50 | 56 | 57 | 58 | 59 | 60 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
77 | 78 | 81 | 82 | 83 | 168 | 169 | 170 |
84 | 85 | 86 | 87 | 88 | 89 |
90 | 91 | 94 | 95 | 96 | 157 | 158 | 159 |
99 | 100 | 101 |
104 | 105 | 108 | 109 | 110 | 111 | 120 | 121 | 122 | 123 |
114 | 115 |
[[HEADLINE]]
118 | 119 |
124 | 125 |
126 | 127 | 128 | 129 |
132 | 133 | 136 | 137 | 138 | 139 | 148 | 149 | 150 | 151 |
142 | 143 | 146 | 147 |
152 | 153 |
154 | 155 | 156 |
160 | 161 |
162 | 163 | 164 | 165 | 166 | 167 |
171 | 172 | 175 | 176 | 177 | 276 | 277 | 278 |
178 | 179 | 180 | 181 | 182 | 183 |
184 | 185 | 188 | 189 | 190 | 265 | 266 | 267 |
193 | 194 | 195 |
198 | 199 | 202 | 203 | 204 | 205 | 226 | 227 | 228 | 229 |
208 | 209 | 212 | 213 | 214 | 221 | 222 | 223 |
215 | 216 | OnePage 219 | 220 |
224 | 225 |
230 | 231 |
232 | 233 | 234 | 235 |
238 | 239 | 242 | 243 | 244 | 245 | 256 | 257 | 258 | 259 |
248 | 249 |
Home        Features         252 | Portfolio
254 | 255 |
260 | 261 |
262 | 263 | 264 |
268 | 269 |
270 | 271 | 272 | 273 | 274 | 275 |
279 | 280 | 283 | 284 | 285 | 366 | 367 | 368 |
286 | 287 | 288 | 289 | 290 |
291 |
292 | 295 | 296 | 297 | 356 | 357 | 358 |
300 | 301 | 302 |
305 | 306 | 309 | 310 | 311 | 312 | 321 | 322 | 323 | 324 | 347 | 348 | 349 | 350 |
315 | 316 |
More than an email template

Only on Mailjet template builder
319 | 320 |
327 | 328 | 331 | 332 | 333 | 342 | 343 | 344 |
336 | 339 | SUBSCRIBE 340 | 341 |
345 | 346 |
351 | 352 |
353 | 354 | 355 |
359 |
360 |
361 | 362 | 363 | 364 | 365 |
369 | 370 | 373 | 374 | 375 | 560 | 561 | 562 |
376 | 377 | 378 | 379 | 380 | 381 |
382 | 383 | 386 | 387 | 388 | 549 | 550 | 551 |
391 | 392 | 393 |
396 | 397 | 400 | 401 | 402 | 403 | 424 | 425 | 426 | 427 | 436 | 437 | 438 | 439 |
406 | 407 | 410 | 411 | 412 | 419 | 420 | 421 |
413 | 414 | 417 | 418 |
422 | 423 |
430 | 431 |
Best audience

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
434 | 435 |
440 | 441 |
442 | 443 | 444 | 445 |
448 | 449 | 452 | 453 | 454 | 455 | 476 | 477 | 478 | 479 | 488 | 489 | 490 | 491 |
458 | 459 | 462 | 463 | 464 | 471 | 472 | 473 |
465 | 466 | 469 | 470 |
474 | 475 |
482 | 483 |
Higher rates

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
486 | 487 |
492 | 493 |
494 | 495 | 496 | 497 |
500 | 501 | 504 | 505 | 506 | 507 | 528 | 529 | 530 | 531 | 540 | 541 | 542 | 543 |
510 | 511 | 514 | 515 | 516 | 523 | 524 | 525 |
517 | 518 | 521 | 522 |
526 | 527 |
534 | 535 |
24/7 Support

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
538 | 539 |
544 | 545 |
546 | 547 | 548 |
552 | 553 |
554 | 555 | 556 | 557 | 558 | 559 |
563 | 564 | 567 | 568 | 569 | 656 | 657 | 658 |
570 | 571 | 572 | 573 | 574 | 575 |
576 | 577 | 580 | 581 | 582 | 645 | 646 | 647 |
585 | 586 | 587 |
590 | 591 | 594 | 595 | 596 | 597 | 606 | 607 | 608 | 609 | 623 | 624 | 625 | 626 | 636 | 637 | 638 | 639 |
600 | 601 |
Why choose us?
604 | 605 |
612 | 613 |

616 |

617 | 618 | 620 | 621 | 622 |
629 | 630 |
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing 633 | elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
634 | 635 |
640 | 641 |
642 | 643 | 644 |
648 | 649 |
650 | 651 | 652 | 653 | 654 | 655 |
659 | 660 | 663 | 664 | 665 | 789 | 790 | 791 |
666 | 667 | 668 | 669 | 670 | 671 |
672 | 673 | 676 | 677 | 678 | 778 | 779 | 780 |
681 | 682 | 683 |
686 | 687 | 690 | 691 | 692 | 693 | 714 | 715 | 716 | 717 |
696 | 697 | 700 | 701 | 702 | 709 | 710 | 711 |
703 | 704 | 707 | 708 |
712 | 713 |
718 | 719 |
720 | 721 | 722 | 723 |
726 | 727 | 730 | 731 | 732 | 733 | 743 | 744 | 745 | 746 | 769 | 770 | 771 | 772 |
736 | 737 |
Great newsletter for the best company out there

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna 740 | aliqua. Ut enim ad minim veniam.
741 | 742 |
749 | 750 | 753 | 754 | 755 | 764 | 765 | 766 |
758 | 761 | READ MORE 762 | 763 |
767 | 768 |
773 | 774 |
775 | 776 | 777 |
781 | 782 |
783 | 784 | 785 | 786 | 787 | 788 |
792 | 793 | 796 | 797 | 798 | 867 | 868 | 869 |
799 | 800 | 801 | 802 | 803 | 804 |
805 | 806 | 809 | 810 | 811 | 856 | 857 | 858 |
814 | 815 | 816 |
819 | 820 | 823 | 824 | 825 | 826 | 835 | 836 | 837 | 838 | 847 | 848 | 849 | 850 |
829 | 830 |

[[DELIVERY_INFO]]

833 | 834 |
841 | 842 |

[[POSTAL_ADDRESS]]

845 | 846 |
851 | 852 |
853 | 854 | 855 |
859 | 860 |
861 | 862 | 863 | 864 | 865 | 866 |
870 | 871 |
872 | 873 | 874 | 875 | -------------------------------------------------------------------------------- /testdata/one-page.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [[HEADLINE]] 6 | 7 | 8 | [[PERMALINK_LABEL]] 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Home        Features         17 | Portfolio 19 | 20 | 21 | 22 | 23 | 24 | More than an email template

Only on Mailjet template builder
25 | SUBSCRIBE 27 |
28 |
29 | 30 | 31 | 32 | Best audience

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
33 |
34 | 35 | 36 | Higher rates

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
37 |
38 | 39 | 40 | 24/7 Support

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
41 |
42 |
43 | 44 | 45 | Why choose us? 46 | 47 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing 48 | elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Great newsletter for the best company out there

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna 57 | aliqua. Ut enim ad minim veniam.
58 | READ MORE 60 |
61 |
62 | 63 | 64 | 65 |

[[DELIVERY_INFO]]

66 |
67 | 68 |

[[POSTAL_ADDRESS]]

69 |
70 |
71 |
72 |
73 |
-------------------------------------------------------------------------------- /testdata/reactivation-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 27 | 32 | 33 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | 50 | 56 | 57 | 58 | 59 | 60 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
75 | 76 | 77 | 78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 | 158 | 159 | 160 |
86 | 87 | 88 |
89 | 90 | 91 | 92 | 93 | 120 | 121 | 122 |
94 | 95 | 96 | 97 | 98 | 99 | 114 | 115 | 116 | 117 |
100 | 101 | 102 | 103 | 104 | 109 | 110 | 111 |
105 | 106 | logo 107 | 108 |
112 | 113 |
118 | 119 |
123 | 124 |
125 | 126 | 127 | 128 |
129 | 130 | 131 | 132 | 133 | 150 | 151 | 152 |
134 | 135 | 136 | 137 | 138 | 139 | 144 | 145 | 146 | 147 |
140 | 141 |
the only way to travel
142 | 143 |
148 | 149 |
153 | 154 |
155 | 156 | 157 |
161 | 162 |
163 | 164 | 165 | 166 | 167 | 168 |
169 | 170 | 171 | 172 | 173 | 236 | 237 | 238 |
174 | 175 | 176 |
177 | 178 | 179 | 180 | 181 | 228 | 229 | 230 |
182 | 183 | 184 | 185 | 186 | 187 | 202 | 203 | 204 | 205 | 214 | 215 | 216 | 217 | 222 | 223 | 224 | 225 |
188 | 189 | 190 | 191 | 192 | 197 | 198 | 199 |
193 | 194 | tickets 195 | 196 |
200 | 201 |
206 | 207 |
Hey {{FirstName}} 208 |
209 |
210 | It's been a long time since you last traveled with us. 211 |
212 | 213 |
218 | 219 |
Have a look at some of the top destinations people are booking right now!
220 | 221 |
226 | 227 |
231 | 232 |
233 | 234 | 235 |
239 | 240 |
241 | 242 | 243 | 244 | 245 | 246 |
247 | 248 | 249 | 250 | 251 | 401 | 402 | 403 |
252 | 253 | 254 |
255 | 256 | 257 | 258 | 259 | 295 | 296 | 297 |
260 | 261 | 262 | 263 | 264 | 265 | 280 | 281 | 282 | 283 | 289 | 290 | 291 | 292 |
266 | 267 | 268 | 269 | 270 | 275 | 276 | 277 |
271 | 272 | New York 273 | 274 |
278 | 279 |
284 | 285 |
New York
286 |

$399 

287 | 288 |
293 | 294 |
298 | 299 |
300 | 301 | 302 | 303 |
304 | 305 | 306 | 307 | 308 | 344 | 345 | 346 |
309 | 310 | 311 | 312 | 313 | 314 | 329 | 330 | 331 | 332 | 338 | 339 | 340 | 341 |
315 | 316 | 317 | 318 | 319 | 324 | 325 | 326 |
320 | 321 | London 322 | 323 |
327 | 328 |
333 | 334 |
London
335 |

$399 

336 | 337 |
342 | 343 |
347 | 348 |
349 | 350 | 351 | 352 |
353 | 354 | 355 | 356 | 357 | 393 | 394 | 395 |
358 | 359 | 360 | 361 | 362 | 363 | 378 | 379 | 380 | 381 | 387 | 388 | 389 | 390 |
364 | 365 | 366 | 367 | 368 | 373 | 374 | 375 |
369 | 370 | Berlin 371 | 372 |
376 | 377 |
382 | 383 |
Berlin
384 |

$399 

385 | 386 |
391 | 392 |
396 | 397 |
398 | 399 | 400 |
404 | 405 |
406 | 407 | 408 | 409 | 410 | 411 |
412 | 413 | 414 | 415 | 416 | 449 | 450 | 451 |
417 | 418 | 419 |
420 | 421 | 422 | 423 | 424 | 441 | 442 | 443 |
425 | 426 | 427 | 428 | 429 | 430 | 435 | 436 | 437 | 438 |
431 | 432 |
Best,

The {{Company}} Team
433 | 434 |
439 | 440 |
444 | 445 |
446 | 447 | 448 |
452 | 453 |
454 | 455 | 456 | 457 | 458 | 459 |
460 | 461 | 462 | 463 | -------------------------------------------------------------------------------- /testdata/reactivation-email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | a { text-decoration: none; color: inherit; } 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | the only way to travel 16 | 17 | 18 | 19 | 20 | 21 | Hey {{FirstName}} 22 |
23 |
24 | It's been a long time since you last traveled with us. 25 |
26 | Have a look at some of the top destinations people are booking right now! 27 |
28 |
29 | 30 | 31 | 32 | New York
33 |

$399 

34 |
35 |
36 | 37 | 38 | London
39 |

$399 

40 |
41 |
42 | 43 | 44 | Berlin
45 |

$399 

46 |
47 |
48 |
49 | 50 | 51 | Best,

The {{Company}} Team
52 |
53 |
54 |
55 |
-------------------------------------------------------------------------------- /testdata/real-estate.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Say hello to RealEstate 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Real Estate 20 |

21 |
22 |
23 | 24 | 25 | Aliquam lorem ante, dapibus in hasellus viverra nulla ut metus varius laoreet. Quisque rutrum lorem dellorus. Aenean imperdiet. 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Villa Semperin 34 | 35 | 36 | 37 | – first offer – 38 | 340,000 $ 39 | Nascetur ridiculus mus. Donec quam felis, ultricies nec 40 | view details 41 | 42 | 43 | 44 | 45 | Lorem Ipsum 46 | Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Lorem Ipsum 47 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Lorem Ipsum 62 | 63 | Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Lorem Ipsum 64 | Call to action 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | Lorem Ipsum 79 | Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. 80 | 81 | 82 | 83 | 84 | 85 | Nullam dictum felis eu pede 86 | 87 | 88 | 89 | 90 | 91 | Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Lorem Ipsum 92 | 93 | 94 | Phasellus viverra null aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Lorem Ipsum 95 | 96 | 97 | Quisque rutrum. Aenean imperdiet viverra nulla ut metus varius laoreet. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Lorem Ipsum 98 | 99 | 100 | 101 | 102 | 103 | – 2. offer – 104 | 198,700 $ 105 | Donec quam felis, ultricies Nascetur ridiculus mus. 106 | view details 107 | 108 | 109 | Window House 23 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | Unsubscribe from this newsletter
Icon made by Freepik from www.flaticon.com
Made by svenhaustein.de
121 |
122 |
123 |
124 |
-------------------------------------------------------------------------------- /testdata/recast.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello world 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Your bimonthly intake of AI, machine learning and bots is here! 15 | 16 | 17 | [[PERMALINK]] 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Your bimonthly intake of AI, machine learning and bots is here! 28 | 29 | 30 | 31 | 32 | ISSUE #18 - DECEMBER 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |

Hello hello!

41 |

Don't panic. This is still the same newsletter, I'm still me. We just updated the design to make it sleeker and easier to read!

42 |

Without further ado, let's dive into AI for medecine, sound recognition models and bot building!

43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 |

Justine - 53 | Recast.AI 54 | team

55 |

Your light in the storm

56 |
57 |
58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |

68 | Modules, are you faster than your shadows? 69 |

70 |

As a developer, should you use modules or code this bit of feature by yourself? Let's find out.

71 |
72 |
73 | 74 | 75 | 76 |
77 | 78 | 79 | 80 |

81 | SoundNet - Learning sound representations from unlabeled video 82 |

83 |

With a deep convolutional network created for sound recognition, the model recognizes objects and scenes only from sounds.

84 |
85 |
86 | 87 | 88 | 89 |
90 | 91 | 92 | 93 |

94 | Three challenges for artificial intelligence in medicine 95 |

96 |

Brandon Ballinger takes us back in time with an historic of AI in medecine and where it could today make a difference.

97 |
98 |
99 | 100 | 101 | 102 |
103 | 104 | 105 | 106 |

107 | How to choose the best channel for your bot: the ultimate cheat sheet 108 |

109 |

Bot channels do not offer the same features: here’s an analysis of a few to help you figure everything out.

110 |
111 |
112 | 113 | 114 | 115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |

125 | What are the differences between AI, machine learning, NLP, and deep learning? 126 |

127 |
128 | 129 | 130 |

So many big words in a booming market, let's take a step back and redefine which is what, and which does what.

131 |
132 |
133 |
134 | 135 | 136 | 137 |

138 | The implications of AI on the future chatbots 139 |

140 |
141 | 142 | 143 |

Artificial intelligence, machine learning, neural networks: how can they be used with chatbots? What does the future hold?

144 |
145 |
146 |
147 | 148 | 149 | 150 |

151 | NLP and the coming content explosion 152 |

153 |
154 | 155 | 156 |

Natural language powered interfaces are taking the world by storm, and we can't help but wonder how they'll change the way we deliver, read and share content.

157 |
158 | 159 |
160 |
161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 |
188 |
-------------------------------------------------------------------------------- /testdata/receipt-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 27 | 32 | 33 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | 49 | 54 | 55 | 56 | 57 | 58 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
75 | 76 | 77 | 78 | 79 | 80 |
81 | 82 | 85 | 86 | 87 | 132 | 133 | 134 |
90 | 91 | 92 |
95 | 96 | 99 | 100 | 101 | 102 | 123 | 124 | 125 | 126 |
105 | 106 | 109 | 110 | 111 | 118 | 119 | 120 |
112 | 113 | 116 | 117 |
121 | 122 |
127 | 128 |
129 | 130 | 131 |
135 | 136 |
137 | 138 | 139 | 140 | 141 | 142 |
143 | 144 | 147 | 148 | 149 | 194 | 195 | 196 |
152 | 153 | 154 |
157 | 158 | 161 | 162 | 163 | 164 | 185 | 186 | 187 | 188 |
167 | 168 | 171 | 172 | 173 | 180 | 181 | 182 |
174 | 175 | 178 | 179 |
183 | 184 |
189 | 190 |
191 | 192 | 193 |
197 | 198 |
199 | 200 | 201 | 202 | 203 | 204 |
205 | 206 | 209 | 210 | 211 | 245 | 246 | 247 |
214 | 215 | 216 |
219 | 220 | 223 | 224 | 225 | 226 | 236 | 237 | 238 | 239 |
229 | 230 |
HELLO 233 |

[[FirstName]]

234 | 235 |
240 | 241 |
242 | 243 | 244 |
248 | 249 |
250 | 251 | 252 | 253 | 254 | 255 |
256 | 257 | 260 | 261 | 262 | 314 | 315 | 316 |
265 | 266 | 267 |
270 | 271 | 274 | 275 | 276 | 277 | 291 | 292 | 293 | 294 | 305 | 306 | 307 | 308 |
280 | 281 |

284 |

285 | 286 | 288 | 289 | 290 |
297 | 298 |
Thank you very much for your purchase. 301 |
302 | Please find the receipt below.
303 | 304 |
309 | 310 |
311 | 312 | 313 |
317 | 318 |
319 | 320 | 321 | 322 | 323 | 324 |
325 | 326 | 329 | 330 | 331 | 456 | 457 | 458 |
334 | 335 | 336 |
339 | 340 | 343 | 344 | 345 | 346 | 355 | 356 | 357 | 358 | 367 | 368 | 369 | 370 |
349 | 350 |
Order Number
353 | 354 |
361 | 362 |
[[OrderNumber]]
365 | 366 |
371 | 372 |
373 | 374 | 375 | 376 |
379 | 380 | 383 | 384 | 385 | 386 | 395 | 396 | 397 | 398 | 407 | 408 | 409 | 410 |
389 | 390 |
Order Date
393 | 394 |
401 | 402 |
[[OrderDate]]
405 | 406 |
411 | 412 |
413 | 414 | 415 | 416 |
419 | 420 | 423 | 424 | 425 | 426 | 435 | 436 | 437 | 438 | 447 | 448 | 449 | 450 |
429 | 430 |
Total Price
433 | 434 |
441 | 442 |
[[TotalPrice]]
445 | 446 |
451 | 452 |
453 | 454 | 455 |
459 | 460 |
461 | 462 | 463 | 464 | 465 | 466 |
467 | 468 | 471 | 472 | 473 | 562 | 563 | 564 |
476 | 477 | 478 |
481 | 482 | 485 | 486 | 487 | 488 | 511 | 512 | 513 | 514 |
491 | 492 | 495 | 496 | 497 | 506 | 507 | 508 |
500 | 503 | Download Receipt 504 | 505 |
509 | 510 |
515 | 516 |
517 | 518 | 519 | 520 |
523 | 524 | 527 | 528 | 529 | 530 | 553 | 554 | 555 | 556 |
533 | 534 | 537 | 538 | 539 | 548 | 549 | 550 |
542 | 545 | Track My Order 546 | 547 |
551 | 552 |
557 | 558 |
559 | 560 | 561 |
565 | 566 |
567 | 568 | 569 | 570 | 571 | 572 |
573 | 574 | 577 | 578 | 579 | 631 | 632 | 633 |
582 | 583 | 584 |
587 | 588 | 591 | 592 | 593 | 594 | 608 | 609 | 610 | 611 | 622 | 623 | 624 | 625 |
597 | 598 |

601 |

602 | 603 | 605 | 606 | 607 |
614 | 615 |
Best, 618 |
619 | The [[CompanyName]] Team
620 | 621 |
626 | 627 |
628 | 629 | 630 |
634 | 635 |
636 | 637 | 638 | 639 | 640 | 641 |
642 | 643 | 644 | 645 | -------------------------------------------------------------------------------- /testdata/receipt-email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | HELLO 16 |

[[FirstName]]

17 |
18 |
19 |
20 | 21 | 22 | 23 | Thank you very much for your purchase. 24 |
25 | Please find the receipt below.
26 |
27 |
28 | 29 | 30 | Order Number 31 | [[OrderNumber]] 32 | 33 | 34 | Order Date 35 | [[OrderDate]] 36 | 37 | 38 | Total Price 39 | [[TotalPrice]] 40 | 41 | 42 | 43 | 44 | Download Receipt 46 | 47 | 48 | Track My Order 50 | 51 | 52 | 53 | 54 | 55 | Best, 56 |
57 | The [[CompanyName]] Team
58 |
59 |
60 |
61 |
-------------------------------------------------------------------------------- /wasm/mjml.wasm.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Boostport/mjml-go/923658d220fd920a102ef8ee42d21eb3ccbff4dc/wasm/mjml.wasm.br --------------------------------------------------------------------------------