Nuxt and Monaco to edit MJML emails
We're going to talk performance and email templates today.
Why performance? Because when I was coding a feature for RssFeedPulse, I came across a problem that almost made me change my mind about my choice of hosting on Cloudflare.
We'll see how an application can become a house of cards, how that house can easily collapse, what prevented me from deploying for a day and, finally, how I solved the problem.
Of course, we will use that to talk about MJML, a very good framework for creating responsive emails and Monaco Editor, a text editor to be integrated into a Nuxt application.
Functionality to code
RssFeedPulse is a SAAS that enables users to trigger newsletters from their blog's RSS feed.
It has the advantage of being simple to use and, above all, very practical for people with static blogs who can't manage subscriptions from simple HTML pages.
All this works well, but the email sent was not editable for users, and above all, in English only.
When you're doing a SAAS, you obviously know the little flaws in your application, but I prefer to wait for the request to come from a user, especially at the beginning, to really code the functionality.
And the request did come:
Now, simple is better but too simple is not :) There are two things which bother me:
- I can't change the English text in my newsletters (The site is in Romanian)
- There is no option for a daily schedule (hourly is too often, weekly is too far apart)
Can I change them somehow?
Thanks
The objective is clear: I need to offer a multilingual version. But above all, it's an opportunity to offer fully customizable email templates.
And for that, it's also an opportunity to test MJML.
Creating responsive emails
If you've never sent HTML e-mails, you're definitely in luck.
Because the HTML used to create emails is hell. It has to meet numerous constraints, and email clients have very personal interpretations of HTML and CSS standards.
(Did you know that the desktop version of Outlook still exists? But that's nothing compared to Lotus Notes...)
But then, if on top of that, you want a responsive email that adapts to your recipient's screen size, it becomes a nightmare. At least for a backend developer like me.
Fortunately, there are libraries to simplify your life, and MJML is one of them. Not only does MJML enable you to create valid e-mails, it also makes them responsive.
If you'd like to see what it looks like, I invite you to try out their live editor: https://mjml.io/try-it-live
In itself, MJML is a text format, so you only need three things to integrate it:
- backend logic to store a template created with MJML
- a function to transform the MJML template into HTML before sending the email
- a front-end text editor for user editing
I'm not going to describe the first part, because storing basic text isn't worth the trouble.
For the MJML to HTML transformation, there are lots of different options.
Depending on the language used, you have the option of using a library developed by the community and listed on the official site.
These include Python, PHP, Rust, .Net and, of course, node.js.
Unfortunately, I use Kotlin/Java. There are some libraries on Github but they don't have full MJML support.
This is precisely the risk with unofficial libraries: a lack of format support, infrequent updates and sometimes a library that stops being maintained.
However, there is an interesting alternative, MJML offers a Rest API to directly transform MJML into HTML.
So that's what I used on the backend.
Of course, there's a risk that this API may one day become a paid API, but for the moment it's free and I'll see later if I need to change.
The conversion function is really trivial:
@Service
class MjmlClient(
@Value("\${mjml.app-id}")
private val appId: String,
@Value("\${mjml.secret-key}")
private val secretKey: String
) {
fun convertMjmlToHtml(mjml: String): String {
val client = WebClient.builder().build()
val response = client.post()
.uri("https://api.mjml.io/v1/render")
.headers { it.setBasicAuth(appId, secretKey) }
.bodyValue(mapOf("mjml" to mjml))
.retrieve()
.bodyToMono(object : ParameterizedTypeReference<Map<String, Any>>() {})
.block()
if (response?.get("errors") != null && response["errors"] is List<*> && (response["errors"] as List<*>).isNotEmpty()) {
throw RuntimeException("Error converting MJML to HTML: ${response["errors"]}")
}
return response?.get("html")?.toString() ?: ""
}
}
Now we need to offer online editing.
Online code editor
In my search for a text editor, I initially looked at somewhat classic editing solutions such as Quill, TipTap and ckeditor, but these are solutions adapted for word processing, not for editing code.
What's more, they're often very complex and cumbersome, whereas what I really needed was syntax highlighting to simplify writing.
My second step was to see if there were any lightweight solutions above Prism, which is a standard solution for syntax highlighting.
I found :
- vue-prism-editor but has not been active for 4 years and is not compatible with the latest versions of vue.
- live prismjs but still in alpha version and not recommended for production use
In conclusion, I looked for a third way, and checked out the Nuxt modules listed on the official site.
Here I found :
- Monaco Editor
- Code Mirror
- Dragon editor
This is the first one I tested, because its documentation inspired me a little more than the other two.
(yes, it's basic, but that kind of impression counts for a lot)
Monaco Editor
Good news: Monaco Editor now has its own Nuxt module. And second good news, it works when you follow the documentation.
Anyway, just this line in the template (plus the module registration in nuxt.config.ts), and that's it, I had a working online editor for my MJML template:
<MonacoEditor v-model="value" lang="html" />
which, with a little work, gave me this:
I was happy, everything worked locally. The preview calls the rendering api on the server side and everything seemed to be perfect.
That's what I thought...
Cloudflare goes wild
A big mistake
To deploy my SSR application, I use Cloudflare.
And then I saw that it's not deploying:
And in the logs:
Σ Total size: 5.74 MB (1.56 MB gzip)
Error: Failed to publish your Function. Got error: Your Worker exceeded the size limit of 1 MiB. Refer to the Workers documentation (https://developers.cloudflare.com/workers/observability/errors/#errors-on-worker-upload) for more details.
Ok.
Apparently, this modification and this library have caused me to exceed the 1Mb size limit for my application, which no longer allows me to deploy.
So I put out a call for help on Twitter to find a lighter alternative, removed unnecessary dependencies from my app and started looking at Code Mirror which also has its Nuxt module.
Attempt with CodeMirror
This time, the documentation page doesn't help.
It took a little more effort and here's the result.
<NuxtCodeMirror
ref="codemirror"
v-model="code"
:extensions="extensions"
:theme="theme"
:auto-focus="true"
:editable="true"
:basic-setup="true"
:indent-with-tab="true"
@on-change="handleChange"
@on-update="handleUpdate"
/>
And a little more js :
<script setup lang="ts">
import type { ViewUpdate } from '@codemirror/view'
import { html } from '@codemirror/lang-html'
import type {CodeMirrorRef} from '#build/nuxt-codemirror'
import { lineNumbersRelative } from '@uiw/codemirror-extensions-line-numbers-relative'
...
const code = ref(campaignToEdit.value.mjml)
const theme = ref<'light' | 'dark' | 'none'>('dark')
const codemirror = ref<CodeMirrorRef>()
const extensions = [html(), lineNumbersRelative]
const handleChange = (value: string, viewUpdate: ViewUpdate) => {
campaignToEdit.value.mjml = value
}
const handleUpdate = (viewUpdate: ViewUpdate) => {
console.log('Editor updated:', viewUpdate)
}
</script>
However, out of luck, it's no lighter:
Σ Total size: 6.03 MB (1.73 MB gzip)
So I called on a friend, a better front-end developer than me... chatGPT.
Yes...
Help from ChatGPT
ChatGPT advised me, with MonacoEditor, to use it without including it in the bundle, i.e. to insert the script on the fly when necessary.
And that's a good idea, as it avoids overloading the bundle. It's less practical from a dev experience point of view, but if it works, I'll take it.
Anyway, I removed the dependency and used MonacoEditor, following the ChatGPT help.
I added a simple div :
<div ref="monacoContainer" class="h-[700px] editor-container" />
And I added the script conditionally, client-side, if it wasn't already loaded
if (process.client) {
if (!window.monacoLoaded) {
window.monacoLoaded = new Promise((resolve) => {
const script = document.createElement('script')
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.51.0/min/vs/loader.js'
script.onload = () => {
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.51.0/min/vs' } })
require(['vs/editor/editor.main'], resolve)
}
document.body.appendChild(script)
})
}
const monaco = await window.monacoLoaded
if (editorInstance) {
editorInstance.dispose() // Dispose of previous editor instance if switching tabs
}
editorInstance = monaco.editor.create(document.querySelector('.editor-container'), {
value: campaignToEdit.value.mjml,
language: 'html',
theme: 'vs-dark',
automaticLayout: true,
})
editorInstance.onDidChangeModelContent(() => {
campaignToEdit.value.mjml = editorInstance.getValue()
})
}
We agree, it's awful, but it worked.
Except for a new tragedy and a slight drop in morale.
Σ Total size: 5.33 MB (1.5 MB gzip)
Error: Failed to publish your Function. Got error: Your Worker exceeded the size limit of 1 MiB. Refer to the Workers documentation (https://developers.cloudflare.com/workers/observability/errors/#errors-on-worker-upload) for more details.
I'll pass on other attempts, I've deleted the images in the images
directory and put them elsewhere, I've tried small optimizations, but nothing helped.
Cloudflare had just changed the rules
From then on, I had my doubts and went to see old deployments that had worked:
Σ Total size: 5.3 MB (1.49 MB gzip)
Actually, I was already over 1Mb before. But Cloudflare was tolerant of this overage and obviously the rules have changed.
It was clearly a big mistake on my part not to have looked at it on the first deployment.
Now I was really considering a change of hosting provider. I couldn't see how to remove 500kb (1 third!) from my application.
But before that, I asked myself how I could really analyze the size of my bundle.
Turns out there's a tool included with Nuxt to do just that.
Make a TreeMap of your bundle
To optimize something, it's always best to start with the numbers.
There's no point in optimizing anything that only represents 0.1% of the final problem.
And I've discovered that Nuxt offers a handy analysis tool.
Just run the command
npx nuxi analyze
and then go to localhost:3000
The result is two treemaps, one on the server side
and another on the client side :
Not too surprisingly, on the client side, I found echarts and a bit of prism too.
But above all, there's one package that seems to come up a lot, and that's Shikijs
Naively, I'd say shikijs and related libs represent almost 2/3 of TreeMap.
ShikiJs, I know what it is, it's a dependency pulled by the nuxt-mdc module.
It's a module I used for the wrong reasons, I wanted to write the rssfeedpulse doc in markdown so as not to bother.
But I could do without it.
So I reworked the doc and deleted nuxt-mdc (and therefore shikijs).
Result:
Σ Total size: 2.51 MB (809 kB gzip)
I've almost halved the size of the bundle.
And now after a hard day :
In short, I'm now using MJML and Monaco to provide additional RssFeedPulse functionality, and I haven't given up on Cloudflare.
Goal achieved.
And if you're subscribed to this site's newsletter (you should be ^^), if all goes well, you should have received an email with the new template I've customized for the occasion.