Introduction
Building a custom CKEditor 5 build from source is officially documented, but it can be a tricky task for those who aren't writing build tasks for their day-to-day job.
Suppose you are a guy like me who only seems to learn how to setup a webpack
build environment when he really needs to, and always forgets how to do it as soon as the project is done ???? -- This guide is for you!
This guide will take you through building CKEditor 5 from source and creating a separate project to consume it through imports so that a custom CKEditor 5 can be used in your web application.
Prerequisites
This post will assume some knowledge in relatively modern web development concepts.
- Knowledge of the
yarn
package management tool. You know how to add dependencies and install them in a project. - Some knowledge of
webpack
-- i.e. you know what it does. - Basic web development knowledge to write additional code to create an editor instance and mount it to a
contenteditable
HTMLDivElement
on an HTML page - Some text editor to do all this in ???? Visual Studio Code is used in this tutorial, but it does not really matter here.
Approach and Expectations
Before we go any further, I'd like to make sure there is an understanding in what "building from source" means for this tutorial. This approach is based on the main tutorial found in the CKEditor 5 documentation. In their documentation, there is an explicit statement:
Similar results to what this method allows can be achieved by customizing an existing build and integrating your custom build. This will give faster build times (since CKEditor 5 will be built once and committed), however, it requires maintaining a separate repository and installing the code from that repository into your project (e.g. by publishing a new npm package or using tools like Lerna). This makes it less convenient than the method described in this scenario.
We cannot use the @ckeditor/ckeditor5-*
packages directly to create an editor. We must pre-assemble one using the packages available, and then provide a mechanism to deliver back an instance of CKEditor 5.
In this case, we can create a factory function to assemble the editor, and return back the instance asynchronously when this factory function is invoked.
To reiterate once more, all the work relating to building a CKEditor 5 instance will be contained in a separate project.
In summary, here are the things to know:
- The custom editor will be in its own package. This is like the one you'd normally publish to the
npm
registry (for example). - Consumers are expected to add the package as a dependency in their project. For the purpose of this tutorial, we can add the package locally with a
file
reference so that we do not have to publish to a registry. - Additional work is needed in order to support TypeScript. This tutorial will go over that because... TypeScript.
Steps
The first step is to go through the initial steps found in the CKEditor 5 tutorial for building the editor from source.
If you want to just jump in right away and follow this tutorial completely, it is possible, and here it is.
Create a folder that will contain your project. In this case for me, it is editor
.
Use npm init
in the folder to create a package.json
file. Give this project a nice name! It will be the package name so that you can use it in another project as a module. For example, I named this project @dev-cms/editor
.
Ensure that in the package.json
file, the main
key is specified to be the correct js
file that is ultimately built. For example, mine is called ckeditor.js
.
In the same project, install the typescript
dependency:
yarn add -D typescript
Also, add some other useful things:
yarn add -D copyfiles ts-loader
Now, add all the minimal dependencies to get something built. For this HOW-TO, I am building an InlineEditor
. I am aware that the official tutorial focuses on ClassicEditor
. The steps are generally the same.
yarn add @ckeditor/ckeditor5-theme-lark \
@ckeditor/ckeditor5-autoformat \
@ckeditor/ckeditor5-basic-styles \
@ckeditor/ckeditor5-block-quote \
@ckeditor/ckeditor5-editor-inline \
@ckeditor/ckeditor5-essentials \
@ckeditor/ckeditor5-heading \
@ckeditor/ckeditor5-link \
@ckeditor/ckeditor5-list \
@ckeditor/ckeditor5-paragraph
Next, install all the other dependencies required by the CKEditor 5 build:
yarn add @ckeditor/ckeditor5-dev-translations \
@ckeditor/ckeditor5-dev-utils \
css-loader@5 \
postcss-loader@4 \
raw-loader@4 \
style-loader@2 \
webpack@5 \
webpack-cli@4
To configure webpack, start with this template found here. Proceed to the next section for more details on what we are doing with this webpack.config.js
.
TypeScript
In order to make TypeScript possible, you will need to modify the webpack.config.js
file and then create the tsconfig.json
.
But that is not enough! In order to properly import the package in any other TypeScript project, you will also need to create the .d.ts
file. Thankfully, you can generate this by having an additional configuration named tsconfig.types.json
.
We need to alter webpack.config.js
to make it "TypeScript" aware -- sorry for the lack of better term. ????
Here is what the output should look like:
module.exports = {
// ... other properties
entry: "./ckeditor.ts",
output: {
path: path.resolve(__dirname, "dist"),
filename: "ckeditor.js",
libraryTarget: "umd",
libraryExport: "default",
},
// ... rest of the stuff here
};
Just be sure to define the entry
as the main TypeScript ts
file containing the code to assemble/build your CKEditor instance. The output
property should also have libraryTarget
and libraryExport
as shown above.
First, the tsconfig.json
should look like this:
{
"compilerOptions": {
"types": [],
"lib": ["ES2019", "ES2020.String", "DOM", "DOM.Iterable"],
"noImplicitAny": true,
"noImplicitOverride": true,
"strict": true,
"module": "es6",
"target": "es2019",
"sourceMap": true,
"allowJs": true,
"moduleResolution": "node",
"skipLibCheck": true
},
"include": ["./**/*.ts"],
"exclude": ["node_modules/**/*"]
}
This is like any other regular tsconfig.json
file specific to some project. The next step is to create the tsconfig.types.json
. This will be used to generate the .d.ts
file:
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable"],
"module": "es6",
"target": "ES2019",
"sourceMap": true,
"allowJs": true,
"moduleResolution": "node",
"skipLibCheck": true,
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "dist",
"stripInternal": true
},
"include": ["*.ts"]
}
Notice the additional compile options: declaration
, emitDeclarationOnly
, declarationDir
, and stripInternal
. If you're pretty seasoned, you can kind of infer what the intention for this config to tell tsc
to do: Look for the ts
files and only emit the interface definitions which will then be emitted to the dist
directory.
Create the appropriate build scripts to copy the additional package.json
and .d.ts
files to the dist
directory. This will be the package source.
Here, we will need to define a build
, build:dev
and postbuild
command for our workflow to build our package:
build
"build": "webpack --mode production && copyfiles ./package.json ./dist && yarn postbuild"
build:dev
"build:dev": "webpack --mode development && copyfiles ./package.json ./dist && yarn postbuild"
build
and build:dev
are just convenience to run webpack
build the bundles in either development or production mode. The last command postbuild
is important. The command will run tsc
again to generate the declarations:
postbuild
"postbuild": "tsc -p ./tsconfig.types.json"
The output will emit into dist
-- therefore, it is not necessary to manually run copyfiles
to copy the output to dist
as we did with package.json
.
Creating the Factory Function
Phew, so far we've done a lot of work just setting up the build chain! We still need some code.
Time to create the ckeditor.ts
file. We will build the editor here and have an exposed function for the consumer to call to return back an instance of the editor.
Let's define a function called CreateEditor
that takes in a simple HTMLDivElement
as an argument, and build a very basic editor. If you are interested in additional customization, I really recommend going through and reading the CKEditor 5 documentation. It will have lots of details how you can add additional plugins and configuration to enrich your editor.
import {
Bold,
Code,
Italic,
Strikethrough,
Underline,
} from "@ckeditor/ckeditor5-basic-styles";
import { Essentials } from "@ckeditor/ckeditor5-essentials";
import { InlineEditor } from "@ckeditor/ckeditor5-editor-inline";
import { Paragraph } from "@ckeditor/ckeditor5-paragraph";
export default function CreateEditor(
sourceElement: HTMLDivElement
) {
return InlineEditor.create(sourceElement, {
plugins: [
Essentials,
Paragraph,
Bold,
Italic,
Strikethrough,
Underline,
Code,
],
toolbar: [
"bold",
"italic",
"strikethrough",
"underline",
"code",
],
})
.then((editor) => {
console.log("Editor is initialized");
return editor;
})
.catch((error) => {
console.error(error.stack);
return null;
});
}
By saving this as ckeditor.ts
, our webpack configuration will read this file in and then begin to build the entire package with the CreateEditor
exported along with the editor. Any consumer of our package will then be able to import this function to create an editor and receive the reference to it to do more stuff!
Congrats! We've built the package! Now, it is time to use it.
Consuming the Package
In your other project which will make use of your CKEditor 5 build, you can add it as a devDependency
to your package.json
by running the following command:
yarn add file:/path/to/the/editor/project
Here is a more concrete example, assuming my dist
is within editor
at the parent directory of the current project.
yarn add file:../editor/dist
Once done, we can just run yarn install --force
again on the project.
With the project now added, we can invoke our custom factory function with something like this:
import CreateEditor from "@dev-cms/editor";
import React from "react";
export const WritePost = () => {
const editableRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
editableRef.current &&
CreateEditor(editableRef.current, {
sourceElementClassNames: [],
});
}, []);
return <div ref={editableRef}></div>;
};
CreateEditor
is the same function exposed by our CKEditor 5 build to be able to construct the CKEditor 5 instance in this case. The important line of code in which I would like to emphasize is in the way to import it.
import CreateEditor from "@dev-cms/editor";
Building this same project, we can then render out the page. I personally added more plugins to my build.
Export More!
Here is a use case which you may want. Suppose we want to define additional helpers and functions in addition to CreateEditor
. This is particularly useful if you're interested in creating a framework on top of CKEditor 5.
As an example, let's define an abstraction over InlineEditor
called WrappedEditor
. It is a simple interface in a file called wrapped-editor.interface.ts
.
import type { InlineEditor } from "@ckeditor/ckeditor5-editor-inline";
export interface IWrappedEditor {
readonly editorInstance: InlineEditor;
getData(): string;
setData(value: string): void;
}
Let's also modify ckeditor.ts
to no longer have a default export in CreateEditor
.
export function CreateEditor(....) { }
Now, we need a new entry point... We can do just that by adding an index.ts
to our project to export all our files.
export * from "./ckeditor";
export * from "./wrapped-editor.interface";
Modify package.json
to now specify the main entry point to be index.js
.
Next, let's modify the webpack.config.js
file. We will need to change the entry
to now be index.ts
, and also output
to indicate a new filename
for our compiled file, and library
property to indicate that we want to output a UMD module called "editor".
module.exports = {
// ... other entries
entry: "./index.ts",
output: {
path: path.resolve(__dirname, "dist"),
filename: "index.js",
library: {
name: "editor",
type: "umd",
},
},
// ... other entries
};
If you then compile again using yarn build:dev
as specified earlier, you'll now notice you'll have additional d.ts
files generated. Going back to the consuming project, you can now consume the new package like this:
import { CreateEditor, IWrappedEditor } from "@dev-cms/editor";
This allows for more convenience in being less restricted to just only being export a factory function. Make what you will of it. ????
Conclusion
Building an editor from source allows us to programmatically control what type of features we need for our CKEditor 5 instance. We don't need to continuously swap out predefined builds as our requirements change throughout the software development lifecycle. Instead, we can simply make code modifications to add, or remove plugins.
If you have any questions, or feedback in how to improve or update this guide, please send an email to me!
References
These are basic references used to "get started" for someone who does not know where to begin.
- CKEditor 5 Documentation - Building the Editor from Source - This is the basic guide in how to set up a project in
webpack
to put the editor together with necessary plugins along with a custom JavaScript code which serves as a factory to initialize an editor instance. - CKEditor 5 Documentation - Building the Editor from Source using TypeScript - Same as above but with using TypeScript.