Turning CKEditor Output into Dynamic Vue Templates
Motivation
In this guide I want to show how you can reprocess the HTML output of CKEditor (or any other WYSIWYG editor for that matter) into powerful customizable Vue templates.
Rendering HTML via v-html
is nice and all, but it's just not enough sometimes.
I write this mostly because there seem to be more tutorials about how to integrate the CKEditor itself into vue based backends than how to work with the output. As if you are expected to treat the HTML as some immutable final product, something "good enough".
But the idea of having less control[1] over an important part of my content seems unacceptable. What about my custom link components, what about the responsive images with different settings than the ones the editor generates, what if I had some fancy component for tables that makes columns sortable or whatever else I might want to implement?
So let's get back control, even if it means using strange and perhaps inefficient means (you have been warned). The end goal here is something similar to the vue enhanced markdown of Nuxt-content,[2] but retaining all the bells and whistles of rich text editing. And yes, everything shown here is used on this very website.
Note that while the first part is pure Vue, the backend section in part two of this guide assumes an Strapi 5 TypeScript project as an example, using the excellent unofficial CKEditor 5 integration, while providing some notes for similar functionality in other CMS choices.
Part One: Fully Dynamic Components
First we need to set up a generic component that can receive any template we want to display. We can create such an component with a <component>
tag, a powerful and elegant tool often used to add more interaction to a site or app.
In case you are unfamiliar with the general concept of dynamic components, a commonly seen example might look like this:
Vue1 <script setup lang="ts">
2 import ComponentA from './ComponentA.vue'
3 import ComponentB from './ComponentB.vue'
4 import {ref} from 'vue'
5
6 const comp = ref(ComponentA)
7
8 </script>
9 <template>
10 <label>Component Switch
11 <input
12 type="checkbox"
13 @change="comp=$event.target.checked?ComponentB:ComponentA"
14 />
15 </label>
16 <component :is="comp" />
17
18 </template>
In the template the :is
prop is bound to a variable, and if we change the component assigned to that variable (accomplished here by toggling a checkbox) the component changes.[3]
Our actual use case is a bit different however. We do not swap components based on events, we only display a single component with a dynamically fetched template.
We achieve this by creating the parent component with the composition API but within the script set-up we define our actual dynamic component Options API style at runtime.
We'll call our component DynamicPost.vue
and the simplest possible version looks like this:
Vue1 <script setup lang="ts">
2 import {computed} from 'vue';
3 import type {Component} from 'vue';
4 const props = defineProps<{content?:string|null}>();
5 const dynamicComponent = computed(():Component => (
6 {template: props.content??''}
7 ));
8 </script>
9 <template>
10 <component :is="dynamicComponent" v-if="content" />
11 </template>
Our template is passed from the parent component into the content
prop as a string. The component itself is defined in a computed reference which enables us to update the component contents when they are received/updated.
The template does not need to do much, it simply renders the component variable once the content is present.
The current result is basically a v-html
directive that can also include directives, @
event listeners and standard vue components.
In order to use our own user defined components we need to import any component[4] we might potentially use in our template and list in the components
property (naturally what you import depends on your project):
Vue1 <script setup lang="ts">
2 import {computed} from 'vue';
3 // A bunch of Components from other parts of the project.
4 import CustomLink from '../navigation/CustomLink.vue';
5 import VueHeader from '../navigation/VueHeader.vue';
6 import ImageContainer from '../containers/ImageContainer.vue';
7 import CodeWrapper from '../containers/CodeWrapper.vue';
8 import type {Component} from 'vue';
9 const props = defineProps<{content?:string|null}>();
10 const dynamicComponent = computed(():Component => (
11 {
12 template: props.content??'',
13 components: {CustomLink, ImageContainer,VueHeader,CodeWrapper}
14 }
15 ));
16 </script>
17 <template>
18 <component :is="dynamicComponent" v-if="content" />
19 </template>
That's all there is to DynamicPost.vue
, you can see it in action at this Vue Playground link. Of course, rendering a static string from a parent component is a very silly example; what we actually want is to fetch our template from our backend or CMS.
Which brings us the next step: Figuring out how and when to transform our WYSIWYG HTML output into a template that actually makes use of our new fancy features.
Part Two: Transforming
So let's say in our CMS we have some content type with a field that we edit with a WYSIWYG editor, in my case CKEditor 5.
Right off the bat I did not want to implement vue template transformations as a CKEditor plugin because a) they are very complicated to write and b) I didn't want the Vue features to potentially break the HTML preview of the editor. so we'll preserve the untouched HMTL in the original field.[5]
Working from the "raw" HTML output, for very simple operations such as turning every <img>
tag into a <ImageContainer>
tag (or whatever we call out custom image component), a simple regex replace might be enough.
But parsing HTML with regex is usually a bad idea and simple approaches often run into complications. For example in meta posts such as this one I might have code sections with examples of raw <img>
tags that I definitely do not want transformed into components.
So, in order to not end up in regex recursion hell another approach seems more effective: Since we're already dealing with HTML, we can simply parse it into a DOM, execute our transformations via queries and node editing, then serialize it back out.
Clearly this gives us all the tools we need, but parsing, manipulating and serializing the entire DOM of a potentially long and complex post whenever it gets fetched from the CMS seems dumb and inefficient. So we implicitly get our answer about where to transform our content: On the backend. Here we only need to transform the HTML once, save the result in a different field in our database and just serve that field over the API.[6]
Simple Transformations
After creating a new simple text field we have several options for parsing a DOM server side, and I decided to go with node-html-parser which fits this project for various reasons:
- It's small and very fast, which is good if the CMS isn't running on a beefy server (mine certainly isn't).
- Being small also means that it only supports very basic operations, which is fine because the whole point of vue components is that vue handles any complex template structures and functionality.
node-html-parser also doesn't really do any validation or formatting on its output which is actually perfect because we want to retain nonstandard features of vue templates like PascalCase tag names for components, special characters in attributes and so on.
Since I am using Strapi,[7] the logic will be implemented in beforeUpdate
[8]lifecycle hook, but similar methods should be available for any decent CMS.[9]
My example content type is called "post" so the lifecycle definitions are defined at the following location of the project src\api\post\content-types\post\lifecycles.ts
In this first example we replace all <a>
tags with <CustomLink>
components, which handle logic such as turning internal links into router links, and <figure>
to <ImageContainer>
, which, as mentioned above, manages responsive images, srcsets and so on.
I'm not going to bloat this post with the implementation of said Vue components, since the main point is to provide a general example usable for arbitrary components anyway.
TypeScript1 import {parse,HTMLElement} from 'node-html-parser';
2 import type {Result} from '@strapi/types/dist/modules/documents/result';
3 const process = event => {
4 // Typing our event data.
5 const entry = event.params.data as Result<'api::post.post'>|undefined;
6 // If the body field wasn't update we have nothing to process.
7 if (!entry?.body) return;
8 9 // Parsing the DOM from CKEditor output.
10 const dom = parse(entry.body);
11 for (const link of dom.querySelectorAll('a')) {
12 // All <a> tags become <CustomLink>.
13 link.replaceWith(
14 new HTMLElement('CustomLink',{}).
15 // Setting a vue prop based on the "target" attribute.
16 // The ":" turns the stringified boolean into a proper boolean in vue.
17 setAttribute(':noBlank', `${link.getAttribute('target')!=='_blank'}`).
18 // The contents of "href" are put into "to"
19 setAttribute('to', link.getAttribute('href') ?? '').
20 // Finally our new node retains all content of the original.
21 set_content(link.childNodes)
22 );
23 }
24 // We only want to transform <figure> that actually contain images.
25 const figureElements = dom.querySelectorAll('figure').
26 filter(e => e.classList.contains('image'));
27 for (const fig of figureElements) {
28 const imgElement = fig.querySelector('img');
29 if (!imgElement) continue;
30 31 // Extracting the data we need from the current tags.
32 const src = imgElement.getAttribute('src') ?? '';
33 const caption = fig.querySelector('figcaption')?.textContent ?? '';
34 fig.replaceWith(
35 // In this case we don't care about retaining child nodes.
36 new HTMLElement('ImageContainer',{}).
37 setAttribute('image',src).
38 setAttribute('caption', caption)
39 );
40 }
41 42 // Here we update the post entity.
43 strapi.documents('api::post.post').update(
44 {
45 documentId:entry.documentId,
46 // The original "body" field is untouched and isn't updated.
47 fields:['body_vue'],
48 data:{
49 // Serializing the processed DOM.
50 body_vue:dom.innerHTML,
51 }
52 }
53 );
54 };
55 // Calling our processing function from the hook.
56 export default {beforeUpdate(event) {process(event);}};
The function of the beforeUpdate
hook is called with an event
argument which under params.data
contains all changed fields of the entry. In our example type api::post.post
our source field is body
, and once we have our DOM, finding and replacing nodes is fairly straightforward.
When we are done, we use the internal strapi.documents().update
method[10] to update the body_vue
field with our transformed content by outputting the entire DOM's innerHTML
property.
This will transform this input:
HTML1 <p style="text-align:justify;">
2 This is an example html snippet with <code class="inline">custom styles</code>
3 and also <a target="_blank" rel="noopener noreferrer" href="https://www.google.com">custom links</a>
4 that might go to <i>external </i>pages, or <a href="../about">maybe not</a>.
5 </p>
6 <p>
7 There's also images:
8 </p>
9 <figure class="image image_resized" style="width:20.91%;">
10 <img style="aspect-ratio:2000/1500;" src="/uploads/kitchen.jpg"
11 srcset="/uploads/thumbnail_kitchen_14eb67786d.jpg 209w,/uploads/small_kitchen_14eb67786d.jpg 500w,/uploads/medium_kitchen_14eb67786d.jpg 750w,/uploads/large_kitchen_14eb67786d.jpg 1000w," sizes="100vw" width="2000" height="1500">
12 <figcaption>
13 Such as this image of a kitchen
14 </figcaption>
15 </figure>
16 <figure class="image image_resized" style="width:33.37%;">
17 <img style="aspect-ratio:1400/890;" src="/uploads/bagel.png"
18 srcset="/uploads/thumbnail_bagel.png 245w,/uploads/small_bagel.png 500w,/uploads/medium_bagel.png 750w,/uploads/large_bagel.png 1000w," sizes="100vw" width="1400" height="890">
19 <figcaption>
20 Or this picture of a bagel.
21 </figcaption>
22 </figure>
Into this output:
HTML1 <p style="text-align:justify;">This is an example html snippet with <code class="inline">custom styles</code>
2 and also
3 <CustomLink :noBlank="false" to="https://www.google.com">custom links</CustomLink>
4 that might go to <i>external </i>pages, or <CustomLink :noBlank="true" to="../about">maybe not</CustomLink>.</p>
5 <p>There's also images:</p>
6 <ImageContainer image="/uploads/kitchen.jpg" caption="Such as this image of a kitchen" ></ImageContainer>
7 <ImageContainer image="/uploads/bagel.png" caption="Or this picture of a bagel." ></ImageContainer>
As you can see the processed version is actually more compact than the original in this case.[11]
Manually Placed Components
So much for the tags that we always want to transform. But this is not always the case.
Let's say we created an inline component that implements a future proof version of the deprecated <marquee>
tag,[12]which is neat but surely we don't want all the text of the post affected, just a few lines we individually select.
This is easily implemented via CKEditor style presets which can add arbitrary classes to elements which we can later target in our processing function.[13]
These style definitions are defined in the src\admin\app.tsx
file of your Strapi project:
TypeScript1 import {setPluginConfig, defaultHtmlPreset} from '@_sh/strapi-plugin-ckeditor';
2 export default {
3 register() {
4 const {editorConfig:{toolbar}} = defaultHtmlPreset
5 console.log({defaultHtmlPreset})
6 setPluginConfig({
7 presets: [{
8 // We keep most settings and editor configs from the default preset.
9 ...defaultHtmlPreset,
10 editorConfig: {
11 ...defaultHtmlPreset.editorConfig,
12 style: {
13 definitions: [
14 // Adding our style definitions.
15 {
16 name: "Vue Marquee",
17 classes: ['vue-marquee'],
18 element: 'span'
19 }
20 ]
21 },
22 // Adding the "Styles" field to the toolbar.
23 toolbar:['style',...toolbar as string[]]
24 },
25 // preview style for the WYSIWYG editor.
26 styles:`.vue-marquee { color:orange; }`,
27 }]
28 });
29 },
30 bootstrap() {},
31 };
There's not much going on here. We simply extend the built in defaultHtmlPreset
with the style property containing our definition(s) and include a CSS style for our new class in the style
property, nothing as fancy as the actual component style, just a basic color to show that there's something there.
The newest version of the Strapi 5 CKEditor integration does not include a style selection by default, so we also insert the 'style'
item into our toolbar preset, so that we can actually select our styles.
Now, in our lifecycle function we simply use a class query selector to find and transform our matching tags:
TypeScript1 for (const marqueeSpan of dom.querySelectorAll('.vue-marquee')) {
2 marqueeSpan.replaceWith(
3 new HTMLElement('VueMarquee',{}).set_content(marqueeSpan.childNodes)
4 );
5 }
If we want even more flexibility, it's worth noting that the CKEditor Style menu also allows us to apply multiple styles by shift clicking. This would make it possible to have secondary styles which add more classes which in the transformation logic could be used to set specific props on our component or alter them in other ways.
Conclusion
I hope this guide helps at least someone out there to exploit the full power of both Vue and CKEditor without compromise.
Is this solution clean and elegant? Perhaps not. But since I just haven't found any guides to achieving this sort of "re-vuification" so to speak, this seems like a good start.
^ As many formatting and plugins CKEditor offers, it's still no match for a fully featured vue component.[1] ^ I would have maybe considered this option if I didn't dislike markdown as much as I do. ^ This basic example would perhaps be more readable with an v-if
directive, but once things get more complex:is
is much more flexible.^ Any variable used in props and listeners will also have to be passed to the component as a property, the same goes for other features like custom directives etc. ^ When we are finished our WYSIWYG editor will technically no longer be "What You See Is What You Get" but rather "What You See Is Slightly Less Than What You Get", a WYSISLTWYG Editor so to speak. ^ Does that mean taking up about twice as much space on the database? Yes. But since this is just text content this won't be blowing up any storage limits unless someone writes novel-length posts every day. ^ Am I a heathen for using react based strapi for my vue frontend? perhaps. But it has all features I need and runs well enough. ^ In Strapi 5, since my post type uses the new Draft/Publish system, the the lifecycle hook triggers whenever we save a new published version of our post, but not when the draft is saved.[2] ^ In Directus,one would be using flows with the items.update
trigger, in Payload CMS there's thebeforeChange
hook, and Wordpress[3] has asave_post
hook.^ This importantly does not trigger any of the update hooks again.[4] ^ The Vue components take care of re-calculating all the extra stuff, for example the the names of the resized images are deterministic, so the scrset
can always be automatically rebuilt.^ Yes, I am aware that CSS animations can create the effect on their own just with classes, this is just a random example. ^ Attributes cannot be set this way, but if push comes to shove it's always possible to edit the HTML directly with the CKEditor Source view, although I prefer to avoid this.
^ Needless to say, as v-html
is already a risky technique, arbitrary templating is even more exploitable ...so make sure to never inject user generated content this way.^ This actually comes in handy since it means less work from the server and the API does not fetch the draft versions of articles anyway. ^ Of course in Wordpress you are working with PHP, so this guide doesn't really apply. ^ You might have to publish an article twice in order to ensure the changes to the processed fields definitely end up in the published version, which is annoying but also only one extra click.
Comments
Nothing here yet.