Long ago I wrote typofilter.js: a small script that puts in «guillemets», em dashes, ellipses, © and assorted little arrows. On the previous version of my home page it lived in the Markdown editor of OctoberCMS, I also made it work with old versions of CKE and TinyMCE, and even built a plugin for Obsidian.
These days, of course, fans of typographically correct text are under threat. Any layman who sees em dashes and guillemets instantly recognizes the author as a generative neural network. But what do you do if you were putting in your little dashes and fancy quotes long before the first neural network recognized a kitten in a picture? What if you've grown attached to typography? Or, the other way around, you want to quietly blend in with the robots and become indistinguishable from them, so nobody accidentally replaces you?
Anyway, personally I'm not giving up typography. So I dragged the plugin over to the new version of the site and got it to play nice with Drupal and CKEditor 5. Fixed a few little issues along the way, too.
What we fixed in typofilter.js
The library is used in other places, not only here. So first we pinned down its current behavior: Claude and I collected about fifty examples (plain text, Markdown, HTML), recorded what it outputs, and turned that into tests on the built-in node:test. After that, every change was checked against this set: only what we're fixing is supposed to change. Plus we added an idempotency check: running it again over already processed text changes nothing, so you can hit the button as many times as you like.
Along the way we fixed several bugs related to Markdown, nested quotes and blocks protected (from typography).
The CKEditor 5 plugin
The CKEditor 4 plugin had to be thrown out entirely: the API is different, and the logic was outdated too (it didn't even support tags yet).
The new plugin adds a toolbar button, the typofilter command and the Ctrl+Shift+T hotkey (Cmd+Shift+T on a Mac). If something is selected, the selection is processed; if not, the whole text.
The plugin is plain JS with no build step. The main subtlety was where to get the CKEditor classes from: in Drupal the editor is built as a DLL (CKEditor5.dll(…)), in the browser build from the CDN everything sits in the global CKEDITOR, and when building with npm the classes are imported from the ckeditor5 package. So the plugin is created by a factory from the classes of whatever editor is on the page, and it finds the DLL or UMD build by itself.
We tested all three options on the current CKEditor 5 v49 and on the version that ships with Drupal 11.
How to install it on a site with CKEditor 5
The simplest option is the browser (UMD) build of CKEditor. Load typofilter.js and the plugin after it, then add Typofilter to the plugins and typofilter to the toolbar in the config:
<link rel="stylesheet" href="/ckeditor5/ckeditor5.css">
<script src="/ckeditor5/ckeditor5.umd.js"></script>
<script src="/typofilter/typofilter.js"></script>
<script src="/typofilter/plugins/ckeditor5/typofilter.js"></script>
<script>
const { ClassicEditor, Essentials, Paragraph, Bold, Link } = CKEDITOR;
ClassicEditor.create(document.querySelector('#editor'), {
licenseKey: 'GPL',
plugins: [Essentials, Paragraph, Bold, Link, Typofilter],
toolbar: ['bold', 'link', '|', 'typofilter'],
typofilter: { label: 'Typography', keystroke: 'Ctrl+Shift+T' },
});
</script>
The CKEditor files come from the ckeditor5 npm package (dist/browser/) or from their CDN. licenseKey: 'GPL' is required starting with CKEditor 44; without it the editor won't start.
If the editor is built with npm and a bundler, you can install the library straight from GitHub (npm install github:graker/typofilter.js) and build the plugin from the classes of the ckeditor5 package:
import { ClassicEditor, Essentials, Paragraph, Plugin, Command, ButtonView } from 'ckeditor5';
import 'typofilter.js/typofilter.js';
import 'typofilter.js/plugins/ckeditor5/typofilter.js';
const Typofilter = window.createTypofilterPlugin({ Plugin, Command, ButtonView });
ClassicEditor.create(document.querySelector('#editor'), {
licenseKey: 'GPL',
plugins: [Essentials, Paragraph, Typofilter],
toolbar: ['typofilter'],
});
How to install it on Drupal
The library now has a composer.json with the drupal-library type, so in a standard Drupal project (drupal/recommended-project) composer will put it into web/libraries/typofilter. There's no package on Packagist, so we add the repository:
composer config repositories.typofilter vcs https://github.com/graker/typofilter.js
composer require graker/typofilter:^1.2
Drupal looks for CKEditor 5 plugins only in modules, so you'll need a module of your own (or any existing custom one). Let's call it mymodule. First we describe the libraries in mymodule.libraries.yml:
typofilter:
js:
/libraries/typofilter/typofilter.js: {}
ckeditor5.typofilter:
js:
/libraries/typofilter/plugins/ckeditor5/typofilter.js: {}
dependencies:
- core/ckeditor5
- mymodule/typofilter
Better not to set version for the library: Drupal will put it into the file URL (?v=1.2.0), and after the library is updated, browsers will keep the old file until you bump the version by hand. Without it, the URL changes on every cache rebuild.
Next we register the plugin in mymodule.ckeditor5.yml:
mymodule_typofilter:
ckeditor5:
plugins:
- typofilter.Typofilter
drupal:
label: Typography
library: mymodule/ckeditor5.typofilter
toolbar_items:
typofilter:
label: Typography
elements: false
Rebuild the cache (drush cr), go to "Configuration → Content authoring → Text formats and editors", open the formats you need and drag the "Typography" button onto the toolbar.
A couple of optional details. To give the button an icon on the toolbar configuration screen instead of an empty square, you need an admin library with CSS for the .ckeditor5-toolbar-button-typofilter class, and the SVG is in the library: plugins/ckeditor5/typofilter.svg. And to make the button label translatable, you can set your own plugin class in drupal.class (a subclass of CKEditor5PluginDefault) and return a config like ['typofilter' => ['label' => (string) $this->t('Typography')]] from getDynamicPluginConfig().
Code and tests are on GitHub, help yourself. Drop me a line if you have any questions.