Nuxt et Monaco pour éditer des emails MJML

By Hugo LassiègeSep 20, 202410 min read

On va parler performance et template d'emails aujourd'hui.

Pourquoi de performance ? Parce qu'en codant une fonctionnalité pour RssFeedPulse, je suis tombé sur un os qui a failli remettre en cause mon choix d'hébergement sur Cloudflare.

On va voir comment une application peut devenir un château de carte, et comment ce château peut facilement s'effondrer, ce qui m'a empêché de déployer pendant une journée et enfin, et enfin comment j'ai résolu ce problème.

Évidemment au passage, on va parler de MJML, un très bon framework pour créer des emails responsives et de Monaco Editor, un éditeur de texte à intégrer dans une application Nuxt.

La fonctionnalité à coder

RssFeedPulse est un SAAS permettant à ces utilisateurs de déclencher des newsletters à partir du flux RSS de leur blog.

Ça a l'avantage d'être simple à utiliser et surtout très pratique pour les personnes ayant des blogs statiques qui ne peuvent pas gérer des abonnements à partir de simples pages HTML.

Tout ça fonctionne bien, mais l'email envoyé n'était pas modifiable pour les utilisateurs, et surtout, en anglais uniquement.

Quand on fait un SAAS, on connaît évidemment les petits défauts de son application, mais je préfère attendre que la demande vienne d'un utilisateur, surtout au début, pour vraiment coder la fonctionnalité.

Et la demande est arrivée :

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

Voilà, l'objectif est clair, il faut proposer une version multilingue. Mais, c'est surtout l'occasion de proposer d'avoir des templates emails totalement personnalisables.

Et pour ça, c'est aussi l'occasion de tester MJML.

Créer des emails responsives

Si vous n'avez jamais envoyé d'emails au format HTML, vous êtes définitivement chanceux.

Parce que le HTML qui permet de créer des emails est un enfer. Il doit répondre à de nombreuses contraintes et les clients emails ont des interprétations très personnelles des standards HTML et CSS.

(Vous saviez que la version desktop d'Outlook existait encore ? Mais bon, c'est rien face à Lotus...)

Mais alors, si en plus, vous souhaitez avoir un email responsive, qui s'adapte à la taille d'écran de votre destinataire, là ça devient cauchemardesque. En tout cas pour un développeur backend comme moi.

Bref, heureusement, il existe des librairies pour vous simplifier la vie et c'est notamment le cas de MJML. Non seulement MJML vous permet de créer des emails valides, mais aussi responsives.

Si vous voulez voir à quoi ça ressemble, je vous invite à tester leur éditeur live : https://mjml.io/try-it-live

En soi, MJML c'est un format de texte donc il faut uniquement trois choses pour l'intégrer :

  • toute une logique côté backend pour stocker un template créé avec MJML
  • une fonction de transformation du template MJML vers du HTML avant l'envoi de l'email
  • un éditeur de texte côté frontend pour permettre l'édition par les utilisateurs

Je ne décris pas la première partie parce que stocker un texte en base, ça ne mérite pas de s'attarder dessus.

Pour la transformation MJML vers HTML, il y a plein d'options différentes.

En fonction du langage utilisé, vous avez l'option d'utiliser une librairie développée par la communauté et listée sur le site officiel.

Vous avez notamment Python, PHP, Rust, .Net et évidemment node.js.

Pas de bol, j'utilise Kotlin/Java. Il y a des librairies sur Github mais qui n'ont pas un support complet du format MJML.

C'est justement le risque avec les librairies non officielles, un manque de support du format, des mises à jour peu fréquentes et parfois une librairie qui s'arrête d'être maintenue.

Cependant, il y a une alternative intéressante, MJML propose une API Rest pour directement transformer le MJML vers du HTML.

Et c'est donc ce que j'ai utilisé côté backend.

Évidemment, il y a un risque que cette API devienne payante un jour mais, pour l'instant, elle est gratuite et je verrai plus tard si je dois changer.

La fonction de conversion est vraiment triviale :

@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() ?: ""
    }
}

Maintenant, il faut proposer une édition en ligne.

Editeur de code en ligne

Pour rechercher un éditeur de texte, j'ai regardé au début les solutions d'édition un peu classiques comme Quill, TipTap, ckeditor mais ce sont des solutions adaptées pour du traitement de texte, pas pour éditer du code.

En plus c'est souvent très complexe et lourd alors que mon besoin était vraiment uniquement d'avoir la coloration syntaxique pour simplifier l'écriture.

Ma seconde piste justement a été de voir s'il y avait des solutions un peu légères au-dessus de Prism, qui est une solution standard pour faire de la coloration syntaxique.

J'ai trouvé :

  • vue-prism-editor mais plus actif depuis 4 ans et pas compatible avec les dernières versions de vue
  • live prismjs mais toujours en version alpha et pas conseillé en production

Conclusion, j'ai regardé une troisième piste, et j'ai été voir du côté des modules Nuxt listés sur le site officiel.

Ici j'ai trouvé :

  • Monaco Editor
  • Code Mirror
  • Dragon editor

C'est ce premier que j'ai testé parce que sa documentation m'a un peu plus inspiré que les deux autres.

(oui, c'est basique, mais ce genre d'impression compte pas mal)

Monaco Editor

Bonne nouvelle, Monaco Editor a donc son module Nuxt. Et deuxième bonne nouvelle, ça marche quand on suit la documentation.

Bref, juste cette ligne dans le template (plus l'enregistrement du module dans le nuxt.config.ts), et c'est bon, j'avais un éditeur en ligne fonctionnelle pour mon template MJML:

<MonacoEditor v-model="value" lang="html" \>

ce qui, avec un peu de travail m'a permis d'obtenir ceci :

L'interface créé avec Monaco Editor
L'interface créé avec Monaco Editor

J'étais content, tout marchait en local. La preview appelle l'api de rendering côté serveur et tout semblait être parfait.

Ça, c'est ce que j'ai cru...

Cloudflare s'emballe

Une erreur de taille

Pour déployer mon application SSR, j'utilise Cloudflare. Et là je vois que ça ne déploie plus :

Erreur de déploiement Cloudflare
Erreur de déploiement Cloudflare

Et dans les 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.

Apparemment, cette modification et cette librairie m'ont fait dépasser la taille de 1Mb pour mon application, ce qui ne me permet plus de déployer.

J'ai donc lancé un appel à l'aide sur Twitter pour trouver une alternative plus légère, j'ai supprimé des dépendances non utilisées de mon application et j'ai commencé à regarder Code Mirror qui a aussi son module Nuxt.

Tentative avec CodeMirror

Cette fois-ci, la page de documentation ne permet pas de s'en sortir tout seul.

Ça m'a demandé un peu plus d'huile de coude et voici le résultat.

<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"
/>

Et un peu plus de 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>

Sauf que, pas de bol, c'est pas plus léger :

Σ Total size: 6.03 MB (1.73 MB gzip)

Donc, j'ai fait appel à un ami, meilleur développeur front que moi... chatGPT.

Oui...

De l'aide de ChatGPT

ChatGPT m'a conseillé, avec MonacoEditor, de l'utiliser sans l'inclure dans le bundle, c'est-à-dire d'insérer le script à la volée lorsqu'il est nécessaire.

Et c'est pas bête, ça permet d'éviter de surcharger le bundle. C'est moins pratique d'un point de vue expérience de dev, mais si ça marche, je prends.

Bref, j'ai supprimé la dépendance, et j'ai utilisé MonacoEditor en suivant l'aide de ChatGPT

J'ai ajouté un simple div :

<div ref="monacoContainer" class="h-[700px] editor-container" />

Et j'ai ajouté le script conditionnellement, côté client, s'il n'était pas déjà chargé

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()
    })
}

On est d'accord, c'est affreux, mais ça a marché.

Sauf que, nouveau drame et petite baisse de moral.

Σ 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.

Je vous passe d'autres tentatives, j'ai supprimé les images dans le répertoire images pour les mettre ailleurs, j'ai essayé de petites optimisations, mais rien n'y faisait.

Cloudflare avait juste changé les règles

À partir de là, j'ai quand même eu un doute et j'ai été voir d'anciens déploiements qui avaient marché :

Σ Total size: 5.3 MB (1.49 MB gzip)

En fait, j'étais déjà au-dessus des 1Mb avant. Mais Cloudflare était tolérant avec ce dépassement et manifestement, les règles ont changé.

Je n'avais jamais rien vu. C'était clairement une grossière erreur de ma part de n'avoir pas regardé cette piste avant.

Là, j'ai vraiment considéré un changement d'hébergeur. Je ne voyais pas comment supprimer 500kb (1 tiers !) de mon application.

Mais avant ça, je me suis demandé comment vraiment analyser la taille de mon bundle.

Il se trouve qu'il y a un outil inclus avec Nuxt pour faire ça.

Faire un TreeMap de son bundle

Pour optimiser quelque chose, c'est toujours préférable de partir des chiffres.

Ça ne sert à rien d'optimiser quoi que ce soit qui ne représenterait que 0,1% du problème final.

Et j'ai découvert que Nuxt propose un outil d'analyse bien pratique.

Il suffit de lancer la commande

npx nuxi analyze

et d'aller ensuite sur localhost:3000

Analyse du bundle Nuxt
Analyse du bundle Nuxt

On obtient donc deux treemap, un côté serveur

Treemap côté serveur
Treemap côté serveur

et un autre côté client :

Treemap côté client
Treemap côté client

Sans trop de surprise, côté client, j'ai trouvé echarts et un peu de prism aussi.

Mais surtout, il y a un package qui semble revenir souvent, c'est Shikijs

A l'œil nu, shikijs et les libs qui sont liés représentent presque 2/3 du TreeMap.

ShikiJs, je sais ce que c'est, c'est une dépendance qui est tirée par le module nuxt-mdc.

C'est un module que j'ai utilisé pour de mauvaises raisons, je voulais écrire la doc de rssfeedpulse en markdown pour pas m'embêter.

Mais je pouvais m'en passer.

J'ai donc retravaillé la doc puis supprimé nuxt-mdc (et donc shikijs).

Résultat :

Σ Total size: 2.51 MB (809 kB gzip)

J'ai presque divisé par deux la taille du bundle.

Et désormais après une journée difficile :

Déploiement réussi
Déploiement réussi

Bref, désormais, j'utilise MJML et Monaco pour proposer une fonctionnalité supplémentaire RssFeedPulse, et je n'ai pas abandonné Cloudflare.

Objectif rempli.

Et si vous êtes inscrit à la newsletter de ce site (vous devriez ^^), si tout se passe bien, vous devriez avoir reçu un email avec le nouveau template que j'ai personnalisé pour l'occasion.


Share this:

Written by Hugo Lassiège

Software engineer, ex-freelance, ex-cofounder, ex-CTO. I love building things, sharing knowledge and helping others.

Copyright © 2024
 Eventuallycoding
  Powered by Bloggrify