ApostropheCMS 3.0 Sprint Recaps 4 & 5: hot noise about rich text

We set out to implement rich text editing and we narrowed our options to a few serious candidates: QuillJS, SlateJS, and CKEditor 5.

For those just joining us: I’m Tom Boutell, the lead architect of ApostropheCMS. And in these posts, I’m following the story of our new version of ApostropheCMS built with cool, modern things like Vue, async/await and webpack.

TL;DR: when it comes to choosing components for your open-source project, finding a license that matches your own is about reassuring the entire community… not just your own team. CKEditor 5 is a great editor, but tiptap is an even better choice, for our particular project. Either way, you get good Vue integration. With tiptap, you get an MIT license and a chance to build the user interface exactly the way you want it.

Three weeks ago, Brian Gantick and I set out to implement rich text editing. Can’t be hard right?

That’s just a little joke for those who have ever tried to build a rich text editor from scratch… and then make it work across browsers!

Thankfully we knew better than to do that. That’s why, going into that week, we had narrowed our rich text editing options to a few serious candidates:

  • QuillJS. Pros: used by big players like LinkedIn. License is compatible with our own super-permissive MIT license. Cons: no nested lists? … Come again?
  • SlateJS. Pros: nested lists (we’re amused that we have to mention this as a “pro”). License is compatible with our own super-permissive MIT license. Cons: not widely adopted.
  • CKEditor 5. Pros: maturity, maturity, maturity! Widespread adoption. A Vue wrapper component supported by the CKEditor team. A modern editor, similar in spirit to QuillJS and SlateJS. Cons: GPL license is not compatible with ours…

But oh snap! The CKSource team reached out to discuss a special license for ApostropheCMS users. An offer they also extend to other open-source projects, by the way.

So naturally we tried it!

Prototyping with CKEditor 5

We began by building a teeny-tiny prototype using plain ol’ contenteditable (please, never ship that).

Then Brian ran with the ball, incorporating CKEditor’s shockingly simple Vue component in place of contenteditable. And we were “almost done.”

Alas, you know the rule: 20% of the work takes 80% of the time!

But, the next two days gave us:

  • Template-level configuration. You can decide what toolbar items and headings to allow on a fine-grained basis.
  • Automatic configuration of sanitize-html. We’ve always had automatic sanitization of your markup, to prevent a paste from Word from inserting “CSS salad” into your nice mobile-responsive website. Now we detect which tags and classes should be allowed automatically, from your toolbar configuration.
  • Click to edit. Surprisingly, this was the big one.

“Click to edit? Really? What was the big deal there?”

Sounds simple, right? You start out looking at plain old rich text on the page, aka the “normal view.” Clicks anywhere on the rich text should replace it with the edit view.

Sure, except… what if you click on a link in that text? Going straight to the editor means it takes several clicks to just follow the link. And if you came to that page for information, rather than to edit it, that’s not a feature.

“So fine, don’t open the editor if it’s a link! Geez.”

Well, that’s the right behavior for rich text widgets. But it might not be for something else. So we need a way to write separate Vue components for editing separate types of widgets… without duplicating all of the code.

No problem! Vue mixins are great for this. Except, we need to import them.

Um. Import them from where?

That mixin lives in the apostrophe-rich-text-widgets module. We might want to import it from within the core of Apostrophe — or from the project level — or from an npm module. We might even want to override and replace it in another npm module, such that everyone else sees the new version.

And we had patterns for all of that… in Apostrophe 2.x. But how do we do it with modern JavaScript’s import statement?

Our first thought: we already import every Vue component found in the src/apos/components subdirectory of any Apostrophe module. Just do the same for src/apos/mixins! Then attach them to the global apos object, as apos.mixins, and we can refer to them in our Vue components…


A little problem with that: import is asynchronous. But all imports run before any non-import code.

And we’re importing all of our components… and all of our mixins.

Which means that the component definitions load before the mixins are registered for their use.


And really… can’t we do better than this? A global namespace for components is not unusual in Vue. But a global namespace for mixins seems like overkill. If you want one, you should be able to import it where you need it.

So we took a long look at Webpack. And tried too hard to write an overcomplicated loader. And thought again. And we did an audaciously simple thing: we copied stuff.


When Apostrophe starts building assets, the src subdirectory of each module is copied to a shared /modules folder. But many modules inherit from another, so we copy from the base class first, then the next, etc. until we get to the module itself.

Through this process, files can be intentionally overwritten for this particular asset build, with the final module’s version always winning out. Which gives us the same flexibility we have when overriding templates in Apostrophe. And fs-extra does all the heavy lifting for us.

But we still have to tell Webpack how to handle an import from this magical folder of Apostrophe modules, right? Isn’t that complicated? Not really, because we already made a simple folder with the right version of everything for each module. We just need an alias:

resolve: {
  extensions: ['*', '.js', '.vue', '.json'],
  alias: {
    // resolve apostrophe modules, with overrides
    'apostrophe': require('path').resolve(modulesDir)

Now we can write:

import ApostropheWidgetMixin from 'apostrophe/apostrophe-

And the appropriate version automatically gets imported.

Well! That was easy. So much for Webpack gymnastics.


Things take a turn: the license

Then we got the details on the license terms for using CKEditor 5 in Apostrophe 3.

And we want to say right off: it’s a generous act on their part, offering to allow use of the software in projects that don’t follow the GNU General Public License that normally applies to CKEditor, if you’re not paying for a commercial license. After all, it’s theirs to do with as they see fit.

But as we read through the terms, we realized there were just too many details that could affect the decision of potential Apostrophe developers considering Apostrophe specifically because of its permissive MIT license. And too many words in general.

And since a big part of our business is enterprise support, we need as many people using ApostropheCMS as humanly possible.

That means a simple, permissive license that fits on a page and covers everything is the only way to go… for us. But for other projects, CKEditor 5 might be a great fit.

And that might even include your own ApostropheCMS project! Nothing prevents a developer from creating and sharing their own ApostropheCMS rich text editor based on CKEditor 5. It just isn’t the right license for our standard offering.

And that’s when Brian Gantick reminded us about tiptap.

Enter tiptap


In our earlier quest to test rich text editors, we had just barely noticed a late entry: TipTap.

Well… some of us barely noticed. Brian definitely noticed, and he kept it on his radar.

So at the crucial moment, he called our attention to the very shiny tiptap demos. And its 100% compatible MIT license. And the fact that it’s built in Vue. And the fact that it’s built on top of ProseMirror, a rich text editor framework trusted by the New York Times.

Yeah… that might be serious enough for our needs.

Besides, tiptap even lets you build your own user interface!

There’s just one problem: tiptap makes you build your own interface. All that pretty stuff in the demos? Not in the box. And, some important stuff isn’t demonstrated.

We knew we wanted a “Styles” dropdown menu, offering a clean switch between “Paragraph, “Main Heading,” “Heading 2 (blue)”, “Heading 2 (red)”, and so on according to the needs of each project.

We also knew we needed a complete “link” dialog box with important options like anchor ids and the target attribute that can be used to open new tabs.

But hey… we always wind up skinning things our way anyway. Wouldn’t it be nice to drive the user interface car from the start?

So first, Brian did a quick proof of concept, showing that tiptap could function in Apostrophe. And then we committed to a two-day lightning sprint in which Alex Bea and I would build out those missing features. Or look for… well… a plan “C.”

Tiptap: it’s elegant. But underdocumented. But elegant.

When things get past the basics, the tiptap developer documentation has a long way to go.

But fortunately? The tiptap source code does not have far to go. It’s, well… lovely. Our only complaint is that we had to level up our “ESnext” JavaScript skills to read it.

And that’s why, in the course of a single day, Alex and I were able to implement both our “Styles” menu and our advanced “Link” dialog box. Even though that meant learning to write custom tiptap extensions.

What can a tiptap extension do? There’s not much it can’t do. You can extend “marks,” which are great for inline content, or “blocks,” which are perfect for headings. And you can represent the unique attributes of your content any way you want to; you’re not married to the DOM. But, you also get to provide simple functions and rules to translate to and from the DOM.

You can even supply keyboard shortcuts… and Markdown-style rules to add things like ## A second level heading without hitting any modifier keys.

It’s just plain elegant.


And that’s fortunate, because it made time for us to divide our forces and cover more ground. I kept pushing on tiptap, while Alex fixed problems with drag-and-drop and diagnosed what turned out to be completely unrelated issues I had unfairly blamed on tiptap.

And as for that documentation? Hey, tiptap is an open source project, and now we know enough to contribute in that area.

Loading tiptap extensions: magic isn’t just for the core

This was all very cool. One little problem: what if we want to add a new toolbar feature later? Without hacking it into the core of Apostrophe, that is? How would we register new tiptap extensions, and new Vue components to go with them?

Components are the easy part: we already automatically import and register everything in the src/apos/components folder of any module as a Vue component. But what about the extensions? Well, it would be nice if they loaded magically too, from src/apos/tiptap-extensions. And this time, there’s nothing to stop us. Because these extensions aren’t actually needed until the rich text editor is used. Which means we can build support for loading them right into apostrophe-assets, and attach an apos.tiptapExtensions array right to the apos object, where the ApostropheRichTextWidgetEditor component can hoover them right up.

What’s in the box?

Mission accomplished! It works, more than well enough to convince us that TipTap is a good choice going forward.

But implementing stuff is really the easy part. The hard part is deciding what should come in the box.

Right now, when you install Apostrophe 2.x and add a rich text widget, this is the toolbar you get:

Nothing. Nothing at all.

Yeah, that’s not very rich.

Of course, we did it that way for a reason. We wanted our own developers to think before adding another item to the rich text toolbar. If the design doesn’t call for blockquotes, it’s not a good idea to just chuck them on the toolbar. What if no one styles them? What if they don’t even make sense in most of the content areas on the site?

But for new developers evaluating Apostrophe for the first time, the default should be a rich one, demonstrating what is possible with Apostrophe. It makes more sense to put a conservative default in a private “boilerplate” project, like our own internal client-boilerplate.

So that brings us to a question: what should be turned on by default? What’s part of a reasonable demonstration that the rich text editor is fully capable, and what’s just in the way or, worse, an invitation to trouble?

We came up with this list. We welcome your input on whether this feels like the right set:

styles (dropdown: P, H2, H3, H4)

We reserved H1 because it is a poor SEO practice to have more than one, and the title of the page is usually output directly on the page via the page template. We left out H5 and H6 because we rarely see them used.


These three felt right for basic text styling. We left out underline because it is a worst practice on the web — too easily confused with a clickable link. And because we don’t love the deprecated u tag that tiptap uses by default.


Well I should hope so, right?


The classics.


code_block doesn’t belong on every website, but it’s frequent enough to be demonstrated by default.


Hey, why not?


We all know the keyboard shortcuts, but it helps to let people know they will work.


tiptap comes with an impressive set of table editing commands, the rest of which become visible when you’re working inside a table. Tables aren’t right for every project, especially not every mobile responsive project. But, genuine tabular data is a real part of many CMS projects.

Looking ahead


So! What will our next sprint focus on?

We have plenty of grunt work to do before Apostrophe 3.x can be released, but for these sprints, we are emphasizing the areas with the most open questions to be answered. And by that standard… rich text is still the right place to put our efforts. Specifically, we’ll likely work on new tiptap extensions to handle text alignment and adding classes pretty much anywhere in inline text.

These two cases should demonstrate our ability to also handle situations like highlighting and fill any other gaps that may arise as our enterprise clients request them.

After that, we should be on a solid footing to just… build… all… the… stuff. Because at the end of the rainbow, there’s…

A fortress? Fortress Apostrophe?

Sure. Let’s go with that.

Sign up for the latest product news and updates.