Migrate extension framework to Editor 2.0 (New Editor)

This guide helps extension authors understand what’s involved in moving their customizations from the Old Editor to the New Editor in AEM Guides, so that they can plan their transition smoothly and with minimal disruption.

IMPORTANT
If you have an existing AEM Guides extension (Old Editor), including custom context menu items, toolbar buttons, dialogs, attribute or metadata logic, or content styling, this guide helps you keep it working with the New Editor.

Overview

  • Your registration does not change: Keep using window.extension / tcx.extension.register.
  • The Editor canvas is a new surface. Context-menu items must declare the new widget id
    markup_editor_menu; in-editor behavior must stop touching the DOM.
  • Stop reading/writing the DOM: Replace tcx.curEditor.* DOM access with the
    guides.editor API: read with runUtil(...), write with runCommand(...), style with decorations, and run global actions (save) through app events .
  • App-shell menus (repository, map viewer, file/folder) are unchanged: They still run on
    the legacy framework.
  • Both editors coexist: Target both with arrays. When loading Register plugins unconditionally; gate only runtime actions by guides.editor.version (which stays 1.0.0 until a file is open, view Detect the editor and bootstrap safely).

Why the change?

Criteria
Legacy CKEditor
New MarkupEditor
Source of truth
DOM
ProseMirror document
Selection
getSelection() on a root document
ProseMirror selection (positions/ranges)
To change content
Mutate DOM attributes/classes
Dispatch a command (transaction)
Rendering
DOM is permanent
DOM is an ephemeral render in a shadow DOM, rebuilt at any time
Styling
Page or clientlib CSS
CSS injected shadow DOM thorugh register plugin. Refer to Hello world: a CSS-only highlight plugin for to use existing classes and add CSS and Migrate rendering-only logic for adding anew class and add styling.

Any extension that mutates the DOM or any DOM changes are not retained, they get wiped out on the next rerender. The migration is fundamentally move from DOM-first to model-first.

Detect the Editor and bootstrap safely

The global guides object is the entry point for all new integrations:

guides.editor    // editor interaction APIs
guides.util      // bundled utility libs (lodash, async)
guides.ready(cb) // fires once at app load (view system ready) — before any file is open

guides.editor.version reports the currently open editor, so it is only meaningful once a
file is actually open:

guides.editor.version
Meaning
2.0.0
A MarkupEditor (ProseMirror) file is open
1.0.0
A legacy CKEditor file is open or no file is open yet
IMPORTANT
When the guides.ready event occurs, no file has yet opened, so version will report as 1.0.0 regardless of whether MarkupEditor is enabled. Do not use version to determine whether plugins get registered (view Plugin Registration and Runtime Gating). Use it only to branch runtime behavior, and evaluate it at the point of execution (e.g., within a menu handler), where a file is guaranteed to be open.

Plugin registration and runtime gating

  • Registration (registerPlugin, one-time setup): Run it unconditionally in guides.ready. It is a harmless no-op on the legacy editor: the legacy editor never reads the plugin registry, and your factory runs only when a MarkupEditor is actually constructed. It does not throw.

  • Runtime calls (runCommand, runUtil, addDecoration, …): Gate by version exists and not equal to “1.0.0” at call time. They don’t throw on the legacy editor (they safely return false/undefined), but gating avoids no-op warnings and lets you keep a legacy fallback.

guides.ready(() => {
  // Always register — inert on legacy, applied only when a MarkupEditor opens.
  guides.editor.registerPlugin(createMyPlugin);
});

function onMenuClick() {
  if (guides.editor.version && guides.editor.version !== "1.0.0") {
    guides.editor.runCommand('surroundWithElement', 'sup'); // MarkupEditor path
  } else {
    // legacy path (or no-op)
  }
}

Pass a factory () => ({ plugin, css }) — to registerPlugin, never a constructed plugin instance. A non-function is the only input it rejects (throws on both editors). Do not cache the editor instance; call guides.editor.* fresh each time.

Hello world: a CSS-only highlight plugin

The smallest useful extension ships only CSS a no-op ProseMirror plugin plus styles. This
highlights every <note> element with a yellow background inside the editor:

guides.ready(() => {
  guides.editor.registerPlugin(() => ({
    plugin: new guides.editor.prosemirror.state.Plugin({}), // no behavior — CSS only
    css: `[data-xml-element="note"] { background: #fff3cd; outline: 1px solid #ffe08a; }`
  }));
});
  • Every element renders as data-xml-element="<tag>", so you can target any DITA element that way
    (note, codeblock, section, table, …).
  • CSS must ship via the registerPlugin: the editor lives in a shadow DOM, so page/clientlib CSS can’t
    reach it.
  • Open a DITA topic containing a <note> to see it applied. Registration is unconditional (§2.1),
    so this is safe even though version is still 1.0.0 at guides.ready time.

Inventory your extension (grep checklist)

# DOM-first reads that will break
grep -rnE "rootDocument|rootElement|getSelection\(|selectedHtml|selectedText|\.xmlDoc|\.ancestors\b" src

# DOM/legacy writes that will break
grep -rnE "updateAttributes\(|setAttribute\(|classList\.|\.saveFile\(|resetDirty\(|validateRangeForInsertion\(" src

# The editor handle itself
grep -rn "tcx.curEditor" src

# Context-menu targeting + page CSS
grep -rnE "contextMenuWidget|dita_editor_menu|author_outline_element" src
grep -rn "dita_content_overrides" .

Every hit is a migration item. Classify each as: context-menu surface, state read, content
write
, global action, rendering-only, or CSS.

Common for both the Editors

The following behaviors and structures apply identically to both the Editors:

  • Registration: window.extension[id] = config and/or tcx.extension.register(id, config) on
    the tcx-loaded event.

  • Config object shape: { id, contextMenuWidget, view: { items }, controller }.

  • App-shell context menus keep their existing widget ids and the legacy behavior:

    table 0-row-2 1-row-2 2-row-2 3-row-2
    Surface Widget id (unchanged)
    Repository panel (file/folder) repository_panel / file_options / folder_options
    Map viewer ditamap_viewer / map_view_options
    Baseline / preset panels baseline_panel_menu / preset_item_menu

    Items targeting these surfaces need no change for the New Editor, do not move them to
    markup_editor_menu.

API replacement reference

Legacy (tcx.curEditor… / DOM)
New MarkupEditor
tcx.curEditor.filePath
guides.editor.filePath
getSelection() / selectedHtml / selectedText
runUtil('getSelectedXml' / 'getSelectedPlainText' / 'hasSelection')
rootDocument.querySelector(tag)
runUtil('findPositionRange' / 'findPositionRanges', tag)
element .getAttribute / xmlDoc.attributes
runUtil('getAttributeAtPosition', pos, name) / getSerializableAttributes(xpath)
root id (querySelector('[concept]').id)
runUtil('getAttributeAtPosition', 0, 'id')
editor.ancestors
runUtil('getAncestorsDetails' / 'getAncestorXpaths')
editor.updateAttributes(attrs, root)
runCommand('setNodeXmlAttributes', 0, attrs)
set attr on element
runCommand('setNodeXmlAttribute', pos, name, value)
wrap / insert / unwrap selection
runCommand('surroundWithElement' / 'insertXml' / 'unwrapNode', …)
canInsertXmlElement / validateRangeForInsertion
canRunCommand(name, …) / canInsertXmlElement(tag)
editor.focus()
guides.editor.focus()
tcx.curEditor.saveFile()
tcx.eventHandler.next(KEYS.AUTHOR_SAVE_KEY)
setAttribute / classList for styling
addDecoration / batchDecorations / registerPlugin
page/clientlib CSS for editor content
registerPlugin({ css }) (shadow DOM)
contextMenuWidget: 'dita_editor_menu'
['dita_editor_menu', 'markup_editor_menu']

Migrate context-menu items (Editor canvas)

This applies only to menus that targeted the editor (dita_editor_menu,
author_outline_element), i.e. the right-click / breadcrumb menu inside the editing surface.

How it routes on the New Editor

window.extension[id]  ─►  filtered by contextMenuWidget == 'markup_editor_menu'
                      ─►  view.items rendered in the canvas menu
   (click) ───────────►  fires an extension event:
                          • eventid is a known global key  → run as a built-in editor command
                          • otherwise                       → your controller[eventid]() runs

Add the new widget id (array keeps legacy working)

// BEFORE
contextMenuWidget: 'dita_editor_menu',
// AFTER
contextMenuWidget: ['dita_editor_menu', 'markup_editor_menu'],

Keep the expected shape

  • Actionable items live under view.items with a data.eventid.
  • Each controller method name matches its eventid exactly.
view: {
  items: [{
    displayName: 'Edit Cross Reference',
    icon: 'link',
    data: { eventid: 'editCrossReference' },
    target: { key: 'displayName', value: 'Cut', viewState: 'prepend' }
  }]
},
controller: {
  editCrossReference() { /* runs on click */ }
}

Re-anchor target

The new menu resolves target against the MarkupEditor’s own menu items.

  • target.key: displayName | id | icon | eventid
  • target.viewState: append | prepend | replace
  • Anchor to a stable native item such as Cut.
  • If the anchor does not resolve, the item still appears but lands at the default position
    (not an error, fix the anchor).

Choose the routing per item

data: { eventid: 'AUTHOR_CUT' }          // built-in command → routed natively, no controller needed
data: { eventid: 'editCrossReference' }  // custom → runs controller.editCrossReference()

Add readOnly: true on an item that must stay enabled in read-only content.

Rewrite the handler body

Handlers usually read the selection and mutate a node, migrate those off the DOM.

Migrate reads (DOM: runUtil)

// BEFORE — DOM selection / queries
const { editor } = tcx.curEditor;
const html = editor.selectedHtml;
const topicId = editor.rootDocument.querySelector('[data-tcx-tag="concept"]').id;

// AFTER — read from the document model
const selectedXml = guides.editor.runUtil('getSelectedXml');
const hasSel      = !!guides.editor.runUtil('hasSelection'); // check if selection is empty
const topicId     = guides.editor.runUtil('getAttributeAtPosition', 0, 'id'); // root = position 0

Find a node by tag, match by id, read an XML attribute:

let value = '';
for (const range of (guides.editor.runUtil('findPositionRanges', 'xref') || [])) {
  const id = guides.editor.runUtil('getAttributeAtPosition', range.from, 'id');
  if (String(id) !== String(targetId)) continue;
  value = guides.editor.runUtil('getAttributeAtPosition', range.from, 'placeholdertext') || '';
  break;
}

Read utilities: getTextPos, getNodePosition, getSelectedXml, getSelectedPlainText,
hasSelection, getAncestorsNames, getAncestorsDetails, getAncestorXpaths,
findPositionRange, findPositionRanges, getAttributeAtPosition, getSerializableAttributes. Refer Appendix.

Migrate writes (DOM mutation: runCommand)

// BEFORE
const root = editor.rootElement.findOne('[data-tcx-tag="concept"]');
editor.updateAttributes({ docOwner: 'Jane' }, root);

// AFTER — update the model; persists across rerenders
guides.editor.runCommand('setNodeXmlAttributes', 0, { docOwner: 'Jane' });
// Set one attribute at a found position
guides.editor.runCommand('setNodeXmlAttribute', pos, 'placeholdertext', text);

// Wrap / insert / unwrap
guides.editor.runCommand('surroundWithElement', 'sup');
guides.editor.runCommand('insertXml', '<sup></sup>', undefined, { setCursorInContent: true });
guides.editor.runCommand('unwrapNode');

Prerequisite

guides.editor.focus();
if (!guides.editor.canInsertXmlElement('xref')) {
  return tcx.util.showAlert('warning', 'xref is not allowed here');
}
if (guides.editor.canRunCommand('surroundWithElement', 'sup')) {
  guides.editor.runCommand('surroundWithElement', 'sup');
}

Commands: setNodeXmlAttributes, setNodeXmlAttribute, surroundWithElement, insertXml,
unwrapNode. Refer Appendix.

Migrate global actions (save/focus: app events)

// BEFORE
tcx.curEditor?.saveFile?.();
// AFTER
tcx.eventHandler.next(tcx.eventHandler.KEYS.AUTHOR_SAVE_KEY);

resetDirty(...) and tcx.curEditor.html have no MarkupEditor equivalent so drop them; saving
through the event handles dirty state centrally. Use guides.editor.focus() for focus.

Migrate rendering-only logic (DOM paint: decorations)

Anything that added CSS classes, data-* attributes, or “display text” by mutating the DOM must
become a decoration, or it vanishes on rerender. Below are simple declarative cases:

guides.editor.addDecoration('important-sections', 'section', {
  class: 'section-important',
  computeAttributes: (node, ctx) => ({ 'data-number-label': String(ctx.index + 1) }),
  filter: (node) => node.attrs?.xmlAttrs?.importance === 'high'
});

guides.editor.batchDecorations([
  { action: 'remove', id: 'legacy-numbering' },
  { action: 'add', id: 'division-numbering', selector: 'conbody', options: { class: 'division-numbering' } }
]);

guides.editor.removeDecoration('important-sections');
guides.editor.clearDecorations();
guides.editor.getDecorations();

Complex cases (custom state, broken-state via transaction meta, widget text): Register a
ProseMirror plugin once, using the exposed libraries:

const createXrefPlugin = () => {
  const { Plugin, PluginKey } = guides.editor.prosemirror.state;
  const { Decoration, DecorationSet } = guides.editor.prosemirror.view;
  return {
    plugin: new Plugin({ key: new PluginKey('xrefDisplay'), props: { decorations(state) { /* … */ } } }),
    css: `.xref-broken { text-decoration: underline wavy red; }`
  };
};

guides.ready(() => guides.editor.registerPlugin(createXrefPlugin));

Register plugins at app load (once), not inside dialogs or repeatedly, the registry does not dedupe. registerPlugin accepts a factory function only, not a plugin instance.
guides.editor.prosemirror exposes: state, model, view, transform, commands, keymap,
history, tables, dropcursor, collab, markdown.

Migrate CSS (page clientlib → shadow DOM)

The MarkupEditor renders inside a shadow DOM; page-level and AEM clientlib CSS do not reach it.

guides.editor.registerPlugin(() => ({
  plugin: new guides.editor.prosemirror.state.Plugin({}),   // no-op, CSS only
  css: `[data-xml-element="codeblock"] { font-family: monospace; background: #f5f5f5; }`
}));

The legacy content clientlib category (apps.guides.xml_editor.dita_content_overrides) still
styles the legacy editor only, keep it if you support both, but know it is inert on MarkupEditor.

Accessing the live EditorView (plugin view prop): DOM escape hatch

Decorations and commands are the preferred approach. However, some effects can’t be implemented as decorations. In those cases, use the plugin view property to access the live EditorView and operate on editorView.dom. This is the only supported way to interact directly with the rendered editor DOM.

const createMyPlugin = () => {
  const { Plugin } = guides.editor.prosemirror.state;
  return {
    plugin: new Plugin({
      view(editorView) {
        const root = editorView.dom;          // the shadow-DOM editor node
        const apply = () => { /* re-color / rewrite target nodes in `root` */ };
        apply();
        return {
          update(view, prevState): apply,                       // re-apply after every rerender
          destroy() { /* remove any listeners/observers */ },
        };
      },
    }),
    css: `/* ... */`,
  };
};

guides.ready(() => guides.editor.registerPlugin(createMyPlugin));

Guardrails:

  • Escape hatch only, use decorations for classes, labels, and styling.
  • editorView.dom is the only supported handle;
  • Re-apply from update() so the change survives rerenders; clean up in destroy().

Plugin registration lifecycle

registerPlugin in guides.ready only registers the factory once. The factory itself runs again
each time a file is opened — every MarkupEditor file open invokes it fresh to build that file’s
plugin instance.

Common issues

  • Where DOM code addresses nodes and Ranges, MarkupEditor addresses positions, plain integers indexing into the document (0 = document start, i.e. the root). A range is { from, to }, two positions bounding a span — not a DOM Range. Positions shift as the document changes, so don’t cache one across an edit.
  • Item doesn’t appear in the New Editor menu: contextMenuWidget is missing
    markup_editor_menu, or the config was registered after the editor opened (config is read
    once at editor construction register at app load).
  • Item appears in the wrong place: target anchor doesn’t resolve; anchor to an item that
    exists in the new menu (e.g. Cut).
  • Change “works” then disappears: You mutated the DOM. Use a command (write) or a decoration
    (style) instead.
  • CSS has no effect: It’s page-level; the editor is in a shadow DOM. Use registerPlugin({ css }).
  • Unsafe guards throw: Patterns like if (!tcx.curEditor && !tcx.curEditor.editor) evaluate
    .editor on a falsy object. Guard on guides.editor capabilities instead:
    if (!guides?.editor) return;.
  • Trying to migrate app-shell menus: Repository/map/file menus are not the editor canvas;
    leave them on their legacy widget ids.

Verification checklist

  • Context-menu items appear in both the legacy and MarkupEditor menus.
  • Items land in expected position.
  • Custom eventid runs controller[eventid]; global keys fire the built-in command.
  • State reads return correct values after typing/rerender (model, not stale DOM).
  • Content writes persist after save and reopen.
  • Decorations survive a rerender.
  • Shadow-DOM CSS visibly applies inside the editor.
  • Save fires via AUTHOR_SAVE_KEY and clears dirty state.
  • readOnly items behave correctly in locked content.
  • Preview or side-by-side; intentional read-only DOM work is left as-is.
  • grep -rn "tcx.curEditor" src is clean (or only the documented, intentional remainder).
  • Plugins registered exactly once, inside guides.ready.

Suggested rollout sequence

  1. Bootstrap: Wrap setup in guides.ready; register plugins unconditionally and add version gating around runtime actions only (For details, view Plugin Registration and Runtime Gating).
  2. Context-menu surface: Add markup_editor_menu, fix target anchors. Items now appear.
  3. Reads: Migrate selection/attribute reads to runUtil.
  4. Writes: Migrate mutations to runCommand; saves to app events.
  5. Rendering: Move DOM styling to decorations / registerPlugin; move CSS to shadow DOM.
  6. Harden: Fix unsafe guards, remove the editor handle, verify on both editors.

Migrate one surface at a time and keep the legacy paths working (arrays + version gating) so a
single extension build runs on both editors throughout the transition.

Appendix A: More exposed utils (examples)

Find the below utils to use through runUtil.

Util
Params → Returns
What it does
getTextPos
(): { start, end }
Current selected text node boundaries
getValidElementNames
(ancestorLevel?): ElementName[]
Element names that could legally be inserted/wrapped at the current selection.
getValidElementNamesBefore
(): ElementName[]
Element names valid immediately before the current selection.
getSelectedText
(): string
Raw selected text.
getSerializableAttributes
(): { [key]: string }
XML attribute map for the current node, keyed by attribute name.
getTagName
(): string | null
Tag name of the current node.
hasSelection
(): boolean
Whether any content is currently selected.
isSelectionEditable
(): boolean
Whether the current selection can be edited.
getAncestorPos
(name): number | undefined
Position of the nearest ancestor with the given element name, from the current selection.
getValidWrapNodeElementNames
(): ElementName[]
Element names valid for wrapNode at the current selection.
getValidRenameNodeElementNames
(): ElementName[]
Element names the current node could legally be renamed to.
getValidSurroundElementNames
(): ElementName[]
Element names valid for surroundWithElement at the current selection.
serialize
(doc?): string
Serializes a ProseMirror doc (or the whole document) to XML.
getSelectedXml
(range?): string
XML for the current selection, or an explicit { from, to } range.
getRangeXml
(xpaths): string
XML for one or more xpath-object ranges (see §8’s xpath caveat — this is the object form, not the string form).
mapToXpath
(position, doc?): XPathPosition
Converts a position to the object-form xpath.
inverseMap
(xpath | position, doc?): number
Converts an object-form xpath (or position) back to a position.
getAncestorsDetails
(): { ancestors, previousSibling, nextSibling, currNode } | undefined
Ancestor chain plus immediate siblings for the current node.
getAncestorsNames
(): ElementName[]
Ancestor chain as element names only, for the current node.
getPreviousSibling
(): ElementName | undefined
Name of the previous sibling element.
getNextSibling
(): ElementName | undefined
Name of the next sibling element.
getAncestorXpaths
(includeNodeAtPosition?): { tag, xpath }[]
Ancestor chain as {tag, xpath} pairs — object-form xpath, not the updateAttributeByXpath string form (§8).
getSelectedPlainText
(range?): string
Plain text of the current selection or an explicit range.
getDecorations
(): string[]
IDs of all decorations currently applied.
getResolvedDitaDocumentTitle
(props?): string
Resolved display title of the DITA document. props: doc to target a specific document, allowedPrefixElements to allow title-prefix elements.

Appendix B: More exposed commands (examples)

The commands below are additional examples of what’s exposed via guides.editor.runCommand(name, ...args).
Guard any command with guides.editor.canRunCommand(name, ...args) first if it might not apply in the current context.

Command
Params
What it does
focusEditor
()
Focuses the editor.
unwrapNode
()
Removes the wrapping element at the current selection, keeping its children.
surroundWithElement
(elementName, attrs?, groupInline?)
Wraps the current selection in a new inline/block element. attrs: XML attribute map to set on the new wrapping element.
insertXml
(xml)
Inserts an XML fragment at the cursor.
replaceSelectionWithXml
(xml)
Replaces the current selection with XML.
insertText
(text)
Inserts plain text at the cursor.
selectNodesFromXpaths
(xpaths)
Selects one or more nodes given object-form xpaths.
delete
()
Deletes the current selection.
undo / redo
()
Standard undo/redo.
removeDecoration
(id)
Removes a single decoration by id.
clearDecorations
()
Removes all decorations in the current open file.
setFileReadOnly
(readOnly: boolean)
Toggles read-only mode for the file.
generateUniqueId
()
Generates and assigns a unique id attribute to the current node.
recommendation-more-help
experience-manager-guides-help-product-guide