From e8649909d56ae19bcf98755ea9bc5c75207fcb00 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 6 Feb 2023 14:02:47 +0800 Subject: [PATCH] auto-encode html and attributes i had mistakenly assumed that mjml would escape these --- README.md | 38 ++-- __snapshots__/index.test.mjs.snap | 290 ++++++++++++++++++++++++++++++ index.js | 17 +- index.test.mjs | 38 ++++ package.json | 3 + 5 files changed, 367 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c1d2a84..875349f 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,16 @@ You include a special tag `mj-replace-id="myId"` into any of your `mj-` tags in ### New `mjml2html` option: `replacers` -The `mjml2html` API is exactly the same as the upstream `mjml` API, but there is an added option called `replacers` which is an object where each key corresponds to a `mj-replace-id` and the value is an object with one or more of the following properties: +The `mjml2html()` API of `mjml-dynamic` is exactly the same as the upstream `mjml` API, but there is an added option called `replacers` which is an object where each key corresponds to a `mj-replace-id` and the value is an object with one or more of the following properties: -- `content` (`string`) - Allows you to change the text content of the element. -- `attributes` (`object`) - Allows you to change the XML attributes of the element. -- `children` (`array`) - Allows you to change the element's children. -- `tagName` (`string`) - Allows you to change the tag to a completely different tag. +| Property | Type | Description | +| --- | --- | --- | +| `content` | `string` | Change the text or HTML content of the element. | +| `attributes` | `object` | Change the HTML attributes of the element. | +| `children` | `array` | Change the element's MJML children elements (for example with `mjml-react`.) | +| `tagName` | `string` | Change the tag to a completely different tag. | -Any of the above `replacers` properties are optional, and you may alternatively supply a function that receives the existing value as its only argument. This can be used to modify the existing values. +Any of the above `replacers` properties are optional. You may alternatively supply a **function** that receives the existing value as its only argument and returns the new value. This can be used to modify the existing values. **`content` and `attributes` replacements are automatically escaped, however if you use the functional `content` replacer, the response is not escaped.** ## Examples @@ -59,9 +61,10 @@ import mjml2html from 'mjml-dynamic'; const replacers = { myId: { - tagName: 'mj-text' - content: 'new text content', - attributes: { color: 'red' }, + tagName: 'mj-button', + // these values are all automatically escaped: + content: 'new text content', + attributes: { color: 'red', href: '&' }, }, // ... more `mj-replace-id`s }; @@ -77,9 +80,9 @@ This will output the equivalent of the following MJML document: - + new text content - + @@ -92,11 +95,13 @@ This will output the equivalent of the following MJML document: ```js const replacers = { myId: { - // adds an additional color attribute: + // Add an additional color attribute, while preserving the existing attributes: attributes: (attributes) => ({ ...attributes, color: 'red' }, - // rewrites the text content: + + // Rewrite the HTML content: content: (content) => content.replace('{{something}}', 'something else'), - // (you may use a template engine line handlebars for more sophisticated replacements) + // NOTE! mjml-dynamic does not automatically escape the replacement HTML here, so you need to do it yourself. + // (You may use a template engine like handlebars for more sophisticated replacements) }, }; ``` @@ -171,7 +176,8 @@ This will output the equivalent of the following MJML document: Example to replace parts of your `.mjml` document with React code: -You have `template.mjml` that you can preview using official MJML tooling: +Assuming you have a `template.mjml` with an overall email layout that you can preview using official MJML tooling: + ```xml @@ -192,7 +198,7 @@ You have `template.mjml` that you can preview using official MJML tooling: ``` -Then you can render the contents of `mj-replace-id="peopleList"` using `mjml-react`: +Then you can render the contents of the element `` using `mjml-react`: ```jsx import readFile from 'fs/promises'; diff --git a/__snapshots__/index.test.mjs.snap b/__snapshots__/index.test.mjs.snap index 980ba78..e970e8e 100644 --- a/__snapshots__/index.test.mjs.snap +++ b/__snapshots__/index.test.mjs.snap @@ -1,5 +1,295 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`escaping escape attributes and content 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + &<>"'å; + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + " +`; + +exports[`escaping not escape functional content 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ +
not escaped
+
+
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + " +`; + exports[`mjml mjml2html 1`] = ` " diff --git a/index.js b/index.js index d1ba414..b107db8 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ const mjml2html = require('mjml'); const { components } = require('mjml-core'); // eslint-disable-next-line import/no-extraneous-dependencies const Parser = require('mjml-parser-xml'); +const entities = require('entities'); // https://github.com/mjmlio/mjml/blob/master/packages/mjml-parser-xml/test/test.js // https://github.com/mjmlio/mjml/discussions/2619 @@ -42,11 +43,21 @@ function parseXml(xml, options = {}) { if (replacersMap.has(replacerId)) { const match = replacersMap.get(replacerId); replacersFound.add(replacerId); - const replacementOrFn = (existing, replacement) => (typeof replacement === 'function' ? replacement(existing) : replacement); + + // auto-encode only non-functional + const replacementOrFn = (existing, replacement, escaper = (v) => v) => (typeof replacement === 'function' ? replacement(existing) : escaper(replacement)); + + // auto-encode newly added attributes, pass thru existing + const deepReplacementOrFn = (existingAttributes, replacement, escaper = (v) => v) => { + const newAttributes = (typeof replacement === 'function' ? replacement(existingAttributes) : replacement); + if (!newAttributes) return {}; + return Object.fromEntries(Object.entries(newAttributes).map(([key, value]) => [key, existingAttributes[key] != null ? value : escaper(value)])); + }; + if (match.tagName != null) ret.tagName = replacementOrFn(tagName, match.tagName); - if (match.attributes != null) ret.attributes = replacementOrFn(attributes, match.attributes); + if (match.attributes != null) ret.attributes = deepReplacementOrFn(attributes, match.attributes, (v) => entities.escapeAttribute(v)); if (match.children != null) ret.children = replacementOrFn(Array.isArray(children) ? children.map(mapTag) : children, match.children); - if (match.content != null) ret.content = replacementOrFn(content, match.content); + if (match.content != null) ret.content = replacementOrFn(content, match.content, (v) => entities.escapeUTF8(v)); } else if (validateReplacers) { throw new Error(`Replacer "${replacerId}" not found in options`); } diff --git a/index.test.mjs b/index.test.mjs index 1d6469d..6ce18dd 100644 --- a/index.test.mjs +++ b/index.test.mjs @@ -536,3 +536,41 @@ describe('validation', () => { expect(() => parseXml(xml, { filePath: __dirname, replacers })).toThrow('Replacer "replaced" not found in document'); }); }); + +describe('escaping', () => { + const xml = `\ + + + + + + + + + + `; + + test('escape attributes and content', () => { + const replacers = { + replaced: { + attributes: { href: '&<>"\'å;' }, + content: '&<>"\'å;', + }, + }; + + const { html } = mjml2html(xml, { replacers }); + expect(html).toMatchSnapshot(); + }); + + test('not escape functional content', () => { + const replacers = { + replaced: { + attributes: (attributes) => ({ ...attributes, href: '&<>"\'å;' }), + content: () => '
not escaped
', + }, + }; + + const { html } = mjml2html(xml, { replacers }); + expect(html).toMatchSnapshot(); + }); +}); diff --git a/package.json b/package.json index a172850..36aa28e 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,8 @@ ], "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules jest" + }, + "dependencies": { + "entities": "^4.4.0" } }