Javascript is required
·
4 min read

Use Shiki to Style Code Blocks in HTML Emails

Use Shiki to Style Code Blocks in HTML Emails Image

I recently developed a custom newsletter service using Nuxt 3. One of the main reasons why I developed it on my own was that I wanted to use good-looking code blocks in my emails.

In this article, I'll explain how I use Shiki to generate nicely styled code blocks in my newsletter emails.

Nuxt 3 & Nuxt Content

Info

If you don't use Nuxt and are only interested in the necessary Shiki customization, you can skip the next sections, and head over to "Shiki Code" section.

On my newsletter website I use Nuxt 3 with the Nuxt Content and Nuxt Tailwind modules.

Nuxt Content uses Shiki that colors tokens with VSCode themes.

By default, it uses code, pre, span and div tags with CSS Flexbox to render the code block.

Unfortunately, flex-direction:column is badly supported in email clients.

Instead, we need to write the code block in an HTML table which is supported in all email clients.

So we need to eject from the default styling and create a custom code component.

Custom Prose Component

Nuxt Content uses Prose components to render markdown files in the DOM.

To overwrite a prose component, we can create a component with the same name in our project components/content/ directory.

In our case, we want to create a custom ProseCode component:

components/content/ProseCode.vue
1<script setup lang="ts">
2const props = withDefaults(
3  defineProps<{ code?: string; language?: string | null; filename?: string | null; highlights?: Array<number> }>(),
4  { code: '', language: null, filename: null, highlights: [] }
5)
6</script>
7
8<template>
9  <div></slot></div>
10</template>

Next, we need to install shiki-es, a standalone build of Shiki fully compatible with all ESM environments:

bash
1# npm
2npm i shiki-es
3
4# yarn
5yarn add shiki-es

We can eject from the default styling by removing the <slot/> tag and adding an html reactive variable that will contain the highlighted which is rendered via the v-html directive:

components/content/ProseCode.vue
1<script setup lang="ts">
2const props = withDefaults(
3  defineProps<{ code?: string; language?: string | null; filename?: string | null; highlights?: Array<number> }>(),
4  { code: '', language: null, filename: null, highlights: [] }
5)
6
7const html = ref('')
8</script>
9
10<template>
11  <div class="code" v-html="html"></div>
12</template>

Shiki Code

Now it's time to manually call Shiki to render our code as an HTML table. We, therefore, use shiki-es, a standalone build of Shiki that is fully compatible with all ESM environments.

Three steps are necessary to generate the HTML code:

  1. Use getHighlighter to get an instance of the Shiki highlighter.
  2. Call codeToThemeTokens with the given code and language to get the tokens that should be rendered.
  3. Use renderToHtml to generate the HTML which should be rendered in the DOM. Its elements object can be used to modify the DOM structure of the code block. Here we add our <table>, <td> and <tr> tags which are necessary for the HTML table.

Let's take a look at the code:

components/content/ProseCode.vue
1<script setup lang="ts">
2import { getHighlighter, Highlighter, renderToHtml } from 'shiki-es'
3
4const props = withDefaults(
5  defineProps<{ code?: string; language?: string | null; filename?: string | null; highlights?: Array<number> }>(),
6  { code: '', language: null, filename: null, highlights: [] }
7)
8
9const html = ref('')
10const moreThanOneLineCode = computed(() => (props.code ? props.code.trim().split('\n').length > 1 : false))
11
12const highlighter = await getHighlighter({
13  theme: 'dark-plus',
14  themes: ['dark-plus'],
15  langs: ['java', 'javascript', 'html', 'css', 'typescript', 'json', 'scss', 'vue'],
16})
17
18const tokens = highlighter.codeToThemedTokens(props.code.trim(), props.language ?? undefined)
19
20html.value = renderToHtml(tokens, {
21  fg: highlighter.getForegroundColor('dark-plus'),
22  bg: highlighter.getBackgroundColor('dark-plus'),
23  // custom element renderer
24  elements: {
25    pre({ className, style, children }: any) {
26      return `<pre tabindex="1" class="${className}" style="${style}">${children}</pre>`
27    },
28    code({ children, className, style }) {
29      return `<code class="${className}" style="${style}"><table>${children}</table></code>`
30    },
31    line({ className, index, children }: any) {
32      const shallHighlight = props.highlights?.includes(index + 1) ?? false
33      const lineNumber = moreThanOneLineCode.value ? `<td><span class="line-number">${index + 1}</span></td>` : ''
34
35      return `<tr class="${className} ${shallHighlight ? 'highlighted-line' : ''}">
36    ${lineNumber}
37    <td>${children}</td>
38    </tr>`
39    },
40  },
41})
42</script>
43
44<template>
45  <div class="code" v-html="html"></div>
46</template>

This code will result in this DOM structure:

Shiki HTML table DOM structure

CSS Styles

As mentioned, I use Tailwind in my project which heavily uses rgb colors. Unfortunately, rgb() works partially in email clients but alpha values and whitespace syntax are not supported.

I use the postcss-preset-env PostCSS plugin to convert modern CSS into something most browsers can understand:

nuxt.config.ts
1export default defineNuxtConfig({
2  // ...
3  postcss: {
4    plugins: {
5      'postcss-preset-env': {},
6      tailwindcss: {},
7      autoprefixer: {},
8    },
9  },
10})

Final Result

For example, this is how a code block in a newsletter email from Weekly Vue News looks like in a Gmail web client:

Code Block Example Gmail Web Client

Conclusion

Styling HTML emails is a real pain but having good-looking code blocks in my newsletter emails was worth the effort. It's very sad that we still need to use HTML tables in HTML emails in the year 2023...

Thankfully, Nuxt Content is very customizable and allows developers to build custom solutions.

If you liked this article, follow me on Twitter to get notified about new blog posts and more content from me.

Alternatively (or additionally), you can subscribe to my weekly Vue newsletter.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
Focus & Code Diff in Nuxt Content Code Blocks Image

Focus & Code Diff in Nuxt Content Code Blocks

A Comprehensive Guide to Data Fetching in Nuxt 3 Image

A Comprehensive Guide to Data Fetching in Nuxt 3

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES Image

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES

Create a Table of Contents With Active States in Nuxt 3 Image

Create a Table of Contents With Active States in Nuxt 3