├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── VERSION.txt ├── mjml ├── __init__.py ├── __init__.pyi ├── core │ ├── __init__.py │ ├── api.py │ └── registry.py ├── elements │ ├── __init__.py │ ├── _base.py │ ├── head │ │ ├── __init__.py │ │ ├── _head_base.py │ │ ├── mj_attributes.py │ │ ├── mj_breakpoint.py │ │ ├── mj_font.py │ │ ├── mj_head.py │ │ ├── mj_html_attributes.py │ │ ├── mj_preview.py │ │ ├── mj_style.py │ │ └── mj_title.py │ ├── mj_accordion.py │ ├── mj_accordion_element.py │ ├── mj_accordion_text.py │ ├── mj_accordion_title.py │ ├── mj_body.py │ ├── mj_button.py │ ├── mj_carousel.py │ ├── mj_carousel_image.py │ ├── mj_column.py │ ├── mj_divider.py │ ├── mj_group.py │ ├── mj_hero.py │ ├── mj_image.py │ ├── mj_navbar.py │ ├── mj_navbar_link.py │ ├── mj_raw.py │ ├── mj_section.py │ ├── mj_social.py │ ├── mj_social_element.py │ ├── mj_spacer.py │ ├── mj_table.py │ ├── mj_text.py │ └── mj_wrapper.py ├── helpers │ ├── __init__.py │ ├── conditional_tag.py │ ├── fonts.py │ ├── json_to_xml.py │ ├── media_queries.py │ ├── mergeOutlookConditionals.py │ ├── preview.py │ ├── py_utils.py │ ├── shorthand_parser.py │ ├── skeleton.py │ ├── suffixCssClasses.py │ └── width_parser.py ├── lib │ ├── __init__.py │ ├── dict_merger.py │ └── tests │ │ ├── __init__.py │ │ └── dict_merger_test.py ├── mjml2html.py ├── py.typed ├── scripts │ ├── __init__.py │ ├── mjml-html-compare │ └── mjml.py └── testing_helpers.py ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── border_parser_test.py ├── custom_components_test.py ├── includes_with_umlauts_test.py ├── missing_functionality_test.py ├── mj_button_mailto_link_test.py ├── mjml2html_test.py ├── testdata │ ├── _custom-expected.html │ ├── _custom.mjml │ ├── _footer.mjml │ ├── _header.mjml │ ├── button-expected.html │ ├── button.mjml │ ├── css-inlining-expected.html │ ├── css-inlining.mjml │ ├── hello-world-expected.html │ ├── hello-world.mjml │ ├── hello-world.mjml.json │ ├── html-entities-expected.html │ ├── html-entities.mjml │ ├── html-without-closing-tag-expected.html │ ├── html-without-closing-tag.mjml │ ├── minimal-expected.html │ ├── minimal.mjml │ ├── missing-whitespace-before-tag-expected.html │ ├── missing-whitespace-before-tag.mjml │ ├── mj-accordion-expected.html │ ├── mj-accordion.mjml │ ├── mj-attributes-expected.html │ ├── mj-attributes.mjml │ ├── mj-body-with-background-color-expected.html │ ├── mj-body-with-background-color.mjml │ ├── mj-breakpoint-expected.html │ ├── mj-breakpoint.mjml │ ├── mj-button-with-width-expected.html │ ├── mj-button-with-width.mjml │ ├── mj-carousel-expected.html │ ├── mj-carousel.mjml │ ├── mj-column-with-attributes-expected.html │ ├── mj-column-with-attributes.mjml │ ├── mj-font-expected.html │ ├── mj-font-multiple-expected.html │ ├── mj-font-multiple.mjml │ ├── mj-font-unused-expected.html │ ├── mj-font-unused.mjml │ ├── mj-font.mjml │ ├── mj-group-expected.html │ ├── mj-group.mjml │ ├── mj-head-with-comment-expected.html │ ├── mj-head-with-comment.mjml │ ├── mj-hero-fixed-expected.html │ ├── mj-hero-fixed.mjml │ ├── mj-hero-fluid-expected.html │ ├── mj-hero-fluid.mjml │ ├── mj-html-attributes-expected.html │ ├── mj-html-attributes.mjml │ ├── mj-image-with-empty-alt-attribute-expected.html │ ├── mj-image-with-empty-alt-attribute.mjml │ ├── mj-image-with-href-expected.html │ ├── mj-image-with-href.mjml │ ├── mj-include-body-expected.html │ ├── mj-include-body.mjml │ ├── mj-navbar-expected.html │ ├── mj-navbar.mjml │ ├── mj-preview-expected.html │ ├── mj-preview.mjml │ ├── mj-raw-expected.html │ ├── mj-raw-head-expected.html │ ├── mj-raw-head-with-tags-expected.html │ ├── mj-raw-head-with-tags.mjml │ ├── mj-raw-head.mjml │ ├── mj-raw-with-tags-expected.html │ ├── mj-raw-with-tags.mjml │ ├── mj-raw.mjml │ ├── mj-section-with-background-expected.html │ ├── mj-section-with-background-url-expected.html │ ├── mj-section-with-background-url.mjml │ ├── mj-section-with-background.mjml │ ├── mj-section-with-css-class-expected.html │ ├── mj-section-with-css-class.mjml │ ├── mj-section-with-full-width-expected.html │ ├── mj-section-with-full-width.mjml │ ├── mj-section-with-mj-class-expected.html │ ├── mj-section-with-mj-class.mjml │ ├── mj-social-expected.html │ ├── mj-social.mjml │ ├── mj-spacer-expected.html │ ├── mj-spacer.mjml │ ├── mj-style-expected.html │ ├── mj-style.mjml │ ├── mj-table-expected.html │ ├── mj-table.mjml │ ├── mj-text-escaped-html-expected.html │ ├── mj-text-escaped-html.mjml │ ├── mj-text-with-tail-text-expected.html │ ├── mj-text-with-tail-text.mjml │ ├── mj-title-expected.html │ ├── mj-title.mjml │ ├── mj-wrapper-expected.html │ ├── mj-wrapper.mjml │ ├── text_with_html-expected.html │ └── text_with_html.mjml └── upstream_alignment_test.py └── tools └── update-expected-html.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Artifacts 2 | 3 | on: 4 | - workflow_dispatch 5 | 6 | jobs: 7 | artifacts: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 5 10 | 11 | steps: 12 | - name: checkout code 13 | uses: actions/checkout@v4 14 | 15 | - uses: astral-sh/setup-uv@v6 16 | 17 | - name: Run tests without optional features 18 | run: | 19 | uv venv venv-test 20 | . venv-test/bin/activate 21 | uv build --wheel --sdist 22 | 23 | - name: upload release artifacts 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: "release-artifacts" 27 | path: dist/* 28 | if-no-files-found: error 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: run 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - ci 8 | pull_request: 9 | branches: 10 | - main 11 | schedule: 12 | # Run tests weekly on Sundays at 5:47 UTC. 13 | # This might help us us detecting unexpected breakage due to changes in 14 | # 3rd-party dependencies. GitHub only triggers this for the default branch. 15 | - cron: '47 5 * * 0' 16 | 17 | 18 | jobs: 19 | tests_cpython: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 25 | container: 26 | image: python:${{ matrix.python-version }} 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Run tests without optional features 32 | run: | 33 | python -m venv venv-test 34 | . venv-test/bin/activate 35 | # Some versions of pip do not recognize the declared "extras" in `setup.cfg`. 36 | # I don't know the exact problematic versions, so let's just upgrade pip. 37 | # GOOD: pip 23.0.1 with setuptools 47.1.0 (Python 3.7) 38 | # BAD: pip 23.0.1 with setuptools 56.0.0 (Python 3.8) 39 | # BAD: pip 23.0.1 with setuptools 58.1.0 (Python 3.9) 40 | # BAD: pip 23.0.1 with setuptools 65.5.0 (Python 3.10) 41 | # GOOD: pip 24.0 with setuptools 65.5.0 (Python 3.11) 42 | if [ "${{ matrix.python-version }}" != "3.6" ]; then 43 | pip install "pip >= 24" 44 | fi 45 | pip install -e .[testing] 46 | pytest -m "not css_inlining" 47 | 48 | - name: Test CSS inlining 49 | run: | 50 | if [ "${{ matrix.python-version }}" != "3.6" ]; then 51 | . venv-test/bin/activate 52 | pip install -e .[css_inlining] 53 | pytest 54 | else 55 | echo "Skipping CSS inlining tests for Python 3.6" 56 | fi 57 | 58 | 59 | tests_pypy: 60 | runs-on: ubuntu-latest 61 | strategy: 62 | fail-fast: false 63 | matrix: 64 | pypy-version: ["3.10", "3.11", "latest"] 65 | container: 66 | image: pypy:${{ matrix.pypy-version }} 67 | 68 | steps: 69 | - uses: actions/checkout@v4 70 | 71 | - name: Run tests without optional features 72 | run: | 73 | python -m venv venv-test 74 | . venv-test/bin/activate 75 | pip install -e .[testing] 76 | pytest -m "not css_inlining" 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | /*.sh 4 | /venv* 5 | /.project 6 | *.pyc 7 | /.pytest_cache/ 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: debug-statements 10 | 11 | - repo: https://github.com/charliermarsh/ruff-pre-commit 12 | # Ruff version. 13 | rev: 'v0.11.9' 14 | hooks: 15 | - id: ruff 16 | # args: [--fix, --exit-non-zero-on-fix] 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 0.11.1 (2025-05-13) 3 | ------------------- 4 | 5 | - fix incorrectly closed tag for Outlook (Bernhard Vallant) 6 | - fix order of and tags for mj-section with background in outlook (Bernhard Vallant) 7 | - add typing stubs for public interface (Dan Lindholm) 8 | - declare support for Python 3.13 9 | - accept also `css_inline >= 0.14` 10 | 11 | 12 | 0.11.0 (2024-02-22) 13 | ------------------- 14 | 15 | - security fix: escaped HTML entities like `>` were unescaped in the final mjml output, leading to potential injection of untrusted user data (reported by @sh-at-cs) 16 | 17 | 18 | 0.10.0 (2023-11-17) 19 | ------------------ 20 | 21 | - fix CSS child selectors 22 | - disable loading remote stylesheets when inlining CSS 23 | - require css-inline 0.11.x for performance improvements 24 | - drop css-inline Python 3.6 support 25 | - fix exception when processing an `mj-section` with `background-size` (reported by Thomas Handorf) 26 | 27 | 28 | 0.9.1 (2023-09-23) 29 | ------------------ 30 | 31 | - require css_inline < 0.10 due to an incompatible API change 32 | - move project out of "alpha" state, it is at least "beta" by now 33 | 34 | 35 | 0.9.0 (2023-05-25) 36 | ------------------ 37 | 38 | - add mjml-accordion, mjml-accordion-element, mjml-accordion-text, mjml-accordion-title, mjml-carousel, mjml-head-breakpoint, mjml-hero, mjml-navbar, mjml-navbar-link, mjml-social, mjml-spacer, and mjml-wrapper components (Casey Holzer) 39 | - add support for custom components (Casey Holzer) 40 | - add support for full-width attribute to mj-section (Casey Holzer) 41 | - renamed project to "mjml-python", dropping the "-stub" suffix 42 | 43 | 44 | 0.8.0 (2022-08-25) 45 | ------------------ 46 | 47 | - parse MJML using BeautifulSoup4 which fixes several issues with HTML inside 48 | `mj-text` (e.g. HTML entities, missing white space) (Casey Holzer) 49 | - prevent exception when trying to set padding for `` (reported by Peter Coles) 50 | - fix setting `width` attribute for `mj-button` (spotted by Michael Romanenko) 51 | - support css inlining (via `inlineStyle`) if "css_inline" is installed (Casey Holzer) 52 | 53 | **New Contributors:** 54 | 55 | - [Casey Holzer](https://www.github.com/caseyjhol) 56 | - [Peter Coles](https://www.github.com/mrcoles) 57 | 58 | 59 | 0.7.0 (2022-03-24) 60 | ------------------ 61 | 62 | - fix incomplete implementation of `background` attributes for `` 63 | - check HTML output against mjml 4.12.0: test suite was updated with outputs 64 | from mjml 4.12.0 and required fixes where ported. 65 | 66 | 67 | 0.6.2 (2021-06-01) 68 | ------------------ 69 | 70 | - fix non-ascii content in included files on Windows 71 | 72 | 73 | 0.6.1 (2021-04-15) 74 | ------------------ 75 | 76 | - mj-button: prevent target="_blank" for mailto links (Thunderbird) 77 | - fix `` 78 | - update test suite to check against html generated by mjml 4.9.0 and 79 | port required fixes to get the test suite passing. 80 | - fix `` with `href` attribute 81 | 82 | 83 | 0.6.0 (2021-03-23) 84 | ------------------ 85 | 86 | - support `` for body components 87 | - mjml_to_html(): also accept plain strings as input 88 | - add support for json-like dict as input 89 | - avoid deprecation warnings about invalid escape sequences in regex patterns 90 | - add support for mj-font, mj-preview and mj-raw 91 | - better error message for unknown tags 92 | 93 | 94 | 0.5.4 (2021-01-15) 95 | ------------------ 96 | 97 | - handle `` section containing HTML comments 98 | - also render attributes with empty values if set explicitely 99 | - implement (basic) support for mj-class 100 | - port "Component.allowedAttributes" from JS mjml 101 | - ability to render "welcome-email.mjml" from "mjmlio/email-templates" 102 | 103 | 104 | 0.5.3 (2020-10-29) 105 | ------------------ 106 | 107 | - stop shipping tests in wheel 108 | - move all requirements to setup.cfg 109 | 110 | 111 | 0.5.2 (2020-09-21) 112 | ------------------ 113 | 114 | - mjml: always return "binary" data (UTF-8) to avoid encoding problems in Windows 115 | 116 | 117 | 0.5.1 (2021-08-20) 118 | ------------------ 119 | 120 | - mjml script: use setuptools-based wrapper so Windows users can run it more easily 121 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mailjet SAS, https://mjml.io 4 | Copyright (c) 2020 Felix Schwarz 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include .github/workflows/*.yml 2 | include CHANGELOG.md 3 | include tests/*.py 4 | include tests/testdata/*.mjml 5 | include tests/testdata/*.html 6 | include tools/*.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mjml-python 2 | 3 | This is an unofficial Python port of [mjml v4](https://github.com/mjmlio/mjml). It is implemented in pure Python and does not require JavaScript/NodeJS. mjml is a markup language created by [Mailjet](https://www.mailjet.com/) and designed to reduce the pain of coding a responsive email. 4 | 5 | ### Installation 6 | 7 | ```sh 8 | pip install mjml 9 | ``` 10 | 11 | ### Usage 12 | 13 | ```py 14 | from mjml import mjml_to_html 15 | with open('foo.mjml', 'rb') as mjml_fp: 16 | result = mjml_to_html(mjml_fp) 17 | assert not result.errors 18 | html: str = result.html 19 | ``` 20 | 21 | Alternatively you can run the code from the CLI: 22 | 23 | ```sh 24 | $ mjml foo.mjml 25 | ``` 26 | 27 | 28 | ## Limitations 29 | 30 | This library only implements a subset of the original MJML project. It lacks several features found in the JavaScript mjml implementation (e.g. minification, beautification and validation). Also the code likely contains many additional bugs. 31 | 32 | The upside is that there are lot of possibilities for you to make a real difference when you improve the code :-) 33 | 34 | 35 | ## Goals / Motivation 36 | 37 | This library should track the [JS version of mjml](https://github.com/mjmlio/mjml) so ideally you should get the same HTML. However even under the best circumstances this library will always lag a bit behind as each change must be translated to Python manually (a mostly mechanical process). 38 | 39 | While I like the idea behind mjml and all the knowledge about the quirks to get acceptable HTML rendering by various email clients we did not want to deploy a Node.js-based stack on our production servers. We did not feel comfortable auditing all 220 JS packages which are installed by `npm install mjml` (and re-doing this whenever new versions are available). Also due to data-privacy concerns we were unable to use any third-party products (i.e. MJML's API offering). 40 | 41 | After a short [spike](https://en.wikipedia.org/wiki/Spike_(software_development)) to check the viability of a Python implementation I went ahead and wrote enough code to ensure some existing messages could be converted to mjml. Currently the library is deployed in some light production scenarios. 42 | 43 | Another benefit of using Python is that we can integrate that in our web apps more closely. As the startup overhead of CPython is much lower than Node.js we can also generate a few mails via CLI applications without massive performance problems. CPython uses \~70ms to translate a trivial mjml template to HTML while Node.JS needs \~650ms. 44 | 45 | 46 | 47 | ## Documentation 48 | 49 | The idea is to implement the mjml XML dialect exactly like the JS implementation so eventually you should be able to use the [official docs](https://mjml.io/documentation/) and other online resources found on [mjml.io](https://mjml.io/). However we are nowhere near that right now! The current code can render the "Hello World" example as well as images, tables and groups but many components remain to be reimplemented. I'd love to see your pull requests to improve the current state though. 50 | 51 | 52 | 53 | ## Alternatives / Additional Resources 54 | 55 | - **django-mjml**: If deploying NodeJS is not an issue and you are using Django you could use the well established [django-mjml](https://github.com/liminspace/django-mjml) library. That library integrates the mjml JavaScript implementation with Django templates so you can access all mjml features. 56 | - **MJML.NET**: This is an unofficial port of mjml to C# ([github repo](https://github.com/LiamRiddell/MJML.NET/)) which supports more components than this Python implementation. 57 | - **mrml**: rust implementation of mjml ([github repo](https://github.com/jdrouet/mrml)) 58 | - [email-bugs](https://github.com/hteumeuleu/email-bugs) is a github project which contains a lot of knowledge about rendering quirks in various email clients. 59 | - [htmlemailcheck](https://www.htmlemailcheck.com/knowledge-base/) is a commercial offering to help you checking email rendering in various environments. I don't have any experience with their services but they provide a free knowledgebase. 60 | - [#emailgeeks](https://email.geeks.chat) - Slack community for email marketers, designers, and developers 61 | -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | 0.11.2dev 2 | -------------------------------------------------------------------------------- /mjml/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .mjml2html import mjml_to_html # noqa: unused-import 3 | -------------------------------------------------------------------------------- /mjml/__init__.pyi: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | if t.TYPE_CHECKING: 4 | import typing_extensions as te 5 | from _typeshed import StrPath, SupportsRead 6 | 7 | from mjml.core.api import Component 8 | 9 | class _Output(t.NamedTuple): 10 | html: str 11 | errors: t.Sequence[str] 12 | 13 | @te.overload 14 | def __getitem__(self, _: t.Literal["html"]) -> str: ... 15 | @te.overload 16 | def __getitem__(self, _: t.Literal["errors"]) -> t.Sequence[str]: ... 17 | @te.overload 18 | def get(self, key: t.Literal["html"], /) -> t.Optional[str]: ... 19 | @te.overload 20 | def get(self, key: t.Literal["html"], default: str, /) -> str: ... 21 | @te.overload 22 | def get(self, key: t.Literal["errors"], /) -> t.Optional[t.Sequence[str]]: ... 23 | @te.overload 24 | def get(self, key: t.Literal["errors"], default: t.Sequence[str], /) -> t.Sequence[str]: ... 25 | 26 | FpOrJson = t.Union[t.Mapping[str, t.Any], str, bytes, SupportsRead[str], SupportsRead[bytes]] 27 | 28 | def mjml_to_html( 29 | xml_fp_or_json: "FpOrJson", 30 | skeleton: t.Optional[str] = None, 31 | template_dir: t.Optional["StrPath"] = None, 32 | custom_components: t.Optional[t.List[t.Type["Component"]]] = None, 33 | ) -> "_Output": ... 34 | -------------------------------------------------------------------------------- /mjml/core/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .api import * 3 | -------------------------------------------------------------------------------- /mjml/core/api.py: -------------------------------------------------------------------------------- 1 | 2 | from dotmap import DotMap 3 | 4 | from ..lib import merge_dicts 5 | from .registry import components 6 | 7 | 8 | __all__ = ['initComponent', 'Component'] 9 | 10 | def initComponent(name, **initialDatas): 11 | if name is None: 12 | return None 13 | component_cls = components[name] 14 | if not component_cls: 15 | return None 16 | 17 | component = component_cls(**initialDatas) 18 | if getattr(component, 'headStyle', None): 19 | component.context['addHeadStyle'](name, component.headStyle) 20 | if getattr(component, 'componentHeadStyle', None): 21 | component.context['addComponentHeadSyle'](component.componentHeadStyle) 22 | return component 23 | 24 | 25 | 26 | class Component: 27 | # LATER: not sure upstream also passes tagName, makes code easier for us 28 | def __init__(self, *, attributes=None, children=(), content='', context=None, 29 | props=None, globalAttributes=None, headStyle=None, tagName=None): 30 | self.children = list(children) 31 | self.content = content 32 | self.context = context 33 | self.tagName = tagName 34 | 35 | self.props = DotMap(merge_dicts(props, {'children': children, 'content': content})) 36 | 37 | # upstream also checks "self.allowed_attrs" 38 | self.attrs = merge_dicts( 39 | self.default_attrs(), 40 | globalAttributes or {}, 41 | attributes or {}, 42 | ) 43 | 44 | # optional attributes (methods) for some components 45 | if headStyle: 46 | self.headStyle = headStyle 47 | 48 | @classmethod 49 | def getTagName(cls): 50 | cls_name = cls.__name__ 51 | return cls_name 52 | 53 | @classmethod 54 | def isRawElement(cls): 55 | cls_value = getattr(cls, 'rawElement', None) 56 | return bool(cls_value) 57 | 58 | # js: static defaultAttributes 59 | @classmethod 60 | def default_attrs(cls): 61 | return {} 62 | 63 | # js: static allowedAttributes 64 | @classmethod 65 | def allowed_attrs(cls): 66 | return () 67 | 68 | def getContent(self): 69 | # Actually "self.content" should not be None but sometimes it is 70 | # (probably due to bugs in this Python port). This special guard 71 | # clause is the final fix to render the "welcome-email.mjml" from 72 | # mjml's "email-templates" repo. 73 | if self.content is None: 74 | return '' 75 | return self.content.strip() 76 | 77 | def getChildContext(self): 78 | return self.context 79 | 80 | # js: getAttribute(name) 81 | def get_attr(self, name, *, missing_ok=False): 82 | is_allowed_attr = name in self.allowed_attrs() 83 | is_default_attr = name in self.default_attrs() 84 | if not missing_ok and (not is_allowed_attr) and (not is_default_attr): 85 | raise AssertionError(f'{self.__class__.__name__} has no declared attr {name}') 86 | return self.attrs.get(name) 87 | getAttribute = get_attr 88 | 89 | def render(self): 90 | return '' 91 | -------------------------------------------------------------------------------- /mjml/core/registry.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | __all__ = ['components', 'register_components', 'register_core_components'] 4 | 5 | from typing import List, Type 6 | 7 | 8 | components = {} 9 | 10 | def register_core_components(): 11 | from ..elements import ( 12 | MjAccordion, 13 | MjAccordionElement, 14 | MjAccordionText, 15 | MjAccordionTitle, 16 | MjBody, 17 | MjButton, 18 | MjCarousel, 19 | MjCarouselImage, 20 | MjColumn, 21 | MjDivider, 22 | MjGroup, 23 | MjHero, 24 | MjImage, 25 | MjNavbar, 26 | MjNavbarLink, 27 | MjRaw, 28 | MjSection, 29 | MjSocial, 30 | MjSocialElement, 31 | MjSpacer, 32 | MjTable, 33 | MjText, 34 | MjWrapper, 35 | ) 36 | from ..elements.head import ( 37 | MjAttributes, 38 | MjBreakpoint, 39 | MjFont, 40 | MjHead, 41 | MjHtmlAttributes, 42 | MjPreview, 43 | MjStyle, 44 | MjTitle, 45 | ) 46 | 47 | register_components([ 48 | MjAccordion, 49 | MjAccordionElement, 50 | MjAccordionText, 51 | MjAccordionTitle, 52 | MjButton, 53 | MjCarousel, 54 | MjCarouselImage, 55 | MjText, 56 | MjDivider, 57 | MjHero, 58 | MjImage, 59 | MjSection, 60 | MjColumn, 61 | MjBody, 62 | MjGroup, 63 | MjTable, 64 | MjRaw, 65 | MjNavbar, 66 | MjNavbarLink, 67 | MjSocial, 68 | MjSocialElement, 69 | MjSpacer, 70 | MjWrapper, 71 | # --- head components --- 72 | MjAttributes, 73 | MjFont, 74 | MjHead, 75 | MjHtmlAttributes, 76 | MjPreview, 77 | MjTitle, 78 | MjStyle, 79 | MjBreakpoint, 80 | ]) 81 | 82 | return components 83 | 84 | 85 | def register_components(source: List[Type]): 86 | for component in source: 87 | components[component.component_name] = component 88 | -------------------------------------------------------------------------------- /mjml/elements/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .mj_accordion import * 3 | from .mj_accordion_element import * 4 | from .mj_accordion_text import * 5 | from .mj_accordion_title import * 6 | from .mj_body import * 7 | from .mj_button import * 8 | from .mj_carousel import * 9 | from .mj_carousel_image import * 10 | from .mj_column import * 11 | from .mj_divider import * 12 | from .mj_group import * 13 | from .mj_hero import * 14 | from .mj_image import * 15 | from .mj_navbar import * 16 | from .mj_navbar_link import * 17 | from .mj_raw import * 18 | from .mj_section import * 19 | from .mj_social import * 20 | from .mj_social_element import * 21 | from .mj_spacer import * 22 | from .mj_table import * 23 | from .mj_text import * 24 | from .mj_wrapper import * 25 | -------------------------------------------------------------------------------- /mjml/elements/head/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .mj_attributes import * 3 | from .mj_breakpoint import * 4 | from .mj_font import * 5 | from .mj_head import * 6 | from .mj_html_attributes import * 7 | from .mj_preview import * 8 | from .mj_style import * 9 | from .mj_title import * 10 | -------------------------------------------------------------------------------- /mjml/elements/head/_head_base.py: -------------------------------------------------------------------------------- 1 | 2 | from mjml.core import Component, initComponent 3 | 4 | 5 | __all__ = ['HeadComponent'] 6 | 7 | class HeadComponent(Component): 8 | def handlerChildren(self): 9 | def handle_children(children): 10 | tagName = children['tagName'] 11 | component = initComponent( 12 | name = tagName, 13 | context = self.getChildContext(), 14 | **children 15 | ) 16 | if not component: 17 | # LATER: hook up with error reporting structure 18 | # (e.g. via "context"? - upstream uses console.error() here) 19 | print(f'No matching component for tag : {tagName}') 20 | return None 21 | 22 | if hasattr(component, 'handler'): 23 | component.handler() 24 | if hasattr(component, 'render'): 25 | return component.render() 26 | return None 27 | 28 | childrens = self.props.children 29 | return tuple(map(handle_children, childrens)) 30 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_attributes.py: -------------------------------------------------------------------------------- 1 | 2 | from mjml.helpers import omit 3 | 4 | from ._head_base import HeadComponent 5 | 6 | 7 | __all__ = ['MjAttributes'] 8 | 9 | class MjAttributes(HeadComponent): 10 | component_name = 'mj-attributes' 11 | 12 | def handler(self): 13 | add = self.context['add'] 14 | _children = self.props.children 15 | 16 | for child in _children: 17 | tagName = child['tagName'] 18 | attributes = child['attributes'] 19 | children = child['children'] 20 | if tagName == 'mj-class': 21 | attr_name = attributes['name'] 22 | add('classes', attr_name, omit(attributes, 'name')) 23 | 24 | assert not children, 'not yet implemented' 25 | # upstream: 26 | # reduce( 27 | # children, 28 | # (acc, { tagName, attributes }) => ({ 29 | # ...acc, 30 | # [tagName]: attributes, 31 | # }), 32 | # {}, 33 | # ), 34 | #def reducer(acc, tn_attr): 35 | # tagName, attributes = tn_attr 36 | # return {'tagName': attributes, **acc} 37 | #add('classesDefault', attr_name, reduce(children, reducer, {})) 38 | else: 39 | if not attributes: 40 | # TODO: not present upstream 41 | continue 42 | add('defaultAttributes', tagName, attributes) 43 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_breakpoint.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjBreakpoint'] 6 | 7 | 8 | class MjBreakpoint(HeadComponent): 9 | component_name = 'mj-breakpoint' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'width': 'unit(px)', 15 | } 16 | 17 | def handler(self): 18 | add = self.context['add'] 19 | add('breakpoint', self.getAttribute('width')) 20 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_font.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjFont'] 6 | 7 | 8 | class MjFont(HeadComponent): 9 | component_name = 'mj-font' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'href' : 'string', 15 | 'name' : 'string', 16 | } 17 | 18 | def handler(self): 19 | add = self.context['add'] 20 | add('fonts', self.getAttribute('name'), self.getAttribute('href')) 21 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_head.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjHead'] 6 | 7 | class MjHead(HeadComponent): 8 | component_name = 'mj-head' 9 | 10 | def handler(self): 11 | return self.handlerChildren() 12 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_html_attributes.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjHtmlAttributes'] 6 | 7 | class MjHtmlAttributes(HeadComponent): 8 | component_name = 'mj-html-attributes' 9 | 10 | def handler(self): 11 | add = self.context['add'] 12 | _children = self.props.children 13 | 14 | for child in _children: 15 | tagName = child['tagName'] 16 | attributes = child['attributes'] 17 | children = child['children'] 18 | if tagName == 'mj-selector': 19 | path = attributes['path'] 20 | 21 | custom = {} 22 | for c in children: 23 | is_mj_html_attribute = (c['tagName'] == 'mj-html-attribute') 24 | has_name = bool(c.get('attributes', {}).get('name', None)) 25 | if is_mj_html_attribute and has_name: 26 | custom[c['attributes']['name']] = c['content'] 27 | 28 | add('htmlAttributes', path, custom) 29 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_preview.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjPreview'] 6 | 7 | 8 | class MjPreview(HeadComponent): 9 | component_name = 'mj-preview' 10 | 11 | def handler(self): 12 | add = self.context['add'] 13 | add('preview', self.getContent()) 14 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_style.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjStyle'] 6 | 7 | class MjStyle(HeadComponent): 8 | component_name = 'mj-style' 9 | 10 | @classmethod 11 | def default_attrs(cls): 12 | return { 13 | 'inline': '', 14 | } 15 | 16 | def handler(self): 17 | add = self.context['add'] 18 | inline_attr = 'inlineStyle' if (self.get_attr('inline') == 'inline') else 'style' 19 | html_str = self.getContent() 20 | # CSS can contain child selectors (e.g. "h1 > p") beautifulsoup only 21 | # returns escaped entities. To make these selectors work, we need to 22 | # unescape these. 23 | css_str = html_str.replace('>', '>') 24 | add(inline_attr, css_str) 25 | -------------------------------------------------------------------------------- /mjml/elements/head/mj_title.py: -------------------------------------------------------------------------------- 1 | 2 | from ._head_base import HeadComponent 3 | 4 | 5 | __all__ = ['MjTitle'] 6 | 7 | class MjTitle(HeadComponent): 8 | component_name = 'mj-title' 9 | 10 | def handler(self): 11 | add = self.context['add'] 12 | add('title', self.getContent()) 13 | -------------------------------------------------------------------------------- /mjml/elements/mj_accordion_element.py: -------------------------------------------------------------------------------- 1 | 2 | from ..helpers import conditionalTag 3 | from ._base import BodyComponent 4 | 5 | 6 | __all__ = ['MjAccordionElement'] 7 | 8 | 9 | class MjAccordionElement(BodyComponent): 10 | component_name = 'mj-accordion-element' 11 | 12 | @classmethod 13 | def allowed_attrs(cls): 14 | return { 15 | 'background-color' : 'color', 16 | 'border' : 'string', 17 | 'font-family' : 'string', 18 | 'icon-align' : 'enum(top,middle,bottom)', 19 | 'icon-width' : 'unit(px,%)', 20 | 'icon-height' : 'unit(px,%)', 21 | 'icon-wrapped-url' : 'string', 22 | 'icon-wrapped-alt' : 'string', 23 | 'icon-unwrapped-url': 'string', 24 | 'icon-unwrapped-alt': 'string', 25 | 'icon-position' : 'enum(left,right)', 26 | } 27 | 28 | @classmethod 29 | def default_attrs(cls): 30 | return { 31 | 'title': { 32 | 'img': { 33 | 'width' : '32px', 34 | 'height': '32px', 35 | }, 36 | }, 37 | } 38 | 39 | # js: getStyles() 40 | def get_styles(self): 41 | return { 42 | 'td' : { 43 | 'padding' : '0px', 44 | 'background-color': self.get_attr('background-color'), 45 | }, 46 | 'label': { 47 | 'font-size' : '13px', 48 | 'font-family': self.get_attr('font-family'), 49 | }, 50 | 'input': { 51 | 'display': 'none', 52 | }, 53 | } 54 | 55 | def handleMissingChildren(self): 56 | from . import MjAccordionText, MjAccordionTitle 57 | 58 | children = self.props['children'] 59 | children_attrs = { 60 | 'border' : self.get_attr('border'), 61 | 'icon-align' : self.get_attr('icon-align'), 62 | 'icon-width' : self.get_attr('icon-width'), 63 | 'icon-height' : self.get_attr('icon-height'), 64 | 'icon-position' : self.get_attr('icon-position'), 65 | 'icon-wrapped-url' : self.get_attr('icon-wrapped-url'), 66 | 'icon-wrapped-alt' : self.get_attr('icon-wrapped-alt'), 67 | 'icon-unwrapped-url': self.get_attr('icon-unwrapped-url'), 68 | 'icon-unwrapped-alt': self.get_attr('icon-unwrapped-alt'), 69 | } 70 | has_title = False 71 | has_text = False 72 | 73 | result = [] 74 | 75 | for child in children: 76 | if child['tagName'] == 'mj-accordion-title': 77 | has_title = True 78 | if child['tagName'] == 'mj-accordion-text': 79 | has_text = True 80 | 81 | if has_title and has_text: 82 | break 83 | 84 | if not has_title: 85 | result.append( 86 | MjAccordionTitle(attributes=children_attrs, context=self.getChildContext()).render() 87 | ) 88 | 89 | result.append(self.renderChildren(children, attributes=children_attrs)) 90 | 91 | if not has_text: 92 | result.append( 93 | MjAccordionText(attributes=children_attrs, context=self.getChildContext()).render() 94 | ) 95 | 96 | return '\n'.join(result) 97 | 98 | 99 | 100 | def render(self): 101 | checkbox_attrs = self.html_attrs( 102 | class_='mj-accordion-checkbox', 103 | type='checkbox', 104 | style='input', 105 | ) 106 | checkbox = f'' 107 | label_attrs = self.html_attrs( 108 | class_='mj-accordion-element', 109 | style='label', 110 | ) 111 | return f''' 112 | 113 | 114 | 120 | 121 | 122 | ''' 123 | -------------------------------------------------------------------------------- /mjml/elements/mj_accordion_text.py: -------------------------------------------------------------------------------- 1 | 2 | from ._base import BodyComponent 3 | 4 | 5 | __all__ = ['MjAccordionText'] 6 | 7 | 8 | class MjAccordionText(BodyComponent): 9 | component_name = 'mj-accordion-text' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'background-color': 'color', 15 | 'font-size' : 'unit(px)', 16 | 'font-family' : 'string', 17 | 'font-weight' : 'string', 18 | 'letter-spacing' : 'unitWithNegative(px,em)', 19 | 'line-height' : 'unit(px,%,)', 20 | 'color' : 'color', 21 | 'padding-bottom' : 'unit(px,%)', 22 | 'padding-left' : 'unit(px,%)', 23 | 'padding-right' : 'unit(px,%)', 24 | 'padding-top' : 'unit(px,%)', 25 | 'padding' : 'unit(px,%){1,4}', 26 | } 27 | 28 | @classmethod 29 | def default_attrs(cls): 30 | return { 31 | 'font-size' : '13px', 32 | 'line-height': '1', 33 | 'padding' : '16px', 34 | } 35 | 36 | # js: getStyles() 37 | def get_styles(self): 38 | return { 39 | 'td' : { 40 | 'background' : self.get_attr('background-color'), 41 | 'font-size' : self.get_attr('font-size'), 42 | 'font-family' : self.get_attr('font-family'), 43 | 'font-weight' : self.get_attr('font-weight'), 44 | 'letter-spacing': self.get_attr('letter-spacing'), 45 | 'line-height' : self.get_attr('line-height'), 46 | 'color' : self.get_attr('color'), 47 | 'padding-bottom': self.get_attr('padding-bottom'), 48 | 'padding-left' : self.get_attr('padding-left'), 49 | 'padding-right' : self.get_attr('padding-right'), 50 | 'padding-top' : self.get_attr('padding-top'), 51 | 'padding' : self.get_attr('padding'), 52 | }, 53 | 'table': { 54 | 'width' : '100%', 55 | 'border-bottom': self.get_attr('border', missing_ok=True), 56 | }, 57 | } 58 | 59 | def renderContent(self): 60 | td_attrs = self.html_attrs( 61 | class_=self.get_attr('css-class', missing_ok=True), 62 | style='td', 63 | ) 64 | return f''' 65 | 66 | {self.getContent()} 67 | 68 | ''' 69 | 70 | def render(self): 71 | div_attrs = self.html_attrs(class_='mj-accordion-content') 72 | table_attrs = self.html_attrs( 73 | cellspacing='0', 74 | cellpadding='0', 75 | style='table', 76 | ) 77 | return f''' 78 |
79 | 80 | 81 | 82 | {self.renderContent()} 83 | 84 | 85 |
86 |
87 | ''' 88 | -------------------------------------------------------------------------------- /mjml/elements/mj_accordion_title.py: -------------------------------------------------------------------------------- 1 | 2 | from ._base import BodyComponent 3 | 4 | 5 | __all__ = ['MjAccordionTitle'] 6 | 7 | from ..helpers import conditionalTag 8 | 9 | 10 | class MjAccordionTitle(BodyComponent): 11 | component_name = 'mj-accordion-title' 12 | 13 | @classmethod 14 | def allowed_attrs(cls): 15 | return { 16 | 'background-color': 'color', 17 | 'color' : 'color', 18 | 'font-size' : 'unit(px)', 19 | 'font-family' : 'string', 20 | 'padding-bottom' : 'unit(px,%)', 21 | 'padding-left' : 'unit(px,%)', 22 | 'padding-right' : 'unit(px,%)', 23 | 'padding-top' : 'unit(px,%)', 24 | 'padding' : 'unit(px,%){1,4}', 25 | } 26 | 27 | @classmethod 28 | def default_attrs(cls): 29 | return { 30 | 'font-size': '13px', 31 | 'padding' : '16px', 32 | } 33 | 34 | # js: getStyles() 35 | def get_styles(self): 36 | return { 37 | 'td' : { 38 | 'width' : '100%', 39 | 'background-color': self.get_attr('background-color'), 40 | 'color' : self.get_attr('color'), 41 | 'font-size' : self.get_attr('font-size'), 42 | 'font-family' : self.get_attr('font-family'), 43 | 'padding-bottom' : self.get_attr('padding-bottom'), 44 | 'padding-left' : self.get_attr('padding-left'), 45 | 'padding-right' : self.get_attr('padding-right'), 46 | 'padding-top' : self.get_attr('padding-top'), 47 | 'padding' : self.get_attr('padding'), 48 | }, 49 | 'table': { 50 | 'width' : '100%', 51 | 'border-bottom': self.get_attr('border', missing_ok=True), 52 | }, 53 | 'td2' : { 54 | 'padding' : '16px', 55 | 'background' : self.get_attr('background-color'), 56 | 'vertical-align': self.get_attr('icon-align', missing_ok=True), 57 | }, 58 | 'img' : { 59 | 'display': 'none', 60 | 'width' : self.get_attr('icon-width', missing_ok=True), 61 | 'height' : self.get_attr('icon-height', missing_ok=True), 62 | }, 63 | } 64 | 65 | def renderTitle(self): 66 | td_attrs = self.html_attrs( 67 | class_=self.get_attr('css-class', missing_ok=True), 68 | style='td', 69 | ) 70 | 71 | return f''' 72 | 73 | {self.getContent()} 74 | 75 | ''' 76 | 77 | def renderIcons(self): 78 | td_attrs = self.html_attrs( 79 | class_='mj-accordion-ico', 80 | style='td2', 81 | ) 82 | img_more_attrs = self.html_attrs( 83 | src=self.get_attr('icon-wrapped-url', missing_ok=True), 84 | alt=self.get_attr('icon-wrapped-alt', missing_ok=True), 85 | class_='mj-accordion-more', 86 | style='img', 87 | ) 88 | img_less_attrs = self.html_attrs( 89 | src=self.get_attr('icon-unwrapped-url', missing_ok=True), 90 | alt=self.get_attr('icon-unwrapped-alt', missing_ok=True), 91 | class_='mj-accordion-less', 92 | style='img', 93 | ) 94 | 95 | return conditionalTag( 96 | f''' 97 | 98 | 99 | 100 | 101 | ''', 102 | True 103 | ) 104 | 105 | def render(self): 106 | content_elements = [self.renderTitle(), self.renderIcons()] 107 | if self.get_attr('icon-position', missing_ok=True) != 'right': 108 | content_elements.reverse() 109 | content = '\n'.join(content_elements) 110 | 111 | div_attrs = self.html_attrs( 112 | class_='mj-accordion-title', 113 | ) 114 | table_attrs = self.html_attrs( 115 | cellspacing='0', 116 | cellpadding='0', 117 | style='table', 118 | ) 119 | 120 | return f''' 121 |
122 | 123 | 124 | 125 | {content} 126 | 127 | 128 |
129 |
130 | ''' 131 | -------------------------------------------------------------------------------- /mjml/elements/mj_body.py: -------------------------------------------------------------------------------- 1 | 2 | from ..lib import merge_dicts 3 | from ._base import BodyComponent 4 | 5 | 6 | __all__ = ['MjBody'] 7 | 8 | class MjBody(BodyComponent): 9 | component_name = 'mj-body' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'background-color': '', 15 | 'css-class' : None, 16 | } 17 | 18 | @classmethod 19 | def default_attrs(cls): 20 | return { 21 | 'width' : '600px', 22 | } 23 | 24 | def get_styles(self): 25 | return { 26 | 'div': { 27 | 'background-color': self.get_attr('background-color'), 28 | }, 29 | } 30 | 31 | def getChildContext(self): 32 | return merge_dicts( 33 | self.context, 34 | {'containerWidth': self.get_attr('width')} 35 | ) 36 | 37 | def render(self): 38 | setBackgroundColor = self.context['setBackgroundColor'] 39 | setBackgroundColor(self.get_attr('background-color')) 40 | 41 | html_attrs = self.html_attrs(class_=self.get_attr('css-class'), style='div') 42 | children_str = self.renderChildren() 43 | return f'
{children_str}
' 44 | -------------------------------------------------------------------------------- /mjml/elements/mj_divider.py: -------------------------------------------------------------------------------- 1 | 2 | from ..helpers import parse_int, widthParser 3 | from ..lib import merge_dicts 4 | from ._base import BodyComponent 5 | 6 | 7 | __all__ = ['MjDivider'] 8 | 9 | class MjDivider(BodyComponent): 10 | component_name = 'mj-divider' 11 | 12 | @classmethod 13 | def allowed_attrs(cls): 14 | return { 15 | 'border-color' : 'color', 16 | 'border-style' : 'string', 17 | 'border-width' : 'unit(px)', 18 | 'container-background-color': 'color', 19 | 'padding' : 'unit(px,%){1,4}', 20 | 'padding-bottom' : 'unit(px,%)', 21 | 'padding-left' : 'unit(px,%)', 22 | 'padding-right' : 'unit(px,%)', 23 | 'padding-top' : 'unit(px,%)', 24 | 'width' : 'unit(px,%)', 25 | 'align' : 'enum(left,center,right)', 26 | # hidden / used by MjColumn 27 | 'vertical-align' : '', 28 | 'css-class' : '', 29 | } 30 | 31 | @classmethod 32 | def default_attrs(cls): 33 | return { 34 | 'border-color' : '#000000', 35 | 'border-style' : 'solid', 36 | 'border-width' : '4px', 37 | 'padding' : '10px 25px', 38 | 'width' : '100%', 39 | 'align' : 'center', 40 | } 41 | 42 | def get_styles(self): 43 | _t = tuple 44 | border_attrs = _t(map(lambda k: self.get_attr(f'border-{k}'), ['style', 'width', 'color'])) 45 | border_attr_str = ' '.join(border_attrs) 46 | p = { 47 | 'border-top': border_attr_str, 48 | 'font-size' : '1px', 49 | 'margin' : '0px auto', 50 | 'width' : self.getAttribute('width'), 51 | } 52 | return { 53 | 'p': p, 54 | 'outlook': merge_dicts(p, {'width': self.getOutlookWidth()}), 55 | } 56 | 57 | def getOutlookWidth(self): 58 | this = self 59 | containerWidth = this.context['containerWidth'] 60 | get_padding = lambda d: self.getShorthandAttrValue('padding', d) 61 | paddingSize = get_padding('right') + get_padding('left') 62 | width = this.getAttribute('width') 63 | parsedWidth, unit = widthParser(width) 64 | 65 | if unit == '%': 66 | px = (parse_int(containerWidth) * parse_int(parsedWidth)) / 100 - paddingSize 67 | return f'{px}px' 68 | elif unit == 'px': 69 | return width 70 | px = parse_int(containerWidth) - paddingSize 71 | return f'{px}px' 72 | 73 | def renderAfter(self): 74 | table_attrs = self.html_attrs( 75 | align = 'center', 76 | border = '0', 77 | cellpadding = '0', 78 | cellspacing = '0', 79 | style = 'outlook', 80 | role = 'presentation', 81 | width = self.getOutlookWidth(), 82 | ) 83 | return f''' 84 | ''' 93 | 94 | def render(self): 95 | return f''' 96 |

97 | {self.renderAfter()} 98 | ''' 99 | -------------------------------------------------------------------------------- /mjml/elements/mj_navbar_link.py: -------------------------------------------------------------------------------- 1 | 2 | from ..helpers import conditionalTag, suffixCssClasses 3 | from ._base import BodyComponent 4 | 5 | 6 | __all__ = ['MjNavbarLink'] 7 | 8 | 9 | class MjNavbarLink(BodyComponent): 10 | component_name = 'mj-navbar-link' 11 | 12 | @classmethod 13 | def allowed_attrs(cls): 14 | return { 15 | 'color' : 'color', 16 | 'font-family' : 'string', 17 | 'font-size' : 'unit(px)', 18 | 'font-style' : 'string', 19 | 'font-weight' : 'string', 20 | 'href' : 'string', 21 | 'name' : 'string', 22 | 'target' : 'string', 23 | 'rel' : 'string', 24 | 'letter-spacing' : 'unitWithNegative(px,em)', 25 | 'line-height' : 'unit(px,%,)', 26 | 'padding-bottom' : 'unit(px,%)', 27 | 'padding-left' : 'unit(px,%)', 28 | 'padding-right' : 'unit(px,%)', 29 | 'padding-top' : 'unit(px,%)', 30 | 'padding' : 'unit(px,%){1,4}', 31 | 'text-decoration': 'string', 32 | 'text-transform' : 'string', 33 | } 34 | 35 | @classmethod 36 | def default_attrs(cls): 37 | return { 38 | 'color' : '#000000', 39 | 'font-family' : 'Ubuntu, Helvetica, Arial, sans-serif', 40 | 'font-size' : '13px', 41 | 'font-weight' : 'normal', 42 | 'line-height' : '22px', 43 | 'padding' : '15px 10px', 44 | 'target' : '_blank', 45 | 'text-decoration': 'none', 46 | 'text-transform' : 'uppercase', 47 | } 48 | 49 | def get_styles(self): 50 | return { 51 | 'a': { 52 | 'display' : 'inline-block', 53 | 'color' : self.getAttribute('color'), 54 | 'font-family' : self.getAttribute('font-family'), 55 | 'font-size' : self.getAttribute('font-size'), 56 | 'font-style' : self.getAttribute('font-style'), 57 | 'font-weight' : self.getAttribute('font-weight'), 58 | 'letter-spacing' : self.getAttribute('letter-spacing'), 59 | 'line-height' : self.getAttribute('line-height'), 60 | 'text-decoration': self.getAttribute('text-decoration'), 61 | 'text-transform' : self.getAttribute('text-transform'), 62 | 'padding' : self.getAttribute('padding'), 63 | 'padding-top' : self.getAttribute('padding-top'), 64 | 'padding-left' : self.getAttribute('padding-left'), 65 | 'padding-right' : self.getAttribute('padding-right'), 66 | 'padding-bottom' : self.getAttribute('padding-bottom'), 67 | }, 68 | 'td': { 69 | 'padding' : self.getAttribute('padding'), 70 | 'padding-top' : self.getAttribute('padding-top'), 71 | 'padding-left' : self.getAttribute('padding-left'), 72 | 'padding-right' : self.getAttribute('padding-right'), 73 | 'padding-bottom': self.getAttribute('padding-bottom'), 74 | }, 75 | } 76 | 77 | def renderContent(self): 78 | href = self.getAttribute('href') 79 | navbar_base_url = self.getAttribute('navbarBaseUrl', missing_ok=True) 80 | link = f'{navbar_base_url}{href}' if navbar_base_url else href 81 | css_class = self.getAttribute('css-class', missing_ok=True) or '' 82 | html_attrs = self.html_attrs( 83 | class_=f'mj-link {css_class}', 84 | href=link, 85 | rel=self.getAttribute('rel'), 86 | target=self.getAttribute('target'), 87 | name=self.getAttribute('name'), 88 | style='a', 89 | ) 90 | 91 | return f''' 92 | 93 | {self.getContent()} 94 | 95 | ''' 96 | 97 | def render(self): 98 | html_attrs = self.html_attrs( 99 | style='td', 100 | class_=suffixCssClasses( 101 | self.getAttribute('css-class', missing_ok=True), 102 | 'outlook', 103 | ), 104 | ) 105 | 106 | return ''.join([ 107 | conditionalTag(f''), 108 | self.renderContent(), 109 | conditionalTag(''), 110 | ]) 111 | -------------------------------------------------------------------------------- /mjml/elements/mj_raw.py: -------------------------------------------------------------------------------- 1 | 2 | from ._base import BodyComponent 3 | 4 | 5 | __all__ = ['MjRaw'] 6 | 7 | 8 | class MjRaw(BodyComponent): 9 | component_name = 'mj-raw' 10 | 11 | rawElement = True 12 | 13 | def render(self): 14 | return self.getContent() 15 | -------------------------------------------------------------------------------- /mjml/elements/mj_spacer.py: -------------------------------------------------------------------------------- 1 | 2 | from ._base import BodyComponent 3 | 4 | 5 | __all__ = ['MjSpacer'] 6 | 7 | 8 | class MjSpacer(BodyComponent): 9 | component_name = 'mj-spacer' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'border' : 'string', 15 | 'border-bottom' : 'string', 16 | 'border-left' : 'string', 17 | 'border-right' : 'string', 18 | 'border-top' : 'string', 19 | 'container-background-color': 'color', 20 | 'padding-bottom' : 'unit(px,%)', 21 | 'padding-left' : 'unit(px,%)', 22 | 'padding-right' : 'unit(px,%)', 23 | 'padding-top' : 'unit(px,%)', 24 | 'padding' : 'unit(px,%){1,4}', 25 | 'height' : 'unit(px,%)', 26 | } 27 | 28 | @classmethod 29 | def default_attrs(cls): 30 | return { 31 | 'height': '20px', 32 | } 33 | 34 | def get_styles(self): 35 | return { 36 | 'div': { 37 | 'height' : self.getAttribute('height'), 38 | 'line-height': self.getAttribute('height'), 39 | }, 40 | } 41 | 42 | def render(self): 43 | html_attrs = self.html_attrs(style='div') 44 | return f'
' 45 | -------------------------------------------------------------------------------- /mjml/elements/mj_table.py: -------------------------------------------------------------------------------- 1 | 2 | from ..helpers import widthParser 3 | from ._base import BodyComponent 4 | 5 | 6 | __all__ = ['MjTable'] 7 | 8 | class MjTable(BodyComponent): 9 | component_name = 'mj-table' 10 | 11 | @classmethod 12 | def allowed_attrs(cls): 13 | return { 14 | 'align' : 'enum(left,right,center)', 15 | 'border' : 'string', 16 | 'cellpadding' : 'integer', 17 | 'cellspacing' : 'integer', 18 | 'container-background-color': 'color', 19 | 'color' : 'color', 20 | 'font-family' : 'string', 21 | 'font-size' : 'unit(px)', 22 | 'font-weight' : 'string', 23 | 'line-height' : 'unit(px,%,)', 24 | 'padding-bottom' : 'unit(px,%)', 25 | 'padding-left' : 'unit(px,%)', 26 | 'padding-right' : 'unit(px,%)', 27 | 'padding-top' : 'unit(px,%)', 28 | 'padding' : 'unit(px,%){1,4}', 29 | 'table-layout' : 'enum(auto,fixed,initial,inherit)', 30 | 'vertical-align' : 'enum(top,bottom,middle)', 31 | 'width' : 'unit(px,%)', 32 | # hidden / used by MjColumn 33 | 'css-class' : '', 34 | } 35 | 36 | @classmethod 37 | def default_attrs(cls): 38 | return { 39 | 'align' : 'left', 40 | 'border' : 'none', 41 | 'cellpadding' : '0', 42 | 'cellspacing' : '0', 43 | 'color' : '#000000', 44 | 'font-family' : 'Ubuntu, Helvetica, Arial, sans-serif', 45 | 'font-size' : '13px', 46 | 'line-height' : '22px', 47 | 'padding' : '10px 25px', 48 | 'table-layout' : 'auto', 49 | 'width' : '100%', 50 | } 51 | 52 | # js: getStyles() 53 | def get_styles(self): 54 | return { 55 | 'table': { 56 | 'color' : self.get_attr('color'), 57 | 'font-family' : self.get_attr('font-family'), 58 | 'font-size' : self.get_attr('font-size'), 59 | 'line-height' : self.get_attr('line-height'), 60 | 'table-layout': self.get_attr('table-layout'), 61 | 'width' : self.get_attr('width'), 62 | 'border' : self.get_attr('border'), 63 | }, 64 | } 65 | 66 | def getWidth(self): 67 | width = self.get_attr('width') 68 | parsedWidth, unit = widthParser(width) 69 | return width if (unit == '%') else parsedWidth 70 | 71 | def render(self): 72 | table_attrs = self.html_attrs( 73 | width = self.getWidth(), 74 | border = '0', 75 | style = 'table', 76 | cellpadding = self.get_attr('cellpadding'), 77 | cellspacing = self.get_attr('cellspacing'), 78 | ) 79 | content_html = self.getContent() 80 | return f''' 81 | {content_html} 82 |
''' 83 | -------------------------------------------------------------------------------- /mjml/elements/mj_text.py: -------------------------------------------------------------------------------- 1 | 2 | from ._base import BodyComponent 3 | 4 | 5 | __all__ = ['MjText'] 6 | 7 | class MjText(BodyComponent): 8 | component_name = 'mj-text' 9 | 10 | @classmethod 11 | def allowed_attrs(cls): 12 | return { 13 | 'align' : 'enum(left,right,center,justify)', 14 | 'background-color' : 'color', 15 | 'color' : 'color', 16 | 'container-background-color': 'color', 17 | 'font-family' : 'string', 18 | 'font-size' : 'unit(px)', 19 | 'font-style' : 'string', 20 | 'font-weight' : 'string', 21 | 'height' : 'unit(px,%)', 22 | 'letter-spacing' : 'unitWithNegative(px,%)', 23 | 'line-height' : 'unit(px,%,)', 24 | 'padding-bottom' : 'unit(px,%)', 25 | 'padding-left' : 'unit(px,%)', 26 | 'padding-right' : 'unit(px,%)', 27 | 'padding-top' : 'unit(px,%)', 28 | 'padding' : 'unit(px,%){1,4}', 29 | 'text-decoration' : 'string', 30 | 'text-transform' : 'string', 31 | 'vertical-align' : 'enum(top,bottom,middle)', 32 | # other attrs 33 | 'css-class' : '', 34 | } 35 | 36 | @classmethod 37 | def default_attrs(cls): 38 | return { 39 | 'align' : 'left', 40 | 'color' : '#000000', 41 | 'font-family' : 'Ubuntu, Helvetica, Arial, sans-serif', 42 | 'font-size' : '13px', 43 | 'line-height' : '1', 44 | 'padding' : '10px 25px', 45 | } 46 | 47 | def get_styles(self): 48 | style_attrs = { 49 | 'font-family': self.get_attr('font-family'), 50 | 'font-size': self.get_attr('font-size'), 51 | 'font-style': self.get_attr('font-style'), 52 | 'font-weight': self.get_attr('font-weight'), 53 | 'letter-spacing': self.get_attr('letter-spacing'), 54 | 'line-height': self.get_attr('line-height'), 55 | 'text-align': self.get_attr('align'), 56 | 'text-decoration': self.get_attr('text-decoration'), 57 | 'text-transform': self.get_attr('text-transform'), 58 | 'color': self.get_attr('color'), 59 | 'height': self.get_attr('height'), 60 | } 61 | return {'text': style_attrs} 62 | 63 | def render(self): 64 | height = self.getAttribute('height') 65 | if not height: 66 | return self._render_content() 67 | 68 | start_conditional = f''' 69 |
70 | ''' # noqa: line-too-long 71 | end_conditional = '
' 72 | return f'''{start_conditional}{self._render_content()}{end_conditional}''' 73 | 74 | def _render_content(self): 75 | content_html = self.getContent() 76 | return '
' + content_html + '
' 77 | -------------------------------------------------------------------------------- /mjml/elements/mj_wrapper.py: -------------------------------------------------------------------------------- 1 | 2 | from ..helpers import suffixCssClasses 3 | from . import MjSection 4 | 5 | 6 | __all__ = ['MjWrapper'] 7 | 8 | 9 | class MjWrapper(MjSection): 10 | component_name = 'mj-wrapper' 11 | 12 | def renderWrappedChildren(self): 13 | children = self.props['children'] 14 | containerWidth = self.context['containerWidth'] 15 | 16 | def render_child(component): 17 | if component.isRawElement(): 18 | return component.render() 19 | td_ie_attrs = component.html_attrs( 20 | align=component.get_attr('align', missing_ok=True), 21 | class_=suffixCssClasses( 22 | component.get_attr('css-class'), 23 | 'outlook', 24 | ), 25 | width=containerWidth, 26 | ) 27 | return f''' 28 | 32 | {component.render()} 33 | 37 | ''' 38 | 39 | return self.renderChildren(children, renderer=render_child) 40 | -------------------------------------------------------------------------------- /mjml/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .conditional_tag import * 3 | from .fonts import * 4 | from .json_to_xml import * 5 | from .media_queries import * 6 | from .mergeOutlookConditionals import * 7 | from .preview import * 8 | from .py_utils import * 9 | from .shorthand_parser import * 10 | from .skeleton import * 11 | from .suffixCssClasses import * 12 | from .width_parser import * 13 | -------------------------------------------------------------------------------- /mjml/helpers/conditional_tag.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | __all__ = [ 4 | 'conditionalTag', 5 | 'msoConditionalTag', 6 | ] 7 | 8 | startConditionalTag = '' 11 | startNegationConditionalTag = '' 12 | startMsoNegationConditionalTag = '' 13 | endNegationConditionalTag = '' 14 | 15 | 16 | def conditionalTag(content, negation=False): 17 | if negation: 18 | start, end = startNegationConditionalTag, endNegationConditionalTag 19 | else: 20 | start, end = startConditionalTag, endConditionalTag 21 | return start + content + end 22 | 23 | 24 | def msoConditionalTag(content, negation=False): 25 | if negation: 26 | start, end = startMsoNegationConditionalTag, endNegationConditionalTag 27 | else: 28 | start, end = startConditionalTag, endConditionalTag 29 | return start + content + end 30 | -------------------------------------------------------------------------------- /mjml/helpers/fonts.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import re 4 | 5 | 6 | __all__ = ['buildFontsTags'] 7 | 8 | def buildFontsTags(content, inlineStyle, fonts=None): 9 | toImport = [] 10 | re_flags = re.IGNORECASE | re.MULTILINE 11 | for (name, url) in (fonts or {}).items(): 12 | regex = re.compile(f'"[^"]*font-family:[^"]*{name}[^"]*"', re_flags) 13 | # double } ("}}") used for escaping 14 | inlineRegex = re.compile(f'font-family:[^;}}]*{name}', re_flags) 15 | # any(map(...)): any of "inlineStyle matches the inlineRegex 16 | if regex.search(content) or any(map(lambda s: inlineRegex.search(s), inlineStyle)): 17 | toImport.append(url) 18 | if not toImport: 19 | return '' 20 | 21 | link_builder = lambda url: f'' 22 | link_tags_str = '\n'.join(map(link_builder, toImport)) 23 | import_builder = lambda url: f'@import url({url});' 24 | import_lines_str = '\n'.join(map(import_builder, toImport)) 25 | return f''' 26 | 27 | {link_tags_str} 28 | 31 | \n 32 | ''' 33 | -------------------------------------------------------------------------------- /mjml/helpers/json_to_xml.py: -------------------------------------------------------------------------------- 1 | """Convert MJML from JSON format to XML.""" 2 | 3 | from typing import Any, Mapping 4 | 5 | 6 | def json_to_xml(root: Mapping[str, Any], indent: str = '') -> str: 7 | attr_dict = dict(root.get('attributes', {}), id=root.get('id')) 8 | attributes = [] 9 | for key, value in attr_dict.items(): 10 | if isinstance(value, str): 11 | attributes.append(f'{key}="{value}"') 12 | attributes_str = (' ' if attributes else '') + ' '.join(attributes) 13 | 14 | if root.get('content'): 15 | content = f'{indent} {root.get("content")}' 16 | elif 'children' in root: 17 | child_indent = f'{indent} ' 18 | children = [] 19 | for child in root.get('children', []): 20 | # {"passport": "hidden"} is a special attribute only present in 21 | # JSON MJML. The attribute is set by Passport (Mailjet's template 22 | # editor) to hide specific elements. 23 | # https://github.com/FelixSchwarz/mjml-python/commit/e1c006e213f26fb6dd9cf406b6e7b849350f6bc7#r47810966 24 | if child.get('attributes', {}).get('passport', {}).get('hidden'): 25 | continue 26 | child_xml = json_to_xml(child, indent=child_indent) 27 | children.append(child_xml) 28 | content = '\n'.join(children) 29 | else: 30 | content = '' 31 | 32 | return f'{indent}<{root["tagName"]}{attributes_str}>\n{content}\n{indent}' 33 | -------------------------------------------------------------------------------- /mjml/helpers/media_queries.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | __all__ = ['buildMediaQueriesTags'] 4 | 5 | def buildMediaQueriesTags(breakpoint, mediaQueries=None): 6 | if not mediaQueries: 7 | return '' 8 | elif hasattr(mediaQueries, 'items'): 9 | # dict 10 | mediaQueries = tuple(mediaQueries.items()) 11 | 12 | def mqStr(item): 13 | className, mediaQuery = item 14 | return f'.{className} {mediaQuery}' 15 | baseMediaQueries = tuple(map(mqStr, mediaQueries)) 16 | media_queries_str = '\n'.join(baseMediaQueries) 17 | 18 | def tbMqStr(item): 19 | className, mediaQuery = item 20 | return f'.moz-text-html .{className} {mediaQuery}' 21 | thunderbirdMediaQueries = tuple(map(tbMqStr, mediaQueries)) 22 | thunderbird_media_queries_str = '\n'.join(thunderbirdMediaQueries) 23 | 24 | return f''' 25 | 30 | 33 | ''' 34 | -------------------------------------------------------------------------------- /mjml/helpers/mergeOutlookConditionals.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | 5 | __all__ = ['mergeOutlookConditionals'] 6 | 7 | # OPTIMIZE ME: — check if previous conditional is `\s*? 70 | 71 | 72 | 73 | 74 | 81 | 91 | 96 | {{ font_tags_str }} 97 | {{ media_queries_str }} 98 | 103 | {{ extra_style }} 104 | {{ headRaw_str }} 105 | 106 | 107 | {{ preview_str }} 108 | {{ content }} 109 | 110 | ''' # noqa: line-too-long 111 | 112 | skeleton_tmpl_str = textwrap.dedent(skeleton_tmpl_str_raw) 113 | -------------------------------------------------------------------------------- /mjml/helpers/suffixCssClasses.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['suffixCssClasses'] 3 | 4 | def suffixCssClasses(classes, suffix): 5 | if not classes: 6 | return '' 7 | class_list = classes.split(' ') 8 | suffixed_classes = map(lambda cls_str: f'{cls_str}-{suffix}', class_list) 9 | return ' '.join(suffixed_classes) 10 | -------------------------------------------------------------------------------- /mjml/helpers/width_parser.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | from collections import namedtuple 4 | 5 | from .py_utils import strip_unit 6 | 7 | 8 | __all__ = ['widthParser'] 9 | 10 | _WidthUnit = namedtuple('_WidthUnit', ('width', 'unit')) 11 | 12 | class WidthUnit(_WidthUnit): 13 | def __new__(cls, width, *, unit='px'): 14 | if not unit: 15 | unit = 'px' 16 | return super().__new__(cls, width=width, unit=unit) 17 | 18 | @property 19 | def parsedWidth(self): 20 | return self.width 21 | 22 | def __str__(self): 23 | return f'{self.width}{self.unit}' 24 | 25 | 26 | unitRegex = re.compile(r'[\d.,]*(\D*)$') 27 | 28 | def widthParser(width, parseFloatToInt=True): 29 | width_str = str(width) 30 | match = unitRegex.search(width_str) 31 | widthUnit = match.group(1) 32 | if (widthUnit == '%') and not parseFloatToInt: 33 | parser = float 34 | else: 35 | parser = int 36 | width = strip_unit(width_str) 37 | parsed_width = parser(width) 38 | # LATER: somehow JS works differently here (as it does not have a strict 39 | # type sytem). parseFloat() might return a number without fractional but 40 | # python does. 41 | width_int = int(width) 42 | if parsed_width == width_int: 43 | parsed_width = width_int 44 | 45 | return WidthUnit(width=parsed_width, unit=widthUnit) 46 | -------------------------------------------------------------------------------- /mjml/lib/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .dict_merger import * 3 | -------------------------------------------------------------------------------- /mjml/lib/dict_merger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # Copyright 2015 Felix Schwarz 3 | # The source code in this file is licensed under the MIT license. 4 | 5 | 6 | __all__ = ['merge_dicts'] 7 | 8 | def merge_dicts(*sources): 9 | # initial code from 10 | # Robin Bryce, Tue, 19 Dec 2006 11 | # PSF license 12 | # http://code.activestate.com/recipes/499335-recursively-update-a-dictionary-without-hitting-py/ 13 | result = {} 14 | for source in sources: 15 | stack = [(source, result)] 16 | while stack: 17 | current_src, current_dst = stack.pop() 18 | for key in (current_src or ()): 19 | src_item_is_dict = isinstance(current_src.get(key), dict) 20 | dst_item_is_dict = isinstance(current_dst.get(key), dict) 21 | if src_item_is_dict and dst_item_is_dict: 22 | stack.append((current_src[key], current_dst[key])) 23 | else: 24 | current_dst[key] = current_src[key] 25 | return result 26 | -------------------------------------------------------------------------------- /mjml/lib/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelixSchwarz/mjml-python/22b762931b6c1dda86d87972b64bfdb9b8a0243a/mjml/lib/tests/__init__.py -------------------------------------------------------------------------------- /mjml/lib/tests/dict_merger_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # Copyright 2015 Felix Schwarz 3 | # The source code in this file is licensed under the MIT license. 4 | 5 | from ..dict_merger import merge_dicts 6 | 7 | 8 | def test_returns_single_dict_unmodified(): 9 | assert merge_dicts({}) == {} 10 | assert merge_dicts({'bar': 42}) == {'bar': 42} 11 | 12 | def test_can_merge_two_dicts_without_modifying_inputs(): 13 | a = {'a': 1} 14 | b = {'b': 2} 15 | assert merge_dicts(a, b) == {'a': 1, 'b': 2} 16 | 17 | def test_can_merge_three_dicts_without_modifying_inputs(): 18 | a = {'a': 1} 19 | b = {'b': 2} 20 | c = {'c': 3} 21 | assert merge_dicts(a, b, c) == {'a': 1, 'b': 2, 'c': 3} 22 | -------------------------------------------------------------------------------- /mjml/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelixSchwarz/mjml-python/22b762931b6c1dda86d87972b64bfdb9b8a0243a/mjml/py.typed -------------------------------------------------------------------------------- /mjml/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelixSchwarz/mjml-python/22b762931b6c1dda86d87972b64bfdb9b8a0243a/mjml/scripts/__init__.py -------------------------------------------------------------------------------- /mjml/scripts/mjml-html-compare: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import sys 5 | 6 | from htmlcompare import assert_same_html 7 | 8 | from mjml import mjml_to_html 9 | 10 | 11 | mjml_filename = Path(sys.argv[1]) 12 | html_filename = Path(sys.argv[2]) 13 | 14 | with mjml_filename.open('rb') as mjml_fp: 15 | result = mjml_to_html(mjml_fp) 16 | 17 | with html_filename.open('rb') as html_fp: 18 | expected_html = html_fp.read() 19 | 20 | assert not result.errors 21 | actual_html = result.html 22 | assert_same_html(expected_html, actual_html, verbose=True) 23 | -------------------------------------------------------------------------------- /mjml/scripts/mjml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | mjml. 4 | 5 | Usage: 6 | mjml [options] 7 | mjml [options] -o 8 | 9 | Options: 10 | --template-dir= base dir for mj-include (default: path of mjml file) 11 | """ 12 | # ruff: noqa: E501 13 | 14 | import sys 15 | from io import BytesIO 16 | from pathlib import Path 17 | 18 | from docopt import docopt 19 | 20 | from mjml.mjml2html import mjml_to_html 21 | 22 | 23 | def main(): 24 | arguments = docopt(__doc__) 25 | mjml_filename = arguments[''] 26 | output_filename = arguments[''] 27 | template_dir = arguments['--template-dir'] 28 | 29 | if mjml_filename == '-': 30 | stdin = sys.stdin.buffer 31 | mjml_fp = BytesIO(stdin.read()) 32 | result = mjml_to_html(mjml_fp, template_dir=template_dir) 33 | else: 34 | with Path(mjml_filename).open('rb') as mjml_fp: 35 | result = mjml_to_html(mjml_fp, template_dir=template_dir) 36 | assert not result.errors, result.errors 37 | 38 | html_str = result.html 39 | if output_filename: 40 | with Path(output_filename).open('w') as html_fp: 41 | html_fp.write(html_str) 42 | else: 43 | # always return "binary" data (HTML encoded as UTF-8 to avoid encoding 44 | # problems in Windows: 45 | # UnicodeEncodeError: 'charmap' codec can't encode character '\ufb02' in position …: character maps to 46 | html_bytes = html_str.encode('utf8') 47 | sys.stdout.buffer.write(html_bytes) 48 | 49 | 50 | if __name__ == '__main__': 51 | main() 52 | -------------------------------------------------------------------------------- /mjml/testing_helpers.py: -------------------------------------------------------------------------------- 1 | 2 | from contextlib import contextmanager 3 | from pathlib import Path 4 | 5 | 6 | __all__ = ['get_mjml_fp', 'load_expected_html'] 7 | 8 | TESTDATA_DIR = Path(__file__).parent / '..' / 'tests' / 'testdata' 9 | 10 | def load_expected_html(test_id): 11 | html_filename = f'{test_id}-expected.html' 12 | with (TESTDATA_DIR / html_filename).open('rb') as html_fp: 13 | expected_html = html_fp.read() 14 | return expected_html 15 | 16 | @contextmanager 17 | def get_mjml_fp(test_id, json=False): 18 | mjml_filename = f'{test_id}.mjml' 19 | if json: 20 | mjml_filename += '.json' 21 | with (TESTDATA_DIR / mjml_filename).open('rb') as mjml_fp: 22 | yield mjml_fp 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 42.0", # `license_files` in `setup.cfg` 4 | "wheel" 5 | ] 6 | 7 | [tool.ruff] 8 | line-length = 100 9 | 10 | ignore = [ 11 | # F403 "`from pythonic_testcase import *` used; unable to detect undefined names 12 | # F405 "… may be undefined, or defined from star imports: …" 13 | # Sometimes star imports are perfectly fine IMHO. 14 | "F403", 15 | "F405", 16 | # E731: "Do not assign a `lambda` expression, use a `def`" 17 | # I think assigning to lambda expressions is ok. 18 | "E731", 19 | ] 20 | 21 | select = [ 22 | # Pyflakes 23 | "F", 24 | # Pycodestyle 25 | "E", 26 | "W", 27 | # isort 28 | "I001", 29 | 30 | # Special rule code to enforce that your noqa directives are "valid", in that 31 | # the violations they say they ignore are actually being triggered on that line 32 | # (and thus suppressed). 33 | # replaces basically "yesqa" 34 | "RUF100", 35 | ] 36 | src = ["mjml", "tests"] 37 | 38 | [tool.ruff.isort] 39 | lines-after-imports = 2 40 | known-first-party = ["mjml"] 41 | combine-as-imports = true 42 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | css_inlining: tests which depend on optional dependencies for CSS inlining 4 | 5 | # "xpassed" should be treated as failure 6 | xfail_strict=true 7 | 8 | # warnings triggered during test discovery - these can not be filtered via conftest.py 9 | filterwarnings = 10 | error 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mjml 3 | version = file: VERSION.txt 4 | description = Python implementation for MJML - a framework that makes responsive-email easy 5 | 6 | long_description = file:README.md 7 | long_description_content_type = text/markdown 8 | 9 | author = Felix Schwarz 10 | author_email = felix.schwarz@oss.schwarz.eu 11 | url = https://github.com/FelixSchwarz/mjml-python 12 | license = MIT 13 | license_files = LICENSE.txt 14 | 15 | classifiers = 16 | Development Status :: 4 - Beta 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: MIT License 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.6 21 | Programming Language :: Python :: 3.7 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Programming Language :: Python :: 3.10 25 | Programming Language :: Python :: 3.11 26 | Programming Language :: Python :: 3.12 27 | Programming Language :: Python :: 3.13 28 | Topic :: Communications :: Email 29 | Topic :: Text Processing :: Markup :: HTML 30 | project_urls = 31 | Code = https://github.com/FelixSchwarz/mjml-python 32 | Issue tracker = https://github.com/FelixSchwarz/mjml-python/issues 33 | 34 | 35 | [options] 36 | python_requires = >= 3.6 37 | 38 | packages = find: 39 | zip_safe = true 40 | include_package_data = true 41 | 42 | install_requires = 43 | # beautifulsoup4 v4.13 uses `from __future__ import annotations` which is 44 | # not supported in Python 3.6 45 | beautifulsoup4 < 4.13; python_version <= '3.6' 46 | beautifulsoup4; python_version > '3.6' 47 | dotmap 48 | docopt 49 | jinja2 50 | 51 | scripts = 52 | mjml/scripts/mjml-html-compare 53 | 54 | 55 | [options.packages.find] 56 | exclude = 57 | tests 58 | 59 | [options.extras_require] 60 | testing = 61 | HTMLCompare >= 0.3.0 # >= 0.3.0: ability to ignore attribute ordering in HTML 62 | lxml 63 | pytest 64 | css_inlining = 65 | # >= 0.11: CSSInliner(inline_style_tags=..., keep_link_tags=..., keep_style_tags=...) 66 | css_inline >= 0.11 67 | 68 | 69 | [options.entry_points] 70 | console_scripts = 71 | mjml = mjml.scripts.mjml:main 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FelixSchwarz/mjml-python/22b762931b6c1dda86d87972b64bfdb9b8a0243a/tests/__init__.py -------------------------------------------------------------------------------- /tests/border_parser_test.py: -------------------------------------------------------------------------------- 1 | 2 | from mjml.helpers import borderParser 3 | 4 | 5 | def test_can_parse_css_none(): 6 | assert borderParser('none') == 0 7 | -------------------------------------------------------------------------------- /tests/custom_components_test.py: -------------------------------------------------------------------------------- 1 | 2 | from htmlcompare import assert_same_html 3 | 4 | from mjml import mjml_to_html 5 | from mjml.elements import MjText 6 | from mjml.testing_helpers import get_mjml_fp, load_expected_html 7 | 8 | 9 | class MjTextCustom(MjText): 10 | component_name = 'mj-text-custom' 11 | 12 | def render(self): 13 | content = super().render() 14 | 15 | return f'
START CUSTOM WRAPPER
{content}
END CUSTOM WRAPPER
' 16 | 17 | class MjTextOverride(MjText): 18 | @classmethod 19 | def default_attrs(cls): 20 | attrs = super().default_attrs() 21 | return { 22 | **attrs, 23 | 'align' : 'right', 24 | 'color' : 'red', 25 | 'font-size' : '26px', 26 | } 27 | 28 | def render(self): 29 | content = super().render() 30 | 31 | return f'
***
{content}
***
' 32 | 33 | 34 | def test_custom_components(): 35 | expected_html = load_expected_html('_custom') 36 | with get_mjml_fp('_custom') as mjml_fp: 37 | result_list = mjml_to_html(mjml_fp, custom_components=[MjTextCustom, MjTextOverride]) 38 | 39 | assert not result_list.errors 40 | list_actual_html = result_list.html 41 | assert_same_html(expected_html, list_actual_html, verbose=True) 42 | -------------------------------------------------------------------------------- /tests/includes_with_umlauts_test.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | from contextlib import contextmanager 4 | from io import StringIO 5 | 6 | from mjml import mjml_to_html 7 | 8 | 9 | # could use "contextlib.chdir" in Python 3.11+ 10 | # https://github.com/python/cpython/commit/3592980f9122ab0d9ed93711347742d110b749c2 11 | @contextmanager 12 | def chdir(path): 13 | old_chdir = os.getcwd() 14 | try: 15 | os.chdir(path) 16 | yield 17 | finally: 18 | os.chdir(old_chdir) 19 | 20 | 21 | def test_can_properly_handle_include_umlauts(tmp_path): 22 | included_mjml = ( 23 | '' 24 | ' ' 25 | ' äöüß' 26 | ' ' 27 | '' 28 | ) 29 | mjml = ( 30 | '' 31 | ' ' 32 | ' foo bar' 33 | ' ' 34 | ' ' 35 | '' 36 | ) 37 | path_footer = tmp_path / 'footer.mjml' 38 | path_footer.write_text(included_mjml, encoding='utf8') 39 | 40 | with chdir(tmp_path): 41 | result = mjml_to_html(StringIO(mjml)) 42 | html = result.html 43 | 44 | assert ('äöüß' in html) 45 | -------------------------------------------------------------------------------- /tests/missing_functionality_test.py: -------------------------------------------------------------------------------- 1 | 2 | from pathlib import Path 3 | 4 | import pytest 5 | from htmlcompare import assert_same_html 6 | 7 | from mjml import mjml_to_html 8 | 9 | 10 | TESTDATA_DIR = Path(__file__).parent / 'missing_functionality' 11 | 12 | # currently there are no tests which are expected to fail 13 | @pytest.mark.parametrize('test_id', []) 14 | @pytest.mark.xfail 15 | def test_missing_functionality(test_id): 16 | mjml_filename = f'{test_id}.mjml' 17 | html_filename = f'{test_id}-expected.html' 18 | with (TESTDATA_DIR / html_filename).open('rb') as html_fp: 19 | expected_html = html_fp.read() 20 | 21 | with (TESTDATA_DIR / mjml_filename).open('rb') as mjml_fp: 22 | result = mjml_to_html(mjml_fp) 23 | 24 | assert not result.errors 25 | actual_html = result.html 26 | assert_same_html(expected_html, actual_html, verbose=True) 27 | -------------------------------------------------------------------------------- /tests/mj_button_mailto_link_test.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | from io import StringIO 4 | 5 | import lxml.html 6 | 7 | from mjml import mjml_to_html 8 | 9 | 10 | def test_no_target_for_mailto_links(): 11 | mjml = ( 12 | '' 13 | ' ' 14 | ' Click me' 15 | ' ' 16 | '' 17 | ) 18 | 19 | result = mjml_to_html(StringIO(mjml)) 20 | html = result.html 21 | mailto_match = re.search(']*>', html) 22 | start, end = mailto_match.span() 23 | match_str = html[start:end] 24 | 25 | a_el = lxml.html.fragment_fromstring(match_str) 26 | assert a_el.attrib['href'] == 'mailto:foo@site.example' 27 | target = a_el.attrib.get('target') 28 | # Thunderbird opens a blank page instead of the new message window if 29 | # the contains 'target="_blank"'. 30 | # https://bugzilla.mozilla.org/show_bug.cgi?id=1677248 31 | # https://bugzilla.mozilla.org/show_bug.cgi?id=1589968 32 | # https://bugzilla.mozilla.org/show_bug.cgi?id=421310 33 | assert not target, f'target="{target}"' 34 | -------------------------------------------------------------------------------- /tests/mjml2html_test.py: -------------------------------------------------------------------------------- 1 | 2 | from io import StringIO 3 | 4 | from mjml import mjml_to_html 5 | 6 | 7 | def test_can_handle_comments_in_mjml(): 8 | mjml = ( 9 | '' 10 | ' ' 11 | ' ' 12 | ' ' 13 | '' 14 | ) 15 | mjml_to_html(StringIO(mjml)) 16 | -------------------------------------------------------------------------------- /tests/testdata/_custom.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This is a new component 6 | This component has been changed 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/testdata/_footer.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is a footer 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/testdata/_header.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is a header 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/testdata/button.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Click me 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/css-inlining.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .box { 5 | border: 5px solid red; 6 | } 7 | .box > div > span { 8 | border: 3px solid blue; 9 | } 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | < Hello World > 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/testdata/hello-world.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello World 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/testdata/hello-world.mjml.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "children": [ 5 | { 6 | "children": [ 7 | { 8 | "children": [ 9 | { 10 | "attributes": { 11 | "src": "/assets/img/logo-small.png", 12 | "width": "100px" 13 | }, 14 | "tagName": "mj-image" 15 | }, 16 | { 17 | "attributes": { 18 | "border-color": "#F45E43" 19 | }, 20 | "tagName": "mj-divider" 21 | }, 22 | { 23 | "attributes": { 24 | "color": "#F45E43", 25 | "font-size": "20px", 26 | "font-family": "helvetica" 27 | }, 28 | "content": "Hello World", 29 | "tagName": "mj-text" 30 | } 31 | ], 32 | "tagName": "mj-column" 33 | } 34 | ], 35 | "tagName": "mj-section" 36 | } 37 | ], 38 | "tagName": "mj-body" 39 | } 40 | ], 41 | "tagName": "mjml" 42 | } 43 | -------------------------------------------------------------------------------- /tests/testdata/html-entities-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
site.example   © 2021 Some Company Ltd.
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/html-entities.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
site.example 7 |   © 2021 Some Company Ltd. 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/testdata/html-without-closing-tag-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
Hello
World
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/html-without-closing-tag.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello
World
6 |
7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /tests/testdata/minimal-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
minimal example
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/minimal.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | minimal example 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/missing-whitespace-before-tag-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
foo bar.
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/missing-whitespace-before-tag.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | foo bar. 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-accordion.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Why use an accordion? 17 | 18 | 19 | Because emails with a lot of content are most of the time a very bad experience on mobile, mj-accordion comes handy when you want to deliver a lot of information in a concise way. 20 | 21 | 22 | 23 | 24 | How it works 25 | 26 | 27 | Content is stacked into tabs and users can expand them at will. If responsive styles are not supported (mostly on desktop clients), tabs are then expanded and your content is readable at once. 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/testdata/mj-attributes-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 69 | 76 | 78 | 80 | 81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 | 89 | 104 | 105 | 106 |
90 | 91 |
92 | 93 | 94 | 95 | 98 | 99 | 100 |
96 |
Hello World!
97 |
101 |
102 | 103 |
107 |
108 | 109 |
110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /tests/testdata/mj-attributes.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Hello World! 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/testdata/mj-body-with-background-color-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
minimal example
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-body-with-background-color.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | minimal example 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-breakpoint-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
Hello World!
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-breakpoint.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-button-with-width.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | send 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/testdata/mj-carousel.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/testdata/mj-column-with-attributes-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 119 | 120 | 121 |
97 | 98 |
99 | 100 | 101 | 102 | 113 | 114 | 115 |
103 | 104 | 105 | 106 | 109 | 110 | 111 |
107 |
Hello World!
108 |
112 |
116 |
117 | 118 |
122 |
123 | 124 |
125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /tests/testdata/mj-column-with-attributes.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Hello World! 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/testdata/mj-font-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
Hello World!
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-font-multiple-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 63 | 68 | 69 | 78 | 85 | 87 | 89 | 90 | 91 | 92 |
93 | 94 |
95 | 96 | 97 | 98 | 113 | 114 | 115 |
99 | 100 |
101 | 102 | 103 | 104 | 107 | 108 | 109 |
105 |
Hello World!
106 |
110 |
111 | 112 |
116 |
117 | 118 |
119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /tests/testdata/mj-font-multiple.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello World! 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/testdata/mj-font-unused-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 69 | 76 | 78 | 80 | 81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 | 89 | 104 | 105 | 106 |
90 | 91 |
92 | 93 | 94 | 95 | 98 | 99 | 100 |
96 |
Hello World!
97 |
101 |
102 | 103 |
107 |
108 | 109 |
110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /tests/testdata/mj-font-unused.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-font.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-group.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Easy and quick

9 |

Write less code, save time and code more efficiently with MJML’s semantic syntax.

10 |
11 |
12 | 13 | 14 | 15 |

Responsive

16 |

MJML is responsive by design on most-popular email clients, even Outlook.

17 |
18 |
19 |
20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /tests/testdata/mj-head-with-comment-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 62 | 64 | 65 | 66 | 67 | 68 |
69 |
70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /tests/testdata/mj-head-with-comment.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/testdata/mj-hero-fixed.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 20 | GO TO SPACE 21 | 22 | 23 | ORDER YOUR TICKET NOW 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/testdata/mj-hero-fluid.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 19 | GO TO SPACE 20 | 21 | 22 | ORDER YOUR TICKET NOW 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/testdata/mj-html-attributes-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 116 | 117 | 118 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 | 110 | 111 | 112 |
103 |
Hello World!
104 |
108 |
Foo bar
109 |
113 |
114 | 115 |
119 |
120 | 121 |
122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /tests/testdata/mj-html-attributes.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 42 6 | bar 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Hello World! 15 | 16 | 17 | Foo bar 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/testdata/mj-image-with-empty-alt-attribute.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-image-with-href.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-include-body.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | foo 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/testdata/mj-navbar.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Getting started 7 | Try it live 8 | Templates 9 | Components 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-preview-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
Hello MJML
91 |
92 | 93 |
94 | 95 | 96 | 97 | 112 | 113 | 114 |
98 | 99 |
100 | 101 | 102 | 103 | 106 | 107 | 108 |
104 |
Hello World!
105 |
109 |
110 | 111 |
115 |
116 | 117 |
118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /tests/testdata/mj-preview.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello MJML 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 62 | 64 | 65 | 66 | 67 |
68 | 69 |
70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-head-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 | 112 | 113 | 114 |
98 | 99 |
100 | 101 | 102 | 103 | 106 | 107 | 108 |
104 |
Hello World!
105 |
109 |
110 | 111 |
115 |
116 | 117 |
118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-head-with-tags-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 69 | 76 | 78 | 80 | 81 | 82 | 83 | 84 | 85 |
86 | 87 |
88 | 89 | 90 | 91 | 106 | 107 | 108 |
92 | 93 |
94 | 95 | 96 | 97 | 100 | 101 | 102 |
98 |
Hello World!
99 |
103 |
104 | 105 |
109 |
110 | 111 |
112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-head-with-tags.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Hello World! 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-head.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Hello World! 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-with-tags-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 62 | 64 | 65 | 66 | 67 |
68 | 69 |

Hello World!

70 |
71 |
72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw-with-tags.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Hello World!

6 |
7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /tests/testdata/mj-raw.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-background-url.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-background.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Foo Bar 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-css-class-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
some text
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-css-class.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-full-width.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | 10 | some text 11 | 12 | 13 | 14 | 15 | some text 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-mj-class-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
some text
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-section-with-mj-class.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | some text 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/testdata/mj-social.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Facebook 8 | 9 | 10 | Google 11 | 12 | 13 | Twitter 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/testdata/mj-spacer.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Foo 6 | 7 | Bar 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/testdata/mj-style-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 92 | 93 | 94 | 95 |
96 | 97 |
98 | 99 | 100 | 101 | 116 | 117 | 118 |
102 | 103 |
104 | 105 | 106 | 107 | 110 | 111 | 112 |
108 |
I'm red and underlined
109 |
113 |
114 | 115 |
119 |
120 | 121 |
122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /tests/testdata/mj-style.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .red-text div { 5 | color: red !important; 6 | text-decoration: underline !important; 7 | } 8 | 9 | 10 | 11 | 12 | 13 | I'm red and underlined 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/testdata/mj-table.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Year 8 | Language 9 | Inspired from 10 | 11 | 12 | 1995 13 | PHP 14 | C, Shell Unix 15 | 16 | 17 | 1995 18 | JavaScript 19 | Scheme, Self 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/testdata/mj-text-escaped-html-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 46 | 56 | 61 | 62 | 63 | 67 | 68 | 77 | 84 | 86 | 88 | 89 | 90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 | 112 | 113 | 114 |
98 | 99 |
100 | 101 | 102 | 103 | 106 | 107 | 108 |
104 |
Pretty unsafe: <script>
105 |
109 |
110 | 111 |
115 |
116 | 117 |
118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /tests/testdata/mj-text-escaped-html.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pretty unsafe: <script> 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/testdata/mj-text-with-tail-text-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
foo
bar
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-text-with-tail-text.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | foo
7 | bar 8 |
9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /tests/testdata/mj-title-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello MJML 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 61 | 62 | 66 | 67 | 76 | 83 | 85 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 96 | 111 | 112 | 113 |
97 | 98 |
99 | 100 | 101 | 102 | 105 | 106 | 107 |
103 |
Hello World!
104 |
108 |
109 | 110 |
114 |
115 | 116 |
117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /tests/testdata/mj-title.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello MJML 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/testdata/mj-wrapper.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | First line of text 7 | 8 | 9 | 10 | 11 | Second line of text 12 | 13 | Third line of text 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/testdata/text_with_html-expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 45 | 55 | 60 | 69 | 76 | 78 | 80 | 81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 | 89 | 107 | 108 | 109 |
90 | 91 |
92 | 93 | 94 | 95 | 101 | 102 | 103 |
96 |
Hello World 97 | 98 |

Have a nice day.

99 |
100 |
104 |
105 | 106 |
110 |
111 | 112 |
113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /tests/testdata/text_with_html.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello World 8 | 9 |

10 | Have a nice day. 11 |

12 |
13 | 14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /tools/update-expected-html.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | usage: update-expected-html.py [-h] [--single-process] DATA_DIR 4 | 5 | Script to update "...-expected.html" test data files with output from mjml 6 | reference implementation (NodeJS). 7 | 8 | positional arguments: 9 | DATA_DIR 10 | 11 | options: 12 | -h, --help show this help message and exit 13 | --single-process run HTML generation in a single process (slower but easier 14 | to debug) 15 | """ 16 | 17 | import argparse 18 | import os 19 | import subprocess 20 | import sys 21 | from collections import namedtuple 22 | from multiprocessing import Pool 23 | from pathlib import Path 24 | 25 | 26 | Job = namedtuple('Job', ('mjml_path', 'expected_path', 'mjml_bin')) 27 | 28 | def job_for_file(mjml_path, mjml_js): 29 | expected_path = mjml_path.parent / (mjml_path.stem + '-expected.html') 30 | return Job( 31 | str(mjml_path.resolve()), 32 | str(expected_path.resolve()), 33 | mjml_bin = mjml_js, 34 | ) 35 | 36 | def _gather_data_files_in_directory(source_dir): 37 | for mjml_path in source_dir.glob('*.mjml'): 38 | if mjml_path.name.startswith('_'): 39 | # "_header.mjml" / "_footer.mjml" 40 | continue 41 | yield mjml_path 42 | 43 | def _gather_jobs(source_path, mjml_js): 44 | if isinstance(source_path, str): 45 | source_path = Path(source_path) 46 | 47 | if source_path.is_dir(): 48 | for mjml_path in _gather_data_files_in_directory(source_path): 49 | job = job_for_file(mjml_path, mjml_js) 50 | yield job 51 | else: 52 | assert source_path.suffix == '.mjml' 53 | job = job_for_file(source_path, mjml_js) 54 | yield job 55 | 56 | def _update_expected_html(job): 57 | mjml_cmd = str(job.mjml_bin) 58 | cmd = [mjml_cmd, job.mjml_path, '-o', job.expected_path] 59 | subprocess.run(cmd) 60 | 61 | def detect_mjml_js(): 62 | if 'MJML' in os.environ: 63 | return os.environ['MJML'] 64 | sys.stderr.write('unable to detect mjml executable, use env variable MJML\n') 65 | sys.exit(20) 66 | 67 | def main(argv=sys.argv): 68 | parser = argparse.ArgumentParser( 69 | description=''' 70 | Script to update "...-expected.html" test data files with output from 71 | mjml reference implementation (NodeJS).''', 72 | ) 73 | parser.add_argument('--single-process', action='store_true', 74 | help='run HTML generation in a single process (slower but easier to debug)') 75 | parser.add_argument('data_dir', metavar='DATA_DIR', type=str) 76 | args = parser.parse_args(args=argv[1:]) 77 | input_ = args.data_dir 78 | 79 | mjml_js = detect_mjml_js() 80 | 81 | # ensure we are using the expected mjml version 82 | subprocess.run([str(mjml_js), '--version']) 83 | 84 | jobs = tuple(_gather_jobs(input_, mjml_js)) 85 | if not jobs: 86 | sys.stderr.write('no mjml files found...\n') 87 | if args.single_process: 88 | for job in jobs: 89 | _update_expected_html(job) 90 | else: 91 | with Pool() as p: 92 | p.map(_update_expected_html, jobs) 93 | 94 | if __name__ == '__main__': 95 | main() 96 | --------------------------------------------------------------------------------