diff --git a/composer.json b/composer.json
index eb28ea2..2fe695f 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,7 @@
     "source": "http://cgit.drupalcode.org/linkit"
   },
   "require" : {
-    "drupal/core": "^8.7.7 || ^9"
+    "drupal/core": "^9 || ^10"
   },
   "license": "GPL-2.0-or-later"
 }
diff --git a/config/schema/linkit.schema.yml b/config/schema/linkit.schema.yml
index c6e3f1d..d5e45e7 100644
--- a/config/schema/linkit.schema.yml
+++ b/config/schema/linkit.schema.yml
@@ -112,3 +112,19 @@ ckeditor.plugin.drupallink:
     linkit_profile:
       type: string
       label: 'Linkit profile'
+
+ckeditor5.plugin.linkit_extension:
+  type: mapping
+  label: Linkit
+  constraints:
+    Callback: [\Drupal\linkit\Plugin\CKEditor5Plugin\Linkit, requireProfileIfEnabled]
+  mapping:
+    linkit_enabled:
+      type: boolean
+      label: 'Use Linkit'
+    linkit_profile:
+      type: string
+      label: 'Linkit profile'
+      constraints:
+        Choice:
+          callback: \Drupal\linkit\Plugin\CKEditor5Plugin\Linkit::validChoices
diff --git a/js/build/linkit.js b/js/build/linkit.js
new file mode 100644
index 0000000..882bb7d
--- /dev/null
+++ b/js/build/linkit.js
@@ -0,0 +1 @@
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.CKEditor5=e():(t.CKEditor5=t.CKEditor5||{},t.CKEditor5.linkit=e())}(self,(()=>(()=>{var t={"ckeditor5/src/core.js":(t,e,i)=>{t.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/typing.js":(t,e,i)=>{t.exports=i("dll-reference CKEditor5.dll")("./src/typing.js")},"dll-reference CKEditor5.dll":t=>{"use strict";t.exports=CKEditor5.dll}},e={};function i(n){var o=e[n];if(void 0!==o)return o.exports;var s=e[n]={exports:{}};return t[n](s,s.exports,i),s.exports}i.d=(t,e)=>{for(var n in e)i.o(e,n)&&!i.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var n={};return(()=>{"use strict";i.d(n,{default:()=>u});var t=i("ckeditor5/src/core.js"),e=i("ckeditor5/src/typing.js");class o extends t.Plugin{init(){this.attrs=["data-entity-type","data-entity-uuid","data-entity-substitution"],this._allowAndConvertExtraAttributes(),this._removeExtraAttributesOnUnlinkCommandExecute(),this._refreshExtraAttributeValues(),this._addExtraAttributesOnLinkCommandExecute()}_allowAndConvertExtraAttributes(){const t=this.editor;t.model.schema.extend("$text",{allowAttributes:this.attrs}),this.attrs.forEach((e=>{t.conversion.for("downcast").attributeToElement({model:e,view:(t,{writer:i})=>{const n=i.createAttributeElement("a",{[e]:t},{priority:5});return i.setCustomProperty("link",!0,n),n}}),t.conversion.for("upcast").elementToAttribute({view:{name:"a",attributes:{[e]:!0}},model:{key:e,value:t=>t.getAttribute(e)}})}))}_addExtraAttributesOnLinkCommandExecute(){const t=this.editor,e=t.commands.get("link");let i=!1;e.on("execute",((e,n)=>{if(n.length<3)return;if(i)return void(i=!1);e.stop(),i=!0;const o=n[n.length-1],s=this.editor.model,r=s.document.selection;s.change((e=>{t.execute("link",...n);const i=r.getFirstPosition();this.attrs.forEach((t=>{if(r.isCollapsed){const n=i.textNode||i.nodeBefore;o[t]?e.setAttribute(t,o[t],e.createRangeOn(n)):e.removeAttribute(t,e.createRangeOn(n)),e.removeSelectionAttribute(t)}else{const i=s.schema.getValidRanges(r.getRanges(),t);for(const n of i)o[t]?e.setAttribute(t,o[t],n):e.removeAttribute(t,n)}}))}))}),{priority:"high"})}_removeExtraAttributesOnUnlinkCommandExecute(){const t=this.editor,i=t.commands.get("unlink"),n=this.editor.model,o=n.document.selection;let s=!1;i.on("execute",(i=>{s||(i.stop(),n.change((()=>{s=!0,t.execute("unlink"),s=!1,n.change((t=>{let i;this.attrs.forEach((s=>{i=o.isCollapsed?[(0,e.findAttributeRange)(o.getFirstPosition(),s,o.getAttribute(s),n)]:n.schema.getValidRanges(o.getRanges(),s);for(const e of i)t.removeAttribute(s,e)}))}))})))}),{priority:"high"})}_refreshExtraAttributeValues(){const t=this.editor,e=this.attrs,i=t.commands.get("link"),n=this.editor.model,o=n.document.selection;e.forEach((t=>{i.set(t,null)})),n.document.on("change",(()=>{e.forEach((t=>{i[t]=o.getAttribute(t)}))}))}static get pluginName(){return"LinkitEditing"}}const s=jQuery;function r(t,e){var i=s("<li>").addClass("linkit-result-line"),n=s("<div>").addClass("linkit-result-line-wrapper");return n.append(s("<span>").html(e.label).addClass("linkit-result-line--title")),e.hasOwnProperty("description")&&n.append(s("<span>").html(e.description).addClass("linkit-result-line--description")),i.append(n).appendTo(t)}function a(t,e){var i=this.element.autocomplete("instance"),n={};e.forEach((function(t){const e=t.hasOwnProperty("group")?t.group:"";n.hasOwnProperty(e)||(n[e]=[]),n[e].push(t)})),s.each(n,(function(e,n){e.length&&t.append('<li class="linkit-result-line--group ui-menu-divider">'+e+"</li>"),s.each(n,(function(e,n){i._renderItemData(t,n)}))}))}class l extends t.Plugin{static get requires(){return[o]}init(){this._state={};this.editor.config.get("linkit");this._enableLinkAutocomplete(),this._handleExtraFormFieldSubmit(),this._handleDataLoadingIntoExtraFormField()}_enableLinkAutocomplete(){const t=this.editor,e=t.config.get("linkit"),i=t.plugins.get("LinkUI").formView;let n=!1;i.extendTemplate({attributes:{class:["ck-vertical-form","ck-link-form_layout-vertical"]}}),t.plugins.get("ContextualBalloon")._rotatorView.content.on("add",((t,o)=>{if(o!==i||n)return;let l;!function(t,e){const{autocompleteUrl:i,selectHandler:n,closeHandler:o,openHandler:l}=e,u={cache:{},ajax:{dataType:"json",jsonp:!1}},d={appendTo:t.closest(".ck-labeled-field-view"),source:function(t,e){const{cache:n}=u;var o=t.term;n.hasOwnProperty(o)?e(n[o]):s.ajax(i,{success:function(t){n[o]=t.suggestions,e(t.suggestions)},data:{q:o},...u.ajax})},select:n,focus:()=>!1,search:()=>!d.isComposing,close:o,open:l,minLength:1,isComposing:!1},c=s(t).autocomplete(d),p=c.data("ui-autocomplete");p.widget().menu("option","items","> :not(.linkit-result-line--group)"),p._renderMenu=a,p._renderItem=r,c.autocomplete("widget").addClass("linkit-ui-autocomplete"),c.on("click",(function(){c.autocomplete("search",c.val())})),c.on("compositionstart.autocomplete",(function(){d.isComposing=!0})),c.on("compositionend.autocomplete",(function(){d.isComposing=!1}))}(i.urlInputView.fieldView.element,{...e,selectHandler:(t,{item:e})=>{if(!e.path)throw"Missing path param."+JSON.stringify(e);if(e.entity_type_id||e.entity_uuid||e.substitution_id){if(!e.entity_type_id||!e.entity_uuid||!e.substitution_id)throw"Missing path param."+JSON.stringify(e);this.set("entityType",e.entity_type_id),this.set("entityUuid",e.entity_uuid),this.set("entitySubstitution",e.substitution_id)}else this.set("entityType",null),this.set("entityUuid",null),this.set("entitySubstitution",null);return t.target.value=e.path,l=!0,!1},openHandler:t=>{l=!1},closeHandler:t=>{l||(this.set("entityType",null),this.set("entityUuid",null),this.set("entitySubstitution",null)),l=!1}}),n=!0}))}_handleExtraFormFieldSubmit(){const t=this.editor,e=t.plugins.get("LinkUI").formView,i=t.commands.get("link");this.listenTo(e,"submit",(()=>{const t={"data-entity-type":this.entityType,"data-entity-uuid":this.entityUuid,"data-entity-substitution":this.entitySubstitution};i.once("execute",((e,i)=>{if(i.length<3)i.push(t);else{if(3!==i.length)throw Error("The link command has more than 3 arguments.");Object.assign(i[2],t)}}),{priority:"highest"})}),{priority:"high"})}_handleDataLoadingIntoExtraFormField(){const t=this.editor.commands.get("link");this.bind("entityType").to(t,"data-entity-type"),this.bind("entityUuid").to(t,"data-entity-uuid"),this.bind("entitySubstitution").to(t,"data-entity-substitution")}static get pluginName(){return"Linkit"}}const u={Linkit:l}})(),n=n.default})()));
\ No newline at end of file
diff --git a/js/ckeditor5_plugins/linkit/README.md b/js/ckeditor5_plugins/linkit/README.md
new file mode 100644
index 0000000..e6d004a
--- /dev/null
+++ b/js/ckeditor5_plugins/linkit/README.md
@@ -0,0 +1,9 @@
+This plugin is largely based on CKEditor 5's [block plugin widget tutorial](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/tutorials/implementing-a-block-widget.html),
+but with added documentation to facilitate better understanding of CKEditor 5
+plugin development and other minor changes.
+
+Within `/src` are the multiple files that will be used by the build process to
+become a CKEditor 5 plugin in `/build`. Technically, everything in these files
+could be in a single `index.js` - the only file the MUST be present is
+`/src/index.js`. However, splitting the plugin into concern-specific files has
+maintainability benefits.
diff --git a/js/ckeditor5_plugins/linkit/src/autocomplete.js b/js/ckeditor5_plugins/linkit/src/autocomplete.js
new file mode 100644
index 0000000..a626b69
--- /dev/null
+++ b/js/ckeditor5_plugins/linkit/src/autocomplete.js
@@ -0,0 +1,138 @@
+const $ = jQuery;
+
+
+/**
+ * Override jQuery UI _renderItem function to output HTML by default.
+ *
+ * @param {object} ul
+ *   The <ul> element that the newly created <li> element must be appended to.
+ * @param {object} item
+ *  The list item to append.
+ *
+ * @return {object}
+ *   jQuery collection of the ul element.
+ */
+function renderItem(ul, item) {
+  var $line = $('<li>').addClass('linkit-result-line');
+  var $wrapper = $('<div>').addClass('linkit-result-line-wrapper');
+  $wrapper.append($('<span>').html(item.label).addClass('linkit-result-line--title'));
+
+  if (item.hasOwnProperty('description')) {
+    $wrapper.append($('<span>').html(item.description).addClass('linkit-result-line--description'));
+  }
+  return $line.append($wrapper).appendTo(ul);
+}
+
+/**
+ * Override jQuery UI _renderMenu function to handle groups.
+ *
+ * @param {object} ul
+ *   An empty <ul> element to use as the widget's menu.
+ * @param {array} items
+ *   An Array of items that match the user typed term.
+ */
+function renderMenu(ul, items) {
+  var self = this.element.autocomplete('instance');
+
+  var grouped_items = {};
+  items.forEach(function (item) {
+    const group = item.hasOwnProperty('group') ? item.group : '';
+    if (!grouped_items.hasOwnProperty(group)) {
+      grouped_items[group] = [];
+    }
+    grouped_items[group].push(item);
+  });
+
+  $.each(grouped_items, function (group, items) {
+    if (group.length) {
+      ul.append('<li class="linkit-result-line--group ui-menu-divider">' + group + '</li>');
+    }
+
+    $.each(items, function (index, item) {
+      self._renderItemData(ul, item);
+    });
+  });
+}
+
+export default function initializeAutocomplete(element, settings) {
+  const { autocompleteUrl, selectHandler, closeHandler, openHandler } = settings;
+  const autocomplete = {
+    cache: {},
+    ajax: {
+      dataType: 'json',
+      jsonp: false,
+    },
+  };
+
+  /**
+   * JQuery UI autocomplete source callback.
+   *
+   * @param {object} request
+   *   The request object.
+   * @param {function} response
+   *   The function to call with the response.
+   */
+  function sourceData(request, response) {
+    const { cache } = autocomplete;
+    /**
+     * Transforms the data object into an array and update autocomplete results.
+     *
+     * @param {object} data
+     *   The data sent back from the server.
+     */
+    function sourceCallbackHandler(data) {
+      cache[term] = data.suggestions;
+      response(data.suggestions);
+    }
+
+    // Get the desired term and construct the autocomplete URL for it.
+    var term = request.term;
+
+    // Check if the term is already cached.
+    if (cache.hasOwnProperty(term)) {
+      response(cache[term]);
+    }
+    else {
+      $.ajax(autocompleteUrl, {
+        success: sourceCallbackHandler,
+        data: {q: term},
+        ...autocomplete.ajax,
+      });
+    }
+  }
+
+  const options = {
+    appendTo: element.closest('.ck-labeled-field-view'),
+    source: sourceData,
+    select: selectHandler,
+    focus: () => false,
+    search: () => !options.isComposing,
+    close: closeHandler,
+    open: openHandler,
+    minLength: 1,
+    isComposing: false,
+  }
+  const $auto = $(element).autocomplete(options);
+
+  // Override a few things.
+  const instance = $auto.data('ui-autocomplete');
+  instance.widget().menu('option', 'items', '> :not(.linkit-result-line--group)');
+  instance._renderMenu = renderMenu;
+  instance._renderItem = renderItem;
+
+
+  $auto.autocomplete('widget').addClass('linkit-ui-autocomplete');
+
+  $auto.on('click', function () {
+    $auto.autocomplete('search', $auto.val());
+  });
+
+  $auto.on('compositionstart.autocomplete', function () {
+    options.isComposing = true;
+  });
+  $auto.on('compositionend.autocomplete', function () {
+    options.isComposing = false;
+  });
+
+  return $auto;
+}
diff --git a/js/ckeditor5_plugins/linkit/src/index.js b/js/ckeditor5_plugins/linkit/src/index.js
new file mode 100644
index 0000000..f68c84e
--- /dev/null
+++ b/js/ckeditor5_plugins/linkit/src/index.js
@@ -0,0 +1,142 @@
+import { Plugin } from 'ckeditor5/src/core';
+import LinkitEditing from './linkitediting';
+import initializeAutocomplete from './autocomplete';
+
+class Linkit extends Plugin {
+  /**
+   * @inheritdoc
+   */
+  static get requires() {
+    return [LinkitEditing];
+  }
+
+  init() {
+    this._state = {};
+    const editor = this.editor;
+    const options = editor.config.get('linkit');
+    this._enableLinkAutocomplete();
+    this._handleExtraFormFieldSubmit();
+    this._handleDataLoadingIntoExtraFormField();
+  }
+
+  _enableLinkAutocomplete() {
+    const editor = this.editor;
+    const options = editor.config.get('linkit');
+    const linkFormView = editor.plugins.get( 'LinkUI' ).formView;
+    let wasAutocompleteAdded = false;
+
+    linkFormView.extendTemplate( {
+      attributes: {
+        class: ['ck-vertical-form', 'ck-link-form_layout-vertical']
+      }
+    } );
+
+    editor.plugins.get( 'ContextualBalloon' )._rotatorView.content.on('add', ( evt, view ) => {
+      if ( view !== linkFormView || wasAutocompleteAdded ) {
+        return;
+      }
+
+      /**
+       * Used to know if a selection was made from the autocomplete results.
+       *
+       * @type {boolean}
+       */
+      let selected;
+
+      initializeAutocomplete(
+        linkFormView.urlInputView.fieldView.element,
+        {
+          ...options,
+          selectHandler: (event, { item }) => {
+            if (!item.path) {
+              throw 'Missing path param.' + JSON.stringify(item);
+            }
+
+            if (item.entity_type_id || item.entity_uuid || item.substitution_id) {
+              if (!item.entity_type_id || !item.entity_uuid || !item.substitution_id) {
+                throw 'Missing path param.' + JSON.stringify(item);
+              }
+
+              this.set('entityType', item.entity_type_id);
+              this.set('entityUuid', item.entity_uuid);
+              this.set('entitySubstitution', item.substitution_id);
+            }
+            else {
+              this.set('entityType', null);
+              this.set('entityUuid', null);
+              this.set('entitySubstitution', null);
+            }
+
+            event.target.value = item.path;
+            selected = true;
+            return false;
+          },
+          openHandler: (event) => {
+            selected = false;
+          },
+          closeHandler: (event) => {
+            if (!selected) {
+              this.set('entityType', null);
+              this.set('entityUuid', null);
+              this.set('entitySubstitution', null);
+            }
+            selected = false;
+          },
+        },
+      );
+
+      wasAutocompleteAdded = true;
+    });
+  }
+
+  _handleExtraFormFieldSubmit() {
+    const editor = this.editor;
+    const linkFormView = editor.plugins.get('LinkUI').formView;
+    const linkCommand = editor.commands.get('link');
+
+    this.listenTo(linkFormView, 'submit', () => {
+      const values = {
+        'data-entity-type': this.entityType,
+        'data-entity-uuid': this.entityUuid,
+        'data-entity-substitution': this.entitySubstitution,
+      }
+      // Stop the execution of the link command caused by closing the form.
+      // Inject the extra attribute value. The highest priority listener here
+      // injects the argument (here below 👇).
+      // - The high priority listener in
+      //   _addExtraAttributeOnLinkCommandExecute() gets that argument and sets
+      //   the extra attribute.
+      // - The normal (default) priority listener in ckeditor5-link sets
+      //   (creates) the actual link.
+      linkCommand.once('execute', (evt, args) => {
+        if (args.length < 3) {
+          args.push(values);
+        } else if (args.length === 3) {
+          Object.assign(args[2], values);
+        } else {
+          throw Error('The link command has more than 3 arguments.')
+        }
+      }, { priority: 'highest' });
+    }, { priority: 'high' });
+  }
+
+  _handleDataLoadingIntoExtraFormField() {
+    const editor = this.editor;
+    const linkCommand = editor.commands.get('link');
+
+    this.bind('entityType').to(linkCommand, 'data-entity-type');
+    this.bind('entityUuid').to(linkCommand, 'data-entity-uuid');
+    this.bind('entitySubstitution').to(linkCommand, 'data-entity-substitution');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  static get pluginName() {
+    return 'Linkit';
+  }
+}
+
+export default {
+  Linkit,
+};
diff --git a/js/ckeditor5_plugins/linkit/src/linkitediting.js b/js/ckeditor5_plugins/linkit/src/linkitediting.js
new file mode 100644
index 0000000..9e17376
--- /dev/null
+++ b/js/ckeditor5_plugins/linkit/src/linkitediting.js
@@ -0,0 +1,191 @@
+import { Plugin } from 'ckeditor5/src/core';
+import { findAttributeRange } from 'ckeditor5/src/typing';
+
+export default class LinkitEditing extends Plugin {
+  init() {
+    this.attrs = ['data-entity-type', 'data-entity-uuid', 'data-entity-substitution'];
+    this._allowAndConvertExtraAttributes();
+    this._removeExtraAttributesOnUnlinkCommandExecute();
+    this._refreshExtraAttributeValues();
+    this._addExtraAttributesOnLinkCommandExecute();
+  }
+
+  _allowAndConvertExtraAttributes() {
+    const editor = this.editor;
+
+    editor.model.schema.extend('$text', { allowAttributes: this.attrs });
+
+    // Model -> View (DOM)
+    this.attrs.forEach((attribute) => {
+      editor.conversion.for('downcast').attributeToElement({
+        model: attribute,
+        view: (value, { writer }) => {
+          const linkViewElement = writer.createAttributeElement('a', {
+            [attribute]: value
+          }, { priority: 5 });
+
+          // Without it the isLinkElement() will not recognize the link and the UI will not show up
+          // when the user clicks a link.
+          writer.setCustomProperty('link', true, linkViewElement);
+
+          return linkViewElement;
+        }
+      });
+
+      // View (DOM/DATA) -> Model
+      editor.conversion.for('upcast')
+        .elementToAttribute({
+          view: {
+            name: 'a',
+            attributes: {
+              [attribute]: true,
+            }
+          },
+          model: {
+            key: attribute,
+            value: viewElement => viewElement.getAttribute(attribute),
+          }
+        });
+    });
+  }
+
+  _addExtraAttributesOnLinkCommandExecute() {
+    const editor = this.editor;
+    const linkCommand = editor.commands.get('link');
+    let linkCommandExecuting = false;
+
+    linkCommand.on('execute', (evt, args) => {
+      // Custom handling is only required if an extra attribute was passed into
+      // editor.execute( 'link', ... ).
+      if (args.length < 3) {
+        return;
+      }
+      if (linkCommandExecuting) {
+        linkCommandExecuting = false;
+        return;
+      }
+
+      // If the additional attribute was passed, we stop the default execution
+      // of the LinkCommand. We're going to create Model#change() block for undo
+      // and execute the LinkCommand together with setting the extra attribute.
+      evt.stop();
+
+      // Prevent infinite recursion by keeping records of when link command is
+      // being executed by this function.
+      linkCommandExecuting = true;
+      const extraAttributeValues = args[args.length - 1];
+      const model = this.editor.model;
+      const selection = model.document.selection;
+
+      // Wrapping the original command execution in a model.change() block to
+      // make sure there's a single undo step when the extra attribute is added.
+      model.change((writer) => {
+        editor.execute('link', ...args);
+
+        const firstPosition = selection.getFirstPosition();
+
+        this.attrs.forEach((attribute) => {
+          if (selection.isCollapsed) {
+            const node = firstPosition.textNode || firstPosition.nodeBefore;
+
+            if (extraAttributeValues[attribute]) {
+              writer.setAttribute(attribute, extraAttributeValues[attribute], writer.createRangeOn(node));
+            } else {
+              writer.removeAttribute(attribute, writer.createRangeOn(node));
+            }
+
+            writer.removeSelectionAttribute(attribute);
+          } else {
+            const ranges = model.schema.getValidRanges(selection.getRanges(), attribute);
+
+            for (const range of ranges) {
+              if (extraAttributeValues[attribute]) {
+                writer.setAttribute(attribute, extraAttributeValues[attribute], range);
+              } else {
+                writer.removeAttribute(attribute, range);
+              }
+            }
+          }
+        });
+      });
+    }, { priority: 'high' } );
+  }
+
+  _removeExtraAttributesOnUnlinkCommandExecute() {
+    const editor = this.editor;
+    const unlinkCommand = editor.commands.get('unlink');
+    const model = this.editor.model;
+    const selection = model.document.selection;
+
+    let isUnlinkingInProgress = false;
+
+    // Make sure all changes are in a single undo step so cancel the original unlink first in the high priority.
+    unlinkCommand.on('execute', evt => {
+      if (isUnlinkingInProgress) {
+        return;
+      }
+
+      evt.stop();
+
+      // This single block wraps all changes that should be in a single undo step.
+      model.change(() => {
+        // Now, in this single "undo block" let the unlink command flow naturally.
+        isUnlinkingInProgress = true;
+
+        // Do the unlinking within a single undo step.
+        editor.execute('unlink');
+
+        // Let's make sure the next unlinking will also be handled.
+        isUnlinkingInProgress = false;
+
+        // The actual integration that removes the extra attribute.
+        model.change(writer => {
+          // Get ranges to unlink.
+          let ranges;
+
+          this.attrs.forEach((attribute) => {
+            if (selection.isCollapsed) {
+              ranges = [findAttributeRange(
+                selection.getFirstPosition(),
+                attribute,
+                selection.getAttribute(attribute),
+                model
+              )];
+            } else {
+              ranges = model.schema.getValidRanges(selection.getRanges(), attribute);
+            }
+
+            // Remove the extra attribute from specified ranges.
+            for (const range of ranges) {
+              writer.removeAttribute(attribute, range);
+            }
+          });
+        });
+      });
+    }, { priority: 'high' });
+  }
+
+  _refreshExtraAttributeValues() {
+    const editor = this.editor;
+    const attributes = this.attrs
+    const linkCommand = editor.commands.get('link');
+    const model = this.editor.model;
+    const selection = model.document.selection;
+
+    attributes.forEach((attribute) => {
+      linkCommand.set(attribute, null);
+    });
+    model.document.on('change', () => {
+      attributes.forEach((attribute) => {
+        linkCommand[attribute] = selection.getAttribute(attribute);
+      });
+    });
+  }
+
+  /**
+   * @inheritdoc
+   */
+  static get pluginName() {
+    return 'LinkitEditing';
+  }
+}
diff --git a/js/scripts/manifest.js b/js/scripts/manifest.js
new file mode 100644
index 0000000..1065ea5
--- /dev/null
+++ b/js/scripts/manifest.js
@@ -0,0 +1,44 @@
+// CKEditor 5 plugins require a manifest file, which must be generated from
+// CKEditor source, and typically requires several manual steps. That process is
+// automated here.
+
+const fs = require('fs');
+const { exec } = require('child_process');
+
+const manifestPath =
+  './node_modules/ckeditor5/build/ckeditor5-dll.manifest.json';
+
+if (!fs.existsSync(manifestPath)) {
+  console.log(
+    'CKEditor manifest not available. Generating one now. This takes a while, but should only need to happen once.',
+  );
+  exec(
+    'yarn --cwd ./node_modules/ckeditor5 install',
+    (error, stdout, stderr) => {
+      if (error) {
+        console.log(`error: ${error.message}`);
+        return;
+      }
+
+      console.log(stdout);
+      exec(
+        'yarn --cwd ./node_modules/ckeditor5 dll:build',
+        (error, stdout, stderr) => {
+          if (error) {
+            console.log(`error: ${error.message}`);
+            return;
+          }
+
+          console.log(stdout);
+          if (fs.existsSync(manifestPath)) {
+            console.log(`Manifest created at  ${manifestPath}`);
+          } else {
+            console.log('error: Unable to create manifest.');
+          }
+        },
+      );
+    },
+  );
+} else {
+  console.log(`Manifest present at ${manifestPath}`);
+}
diff --git a/linkit.ckeditor5.yml b/linkit.ckeditor5.yml
new file mode 100644
index 0000000..5e00227
--- /dev/null
+++ b/linkit.ckeditor5.yml
@@ -0,0 +1,15 @@
+linkit_extension:
+  ckeditor5:
+    plugins:
+      - linkit.Linkit
+  drupal:
+    label: Linkit
+    library: linkit/ckeditor5
+    class: Drupal\linkit\Plugin\CKEditor5Plugin\Linkit
+    elements:
+      - <a data-entity-type data-entity-uuid data-entity-substitution>
+    conditions:
+      requiresConfiguration:
+        linkit_enabled: true
+      plugins:
+        - ckeditor5_link
diff --git a/linkit.info.yml b/linkit.info.yml
index e9ada13..2753daf 100644
--- a/linkit.info.yml
+++ b/linkit.info.yml
@@ -2,7 +2,7 @@ name: Linkit
 type: module
 description: 'Provides an easy interface for internal and external linking with wysiwyg editors.'
 package: User interface
-core_version_requirement: ^8.8 || ^9
+core_version_requirement: ^8.8 || ^9 || ^10
 configure: entity.linkit_profile.collection
 test_dependencies:
   - imce:imce
diff --git a/linkit.libraries.yml b/linkit.libraries.yml
index 9e00dd3..2750933 100644
--- a/linkit.libraries.yml
+++ b/linkit.libraries.yml
@@ -30,3 +30,16 @@ linkit.filter_html.admin:
   version: VERSION
   js:
     js/linkit.filter_html.admin.js: {}
+
+ckeditor5:
+  version: VERSION
+  js:
+    js/build/linkit.js: { minified: true }
+  css:
+    component:
+      css/linkit.autocomplete.css: {}
+  dependencies:
+    - core/jquery
+    - core/drupal.autocomplete
+    - core/underscore
+    - core/drupal.ajax
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4ea900c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+  "name": "drupal-linkit",
+  "version": "5.0.0",
+  "description": "Provides an easy interface for internal and external linking with wysiwyg editors.",
+  "author": "",
+  "license": "GPL-2.0-or-later",
+  "scripts": {
+    "watch": "yarn manifest && webpack --mode development --watch",
+    "build": "yarn manifest && webpack",
+    "manifest": "node ./js/scripts/manifest.js"
+  },
+  "devDependencies": {
+    "@ckeditor/ckeditor5-dev-utils": "^25.2.0",
+    "ckeditor5": "^34.1.0",
+    "raw-loader": "^4.0.2",
+    "terser-webpack-plugin": "^5.2.0",
+    "webpack": "^5.51.1",
+    "webpack-cli": "^4.4.0"
+  },
+  "peerDependencies": {
+    "ckeditor5": "29.2.0"
+  }
+}
diff --git a/src/Plugin/CKEditor4To5Upgrade/Linkit.php b/src/Plugin/CKEditor4To5Upgrade/Linkit.php
new file mode 100644
index 0000000..bd60623
--- /dev/null
+++ b/src/Plugin/CKEditor4To5Upgrade/Linkit.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\linkit\Plugin\CKEditor4To5Upgrade;
+
+use Drupal\ckeditor5\HTMLRestrictions;
+use Drupal\ckeditor5\Plugin\CKEditor4To5UpgradePluginInterface;
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\filter\FilterFormatInterface;
+
+/**
+ * Provides the CKEditor 4 to 5 upgrade for Linkit's CKEditor plugin.
+ *
+ * @CKEditor4To5Upgrade(
+ *   id = "linkit",
+ *   cke4_plugin_settings = {
+ *     "drupallink",
+ *   }
+ * )
+ */
+class Linkit extends PluginBase implements CKEditor4To5UpgradePluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function mapCKEditor4ToolbarButtonToCKEditor5ToolbarItem(string $cke4_button, HTMLRestrictions $text_format_html_restrictions): ?array {
+    throw new \OutOfBoundsException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function mapCKEditor4SettingsToCKEditor5Configuration(string $cke4_plugin_id, array $cke4_plugin_settings): ?array {
+    switch ($cke4_plugin_id) {
+      // @see \Drupal\linkit\Plugin\CKEditorPlugin\LinkitDrupalLink
+      // @see \Drupal\linkit\Plugin\CKEditor5Plugin\Linkit
+      case 'drupallink':
+        $sanitized = [];
+        if (!isset($cke4_plugin_settings['linkit_enabled']) || !isset($cke4_plugin_settings['linkit_profile'])) {
+          $sanitized['linkit_enabled'] = FALSE;
+        }
+        else {
+          $sanitized['linkit_enabled'] = (bool) $cke4_plugin_settings['linkit_enabled'];
+          if ($sanitized['linkit_enabled']) {
+            $sanitized['linkit_profile'] = $cke4_plugin_settings['linkit_profile'];
+          }
+        }
+        return ['linkit_extension' => $sanitized];
+
+      default:
+        throw new \OutOfBoundsException();
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function computeCKEditor5PluginSubsetConfiguration(string $cke5_plugin_id, FilterFormatInterface $text_format): ?array {
+    throw new \OutOfBoundsException();
+  }
+
+}
diff --git a/src/Plugin/CKEditor5Plugin/Linkit.php b/src/Plugin/CKEditor5Plugin/Linkit.php
new file mode 100644
index 0000000..a0cc779
--- /dev/null
+++ b/src/Plugin/CKEditor5Plugin/Linkit.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\linkit\Plugin\CKEditor5Plugin;
+
+use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
+use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
+use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Url;
+use Drupal\editor\EditorInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+
+/**
+ * CKEditor 5 Linkit plugin configuration.
+ */
+class Linkit extends CKEditor5PluginDefault implements CKEditor5PluginConfigurableInterface, ContainerFactoryPluginInterface {
+
+  use CKEditor5PluginConfigurableTrait;
+
+  /**
+   * The Linkit profile storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $linkitProfileStorage;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityStorageInterface $linkit_profile_storage) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->linkitProfileStorage = $linkit_profile_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager')->getStorage('linkit_profile')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $all_profiles = $this->linkitProfileStorage->loadMultiple();
+
+    $options = [];
+    foreach ($all_profiles as $profile) {
+      $options[$profile->id()] = $profile->label();
+    }
+
+    $form['linkit_profile'] = [
+      '#wrapper_attributes' => ['class' => ['container-inline']],
+      '#type' => 'select',
+      '#title' => $this->t('Linkit profile'),
+      '#options' => $options,
+      '#default_value' => $this->configuration['linkit_profile'] ?? '',
+      '#empty_option' => $this->t('- Linkit disabled -'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * Config validation callback: require linkit_profile if linkit_enabled=TRUE.
+   *
+   * @param array $values
+   *   The configuration subtree for ckeditor5.plugin.linkit_extension.
+   * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
+   *   The validation execution context.
+   *
+   * @see linkit.schema.yml
+   */
+  public static function requireProfileIfEnabled(array $values, ExecutionContextInterface $context): void {
+    if ($values['linkit_enabled'] === TRUE && empty($values['linkit_profile'])) {
+      $context->buildViolation(t('Linkit is enabled, please select the Linkit profile you wish to use.'))
+        ->atPath('linkit_profile')
+        ->addViolation();
+    }
+    elseif ($values['linkit_enabled'] === FALSE && !empty($values['linkit_profile'])) {
+      $context->buildViolation(t('Linkit is disabled; it does not make sense to associate a Linkit profile.'))
+        ->atPath('linkit_profile')
+        ->addViolation();
+    }
+  }
+
+  /**
+   * Computes all valid choices for the "linkit_profile" setting.
+   *
+   * @see linkit.schema.yml
+   *
+   * @return string[]
+   *   All valid choices.
+   */
+  public static function validChoices(): array {
+    $linkit_profile_storage = \Drupal::service('entity_type.manager')->getStorage('linkit_profile');
+    assert($linkit_profile_storage instanceof EntityStorageInterface);
+    return array_keys($linkit_profile_storage->loadMultiple());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    // Match the config schema structure at ckeditor5.plugin.linkit_extension.
+    if (empty($form_state->getValue('linkit_profile'))) {
+      $form_state->unsetValue('linkit_profile');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->configuration['linkit_enabled'] = $form_state->hasValue('linkit_profile');
+    // `linkit_profile` only is relevant when Linkit is enabled.
+    if ($this->configuration['linkit_enabled']) {
+      $this->configuration['linkit_profile'] = $form_state->getValue('linkit_profile');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'linkit_enabled' => FALSE,
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
+    assert($this->configuration['linkit_enabled'] === TRUE);
+    return [
+      'linkit' => [
+        'profile' => $this->configuration['linkit_profile'],
+        'autocompleteUrl' => Url::fromRoute('linkit.autocomplete', ['linkit_profile_id' => $this->configuration['linkit_profile']])
+          ->toString(TRUE)
+          ->getGeneratedUrl()
+      ],
+    ];
+  }
+
+}
diff --git a/src/Plugin/Linkit/Matcher/EntityMatcher.php b/src/Plugin/Linkit/Matcher/EntityMatcher.php
index e5a9413..ebb9780 100644
--- a/src/Plugin/Linkit/Matcher/EntityMatcher.php
+++ b/src/Plugin/Linkit/Matcher/EntityMatcher.php
@@ -365,6 +365,7 @@ class EntityMatcher extends ConfigurableMatcherBase {
 
     $entity_type = $this->entityTypeManager->getDefinition($this->targetType);
     $query = $this->entityTypeManager->getStorage($this->targetType)->getQuery();
+    $query->accessCheck(TRUE);
     $label_key = $entity_type->getKey('label');
 
     if ($label_key) {
diff --git a/tests/src/Functional/Controller/LinkitControllerTest.php b/tests/src/Functional/Controller/LinkitControllerTest.php
index e931e85..0635e64 100644
--- a/tests/src/Functional/Controller/LinkitControllerTest.php
+++ b/tests/src/Functional/Controller/LinkitControllerTest.php
@@ -24,7 +24,7 @@ class LinkitControllerTest extends LinkitBrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     $this->linkitProfile = $this->createProfile();
diff --git a/tests/src/Functional/LinkitBrowserTestBase.php b/tests/src/Functional/LinkitBrowserTestBase.php
index bc61b6c..987ccff 100644
--- a/tests/src/Functional/LinkitBrowserTestBase.php
+++ b/tests/src/Functional/LinkitBrowserTestBase.php
@@ -38,7 +38,7 @@ abstract class LinkitBrowserTestBase extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     $this->placeBlock('page_title_block');
diff --git a/tests/src/Functional/LinkitUpdateTest.php b/tests/src/Functional/LinkitUpdateTest.php
index 140fe12..851e6eb 100644
--- a/tests/src/Functional/LinkitUpdateTest.php
+++ b/tests/src/Functional/LinkitUpdateTest.php
@@ -28,7 +28,7 @@ class LinkitUpdateTest extends UpdatePathTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     $this->configFactory = $this->container->get('config.factory');
   }
diff --git a/tests/src/Functional/MatcherAdminTest.php b/tests/src/Functional/MatcherAdminTest.php
index 2db6653..0733744 100644
--- a/tests/src/Functional/MatcherAdminTest.php
+++ b/tests/src/Functional/MatcherAdminTest.php
@@ -34,7 +34,7 @@ class MatcherAdminTest extends LinkitBrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     $this->manager = $this->container->get('plugin.manager.linkit.matcher');
 
diff --git a/tests/src/FunctionalJavascript/LinkitDialogCKEditor5Test.php b/tests/src/FunctionalJavascript/LinkitDialogCKEditor5Test.php
new file mode 100644
index 0000000..91c3d52
--- /dev/null
+++ b/tests/src/FunctionalJavascript/LinkitDialogCKEditor5Test.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Drupal\Tests\linkit\FunctionalJavascript;
+
+use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
+use Drupal\editor\Entity\Editor;
+use Drupal\entity_test\Entity\EntityTestMul;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\linkit\MatcherInterface;
+use Drupal\linkit\Tests\ProfileCreationTrait;
+use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Tests the Linkit extensions to the CKEditor 5 Link plugin.
+ *
+ * @group linkit
+ * @group ckeditor5
+ */
+class LinkitDialogCKEditor5Test extends WebDriverTestBase {
+
+  use ProfileCreationTrait;
+  use CKEditor5TestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'ckeditor5',
+    'filter',
+    'linkit',
+    'entity_test',
+    'language',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * An instance of the "CKEditor" text editor plugin.
+   *
+   * @var \Drupal\ckeditor\Plugin\Editor\CKEditor
+   */
+  protected $ckeditor;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $matcher_manager = $this->container->get('plugin.manager.linkit.matcher');
+    $linkit_profile = $this->createProfile();
+    $plugin = $matcher_manager->createInstance('entity:entity_test_mul');
+    assert($plugin instanceof MatcherInterface);
+    $linkit_profile->addMatcher($plugin->getConfiguration());
+    $linkit_profile->save();
+
+    // Create text format, associate CKEditor 5, validate.
+    FilterFormat::create([
+      'format' => 'test_format',
+      'name' => 'Test format',
+      'filters' => [
+        'filter_html' => [
+          'status' => TRUE,
+          'settings' => [
+            'allowed_html' => '<p> <br> <a href data-entity-type data-entity-uuid data-entity-substitution>',
+          ],
+        ],
+      ],
+    ])->save();
+    Editor::create([
+      'format' => 'test_format',
+      'editor' => 'ckeditor5',
+      'settings' => [
+        'toolbar' => [
+          'items' => [
+            'link',
+          ],
+        ],
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => $linkit_profile->id(),
+          ],
+        ],
+      ],
+    ])->save();
+    $this->assertSame([], array_map(
+      function (ConstraintViolation $v) {
+        return (string) $v->getMessage();
+      },
+      iterator_to_array(CKEditor5::validatePair(
+        Editor::load('test_format'),
+        FilterFormat::load('test_format')
+      ))
+    ));
+
+    // Create a node type for testing.
+    $this->drupalCreateContentType(['type' => 'page']);
+
+    $account = $this->drupalCreateUser([
+      'create page content',
+      'use text format test_format',
+      'view test entity',
+    ]);
+
+    $this->drupalLogin($account);
+  }
+
+  /**
+   * Test the link dialog.
+   */
+  public function testLinkDialog() {
+    $session = $this->getSession();
+    $assert_session = $this->assertSession();
+    $page = $session->getPage();
+
+    // Adds additional languages.
+    $langcodes = ['sv', 'da', 'fi'];
+    foreach ($langcodes as $langcode) {
+      ConfigurableLanguage::createFromLangcode($langcode)->save();
+    }
+
+    // Create a test entity.
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    $entity = EntityTestMul::create(['name' => 'Foo']);
+    $entity->save();
+
+    $this->drupalGet('node/add/page');
+    $this->waitForEditor();
+    $this->pressEditorButton('Link');
+
+    // Find the href field.
+    $balloon = $this->assertVisibleBalloon('.ck-link-form');
+    $autocomplete_field = $balloon->find('css', '.ck-input-text');
+
+    // Make sure all fields are empty.
+    $this->assertEmpty($autocomplete_field->getValue(), 'Autocomplete field is empty.');
+
+    // Make sure the autocomplete result container is hidden.
+    $autocomplete_container = $assert_session->elementExists('css', '.ck-link-form .linkit-ui-autocomplete');
+    $this->assertFalse($autocomplete_container->isVisible());
+
+    // Trigger a keydown event to activate a autocomplete search.
+    $autocomplete_field->setValue('f');
+    $this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
+    $this->assertTrue($this->getSession()->wait(5000, "document.querySelectorAll('.linkit-result-line.ui-menu-item').length > 0"));
+
+    // Make sure the autocomplete result container is visible.
+    $this->assertTrue($autocomplete_container->isVisible());
+
+    // Find all the autocomplete results.
+    $results = $page->findAll('css', '.linkit-result-line.ui-menu-item');
+    $this->assertCount(1, $results);
+
+    // Find the first result and click it.
+    $results[0]->click();
+
+    // Make sure the linkit field field is populated with the test entity's URL.
+    $expected_url = base_path() . 'entity_test_mul/manage/1';
+    $this->assertSame($expected_url, $autocomplete_field->getValue());
+    $balloon->pressButton('Save');
+    // Assert balloon was closed by pressing its "Save" button.
+    $this->assertFalse($page->find('css', '.ck-balloon-panel')->isVisible());
+
+    // Make sure all attributes are populated.
+    $linkit_link = $assert_session->waitForElementVisible('css', '.ck-content a');
+    $this->assertNotNull($linkit_link);
+    $this->assertSame($expected_url, $linkit_link->getAttribute('href'));
+    $this->assertSame('entity_test_mul', $linkit_link->getAttribute('data-entity-type'));
+    $this->assertSame($entity->uuid(), $linkit_link->getAttribute('data-entity-uuid'));
+    $this->assertSame('canonical', $linkit_link->getAttribute('data-entity-substitution'));
+
+    // Open the edit link dialog by moving selection to the link, verifying the
+    // "Link" button is off before and on after, and then pressing that button.
+    $this->assertFalse($this->getEditorButton('Link')->hasClass('ck-on'));
+    $this->selectTextInsideElement('a');
+    $this->assertTrue($this->getEditorButton('Link')->hasClass('ck-on'));
+    $this->pressEditorButton('Link');
+    $this->assertVisibleBalloon('.ck-link-actions');
+    $edit_button = $this->getBalloonButton('Edit link');
+    $edit_button->click();
+    $link_edit_balloon = $this->assertVisibleBalloon('.ck-link-form');
+    $autocomplete_field = $link_edit_balloon->find('css', '.ck-input-text');
+    $this->assertSame($expected_url, $autocomplete_field->getValue());
+    // Click to trigger the reset of the the autocomplete status.
+    $autocomplete_field->click();
+    // Enter a URL and verify that no link suggestions are found.
+    $autocomplete_field->setValue('http://example.com');
+    $autocomplete_field->click();
+    $this->assertSession()->assertWaitOnAjaxRequest();
+    $this->assertSession()->waitForElementVisible('css', '.linkit-result-line.ui-menu-item');
+    $results = $page->findAll('css', '.linkit-result-line.ui-menu-item');
+    $this->assertCount(1, $results);
+    $this->assertSame('http://example.com', $results[0]->find('css', '.linkit-result-line--title')->getText());
+    $this->assertSame('No content suggestions found. This URL will be used as is.', $results[0]->find('css', '.linkit-result-line--description')->getText());
+    // Accept the first autocomplete suggestion.
+    $results[0]->click();
+    $link_edit_balloon->pressButton('Save');
+    $this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-button-save'));
+    // Assert balloon is still visible, but now it's again the link actions one.
+    $this->assertVisibleBalloon('.ck-link-actions');
+    // Assert balloon can be closed by clicking elsewhere in the editor.
+    $page->find('css', '.ck-editor__editable')->click();
+    $this->assertFalse($page->find('css', '.ck-balloon-panel')->isVisible());
+
+    $changed_link = $assert_session->waitForElementVisible('css', '.ck-content [href="http://example.com"]');
+    $this->assertNotNull($changed_link);
+    foreach ([
+      'data-entity-type',
+      'data-entity-uuid',
+      'data-entity-substitution',
+    ] as $attribute_name) {
+      $this->assertFalse($changed_link->hasAttribute($attribute_name), "Link should no longer have $attribute_name");
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathCompletenessTest.php b/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathCompletenessTest.php
new file mode 100644
index 0000000..cc5c324
--- /dev/null
+++ b/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathCompletenessTest.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\linkit\Kernel\CKEditor4To5Upgrade;
+
+use Drupal\Tests\ckeditor5\Kernel\CKEditor4to5UpgradeCompletenessTest;
+
+/**
+ * @covers \Drupal\linkit\Plugin\CKEditor4To5Upgrade\Linkit
+ * @group linkit
+ * @group ckeditor5
+ * @internal
+ * @requires module ckeditor5
+ */
+class UpgradePathCompletenessTest extends CKEditor4to5UpgradeCompletenessTest {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['linkit'];
+
+}
diff --git a/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathTest.php b/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathTest.php
new file mode 100644
index 0000000..eff4f83
--- /dev/null
+++ b/tests/src/Kernel/CKEditor4To5Upgrade/UpgradePathTest.php
@@ -0,0 +1,188 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\linkit\Kernel\CKEditor4To5Upgrade;
+
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\Tests\ckeditor5\Kernel\SmartDefaultSettingsTest;
+
+/**
+ * @covers \Drupal\linkit\Plugin\CKEditor4To5Upgrade\Linkit
+ * @group linkit
+ * @group ckeditor5
+ * @requires module ckeditor5
+ * @internal
+ */
+class UpgradePathTest extends SmartDefaultSettingsTest {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'linkit',
+    // Because modules/linkit/config/optional/linkit.linkit_profile.default.yml
+    // will only then get installed.
+    'node',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installConfig(['linkit']);
+
+    $filter_config = [
+      'filter_html' => [
+        'status' => 1,
+        'settings' => [
+          'allowed_html' => '<p> <br> <strong> <a href>',
+        ],
+      ],
+    ];
+    FilterFormat::create([
+      'format' => 'linkit_disabled',
+      'name' => 'Linkit disabled',
+      'filters' => $filter_config,
+    ])->setSyncing(TRUE)->save();
+    FilterFormat::create([
+      'format' => 'linkit_enabled_misconfigured_format',
+      'name' => 'Linkit enabled on a misconfigured format',
+      'filters' => $filter_config,
+    ])->setSyncing(TRUE)->save();
+    FilterFormat::create([
+      'format' => 'linkit_enabled',
+      'name' => 'Linkit enabled on a well-configured format',
+      'filters' => [
+        'filter_html' => [
+          'status' => 1,
+          'settings' => [
+            'allowed_html' => '<p> <br> <strong> <a href data-entity-type data-entity-uuid data-entity-substitution>',
+          ],
+        ],
+      ],
+    ])->setSyncing(TRUE)->save();
+
+    $generate_editor_settings = function (array $linkit_cke4_settings) {
+      return [
+        'toolbar' => [
+          'rows' => [
+            0 => [
+              [
+                'name' => 'Basic Formatting',
+                'items' => [
+                  'Bold',
+                  'Format',
+                  'DrupalLink'
+                ],
+              ],
+            ],
+          ],
+        ],
+        'plugins' => [
+          'drupallink' => $linkit_cke4_settings,
+        ],
+      ];
+    };
+
+    Editor::create([
+      'format' => 'linkit_disabled',
+      'editor' => 'ckeditor',
+      'settings' => $generate_editor_settings([
+        'linkit_enabled' => FALSE,
+        'linkit_profile' => '',
+      ]),
+    ])->setSyncing(TRUE)->save();
+    Editor::create([
+      'format' => 'linkit_enabled_misconfigured_format',
+      'editor' => 'ckeditor',
+      'settings' => $generate_editor_settings([
+        'linkit_enabled' => TRUE,
+        'linkit_profile' => 'default',
+      ]),
+    ])->setSyncing(TRUE)->save();
+    Editor::create([
+      'format' => 'linkit_enabled',
+      'editor' => 'ckeditor',
+      'settings' => $generate_editor_settings([
+        'linkit_enabled' => TRUE,
+        'linkit_profile' => 'default',
+      ]),
+    ])->setSyncing(TRUE)->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function provider() {
+    $expected_ckeditor5_toolbar = [
+      'items' => [
+        'bold',
+        'link',
+      ],
+    ];
+
+    yield "linkit disabled" => [
+      'format_id' => 'linkit_disabled',
+      'filters_to_drop' => [],
+      'expected_ckeditor5_settings' => [
+        'toolbar' => $expected_ckeditor5_toolbar,
+        'plugins' => [],
+      ],
+      'expected_superset' => '',
+      'expected_fundamental_compatibility_violations' => [],
+      'expected_db_logs' => [],
+      'expected_messages' => [],
+    ];
+
+    yield "linkit enabled on a misconfigured text format" => [
+      'format_id' => 'linkit_enabled_misconfigured_format',
+      'filters_to_drop' => [],
+      'expected_ckeditor5_settings' => [
+        'toolbar' => $expected_ckeditor5_toolbar,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => 'default',
+          ],
+        ],
+      ],
+      'expected_superset' => '<a data-entity-type data-entity-uuid data-entity-substitution>',
+      'expected_fundamental_compatibility_violations' => [],
+      'expected_db_logs' => [],
+      'expected_messages' => [
+        'warning' => [
+          'Updating to CKEditor 5 added support for some previously unsupported tags/attributes. A plugin introduced support for the following:   These attributes: <em class="placeholder"> data-entity-type (for &lt;a&gt;), data-entity-uuid (for &lt;a&gt;), data-entity-substitution (for &lt;a&gt;)</em>; Additional details are available in your logs.',
+        ],
+      ],
+    ];
+
+    yield "linkit enabled on a well-configured text format" => [
+      'format_id' => 'linkit_enabled',
+      'filters_to_drop' => [],
+      'expected_ckeditor5_settings' => [
+        'toolbar' => $expected_ckeditor5_toolbar,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => 'default',
+          ],
+        ],
+      ],
+      'expected_superset' => '',
+      'expected_fundamental_compatibility_violations' => [],
+      'expected_db_logs' => [],
+      'expected_messages' => [],
+    ];
+
+    // Verify that none of the core test cases are broken; especially important
+    // for Linkit since it extends the behavior of Drupal core.
+    foreach (parent::provider() as $label => $case) {
+      yield $label => $case;
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/LinkitEditorLinkDialogTest.php b/tests/src/Kernel/LinkitEditorLinkDialogTest.php
index 0a1963b..16d0645 100644
--- a/tests/src/Kernel/LinkitEditorLinkDialogTest.php
+++ b/tests/src/Kernel/LinkitEditorLinkDialogTest.php
@@ -71,6 +71,7 @@ class LinkitEditorLinkDialogTest extends LinkitKernelTestBase {
     $format->save();
 
     // Set up editor.
+    $ckeditor = $this->container->get('plugin.manager.editor')->createInstance('ckeditor');
     $this->editor = Editor::create([
       'format' => 'filtered_html',
       'editor' => 'ckeditor',
@@ -82,7 +83,7 @@ class LinkitEditorLinkDialogTest extends LinkitKernelTestBase {
           'linkit_profile' => $this->linkitProfile->id(),
         ],
       ],
-    ]);
+    ] + $ckeditor->getDefaultSettings());
     $this->editor->save();
   }
 
diff --git a/tests/src/Kernel/ValidatorsTest.php b/tests/src/Kernel/ValidatorsTest.php
new file mode 100644
index 0000000..9d7fa61
--- /dev/null
+++ b/tests/src/Kernel/ValidatorsTest.php
@@ -0,0 +1,136 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\linkit\Kernel;
+
+use Drupal\Tests\ckeditor5\Kernel\ValidatorsTest as CKEditor5CoreValidatorsTest;
+
+/**
+ * @covers \Drupal\linkit\Plugin\CKEditor5Plugin\Linkit::validChoices
+ * @covers \Drupal\linkit\Plugin\CKEditor5Plugin\Linkit::requireProfileIfEnabled
+ * @covers linkit.schema.yml
+ *
+ * @group linkit
+ */
+class ValidatorsTest extends CKEditor5CoreValidatorsTest {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'linkit',
+    // @see config/optional/linkit.linkit_profile.default.yml
+    'node',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    // @see config/optional/linkit.linkit_profile.default.yml
+    $this->installConfig(['linkit']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function provider(): array {
+    $linkit_test_cases_toolbar_settings = ['items' => ['link']];
+
+    $data = [];
+    $data['VALID: installing the linkit module without configuring the existing text editors'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [],
+      ],
+      'violations' => [],
+    ];
+    $data['INVALID: linkit — invalid manually created configuration'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => 'no',
+          ],
+        ],
+      ],
+      'violations' => [
+        'settings.plugins.linkit_extension.linkit_enabled' => 'This value should be of the correct primitive type.',
+      ],
+    ];
+    $data['VALID: linkit off'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => FALSE,
+          ],
+        ],
+      ],
+      'violations' => [],
+    ];
+    $data['VALID: linkit off, profile selected'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => 'default',
+          ],
+        ],
+      ],
+      'violations' => [],
+    ];
+    $data['INVALID: linkit on, no profile selected'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+          ],
+        ],
+      ],
+      'violations' => [
+        'settings.plugins.linkit_extension.linkit_profile' => 'Linkit is enabled, please select the Linkit profile you wish to use.',
+      ],
+    ];
+    $data['INVALID: linkit on, non-existent profile selected'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => 'nonexistent',
+          ],
+        ],
+      ],
+      'violations' => [
+        'settings.plugins.linkit_extension.linkit_profile' => 'The value you selected is not a valid choice.',
+      ],
+    ];
+    $data['VALID: linkit on, existing profile selected'] = [
+      'settings' => [
+        'toolbar' => $linkit_test_cases_toolbar_settings,
+        'plugins' => [
+          'linkit_extension' => [
+            'linkit_enabled' => TRUE,
+            'linkit_profile' => 'default',
+          ],
+        ],
+      ],
+      'violations' => [],
+    ];
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function providerPair(): array {
+    // Linkit is 100% independent of the text format, so no need for this test.
+    return [];
+  }
+
+}
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..afb1bbc
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,57 @@
+const path = require('path');
+const fs = require('fs');
+const webpack = require('webpack');
+const TerserPlugin = require('terser-webpack-plugin');
+
+function getDirectories(srcpath) {
+  return fs
+    .readdirSync(srcpath)
+    .filter((item) => fs.statSync(path.join(srcpath, item)).isDirectory());
+}
+
+module.exports = [];
+// Loop through every subdirectory in src, each a different plugin, and build
+// each one in ./build.
+getDirectories('./js/ckeditor5_plugins').forEach((dir) => {
+  const bc = {
+    mode: 'production',
+    optimization: {
+      minimize: true,
+      minimizer: [
+        new TerserPlugin({
+          terserOptions: {
+            format: {
+              comments: false,
+            },
+          },
+          test: /\.js(\?.*)?$/i,
+          extractComments: false,
+        }),
+      ],
+      moduleIds: 'named',
+    },
+    entry: {
+      path: path.resolve(__dirname, 'js/ckeditor5_plugins', dir, 'src/index.js')
+    },
+    output: {
+      path: path.resolve(__dirname, './js/build'),
+      filename: `${dir}.js`,
+      library: ['CKEditor5', dir],
+      libraryTarget: 'umd',
+      libraryExport: 'default'
+    },
+    plugins: [
+      new webpack.DllReferencePlugin({
+        manifest: require('./node_modules/ckeditor5/build/ckeditor5-dll.manifest.json'), // eslint-disable-line global-require, import/no-unresolved
+        scope: 'ckeditor5/src',
+        name: 'CKEditor5.dll',
+      }),
+    ],
+    module: {
+      rules: [{ test: /\.svg$/, use: 'raw-loader' }],
+    },
+    devtool: false,
+  };
+
+  module.exports.push(bc);
+});
