🎨 Custom HTML Renderer

The TOAST UI Editor (henceforth referred to as ‘Editor’) provides a way to customize the final HTML contents.

The Editor uses its own markdown parser called ToastMark, which has two steps for converting markdown text to HTML text. The first step is converting markdown text into AST(Abstract Syntax Tree), and the second step is generating HTML text from the AST. Although it’s tricky to customize the first step, the second step can be easily customized by providing a set of functions that convert a certain type of node to HTML string.

Basic Usage

The Editor accepts the customHTMLRenderer option, which is a key-value object. The keys of the object is types of node of the AST, and the values are convertor functions to be used for converting a node to a list of tokens.

The following code is a basic example of using customHTMLRenderer option.

  1. const editor = new Editor({
  2. el: document.querySelector('#editor'),
  3. customHTMLRenderer: {
  4. heading(node, context) {
  5. return {
  6. type: context.entering ? 'openTag' : 'closeTag',
  7. tagName: 'div',
  8. classNames: [`heading-${node.level}`]
  9. };
  10. },
  11. text(node, context) {
  12. const strongContent = node.parent.type === 'strong';
  13. return {
  14. type: 'text',
  15. content: strongContent ? node.literal.toUpperCase() : node.literal
  16. };
  17. },
  18. linebreak(node, context) {
  19. return {
  20. type: 'html',
  21. content: '\n<br />\n'
  22. };
  23. }
  24. }
  25. });

If we set the following markdown content,

  1. ## Heading
  2. Hello
  3. World

The final HTML content will be like below.

  1. <div class="heading-2">HEADING</div>
  2. <p>Hello<br /><br />World</p>

Tokens

As you can see in the basic example above, each convertor function returns a token object instead of returning HTML string directly. The token objects are converted to HTML string automatically by internal module. The reason we use tokens instead of HTML string is that tokens are much easier to reuse as they contain structural information which can be used by overriding functions.

There are four token types available for the token objects, which are openTag, closeTag, text, and html.

openTag

The openTag type token represents an opening tag string. A openTag type token has tagName, attributes, classNames properties to specify the data for generating HTML string. For example, following token object,

  1. {
  2. type: 'openTag',
  3. tagName: 'a',
  4. classNames: ['my-class1', 'my-class2']
  5. attributes: {
  6. target: '_blank',
  7. href: 'http://ui.toast.com'
  8. }
  9. }

is converted to the HTML string below.

  1. <a class="my-class1 my-class2" href="http://ui.toast.com" target="_blank"></a>

To specify self-closing tags like <br />, and <hr /> , you can use selfClose options like below.

  1. {
  2. type: 'openTag',
  3. tagName: 'br',
  4. classNames: ['my-class'],
  5. selfClose: true
  6. }
  1. <br class="my-class" />

closeTag

The closeTag type token represents a closing tag string. A closeTag type token does not contain additional information other than tagName.

  1. {
  2. type: 'closeTag',
  3. tagName: 'a'
  4. }
  1. </a>

text

The text type token represents a plain text string. This token only has a content property and HTML characters in the value are escaped in the converted string.

  1. {
  2. type: 'text',
  3. content: '<br />'
  4. }
  1. &lt;br /&gt;

html

The html type token represents a raw HTML string. Like the text type token, this token also has content property and the value is used as is without modification.

  1. {
  2. type: 'html',
  3. content: '<br />'
  4. }
  1. <br />

Node

The first parameter of a convertor function is a Node type object which is the main element of the AST(Abstract Syntax Tree) constructed by the ToastMark. Every node has common properties for constructing a tree, such as parent, firstChild, lastChild, prev, and next.

In addition, each node has its own properties based on its type. For example, a heading type node has level property to represent the level of heading, and a link type node has a destination property to represent the URL of the link.

The following markdown text and AST tree object will help you understand the structure of AST generated by the ToastMark.

  1. ## TOAST UI
  2. **Hello** World!
  1. {
  2. type: 'document',
  3. firstChild: {
  4. type: 'heading',
  5. level: 2,
  6. parent: //[document node],
  7. firstChild:
  8. type: 'text',
  9. parent: //[heading node],
  10. literal: 'TOAST UI'
  11. },
  12. next: {
  13. type: 'paragraph',
  14. parent: //[document node],
  15. firstChild: {
  16. type: 'strong',
  17. parent: //[paragraph node],
  18. firstChild: {
  19. type: 'text',
  20. parent: //[strong node],
  21. literal: 'Hello'
  22. },
  23. next: {
  24. type: 'text',
  25. parent: //[paragraph node],
  26. literal: 'World !'
  27. }
  28. }
  29. }
  30. }
  31. }

The type definition of each node can be found in the source code.

Context

When the Editor tries to generate HTML string using an AST, every node in the AST is traversed in pre-order fashion. Whenever a node is visited, a convertor function of which the key is the same as the type of the node is invoked. At this point, a context object is given to the convertor function as a second parameter.

entering

Every node in an AST except leaf nodes is visited twice during a traversal. The fisrt time when the node is visited, and the second time after all the children of the node are visited. We can determine in which pace the convertor is invoked using entering property of the context object.

The following code is a typical example using entering property.

  1. const editor = new Editor({
  2. el: document.querySelector('#editor'),
  3. customHTMLRenderer: {
  4. heading({ level }, { entering }) {
  5. return {
  6. type: entering ? 'openTag' : 'closeTag',
  7. tagName: `h${level}`
  8. };
  9. },
  10. text({ literal }) {
  11. return {
  12. type: 'text',
  13. content: node.literal
  14. };
  15. }
  16. }
  17. });

The heading convertor function is using context.entering to determin the type of returning token object. The type is openTag when the value is true, otherwise is closeTag. The text convertor function doens’t need to use entering property as it is invoked only once for the first visit.

Now, if we set the following markdown text to the editor,

  1. # TOAST UI

The AST genereted by ToastMark will be like below. (only essential properties are specified)

  1. {
  2. type: 'document',
  3. firstChild: {
  4. type: 'heading',
  5. level: 1,
  6. firstChild: {
  7. type: 'text',
  8. literal: 'TOAST UI'
  9. }
  10. }
  11. }

After finishing a traversal, tokens returned by convertor functions are stored in an array like below.

  1. [
  2. { type: 'openTag', tagName: 'h1' },
  3. { type: 'text', content: 'TOAST UI' },
  4. { type: 'closeTag', tagName: 'h1' }
  5. ];

Finally, the array of token is converted to HTML string.

  1. <h1>TOAST UI</h1>

origin()

If we want to use original convertor function inside the overriding function, we can use origin() function.

For example, if the return value of original convertor function for link node is like below,

entering: true

  1. {
  2. type: 'openTag',
  3. tagName: 'a',
  4. attributes: {
  5. href: 'http://ui.toast.com',
  6. title: 'TOAST UI'
  7. }
  8. }

entering: false

  1. {
  2. type: 'closeTag',
  3. tagName: 'a'
  4. }

The following code will set target="_blank" attribute to the result object only when entering state is true.

  1. const editor = new Editor({
  2. el: document.querySelector('#editor'),
  3. customHTMLRenderer: {
  4. link(node, context) {
  5. const { origin, entering } = context;
  6. const result = origin();
  7. if (entering) {
  8. result.attributes.target = '_blank';
  9. }
  10. return result;
  11. }
  12. },
  13. }

entering: true

  1. {
  2. type: 'openTag',
  3. tagName: 'a',
  4. attributes: {
  5. href: 'http://ui.toast.com',
  6. target: '_blank',
  7. title: 'TOAST UI'
  8. }
  9. }

Advanced Usage

getChildrenText()

In a normal situation, a node doesn’t need to care about it’s children as their content will be handled by their own convertor functions. However, sometimes a node needs to get the children content to set the value of it’s attribute. For this use case, a context object provides the getChildrenText() function.

For example, if a heading element wants to set it’s id based on its children content, we can use the getChildrenText() function like the code below.

  1. const editor = new Editor({
  2. el: document.querySelector('#editor'),
  3. customHTMLRenderer: {
  4. heading({ level }, { entering, getChildrenText }) {
  5. const tagName = `h${level}`;
  6. if (entering) {
  7. return {
  8. type: 'openTag',
  9. tagName,
  10. attributes: {
  11. id: getChildrenText(node)
  12. .trim()
  13. .replace(/\s+/g, '-')
  14. }
  15. };
  16. }
  17. return { type: 'closeTag', tagName };
  18. }
  19. }
  20. });

Now, if we set the markdown text below,

  1. # Hello _World_

The return value of getChildrenText() inside the heading convertor function will be Hello World. As we are replacing white spaces into -, the final HTML string through the custom renderer will be like below.

  1. <h1 id="Hello-World">Hello <em>World</em></h1>

skipChildren()

The skipChildren() function skips traversal of child nodes. This function is useful when we want to use the content of children only for the attribute of current node, instead of generating child elements.

For example, image node has children which represents the description of the image. However, if we want to use an img element for representing a image node, we can’t use child elements as an img element cannot have children. In this case, we need to invoke skipChildren() to prevent child nodes from being converted to additional HTML string. Instead, we can use getChildrenText() to get the text content of children, and set it to the alt attribute.

The following code example is an simplified version of built-in convertor function for an image type node.

  1. function image(node, context) {
  2. const { destination } = node;
  3. const { getChildrenText, skipChildren } = context;
  4. skipChildren();
  5. return {
  6. type: 'openTag',
  7. tagName: 'img',
  8. selfClose: true,
  9. attributes: {
  10. src: destination,
  11. alt: getChildrenText(node)
  12. }
  13. };
  14. }

Using Multiple Tags for a Node

A convertor function can also returns an array of token object. This is useful when we want to convert a node to nested elements. The following code example shows how to convert a codeBlock node to <pre><code>...</code></pre> tag string.

  1. function codeBlock(node) {
  2. return [
  3. { type: 'openTag', tagName: 'pre', classNames: ['code-block'] },
  4. { type: 'openTag', tagName: 'code' },
  5. { type: 'text', content: node.literal },
  6. { type: 'closeTag', tagName: 'code' },
  7. { type: 'closeTag', tagName: 'pre' }
  8. ];
  9. }

Controlling Newlines

In a normal situation, we don’t need to care about formatting of converted HTML string. However, as the ToastMark support CommonMark Spec, the renderer supports an option to control new-lines to pass the official test cases.

The outerNewline and innerNewline property can be added to token objects to control white spaces. The following example will help you understand how to use these properties.

Token Array

  1. [
  2. {
  3. type: 'text',
  4. content: 'Hello'
  5. },
  6. {
  7. type: 'openTag',
  8. tagName: 'p',
  9. outerNewLine: true,
  10. innerNewLine: true
  11. },
  12. {
  13. type: 'html',
  14. content: '<strong>My</strong>'
  15. outerNewLine: true,
  16. },
  17. {
  18. type: 'closeTag',
  19. tagName: 'p',
  20. innerNewLine: true
  21. },
  22. {
  23. type: 'text',
  24. content: 'World'
  25. }
  26. ]

Converted HTML string

  1. Hello
  2. <p>
  3. <strong>My</strong>
  4. </p>
  5. World

As you can see in the example above, outerNewLine of openTag adds \n before the tag string, whereas one of closeTag adds \n after the tag string. In contrast, innerNewLine of openTag adds \n after the tag string, whereas one of closeTag adds \n before the tag string. In addition, consecutive newlines are merged into one newline to prevent duplication.