Skip to content

DocBook XML WYSIWYG editor ​

A WYSIWYG DocBook XML editor, made by customizing CKEditor 5 with CKEditor 5 Elements. It edits a Simplified DocBook article visually, and the data of the editor is the DocBook XML of the article: editor.getData() returns it, and editor.setData() loads one. The content is its elements, each by its DocBook name: a section holds its title, its paras, its lists and its sections, and an emphasis is an element in a line of text.

The XML below the editor is its data, as it changes: edit the article and see it follow, or press Source to edit the XML itself.

  • Right-click an element, or press Shift+F10: its menu, to insert, move, copy or delete it. The breadcrumbs show the path of the DocBook elements at the cursor, e.g. section › itemizedlist › listitem › para, and open the same menu.
  • Alt+Enter: quick insert, the elements that DocBook allows at the cursor: a list item in a list, a title in a section.
  • Title, Section, the two lists, Block quote, Program listing, Emphasis and Literal are the commands of the elements, by their kind.
  • Enter in a list item makes the next item, and leaves the list in an empty one. In a program listing, it inserts a new line, and leaves it on an empty last line.
  • A section or a list item has a floating toolbar, to move, duplicate or delete it.
  • Click a link: a modal with its attributes, the url.

Run it as a page of its own, with the XML below the editor: pnpm example:docbook.

Why CKEditor 5 Elements for DocBook XML ​

  • The editor is element first, as DocBook is. Each DocBook element is an element of the editor, with its name and its attributes; formatting, such as emphasis, is an element too, not a mark on the text. What you edit is the tree of the article.
  • The XML round-trips. The article is saved as it is loaded, element for element: no HTML in between, no fillers, and the attributes as they are.
  • The DocBook elements are a JSON file. The element schema declares what each one holds and where it goes, and the editor rejects a contradictory one. The lists and the program listings work as DocBook's do.
  • It is CKEditor 5. Its undo, its clipboard and its source view work as usual, with the element features: menus, toolbars and breadcrumbs of the elements.

How it is made ​

The example is the ckeditor5-elements-docbook-example package. It is CKEditor 5 Elements as it is, with the parts below. Its XML is the one of ckeditor5-elements-xml-example, which the DITA example uses too.

The elements: an element schema ​

Each DocBook element is an element of the element schema, with its DocBook name. The para is CKEditor's paragraph, so typing, Enter and an empty editor work as usual.

schema.json
json
{
	"section": { "type": "container", "editingTag": "section" },
	"title": { "type": "block", "allowIn": ["$root", "section"] },
	"itemizedlist": { "type": "container", "listItem": "listitem", "editingTag": "ul" },
	"orderedlist": { "type": "container", "listItem": "listitem", "editingTag": "ol" },
	"listitem": { "type": "container", "editingTag": "li" },
	"blockquote": { "type": "container", "editingTag": "blockquote" },
	"programlisting": { "type": "block", "verbatim": true },
	"emphasis": { "type": "block", "isInline": true, "editingTag": "em" },
	"literal": { "type": "block", "isInline": true, "editingTag": "code" },
	"ulink": { "type": "block", "isInline": true, "allowAttributes": ["url"], "editingTag": "a" }
}
  • A section, a list item and a block quote are containers: they hold blocks. A title, a programlisting and the paragraph are blocks: they hold text. emphasis, literal and ulink are inline blocks: elements in a line of text, which hold text.
  • The itemizedlist and the orderedlist are lists of listitems (listItem): they hold only list items, which are only in lists, and Enter makes the next item. Their commands make lists of the blocks.
  • The programlisting is verbatim: it keeps its whitespace, and Enter inserts a new line in it.
  • allowIn keeps a title in the article and in sections.
  • editingTag makes the editing view HTML that the browser edits well: ul and li for the lists, em, code and a in the text. (A verbatim block is a pre already.) It is the editing view only: the data is DocBook.

The data: XML ​

XmlDocumentDataProcessor is the data processor of the editor: it turns the XML of the article into the view, and the view back into XML.

xmlDocumentDataProcessor.ts
ts
import {
	ViewDomConverter,
	type DataProcessor,
	type MatcherPattern,
	type ViewDocument,
	type ViewDocumentFragment,
	type ViewElement,
	type ViewNode
} from 'ckeditor5';

/**
 * The options of {@link XmlDocumentDataProcessor}.
 */
export interface XmlDocumentDataProcessorOptions {
	/**
	 * The name of the root element of the document, e.g. `article` or `topic`: the content of the editor is its content.
	 */
	root: string;

	/**
	 * What comes before the root element: the XML declaration, and the document type, e.g.
	 * `'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE …>\n'`.
	 */
	prolog: string;

	/**
	 * The names of the block-level elements of the data, e.g. `section` and `para`: each one is on a line of its own, and
	 * the whitespace between them is not text.
	 */
	blockElements: readonly string[];

	/**
	 * The names of the elements whose whitespace is kept as it is, e.g. the verbatim blocks of an element schema.
	 */
	preElements?: readonly string[];
}

/**
 * The data processor of an XML document: the editor data is the XML of the document, and the content of the editor is
 * the content of its root element.
 *
 * * {@link toView} parses the XML with the browser's XML parser, so data that is not well-formed is rejected, and takes
 *   the children of the root element. The attributes of the root element are kept, for the data written next, e.g. the
 *   `id` of a topic.
 * * {@link toData} writes the XML from the view, element for element: each element with its name and its attributes,
 *   the block-level elements on lines of their own, indented, and the text escaped. An empty element is written as
 *   `<para/>`. There are no fillers, which HTML needs and XML does not have.
 */
export default class XmlDocumentDataProcessor implements DataProcessor {
	public skipComments = true;

	private readonly _domParser = new DOMParser();
	private readonly _domConverter: ViewDomConverter;
	private readonly _root: string;
	private readonly _prolog: string;
	private readonly _blockElements: ReadonlySet<string>;

	/**
	 * The attributes of the root element of the data loaded last, written in the data again.
	 */
	private _rootAttributes = '';

	constructor(
		viewDocument: ViewDocument,
		{ root, prolog, blockElements, preElements = [] }: XmlDocumentDataProcessorOptions
	) {
		this._domConverter = new ViewDomConverter(viewDocument, { renderingMode: 'data' });

		// The whitespace between block-level elements is the indentation of the XML, not text; in a `pre` element, it is.
		this._domConverter.blockElements.push(...blockElements);
		this._domConverter.preElements.push(...preElements);
		this._root = root;
		this._prolog = prolog;
		this._blockElements = new Set(blockElements);
	}

	/**
	 * Converts the XML of a document to the view: the content of its root element. Throws for data that is not
	 * well-formed XML, or whose root is another element.
	 */
	public toView(data: string): ViewDocumentFragment {
		const fragment = document.createDocumentFragment();

		this._rootAttributes = '';

		if (data.trim()) {
			const xml = this._domParser.parseFromString(data, 'application/xml');
			const error = xml.getElementsByTagName('parsererror').item(0);

			if (error) {
				throw new Error(`The data is not well-formed XML: ${error.textContent.trim()}`);
			}

			const root = xml.documentElement;

			if (root.localName !== this._root) {
				throw new Error(`The data must be a "${this._root}", not a "${root.localName}".`);
			}

			this._rootAttributes = writeAttributes(Array.from(root.attributes, ({ name, value }) => [name, value]));

			for (const child of Array.from(root.childNodes)) {
				fragment.append(document.importNode(child, true));
			}
		}

		return this._domConverter.domToView(fragment, {
			keepOriginalCase: true,
			skipComments: this.skipComments
		}) as ViewDocumentFragment;
	}

	/**
	 * Converts the view to the XML of a document.
	 */
	public toData(viewFragment: ViewDocumentFragment): string {
		const content = Array.from(viewFragment.getChildren())
			.map(node => this._writeLine(node, 1))
			.join('\n');
		const start = `<${this._root}${this._rootAttributes}`;

		return `${this._prolog}${content ? `${start}>\n${content}\n</${this._root}>` : `${start}/>`}\n`;
	}

	public registerRawContentMatcher(pattern: MatcherPattern): void {
		this._domConverter.registerRawContentMatcher(pattern);
	}

	/**
	 * Does nothing: the data has no fillers.
	 */
	public useFillerType(): void {
		// The data is written from the view, which has no fillers.
	}

	/**
	 * Writes a node on a line of its own, indented: a block-level element, with its children on lines of their own when
	 * there are block-level elements among them, or anything else as inline content.
	 */
	private _writeLine(node: ViewNode, depth: number): string {
		const indent = '\t'.repeat(depth);

		if (!this._isBlock(node)) {
			return indent + writeInline(node);
		}

		const children = Array.from(node.getChildren());
		const start = `<${node.name}${writeAttributes(node.getAttributes())}`;

		if (!children.length) {
			return `${indent}${start}/>`;
		}

		if (children.some(child => this._isBlock(child))) {
			const lines = children.map(child => this._writeLine(child, depth + 1)).join('\n');

			return `${indent}${start}>\n${lines}\n${indent}</${node.name}>`;
		}

		return `${indent}${start}>${children.map(writeInline).join('')}</${node.name}>`;
	}

	private _isBlock(node: ViewNode): node is ViewElement {
		return node.is('element') && this._blockElements.has(node.name);
	}
}

/**
 * Writes a node in a line of text: text, escaped, or an element with its content.
 */
function writeInline(node: ViewNode): string {
	if (node.is('$text')) {
		return escape(node.data);
	}

	if (!node.is('element')) {
		return '';
	}

	const children = Array.from(node.getChildren());
	const start = `<${node.name}${writeAttributes(node.getAttributes())}`;

	return children.length ? `${start}>${children.map(writeInline).join('')}</${node.name}>` : `${start}/>`;
}

function writeAttributes(attributes: Iterable<[string, string]>): string {
	return Array.from(attributes)
		.map(([key, value]) => ` ${key}="${escape(value).replaceAll('"', '&quot;')}"`)
		.join('');
}

function escape(text: string): string {
	return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
  • It reads the XML with the browser's XML parser, so data that is not well-formed is rejected, with the error of the parser; so is a document whose root is not an article. The content of the editor is the content of the article, and the attributes of the article are kept.
  • It writes the XML from the view, element for element: each element with its name and its attributes, the block-level ones on lines of their own, indented, the text escaped, and an empty element as <para/>. It writes no fillers: HTML needs them for empty blocks, XML does not have them.
  • The whitespace between the block-level elements is the indentation, not text. In the verbatim blocks of the schema, from getVerbatimElements(), it is kept.
  • As for any CKEditor data, getData() is empty for an empty editor; getData({ trim: 'none' }) is an article with an empty paragraph.

The converters: DocBook names in the data ​

The converters of the schema write each element with its name, and its attributes with theirs (<ulink url="…">): elementSchemaConverters: 'names'. The editing view stays HTML, from the same converters.

The XmlDocument plugin sets the data processor, with the block-level elements and the verbatim blocks of the schema, and makes CKEditor's paragraph the para of the data. It is configured by the xmlDocument option of the editor: the root element, the prolog, and the name of the paragraph.

ts
elementSchemaConverters: 'names',
xmlDocument: { root: 'article', prolog: DOCBOOK_PROLOG, paragraph: 'para', className: 'docbook' }
xmlDocument.ts
ts
import { Paragraph, Plugin, type DowncastInsertEvent, type ModelElement } from 'ckeditor5';
import { ElementSchema, getVerbatimElements } from 'ckeditor5-elements';
import XmlDocumentDataProcessor from './xmlDocumentDataProcessor.js';

/**
 * The `xmlDocument` option: the XML document whose content is the content of the editor.
 */
export interface XmlDocumentConfig {
	/**
	 * The name of the root element of the document, e.g. `article` or `topic`.
	 */
	root: string;

	/**
	 * What comes before the root element: the XML declaration and the document type.
	 */
	prolog: string;

	/**
	 * The name of CKEditor's paragraph in the data, e.g. `para` or `p`. The paragraph is the paragraph of the document,
	 * so typing, Enter and an empty editor work as usual.
	 */
	paragraph: string;

	/**
	 * The class of the editable, for the styles of the content, e.g. `docbook`.
	 */
	className?: string;
}

/**
 * Edits an XML document whose elements are the elements of the element schema, by their names: the editor data is the
 * XML of the document (see {@link XmlDocumentDataProcessor}), and the content of the editor is the content of its root
 * element. It is configured by the `xmlDocument` option.
 *
 * * The converters of the schema write the elements with their names, and their attributes as they are, e.g.
 *   `<xref href="…">`: the `elementSchemaConverters` option is `'names'`. The editing view stays HTML: `div` and `span`,
 *   or the `editingTag` of the element.
 * * CKEditor's paragraph is the paragraph of the document, e.g. `para`.
 * * The verbatim blocks of the schema keep their whitespace in the data.
 * * A soft break (Shift+Enter) of a paragraph is a new line in the data, as XML has no element for it.
 */
export default class XmlDocument extends Plugin {
	public static get pluginName(): 'XmlDocument' {
		return 'XmlDocument' as const;
	}

	public static get requires(): readonly [typeof Paragraph, typeof ElementSchema] {
		return [Paragraph, ElementSchema] as const;
	}

	public init(): void {
		const editor = this.editor;
		const { paragraph, className } = this._getConfig();

		if (editor.config.get('elementSchemaConverters') !== 'names') {
			throw new Error(
				"XmlDocument: set elementSchemaConverters to 'names', so the data has the names of the elements."
			);
		}

		editor.conversion
			.for('dataDowncast')
			.elementToElement({ model: 'paragraph', view: paragraph, converterPriority: 'high' });
		editor.conversion.for('upcast').elementToElement({ view: paragraph, model: 'paragraph' });
		this._convertSoftBreak();

		if (className) {
			this.listenTo(editor.ui, 'ready', () => {
				const root = editor.editing.view.document.getRoot();

				if (root) {
					editor.editing.view.change(writer => {
						writer.addClass(className, root);
					});
				}
			});
		}
	}

	/**
	 * Sets the data processor, once the element schema is registered, and before the data is loaded.
	 */
	public afterInit(): void {
		const editor = this.editor;
		const { root, prolog, paragraph } = this._getConfig();
		const definition = editor.plugins.get(ElementSchema).definition ?? {};

		editor.data.processor = new XmlDocumentDataProcessor(editor.data.viewDocument, {
			root,
			prolog,
			blockElements: [
				paragraph,
				...Object.entries(definition)
					.filter(([, element]) => !element.isInline)
					.map(([name]) => name)
			],
			preElements: getVerbatimElements(definition)
		});
	}

	/**
	 * A soft break is a new line of the text in the data, as XML has no element for it.
	 */
	private _convertSoftBreak(): void {
		this.editor.conversion.for('dataDowncast').add(dispatcher => {
			dispatcher.on<DowncastInsertEvent<ModelElement>>(
				'insert:softBreak',
				(evt, data, { consumable, mapper, writer }) => {
					if (consumable.consume(data.item, 'insert')) {
						writer.insert(mapper.toViewPosition(data.range.start), writer.createText('\n'));
					}
				},
				{ priority: 'high' }
			);
		});
	}

	private _getConfig(): XmlDocumentConfig {
		const config = this.editor.config.get('xmlDocument');

		if (!config) {
			throw new Error('XmlDocument: the xmlDocument option is required: { root, prolog, paragraph }.');
		}

		return config;
	}
}

declare module 'ckeditor5' {
	interface EditorConfig {
		/**
		 * The XML document whose content is the content of the editor. See {@link XmlDocument}.
		 */
		xmlDocument?: XmlDocumentConfig;
	}
}

The breadcrumbs show the path of the data, so they show the DocBook names. A soft break (Shift+Enter) in a paragraph is a new line in the data: DocBook has no element for it.

The rest is the element schema's: the lists and the program listing work as their keys say, with no code of the example.

The editor ​

config.ts
ts
import {
	Essentials,
	IconBulletedList,
	IconCode,
	IconCodeBlock,
	IconDocumentOutline,
	IconHeading2,
	IconItalic,
	IconNumberedList,
	IconQuote,
	Paragraph,
	SourceEditing,
	type EditorConfig
} from 'ckeditor5';
import {
	ElementBreadcrumbs,
	ElementActions,
	ElementAttributes,
	ElementClickable,
	ElementClipboard,
	ElementContextMenu,
	ElementSchema,
	ElementSchemaCommands,
	ElementSelectable,
	ElementToolbar,
	ElementQuickInsert,
	insertIcon,
	pasteIcon,
	type ElementMenuConfig
} from 'ckeditor5-elements';
import { XmlDocument } from 'ckeditor5-elements-xml-example';
import schema from './schema.json';

/**
 * What comes before the article: the XML declaration, and the document type of Simplified DocBook 1.1.
 */
export const DOCBOOK_PROLOG =
	'<?xml version="1.0" encoding="UTF-8"?>\n' +
	'<!DOCTYPE article PUBLIC "-//OASIS//DTD Simplified DocBook XML V1.1//EN" ' +
	'"http://www.oasis-open.org/docbook/xml/simple/1.1/sdocbook.dtd">\n';

/**
 * The menu of an element, for the context menu and the breadcrumbs: inserting, the element actions, the clipboard, its
 * attributes, and deleting.
 */
export const docBookMenu: ElementMenuConfig = (element, editor) => [
	{
		title: 'Insert',
		icon: insertIcon,
		items: [editor.commands.get('quickInsertBefore'), editor.commands.get('quickInsertAfter')]
	},
	editor.commands.get('duplicateElement'),
	'|',
	editor.commands.get('moveElementUp'),
	editor.commands.get('moveElementDown'),
	'|',
	editor.commands.get('copyElement'),
	editor.commands.get('cutElement'),
	{
		title: 'Paste',
		icon: pasteIcon,
		items: [editor.commands.get('pasteElementBefore'), editor.commands.get('pasteElementAfter')]
	},
	'|',
	editor.commands.get('showElementAttributes'),
	editor.commands.get('deleteElement')
];

const ACTIONS = ['moveElementUp', 'moveElementDown', '|', 'duplicateElement', 'deleteElement'];

/**
 * The editor config of the DocBook example: the elements of `schema.json`, by their DocBook names, with the data of a
 * Simplified DocBook article (see `XmlDocument`), and the features of `ckeditor5-elements`. Without `attachTo`,
 * `licenseKey` and the data.
 */
export const docBookConfig: EditorConfig = {
	plugins: [
		Essentials,
		SourceEditing,
		Paragraph,
		ElementSchema,
		ElementSchemaCommands,
		ElementSelectable,
		XmlDocument,
		ElementContextMenu,
		ElementActions,
		ElementClipboard,
		ElementAttributes,
		ElementClickable,
		ElementToolbar,
		ElementQuickInsert,
		ElementBreadcrumbs
	],
	toolbar: {
		items: [
			'undo',
			'redo',
			'sourceEditing',
			'|',
			'title',
			'section',
			'|',
			'itemizedlist',
			'orderedlist',
			'blockquote',
			'programlisting',
			'|',
			'emphasis',
			'literal',
			// The path of the DocBook elements at the cursor, on a toolbar line of its own.
			'-',
			'breadcrumbs'
		],
		shouldNotGroupWhenFull: true
	},
	// The elements of DocBook, by their names in the data. Their editing view is HTML, from the converters of the schema.
	elementSchema: schema,
	elementSchemaConverters: 'names',
	// The data: the XML of the article, with the elements by their names, and CKEditor's paragraph as `para`.
	xmlDocument: { root: 'article', prolog: DOCBOOK_PROLOG, paragraph: 'para', className: 'docbook' },
	// A command and a button for each element, by its kind; the lists get the command of a list.
	elementSchemaCommands: {
		title: { title: 'Title', icon: IconHeading2 },
		section: { title: 'Section', icon: IconDocumentOutline },
		itemizedlist: { title: 'Bulleted list', icon: IconBulletedList },
		orderedlist: { title: 'Numbered list', icon: IconNumberedList },
		blockquote: { title: 'Block quote', icon: IconQuote },
		programlisting: { title: 'Program listing', icon: IconCodeBlock },
		emphasis: { title: 'Emphasis', icon: IconItalic, keystroke: 'Ctrl+I' },
		literal: { title: 'Literal', icon: IconCode }
	},
	elementContextMenu: docBookMenu,
	elementBreadcrumbs: { menu: docBookMenu },
	// Click a link: a modal with its attributes, the `url`.
	elementClickable: [
		{
			element: 'ulink',
			callback: (element, editor) => editor.commands.get('showElementAttributes')?.executeFor(element)
		}
	],
	elementToolbar: [
		{ element: 'section', items: ['section', '|', ...ACTIONS], ariaLabel: 'Section toolbar' },
		{ element: 'listitem', items: ACTIONS, ariaLabel: 'List item toolbar' }
	],
	elementQuickInsert: {
		labels: {
			paragraph: 'Paragraph',
			title: 'Title',
			section: 'Section',
			itemizedlist: 'Bulleted list',
			orderedlist: 'Numbered list',
			listitem: 'List item',
			blockquote: 'Block quote',
			programlisting: 'Program listing',
			emphasis: 'Emphasis',
			literal: 'Literal',
			softBreak: 'Line break'
		},
		// A link needs its address: write it in the source. A list is made of blocks, with its button.
		exclude: ['ulink', 'itemizedlist', 'orderedlist']
	}
};
article.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE article PUBLIC "-//OASIS//DTD Simplified DocBook XML V1.1//EN" "http://www.oasis-open.org/docbook/xml/simple/1.1/sdocbook.dtd">
<article>
	<title>An article in Simplified DocBook</title>
	<para>This article is <emphasis>DocBook XML</emphasis>: the editor loads it, and saves it as it is. Each part of it is an element of the editor, with its DocBook name.</para>
	<section>
		<title>Elements, all the way down</title>
		<para>A <literal>section</literal> holds its <literal>title</literal>, its paragraphs, its lists and its sections. An <emphasis>emphasis</emphasis> is an element too, in a line of text.</para>
		<itemizedlist>
			<listitem>
				<para>Put the cursor here: the breadcrumbs show the path of the DocBook elements.</para>
			</listitem>
			<listitem>
				<para>Right-click an element for its menu, or press Alt+Enter to insert an element where the cursor is.</para>
			</listitem>
		</itemizedlist>
	</section>
	<section>
		<title>Code, quotes and links</title>
		<programlisting>const article = editor.getData();
// The article, as DocBook XML.</programlisting>
		<blockquote>
			<para>Press <emphasis>Source</emphasis> to see the XML, and to edit it.</para>
		</blockquote>
		<para>A link keeps its address: <ulink url="https://docbook.org/">DocBook</ulink>. Click it to see its attributes.</para>
	</section>
</article>

What it does not do ​

  • It is a part of Simplified DocBook: an article of sections, titles, paragraphs, lists, block quotes, program listings, emphasis, literals and links. Tables, figures, articleinfo and the other elements are not declared, so the editor does not keep them.
  • The schema says where an element goes, not in which order or how many times. The editor does not require the title of a section to be its first child: a section made with the Section button has no title until you add one.
  • The address of a link is edited in the source. The editor shows it, and keeps it.
  • Comments and processing instructions are not kept, and whitespace outside a program listing is not either, as in most XML.