Added support to define mosaico templates in MJML. (A wizard that shows how to do this is TODO.)
Adopted some core features (router, etc.) from IVIS.
This commit is contained in:
parent
3b20ac5ce7
commit
ad9f5d16bf
28 changed files with 1381 additions and 538 deletions
|
|
@ -1,48 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes
|
||||
from 'prop-types';
|
||||
import PropTypes from 'prop-types';
|
||||
import {withTranslation} from '../lib/i18n';
|
||||
import {
|
||||
LinkButton,
|
||||
requiresAuthenticatedUser,
|
||||
Title,
|
||||
withPageHelpers
|
||||
} from '../lib/page'
|
||||
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
|
||||
import {
|
||||
Button,
|
||||
ButtonRow, CheckBox,
|
||||
ButtonRow,
|
||||
CheckBox,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
StaticField, TableSelect,
|
||||
StaticField,
|
||||
TableSelect,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../lib/form';
|
||||
import {withErrorHandling} from '../lib/error-handling';
|
||||
import {
|
||||
NamespaceSelect,
|
||||
validateNamespace
|
||||
} from '../lib/namespace';
|
||||
import {DeleteModalDialog} from "../lib/modals";
|
||||
import mailtrainConfig
|
||||
from 'mailtrainConfig';
|
||||
import {
|
||||
getEditForm,
|
||||
getTemplateTypes,
|
||||
getTypeForm
|
||||
} from './helpers';
|
||||
import axios
|
||||
from '../lib/axios';
|
||||
import styles
|
||||
from "../lib/styles.scss";
|
||||
import {NamespaceSelect, validateNamespace} from '../lib/namespace';
|
||||
import {ContentModalDialog, DeleteModalDialog} from "../lib/modals";
|
||||
import mailtrainConfig from 'mailtrainConfig';
|
||||
import {getEditForm, getTemplateTypes, getTypeForm} from './helpers';
|
||||
import axios from '../lib/axios';
|
||||
import styles from "../lib/styles.scss";
|
||||
import {getUrl} from "../lib/urls";
|
||||
import {TestSendModalDialog} from "./TestSendModalDialog";
|
||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||
import moment
|
||||
from 'moment';
|
||||
import moment from 'moment';
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
|
|
@ -62,6 +47,9 @@ export default class CUD extends Component {
|
|||
showMergeTagReference: false,
|
||||
elementInFullscreen: false,
|
||||
showTestSendModal: false,
|
||||
showExportModal: false,
|
||||
exportModalContentType: null,
|
||||
exportModalTitle: ''
|
||||
};
|
||||
|
||||
this.initForm({
|
||||
|
|
@ -71,6 +59,11 @@ export default class CUD extends Component {
|
|||
});
|
||||
|
||||
this.sendModalGetDataHandler = ::this.sendModalGetData;
|
||||
this.exportModalGetContentHandler = ::this.exportModalGetContent;
|
||||
|
||||
// This is needed here because if this is passed as an anonymous function, it will reset the editorNode to null with each render.
|
||||
// This becomes a problem when Show HTML button is pressed because that one tries to access the editorNode while it is null.
|
||||
this.editorNodeRefHandler = node => this.editorNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -244,6 +237,19 @@ export default class CUD extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
showExportModal(contentType, title) {
|
||||
this.setState({
|
||||
showExportModal: true,
|
||||
exportModalContentType: contentType,
|
||||
exportModalTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
async exportModalGetContent() {
|
||||
const typeKey = this.getFormValue('type');
|
||||
return await this.templateTypes[typeKey].exportContent(this, this.state.exportModalContentType);
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const isEdit = !!this.props.entity;
|
||||
|
|
@ -282,6 +288,13 @@ export default class CUD extends Component {
|
|||
onHide={() => this.setState({showTestSendModal: false})}
|
||||
getDataAsync={this.sendModalGetDataHandler}/>
|
||||
}
|
||||
{isEdit &&
|
||||
<ContentModalDialog
|
||||
title={this.state.exportModalTitle}
|
||||
visible={this.state.showExportModal}
|
||||
onHide={() => this.setState({showExportModal: false})}
|
||||
getContentAsync={this.exportModalGetContentHandler}/>
|
||||
}
|
||||
{canDelete &&
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
|
||||
|
|
@ -124,6 +124,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
|
@ -137,6 +138,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, metadata, model} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'mosaicoTemplate']: '',
|
||||
[prefix + 'mosaicoData']: {}
|
||||
|
|
@ -189,7 +195,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
|
||||
|
|
@ -199,6 +205,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
|
@ -212,6 +219,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, metadata, model} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0].key,
|
||||
[prefix + 'mosaicoData']: {}
|
||||
|
|
@ -267,7 +279,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<GrapesJSHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
entityTypeId={entityTypeId}
|
||||
initialSource={owner.getFormValue(prefix + 'grapesJSData').source}
|
||||
|
|
@ -277,6 +289,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
|
@ -290,6 +303,12 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source, style} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
if (contentType === 'mjml') return source;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'grapesJSSourceType']: GrapesJSSourceType.MJML,
|
||||
[prefix + 'grapesJSData']: {}
|
||||
|
|
@ -323,7 +342,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<CKEditorHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialSource={owner.getFormValue(prefix + 'ckeditor4Data').source}
|
||||
entityTypeId={entityTypeId}
|
||||
|
|
@ -331,6 +350,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
|
@ -343,6 +363,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'ckeditor4Data']: {}
|
||||
}),
|
||||
|
|
@ -394,7 +419,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<CodeEditorHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
entityTypeId={entityTypeId}
|
||||
initialSource={owner.getFormValue(prefix + 'codeEditorData').source}
|
||||
|
|
@ -403,6 +428,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
|
@ -415,6 +441,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'codeEditorSourceType']: CodeEditorSourceType.HTML,
|
||||
[prefix + 'codeEditorData']: {}
|
||||
|
|
@ -607,7 +638,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
|
|||
height="400px"
|
||||
mode="text"
|
||||
label={t('templateContentPlainText')}
|
||||
help={<Trans i18nKey="toExtractTheTextFromHtmlClickHerePlease">To extract the text from HTML click <ActionLink onClickAsync={::owner.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}
|
||||
help={<Trans i18nKey="toExtractTheTextFromHtmlClickHerePlease">To extract the text from HTML click <ActionLink onClickAsync={::owner.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
InputField, StaticField,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../../lib/form';
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
getTemplateTypesOrder
|
||||
} from "./helpers";
|
||||
import {withComponentMixins} from "../../lib/decorator-helpers";
|
||||
import styles from "../../lib/styles.scss";
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
|
|
@ -67,7 +68,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
this.templateTypes[data.type].afterLoad(this, data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -132,7 +133,7 @@ export default class CUD extends Component {
|
|||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
this.templateTypes[data.type].beforeSave(data);
|
||||
this.templateTypes[data.type].beforeSave(this, data);
|
||||
});
|
||||
|
||||
if (submitResult) {
|
||||
|
|
@ -163,10 +164,6 @@ export default class CUD extends Component {
|
|||
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
|
||||
|
||||
const typeKey = this.getFormValue('type');
|
||||
let form = null;
|
||||
if (typeKey) {
|
||||
form = this.templateTypes[typeKey].getForm(this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -186,15 +183,22 @@ export default class CUD extends Component {
|
|||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="name" label={t('name')}/>
|
||||
<TextArea id="description" label={t('description')}/>
|
||||
<Dropdown id="type" label={t('type')} options={this.typeOptions}/>
|
||||
{isEdit ?
|
||||
<StaticField id="type" className={styles.formDisabled} label={t('type')}>
|
||||
{typeKey && this.templateTypes[typeKey].typeName}
|
||||
</StaticField>
|
||||
:
|
||||
<Dropdown id="type" label={t('type')} options={this.typeOptions}/>
|
||||
}
|
||||
<NamespaceSelect/>
|
||||
|
||||
{form}
|
||||
{isEdit && typeKey && this.templateTypes[typeKey].getForm(this)}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>}
|
||||
{isEdit && typeKey && this.templateTypes[typeKey].getButtons(this)}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,67 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {ACEEditor} from "../../lib/form";
|
||||
import {ACEEditor, Button} from "../../lib/form";
|
||||
import 'brace/mode/html'
|
||||
import 'brace/mode/xml'
|
||||
import {ContentModalDialog} from "../../lib/modals";
|
||||
import mjml2html from "./mjml-mosaico";
|
||||
import styles from "../../lib/styles.scss";
|
||||
|
||||
|
||||
export function getTemplateTypesOrder() {
|
||||
return [/* 'mjml' , */ 'html'];
|
||||
return [ 'mjml' , 'html'];
|
||||
}
|
||||
|
||||
export function getTemplateTypes(t) {
|
||||
const templateTypes = {};
|
||||
|
||||
function getMjml(owner) {
|
||||
return owner.getFormValue('mjml') || '';
|
||||
}
|
||||
|
||||
function generateHtmlFromMjml(owner) {
|
||||
const mjml = getMjml(owner);
|
||||
|
||||
try {
|
||||
const res = mjml2html(mjml);
|
||||
return res.html;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function validateMjml(owner) {
|
||||
const mjml = getMjml(owner);
|
||||
|
||||
try {
|
||||
const res = mjml2html(mjml);
|
||||
|
||||
if (res.errors.length > 0) {
|
||||
const msg = (
|
||||
<div>
|
||||
<p>{t('Invalid MJML')}</p>
|
||||
<ul className={styles.errorsList}>
|
||||
{res.errors.map((err, idx) => <li key={idx}>Line {err.line}: {err.message}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
owner.setFormStatusMessage('danger', msg);
|
||||
} else {
|
||||
owner.setFormStatusMessage('success', t('MJML is valid.'));
|
||||
}
|
||||
} catch (err) {
|
||||
owner.setFormStatusMessage('danger', t('Invalid MJML.'));
|
||||
}
|
||||
}
|
||||
|
||||
function setExportModalVisibility(owner, visible) {
|
||||
owner.setState({
|
||||
exportModalVisible: visible
|
||||
})
|
||||
}
|
||||
|
||||
function clearBeforeSend(data) {
|
||||
delete data.html;
|
||||
delete data.mjml;
|
||||
|
|
@ -20,31 +70,44 @@ export function getTemplateTypes(t) {
|
|||
templateTypes.html = {
|
||||
typeName: t('html'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
afterLoad: (owner, data) => {
|
||||
data.html = data.data.html;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
beforeSave: (owner, data) => {
|
||||
data.data = {
|
||||
html: data.html
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
getButtons: owner => null
|
||||
};
|
||||
|
||||
templateTypes.mjml = {
|
||||
typeName: t('mjml'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="xml" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
getForm: owner => (
|
||||
<>
|
||||
<ContentModalDialog visible={!!owner.state.exportModalVisible} title={t('HTML')} getContentAsync={async () => generateHtmlFromMjml(owner)} onHide={() => setExportModalVisibility(owner, false)}/>
|
||||
<ACEEditor id="mjml" height="700px" mode="xml" label={t('templateContent')}/>
|
||||
</>
|
||||
),
|
||||
afterLoad: (owner, data) => {
|
||||
data.mjml = data.data.mjml;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
beforeSave: (owner, data) => {
|
||||
data.data = {
|
||||
mjml: data.mjml
|
||||
mjml: data.mjml,
|
||||
html: generateHtmlFromMjml(owner)
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
getButtons: owner => (
|
||||
<>
|
||||
<Button className="btn-success" icon="check-circle" label={t('Validate')} onClickAsync={async () => validateMjml(owner)}/>
|
||||
<Button className="btn-success" icon="file-code" label={t('Show HTML')} onClickAsync={async () => setExportModalVisibility(owner, true)}/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
return templateTypes;
|
||||
|
|
|
|||
467
client/src/templates/mosaico/mjml-mosaico.js
Normal file
467
client/src/templates/mosaico/mjml-mosaico.js
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
'use strict';
|
||||
|
||||
import htmlparser from 'htmlparser2'
|
||||
import min from 'lodash/min';
|
||||
import mjml, {MJML, BodyComponent, HeadComponent} from "../../lib/mjml";
|
||||
import shortid from "shortid";
|
||||
|
||||
function getId() {
|
||||
return shortid.generate();
|
||||
}
|
||||
|
||||
const parents = [];
|
||||
|
||||
function pushParent(parent) {
|
||||
parents.push(parent);
|
||||
}
|
||||
|
||||
function popParent() {
|
||||
parents.pop();
|
||||
}
|
||||
|
||||
function getParent() {
|
||||
if (parents.length > 0) {
|
||||
return parents[parents.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMosaicoEditable(block, src) {
|
||||
let newSrc = src;
|
||||
let offset = 0;
|
||||
|
||||
const parser = new htmlparser.Parser(
|
||||
{
|
||||
onopentag: (name, attrs) => {
|
||||
const fragment = src.substring(parser.startIndex, parser.endIndex);
|
||||
|
||||
const tagAttrsRe = RegExp(`(<\\s*${name})((?:\\s+[a-z0-9-_]+\\s*=\\s*"[^"]*")*)`, 'i');
|
||||
const [ , tagStr, attrsStr] = fragment.match(tagAttrsRe);
|
||||
|
||||
const attrsRe = new RegExp(/([a-z0-9-_]+)\s*=\s*"([^"]*)"/g);
|
||||
const attrsMatches = attrsStr.matchAll(attrsRe);
|
||||
|
||||
let newFragment = tagStr;
|
||||
|
||||
|
||||
for (const attrMatch of attrsMatches) {
|
||||
if (attrMatch[1] === 'mosaico-editable') {
|
||||
const propertyId = attrMatch[2];
|
||||
block.addMosaicoProperty(propertyId);
|
||||
|
||||
newFragment += ` data-ko-editable="${propertyId}"`;
|
||||
} else {
|
||||
newFragment += ` ${attrMatch[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
newSrc = newSrc.substring(0, parser.startIndex + offset) + newFragment + newSrc.substring(parser.endIndex + offset);
|
||||
offset += newFragment.length - fragment.length;
|
||||
}
|
||||
},
|
||||
{
|
||||
recognizeCDATA: true,
|
||||
decodeEntities: false,
|
||||
recognizeSelfClosing: true,
|
||||
lowerCaseAttributeNames: false,
|
||||
}
|
||||
);
|
||||
|
||||
parser.write(src);
|
||||
parser.end();
|
||||
|
||||
return newSrc;
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoProperty extends HeadComponent {
|
||||
static endingTag = true
|
||||
|
||||
static allowedAttributes = {
|
||||
'property-id': 'string',
|
||||
label: 'string',
|
||||
widget: 'string',
|
||||
}
|
||||
|
||||
handler() {
|
||||
const { add } = this.context;
|
||||
|
||||
add('style', ` @supports -ko-blockdefs { ${this.getAttribute('property-id')} { label: ${this.getAttribute('label')}; widget: ${this.getAttribute('widget')} } }`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoContainer extends BodyComponent {
|
||||
static endingTag = false
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div data-ko-container="main" data-ko-wrap="false">
|
||||
${this.renderChildren()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoBlock extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const blockId = this.getAttribute('block-id');
|
||||
this.blockId = blockId || `block_${getId()}`;
|
||||
this.mosaicoProperties = [];
|
||||
}
|
||||
|
||||
componentHeadStyle = breakpoint => {
|
||||
const propertiesOut = this.mosaicoProperties.length > 0 ? `; properties: ${this.mosaicoProperties.join(' ')}`: '';
|
||||
|
||||
return `
|
||||
@supports -ko-blockdefs {
|
||||
${this.blockId} { label: ${this.getAttribute('label')}${propertiesOut} }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static endingTag = false
|
||||
|
||||
static allowedAttributes = {
|
||||
'block-id': 'string',
|
||||
'label': 'string'
|
||||
}
|
||||
|
||||
addMosaicoProperty(property) {
|
||||
this.mosaicoProperties.push(property);
|
||||
}
|
||||
|
||||
render() {
|
||||
pushParent(this);
|
||||
const result = `
|
||||
<div
|
||||
${this.htmlAttributes({
|
||||
class: this.getAttribute('css-class'),
|
||||
'data-ko-block': this.blockId
|
||||
})}
|
||||
>
|
||||
${this.renderChildren()}
|
||||
</div>
|
||||
`;
|
||||
popParent();
|
||||
|
||||
return handleMosaicoEditable(this, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoInnerBlock extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const blockId = this.getAttribute('block-id');
|
||||
this.blockId = blockId || `block_${getId()}`;
|
||||
this.mosaicoProperties = [];
|
||||
}
|
||||
|
||||
componentHeadStyle = breakpoint => {
|
||||
const propertiesOut = this.mosaicoProperties.length > 0 ? `; properties: ${this.mosaicoProperties.join(' ')}`: '';
|
||||
|
||||
return `
|
||||
@supports -ko-blockdefs {
|
||||
${this.blockId} { label: ${this.getAttribute('label')}${propertiesOut} }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static endingTag = false
|
||||
|
||||
static allowedAttributes = {
|
||||
'block-id': 'string',
|
||||
'label': 'string'
|
||||
}
|
||||
|
||||
addMosaicoProperty(property) {
|
||||
this.mosaicoProperties.push(property);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
pushParent(this);
|
||||
const result = `
|
||||
<div
|
||||
${this.htmlAttributes({
|
||||
class: this.getAttribute('css-class'),
|
||||
'data-ko-block': this.blockId
|
||||
})}
|
||||
>
|
||||
<table
|
||||
${this.htmlAttributes({
|
||||
border: '0',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0',
|
||||
role: 'presentation',
|
||||
style: 'table',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
${this.renderChildren(children, {
|
||||
renderer: component => component.constructor.isRawElement() ? component.render() : `
|
||||
<tr>
|
||||
<td
|
||||
${component.htmlAttributes({
|
||||
align: component.getAttribute('align'),
|
||||
'vertical-align': component.getAttribute('vertical-align'),
|
||||
class: component.getAttribute('css-class'),
|
||||
style: {
|
||||
background: component.getAttribute('container-background-color'),
|
||||
'font-size': '0px',
|
||||
padding: component.getAttribute('padding'),
|
||||
'padding-top': component.getAttribute('padding-top'),
|
||||
'padding-right': component.getAttribute('padding-right'),
|
||||
'padding-bottom': component.getAttribute('padding-bottom'),
|
||||
'padding-left': component.getAttribute('padding-left'),
|
||||
'word-break': 'break-word',
|
||||
},
|
||||
})}
|
||||
>
|
||||
${component.render()}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
})}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
popParent();
|
||||
|
||||
return handleMosaicoEditable(this, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Adapted from https://github.com/mjmlio/mjml/blob/master/packages/mjml-image/src/index.js
|
||||
class MjMosaicoImage extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const propertyId = this.getAttribute('property-id');
|
||||
this.propertyId = propertyId || `image_${getId()}`;
|
||||
|
||||
getParent().addMosaicoProperty(this.propertyId);
|
||||
}
|
||||
|
||||
static tagOmission = true
|
||||
|
||||
static allowedAttributes = {
|
||||
'property-id': 'string',
|
||||
'placeholder-height': 'integer',
|
||||
'href-editable': 'boolean',
|
||||
alt: 'string',
|
||||
href: 'string',
|
||||
name: 'string',
|
||||
title: 'string',
|
||||
rel: 'string',
|
||||
align: 'enum(left,center,right)',
|
||||
border: 'string',
|
||||
'border-bottom': 'string',
|
||||
'border-left': 'string',
|
||||
'border-right': 'string',
|
||||
'border-top': 'string',
|
||||
'border-radius': 'unit(px,%){1,4}',
|
||||
'container-background-color': 'color',
|
||||
'fluid-on-mobile': 'boolean',
|
||||
padding: 'unit(px,%){1,4}',
|
||||
'padding-bottom': 'unit(px,%)',
|
||||
'padding-left': 'unit(px,%)',
|
||||
'padding-right': 'unit(px,%)',
|
||||
'padding-top': 'unit(px,%)',
|
||||
target: 'string',
|
||||
width: 'unit(px)',
|
||||
height: 'unit(px)',
|
||||
}
|
||||
|
||||
static defaultAttributes = {
|
||||
align: 'center',
|
||||
border: '0',
|
||||
height: 'auto',
|
||||
padding: '10px 25px',
|
||||
target: '_blank',
|
||||
'placeholder-height': '400',
|
||||
'href-editable': false
|
||||
}
|
||||
|
||||
getStyles() {
|
||||
const width = this.getContentWidth();
|
||||
const fullWidth = this.getAttribute('full-width') === 'full-width';
|
||||
|
||||
return {
|
||||
img: {
|
||||
border: this.getAttribute('border'),
|
||||
'border-left': this.getAttribute('left'),
|
||||
'border-right': this.getAttribute('right'),
|
||||
'border-top': this.getAttribute('top'),
|
||||
'border-bottom': this.getAttribute('bottom'),
|
||||
'border-radius': this.getAttribute('border-radius'),
|
||||
display: 'block',
|
||||
outline: 'none',
|
||||
'text-decoration': 'none',
|
||||
height: this.getAttribute('height'),
|
||||
'min-width': fullWidth ? '100%' : null,
|
||||
width: '100%',
|
||||
'max-width': fullWidth ? '100%' : null,
|
||||
},
|
||||
td: {
|
||||
width: fullWidth ? null : width,
|
||||
},
|
||||
table: {
|
||||
'min-width': fullWidth ? '100%' : null,
|
||||
'max-width': fullWidth ? '100%' : null,
|
||||
width: fullWidth ? width : null,
|
||||
'border-collapse': 'collapse',
|
||||
'border-spacing': '0px',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
getContentWidth() {
|
||||
const width = this.getAttribute('width')
|
||||
? parseInt(this.getAttribute('width'), 10)
|
||||
: Infinity;
|
||||
|
||||
const {box} = this.getBoxWidths();
|
||||
|
||||
return min([box, width])
|
||||
}
|
||||
|
||||
renderImage() {
|
||||
const height = this.getAttribute('height');
|
||||
|
||||
const img = `
|
||||
<img
|
||||
${this.htmlAttributes({
|
||||
alt: this.getAttribute('alt'),
|
||||
height: height && (height === 'auto' ? undefined : parseInt(height, 10)),
|
||||
style: 'img',
|
||||
title: this.getAttribute('title'),
|
||||
width: this.getContentWidth(),
|
||||
|
||||
'data-ko-editable': this.propertyId + '.src',
|
||||
'data-ko-placeholder-height': this.getAttribute('placeholder-height'),
|
||||
src: "[PLACEHOLDER]"
|
||||
})}
|
||||
/>
|
||||
`;
|
||||
|
||||
if (this.getAttribute('href-editable')) {
|
||||
return `
|
||||
<a
|
||||
${this.htmlAttributes({
|
||||
'data-ko-link': this.propertyId + '.url',
|
||||
href: this.getAttribute('href') || '',
|
||||
target: this.getAttribute('target'),
|
||||
rel: this.getAttribute('rel'),
|
||||
name: this.getAttribute('name'),
|
||||
})}
|
||||
>
|
||||
${img}
|
||||
</a>
|
||||
`;
|
||||
} else if (this.getAttribute('href')) {
|
||||
return `
|
||||
<a
|
||||
${this.htmlAttributes({
|
||||
href: this.getAttribute('href'),
|
||||
target: this.getAttribute('target'),
|
||||
rel: this.getAttribute('rel'),
|
||||
name: this.getAttribute('name'),
|
||||
})}
|
||||
>
|
||||
${img}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
headStyle = breakpoint => `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
table.full-width-mobile { width: 100% !important; }
|
||||
td.full-width-mobile { width: auto !important; }
|
||||
}
|
||||
`
|
||||
|
||||
render() {
|
||||
return `
|
||||
<table
|
||||
${this.htmlAttributes({
|
||||
border: '0',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0',
|
||||
role: 'presentation',
|
||||
style: 'table',
|
||||
class:
|
||||
this.getAttribute('fluid-on-mobile')
|
||||
? 'full-width-mobile'
|
||||
: null,
|
||||
})}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td ${this.htmlAttributes({
|
||||
style: 'td',
|
||||
class:
|
||||
this.getAttribute('fluid-on-mobile')
|
||||
? 'full-width-mobile'
|
||||
: null,
|
||||
})}>
|
||||
${this.renderImage()}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const mjmlInstance = new MJML();
|
||||
|
||||
mjmlInstance.registerComponent(MjMosaicoContainer);
|
||||
mjmlInstance.registerComponent(MjMosaicoBlock);
|
||||
mjmlInstance.registerComponent(MjMosaicoInnerBlock);
|
||||
mjmlInstance.registerComponent(MjMosaicoImage);
|
||||
mjmlInstance.registerComponent(MjMosaicoProperty);
|
||||
|
||||
mjmlInstance.registerDependencies({
|
||||
'mj-mosaico-container': ['mj-mosaico-block', 'mj-mosaico-inner-block'],
|
||||
'mj-body': ['mj-mosaico-container', 'mj-mosaico-block'],
|
||||
'mj-section': ['mj-mosaico-container', 'mj-mosaico-block'],
|
||||
'mj-column': ['mj-mosaico-container', 'mj-mosaico-inner-block', 'mj-mosaico-image'],
|
||||
'mj-mosaico-block': ['mj-section', 'mj-column'],
|
||||
'mj-mosaico-inner-block': ['mj-mosaico-image', 'mj-divider', 'mj-text', 'mj-image', 'mj-table']
|
||||
});
|
||||
|
||||
mjmlInstance.addToHeader(`
|
||||
<style type="text/css">
|
||||
@supports -ko-blockdefs {
|
||||
text { label: Paragraph; widget: text }
|
||||
image { label: Image; properties: src url alt }
|
||||
link { label: Link; properties: text url }
|
||||
url { label: Link; widget: url }
|
||||
src { label: Image; widget: src }
|
||||
alt {
|
||||
label: Alternative Text;
|
||||
widget: text;
|
||||
help: Alternative text will be shown on email clients that does not download image automatically;
|
||||
}
|
||||
|
||||
template { label: Page }
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
|
||||
export default function mjml2html(src) {
|
||||
return mjmlInstance.mjml2html(src);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue