diff --git a/composer.json b/composer.json
index 1d0fde55c63eb96209ff6852402d4b0a67c02d26..bf8e4babdd5d7be5d121b8f293c35de424a8a8ed 100644
--- a/composer.json
+++ b/composer.json
@@ -178,7 +178,8 @@
         "gdsmith/jquery.easing": "1.4.1",
         "oomphinc/composer-installers-extender": "2.0",
         "pantheon-systems/quicksilver-pushback": "1.0.1",
-        "wcm-osu/wcm_simplesamlphp_auth": "^3.1"
+        "wcm-osu/wcm_simplesamlphp_auth": "^3.1",
+        "wikimedia/composer-merge-plugin": "^2.1"
     },
     "conflict": {
         "drupal/drupal": "*"
@@ -243,6 +244,11 @@
                 "type:drupal-drush"
             ]
         },
+        "merge-plugin": {
+            "include": [
+                "web/modules/anchor_link/composer.libraries.json"
+            ]
+        },
         "build-env": {
             "install-cms": [
                 "drush site-install standard --account-mail={account-mail} --account-name={account-name} --account-pass={account-pass} --site-mail={site-mail} --site-name={site-name} --yes",
@@ -317,7 +323,8 @@
             "cweagans/composer-patches": true,
             "drupal/console-extend-plugin": true,
             "drupal/core-composer-scaffold": true,
-            "oomphinc/composer-installers-extender": true
+            "oomphinc/composer-installers-extender": true,
+            "wikimedia/composer-merge-plugin": true
         }
     }
 }
diff --git a/composer.lock b/composer.lock
index ab80cf12916d8fb05b4261b01965611aa5909b33..361060b228c5fd97cba036efb3231510c6079216 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "d75c17d6559c3b1eb3cbc704e5aba150",
+    "content-hash": "93bb43fdcf2ab8cbc619bd7d2538a7b7",
     "packages": [
         {
             "name": "asm89/stack-cors",
@@ -8611,6 +8611,15 @@
             },
             "time": "2023-08-13T19:53:39+00:00"
         },
+        {
+            "name": "northernco/ckeditor5-anchor-drupal",
+            "version": "0.4.0",
+            "dist": {
+                "type": "tar",
+                "url": "https://registry.npmjs.org/@northernco/ckeditor5-anchor-drupal/-/ckeditor5-anchor-drupal-0.4.0.tgz"
+            },
+            "type": "drupal-library"
+        },
         {
             "name": "oomphinc/composer-installers-extender",
             "version": "2.0.0",
@@ -13753,6 +13762,62 @@
                 "source": "https://github.com/webmozarts/assert/tree/1.11.0"
             },
             "time": "2022-06-03T18:03:27+00:00"
+        },
+        {
+            "name": "wikimedia/composer-merge-plugin",
+            "version": "v2.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/wikimedia/composer-merge-plugin.git",
+                "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/wikimedia/composer-merge-plugin/zipball/a03d426c8e9fb2c9c569d9deeb31a083292788bc",
+                "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc",
+                "shasum": ""
+            },
+            "require": {
+                "composer-plugin-api": "^1.1||^2.0",
+                "php": ">=7.2.0"
+            },
+            "require-dev": {
+                "composer/composer": "^1.1||^2.0",
+                "ext-json": "*",
+                "mediawiki/mediawiki-phan-config": "0.11.1",
+                "php-parallel-lint/php-parallel-lint": "~1.3.1",
+                "phpspec/prophecy": "~1.15.0",
+                "phpunit/phpunit": "^8.5||^9.0",
+                "squizlabs/php_codesniffer": "~3.7.1"
+            },
+            "type": "composer-plugin",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                },
+                "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin"
+            },
+            "autoload": {
+                "psr-4": {
+                    "Wikimedia\\Composer\\Merge\\V2\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bryan Davis",
+                    "email": "bd808@wikimedia.org"
+                }
+            ],
+            "description": "Composer plugin to merge multiple composer.json files",
+            "support": {
+                "issues": "https://github.com/wikimedia/composer-merge-plugin/issues",
+                "source": "https://github.com/wikimedia/composer-merge-plugin/tree/v2.1.0"
+            },
+            "time": "2023-04-15T19:07:00+00:00"
         }
     ],
     "packages-dev": [],
diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
index b50eb9b24d5197c598cb1d437f3eacb58b1786fc..0b4effa761300821efaf7a7bd8fffa87fac233c4 100644
--- a/vendor/composer/autoload_classmap.php
+++ b/vendor/composer/autoload_classmap.php
@@ -6329,6 +6329,14 @@
     'Webmozart\\Assert\\Assert' => $vendorDir . '/webmozart/assert/src/Assert.php',
     'Webmozart\\Assert\\InvalidArgumentException' => $vendorDir . '/webmozart/assert/src/InvalidArgumentException.php',
     'Webmozart\\Assert\\Mixin' => $vendorDir . '/webmozart/assert/src/Mixin.php',
+    'Wikimedia\\Composer\\Merge\\V2\\ExtraPackage' => $vendorDir . '/wikimedia/composer-merge-plugin/src/ExtraPackage.php',
+    'Wikimedia\\Composer\\Merge\\V2\\Logger' => $vendorDir . '/wikimedia/composer-merge-plugin/src/Logger.php',
+    'Wikimedia\\Composer\\Merge\\V2\\MergePlugin' => $vendorDir . '/wikimedia/composer-merge-plugin/src/MergePlugin.php',
+    'Wikimedia\\Composer\\Merge\\V2\\MissingFileException' => $vendorDir . '/wikimedia/composer-merge-plugin/src/MissingFileException.php',
+    'Wikimedia\\Composer\\Merge\\V2\\MultiConstraint' => $vendorDir . '/wikimedia/composer-merge-plugin/src/MultiConstraint.php',
+    'Wikimedia\\Composer\\Merge\\V2\\NestedArray' => $vendorDir . '/wikimedia/composer-merge-plugin/src/NestedArray.php',
+    'Wikimedia\\Composer\\Merge\\V2\\PluginState' => $vendorDir . '/wikimedia/composer-merge-plugin/src/PluginState.php',
+    'Wikimedia\\Composer\\Merge\\V2\\StabilityFlags' => $vendorDir . '/wikimedia/composer-merge-plugin/src/StabilityFlags.php',
     'cweagans\\Composer\\PatchEvent' => $vendorDir . '/cweagans/composer-patches/src/PatchEvent.php',
     'cweagans\\Composer\\PatchEvents' => $vendorDir . '/cweagans/composer-patches/src/PatchEvents.php',
     'cweagans\\Composer\\Patches' => $vendorDir . '/cweagans/composer-patches/src/Patches.php',
diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php
index 7ead6da98496fa82f5b570bc7108580655f46f75..573e91ea15bd858df7ecde30b2c90ce4d0e14c14 100644
--- a/vendor/composer/autoload_psr4.php
+++ b/vendor/composer/autoload_psr4.php
@@ -11,6 +11,7 @@
     'phootwork\\collection\\' => array($vendorDir . '/phootwork/collection'),
     'enshrined\\svgSanitize\\' => array($vendorDir . '/enshrined/svg-sanitize/src'),
     'cweagans\\Composer\\' => array($vendorDir . '/cweagans/composer-patches/src'),
+    'Wikimedia\\Composer\\Merge\\V2\\' => array($vendorDir . '/wikimedia/composer-merge-plugin/src'),
     'Webmozart\\Assert\\' => array($vendorDir . '/webmozart/assert/src'),
     'Twig\\Extra\\Intl\\' => array($vendorDir . '/twig/intl-extra'),
     'Twig\\' => array($vendorDir . '/twig/twig/src'),
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
index 6661619c25224a7c025d083e5b9489a5a2646860..d9b8548c6596c48ba584dda7b3d99022de573f61 100644
--- a/vendor/composer/autoload_static.php
+++ b/vendor/composer/autoload_static.php
@@ -45,6 +45,7 @@ class ComposerStaticInit5c689ffcd54b9e495ed983fdce09b530
         ),
         'W' => 
         array (
+            'Wikimedia\\Composer\\Merge\\V2\\' => 28,
             'Webmozart\\Assert\\' => 17,
         ),
         'T' => 
@@ -204,6 +205,10 @@ class ComposerStaticInit5c689ffcd54b9e495ed983fdce09b530
         array (
             0 => __DIR__ . '/..' . '/cweagans/composer-patches/src',
         ),
+        'Wikimedia\\Composer\\Merge\\V2\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src',
+        ),
         'Webmozart\\Assert\\' => 
         array (
             0 => __DIR__ . '/..' . '/webmozart/assert/src',
@@ -6963,6 +6968,14 @@ class ComposerStaticInit5c689ffcd54b9e495ed983fdce09b530
         'Webmozart\\Assert\\Assert' => __DIR__ . '/..' . '/webmozart/assert/src/Assert.php',
         'Webmozart\\Assert\\InvalidArgumentException' => __DIR__ . '/..' . '/webmozart/assert/src/InvalidArgumentException.php',
         'Webmozart\\Assert\\Mixin' => __DIR__ . '/..' . '/webmozart/assert/src/Mixin.php',
+        'Wikimedia\\Composer\\Merge\\V2\\ExtraPackage' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/ExtraPackage.php',
+        'Wikimedia\\Composer\\Merge\\V2\\Logger' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/Logger.php',
+        'Wikimedia\\Composer\\Merge\\V2\\MergePlugin' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/MergePlugin.php',
+        'Wikimedia\\Composer\\Merge\\V2\\MissingFileException' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/MissingFileException.php',
+        'Wikimedia\\Composer\\Merge\\V2\\MultiConstraint' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/MultiConstraint.php',
+        'Wikimedia\\Composer\\Merge\\V2\\NestedArray' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/NestedArray.php',
+        'Wikimedia\\Composer\\Merge\\V2\\PluginState' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/PluginState.php',
+        'Wikimedia\\Composer\\Merge\\V2\\StabilityFlags' => __DIR__ . '/..' . '/wikimedia/composer-merge-plugin/src/StabilityFlags.php',
         'cweagans\\Composer\\PatchEvent' => __DIR__ . '/..' . '/cweagans/composer-patches/src/PatchEvent.php',
         'cweagans\\Composer\\PatchEvents' => __DIR__ . '/..' . '/cweagans/composer-patches/src/PatchEvents.php',
         'cweagans\\Composer\\Patches' => __DIR__ . '/..' . '/cweagans/composer-patches/src/Patches.php',
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 6348f5b1d06843a3b3a165c4d77fa178c5ac33cd..a64ddc1d13b2b1f73814c77836bebcf83b3bfa88 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -9023,6 +9023,18 @@
             },
             "install-path": "../nikic/php-parser"
         },
+        {
+            "name": "northernco/ckeditor5-anchor-drupal",
+            "version": "0.4.0",
+            "version_normalized": "0.4.0.0",
+            "dist": {
+                "type": "tar",
+                "url": "https://registry.npmjs.org/@northernco/ckeditor5-anchor-drupal/-/ckeditor5-anchor-drupal-0.4.0.tgz"
+            },
+            "type": "drupal-library",
+            "installation-source": "dist",
+            "install-path": "../../web/libraries/ckeditor5-anchor-drupal"
+        },
         {
             "name": "oomphinc/composer-installers-extender",
             "version": "2.0.0",
@@ -14364,6 +14376,65 @@
                 "source": "https://github.com/webmozarts/assert/tree/1.11.0"
             },
             "install-path": "../webmozart/assert"
+        },
+        {
+            "name": "wikimedia/composer-merge-plugin",
+            "version": "v2.1.0",
+            "version_normalized": "2.1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/wikimedia/composer-merge-plugin.git",
+                "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/wikimedia/composer-merge-plugin/zipball/a03d426c8e9fb2c9c569d9deeb31a083292788bc",
+                "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc",
+                "shasum": ""
+            },
+            "require": {
+                "composer-plugin-api": "^1.1||^2.0",
+                "php": ">=7.2.0"
+            },
+            "require-dev": {
+                "composer/composer": "^1.1||^2.0",
+                "ext-json": "*",
+                "mediawiki/mediawiki-phan-config": "0.11.1",
+                "php-parallel-lint/php-parallel-lint": "~1.3.1",
+                "phpspec/prophecy": "~1.15.0",
+                "phpunit/phpunit": "^8.5||^9.0",
+                "squizlabs/php_codesniffer": "~3.7.1"
+            },
+            "time": "2023-04-15T19:07:00+00:00",
+            "type": "composer-plugin",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                },
+                "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin"
+            },
+            "installation-source": "dist",
+            "autoload": {
+                "psr-4": {
+                    "Wikimedia\\Composer\\Merge\\V2\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bryan Davis",
+                    "email": "bd808@wikimedia.org"
+                }
+            ],
+            "description": "Composer plugin to merge multiple composer.json files",
+            "support": {
+                "issues": "https://github.com/wikimedia/composer-merge-plugin/issues",
+                "source": "https://github.com/wikimedia/composer-merge-plugin/tree/v2.1.0"
+            },
+            "install-path": "../wikimedia/composer-merge-plugin"
         }
     ],
     "dev": true,
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index cc58b30ceba387ea31e889d8b8a29edb81f577e6..0a6cdaa9c5293e3ac0f47c1c09ce9f72186b6da1 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'osu-asc-webservices/d8-upstream',
         'pretty_version' => 'dev-main',
         'version' => 'dev-main',
-        'reference' => '22f3969edfcce506bafb7fd8ccf08cb806c80745',
+        'reference' => '83a6cc7700ce11d7660742b1824a5e31a66677fa',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -1384,6 +1384,15 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'northernco/ckeditor5-anchor-drupal' => array(
+            'pretty_version' => '0.4.0',
+            'version' => '0.4.0.0',
+            'reference' => NULL,
+            'type' => 'drupal-library',
+            'install_path' => __DIR__ . '/../../web/libraries/ckeditor5-anchor-drupal',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
         'oomphinc/composer-installers-extender' => array(
             'pretty_version' => '2.0.0',
             'version' => '2.0.0.0',
@@ -1402,7 +1411,7 @@
         'osu-asc-webservices/d8-upstream' => array(
             'pretty_version' => 'dev-main',
             'version' => 'dev-main',
-            'reference' => '22f3969edfcce506bafb7fd8ccf08cb806c80745',
+            'reference' => '83a6cc7700ce11d7660742b1824a5e31a66677fa',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
@@ -2141,5 +2150,14 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'wikimedia/composer-merge-plugin' => array(
+            'pretty_version' => 'v2.1.0',
+            'version' => '2.1.0.0',
+            'reference' => 'a03d426c8e9fb2c9c569d9deeb31a083292788bc',
+            'type' => 'composer-plugin',
+            'install_path' => __DIR__ . '/../wikimedia/composer-merge-plugin',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
     ),
 );
diff --git a/vendor/wikimedia/composer-merge-plugin/LICENSE b/vendor/wikimedia/composer-merge-plugin/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..3c9804a6af2d6b844eeebfdda86761a17794b1fc
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/vendor/wikimedia/composer-merge-plugin/README.md b/vendor/wikimedia/composer-merge-plugin/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d3274a7ead7378bb655472e53e0e4efc7dd4b327
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/README.md
@@ -0,0 +1,251 @@
+[![Latest Stable Version]](https://packagist.org/packages/wikimedia/composer-merge-plugin) [![License]](https://github.com/wikimedia/composer-merge-plugin/blob/master/LICENSE)
+[![Build Status]](https://github.com/wikimedia/composer-merge-plugin/actions/workflows/CI.yaml)
+[![Code Coverage]](https://scrutinizer-ci.com/g/wikimedia/composer-merge-plugin/?branch=master)
+
+Composer Merge Plugin
+=====================
+
+Merge multiple composer.json files at [Composer] runtime.
+
+Composer Merge Plugin is intended to allow easier dependency management for
+applications which ship a composer.json file and expect some deployments to
+install additional Composer managed libraries. It does this by allowing the
+application's top level `composer.json` file to provide a list of optional
+additional configuration files. When Composer is run it will parse these files
+and merge their configuration settings into the base configuration. This
+combined configuration will then be used when downloading additional libraries
+and generating the autoloader.
+
+Composer Merge Plugin was created to help with installation of [MediaWiki]
+which has core library requirements as well as optional libraries and
+extensions which may be managed via Composer.
+
+
+Installation
+------------
+
+Composer Merge Plugin 1.4.x (and older) requires Composer 1.x.
+
+Composer Merge Plugin 2.0.x (and newer) is compatible with both Composer 2.x and 1.x.
+
+```
+$ composer require wikimedia/composer-merge-plugin
+```
+
+### Upgrading from Composer 1 to 2
+
+If you are already using Composer Merge Plugin 1.4 (or older) and you are updating the plugin to 2.0 (or newer), it is recommended that you update the plugin first using Composer 1.
+
+If you update the incompatible plugin using Composer 2, the plugin will be ignored:
+
+> The "wikimedia/composer-merge-plugin" plugin was skipped because it requires a Plugin API version ("^1.0") that does not match your Composer installation ("2.0.0"). You may need to run composer update with the "--no-plugins" option.
+
+Consequently, Composer will be unaware of the merged dependencies and will remove them requiring you to run `composer update` again to reinstall merged dependencies.
+
+
+Usage
+-----
+
+```json
+{
+    "require": {
+        "wikimedia/composer-merge-plugin": "dev-master"
+    },
+    "extra": {
+        "merge-plugin": {
+            "include": [
+                "composer.local.json",
+                "extensions/*/composer.json"
+            ],
+            "require": [
+                "submodule/composer.json"
+            ],
+            "recurse": true,
+            "replace": false,
+            "ignore-duplicates": false,
+            "merge-dev": true,
+            "merge-extra": false,
+            "merge-extra-deep": false,
+            "merge-replace": true,
+            "merge-scripts": false
+        }
+    }
+}
+```
+
+### Updating sub-levels `composer.json` files
+
+
+In order for Composer Merge Plugin to install dependencies from updated or newly created sub-level `composer.json` files in your project you need to run the command:
+
+```
+$ composer update
+```
+
+This will [instruct Composer to recalculate the file hash](https://getcomposer.org/doc/03-cli.md#update) for the top-level `composer.json` thus triggering Composer Merge Plugin to look for the sub-level configuration files and update your dependencies.
+
+
+Plugin configuration
+--------------------
+
+The plugin reads its configuration from the `merge-plugin` section of your
+composer.json's `extra` section. An `include` setting is required to tell
+Composer Merge Plugin which file(s) to merge.
+
+
+### include
+
+The `include` setting can specify either a single value or an array of values.
+Each value is treated as a PHP `glob()` pattern identifying additional
+composer.json style configuration files to merge into the root package
+configuration for the current Composer execution.
+
+The following sections of the found configuration files will be merged into
+the Composer root package configuration as though they were directly included
+in the top-level composer.json file:
+
+* [autoload](https://getcomposer.org/doc/04-schema.md#autoload)
+* [autoload-dev](https://getcomposer.org/doc/04-schema.md#autoload-dev)
+  (optional, see [merge-dev](#merge-dev) below)
+* [conflict](https://getcomposer.org/doc/04-schema.md#conflict)
+* [provide](https://getcomposer.org/doc/04-schema.md#provide)
+* [replace](https://getcomposer.org/doc/04-schema.md#replace)
+  (optional, see [merge-replace](#merge-replace) below)
+* [repositories](https://getcomposer.org/doc/04-schema.md#repositories)
+* [require](https://getcomposer.org/doc/04-schema.md#require)
+* [require-dev](https://getcomposer.org/doc/04-schema.md#require-dev)
+  (optional, see [merge-dev](#merge-dev) below)
+* [suggest](https://getcomposer.org/doc/04-schema.md#suggest)
+* [extra](https://getcomposer.org/doc/04-schema.md#extra)
+  (optional, see [merge-extra](#merge-extra) below)
+* [scripts](https://getcomposer.org/doc/04-schema.md#scripts)
+  (optional, see [merge-scripts](#merge-scripts) below)
+
+
+### require
+
+The `require` setting is identical to [`include`](#include) except when
+a pattern fails to match at least one file then it will cause an error.
+
+### recurse
+
+By default the merge plugin is recursive; if an included file has
+a `merge-plugin` section it will also be processed. This functionality can be
+disabled by adding a `"recurse": false` setting.
+
+
+### replace
+
+By default, Composer's conflict resolution engine is used to determine which
+version of a package should be installed when multiple files specify the same
+package. A `"replace": true` setting can be provided to change to a "last
+version specified wins" conflict resolution strategy. In this mode, duplicate
+package declarations found in merged files will overwrite the declarations
+made by earlier files. Files are loaded in the order specified by the
+`include` setting with globbed files being processed in alphabetical order.
+
+### ignore-duplicates
+
+By default, Composer's conflict resolution engine is used to determine which
+version of a package should be installed when multiple files specify the same
+package. An `"ignore-duplicates": true` setting can be provided to change to
+a "first version specified wins" conflict resolution strategy. In this mode,
+duplicate package declarations found in merged files will be ignored in favor
+of the declarations made by earlier files. Files are loaded in the order
+specified by the `include` setting with globbed files being processed in
+alphabetical order.
+
+Note: `"replace": true` and `"ignore-duplicates": true` modes are mutually
+exclusive. If both are set, `"ignore-duplicates": true` will be used.
+
+### merge-dev
+
+By default, `autoload-dev` and `require-dev` sections of included files are
+merged. A `"merge-dev": false` setting will disable this behavior.
+
+
+### merge-extra
+
+A `"merge-extra": true` setting enables the merging the contents of the
+`extra` section of included files as well. The normal merge mode for the extra
+section is to accept the first version of any key found (e.g. a key in the
+master config wins over the version found in any imported config). If
+`replace` mode is active ([see above](#replace)) then this behavior changes
+and the last key found will win (e.g. the key in the master config is replaced
+by the key in the imported config). If `"merge-extra-deep": true` is specified
+then, the sections are merged similar to array_merge_recursive() - however
+duplicate string array keys are replaced instead of merged, while numeric
+array keys are merged as usual. The usefulness of merging the extra section
+will vary depending on the Composer plugins being used and the order in which
+they are processed by Composer.
+
+Note that `merge-plugin` sections are excluded from the merge process, but are
+always processed by the plugin unless [recursion](#recurse) is disabled.
+
+### merge-replace
+
+By default, the `replace` section of included files are merged.
+A `"merge-replace": false` setting will disable this behavior.
+
+### merge-scripts
+
+A `"merge-scripts": true` setting enables merging the contents of the
+`scripts` section of included files as well. The normal merge mode for the
+scripts section is to accept the first version of any key found (e.g. a key in
+the master config wins over the version found in any imported config). If
+`replace` mode is active ([see above](#replace)) then this behavior changes
+and the last key found will win (e.g. the key in the master config is replaced
+by the key in the imported config).
+
+Note: [custom commands][] added by merged configuration will work when invoked
+as `composer run-script my-cool-command` but will not be available using the
+normal `composer my-cool-command` shortcut.
+
+
+Running tests
+-------------
+
+```
+$ composer install
+$ composer test
+```
+
+
+Contributing
+------------
+
+Bug, feature requests and other issues should be reported to the [GitHub
+project]. We accept code and documentation contributions via Pull Requests on
+GitHub as well.
+
+- [PSR-2 Coding Standard][] is used by the project. The included test
+  configuration uses [PHP_CodeSniffer][] to validate the conventions.
+- Tests are encouraged. Our test coverage isn't perfect but we'd like it to
+  get better rather than worse, so please try to include tests with your
+  changes.
+- Keep the documentation up to date. Make sure `README.md` and other
+  relevant documentation is kept up to date with your changes.
+- One pull request per feature. Try to keep your changes focused on solving
+  a single problem. This will make it easier for us to review the change and
+  easier for you to make sure you have updated the necessary tests and
+  documentation.
+
+
+License
+-------
+
+Composer Merge plugin is licensed under the MIT license. See the
+[`LICENSE`](LICENSE) file for more details.
+
+
+---
+[Composer]: https://getcomposer.org/
+[MediaWiki]: https://www.mediawiki.org/wiki/MediaWiki
+[GitHub project]: https://github.com/wikimedia/composer-merge-plugin
+[PSR-2 Coding Standard]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md
+[PHP_CodeSniffer]: http://pear.php.net/package/PHP_CodeSniffer
+[Latest Stable Version]: https://img.shields.io/packagist/v/wikimedia/composer-merge-plugin.svg?style=flat
+[License]: https://img.shields.io/packagist/l/wikimedia/composer-merge-plugin.svg?style=flat
+[Build Status]: https://github.com/wikimedia/composer-merge-plugin/actions/workflows/CI.yaml/badge.svg
+[Code Coverage]: https://img.shields.io/scrutinizer/coverage/g/wikimedia/composer-merge-plugin/master.svg?style=flat
+[custom commands]: https://getcomposer.org/doc/articles/scripts.md#writing-custom-commands
diff --git a/vendor/wikimedia/composer-merge-plugin/composer.json b/vendor/wikimedia/composer-merge-plugin/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..e3e7bc9520bcf0ba98911127dca0d91c53a84568
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/composer.json
@@ -0,0 +1,57 @@
+{
+    "name": "wikimedia/composer-merge-plugin",
+    "description": "Composer plugin to merge multiple composer.json files",
+    "type": "composer-plugin",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Bryan Davis",
+            "email": "bd808@wikimedia.org"
+        }
+    ],
+    "minimum-stability": "dev",
+    "prefer-stable": true,
+    "require": {
+        "php": ">=7.2.0",
+        "composer-plugin-api": "^1.1||^2.0"
+    },
+    "require-dev": {
+        "ext-json": "*",
+        "composer/composer": "^1.1||^2.0",
+        "mediawiki/mediawiki-phan-config": "0.11.1",
+        "php-parallel-lint/php-parallel-lint": "~1.3.1",
+        "phpspec/prophecy": "~1.15.0",
+        "phpunit/phpunit": "^8.5||^9.0",
+        "squizlabs/php_codesniffer": "~3.7.1"
+    },
+    "autoload": {
+        "psr-4": {
+            "Wikimedia\\Composer\\Merge\\V2\\": "src/"
+        }
+    },
+    "extra": {
+        "branch-alias": {
+             "dev-master": "2.x-dev"
+        },
+        "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin"
+    },
+    "config": {
+        "optimize-autoloader": true,
+        "sort-packages": true
+    },
+    "scripts": {
+        "coverage": [
+            "phpunit --log-junit=reports/unitreport.xml --coverage-text --coverage-html=reports/coverage --coverage-clover=reports/coverage.xml",
+            "phpcs --encoding=utf-8 --standard=PSR2 --report-checkstyle=reports/checkstyle-phpcs.xml --report-full --extensions=php src/* tests/phpunit/*"
+        ],
+        "phan": "phan -d . --long-progress-bar --allow-polyfill-parser",
+        "phpcs": "phpcs --encoding=utf-8 --standard=PSR2 --extensions=php src/* tests/phpunit/*",
+        "phpunit": "phpunit",
+        "test": [
+            "composer validate --no-interaction",
+            "parallel-lint src tests",
+            "@phpunit",
+            "@phpcs"
+        ]
+    }
+}
diff --git a/vendor/wikimedia/composer-merge-plugin/src/ExtraPackage.php b/vendor/wikimedia/composer-merge-plugin/src/ExtraPackage.php
new file mode 100644
index 0000000000000000000000000000000000000000..2471cb06ddb5ceb762a6fe99fd1201569b98c897
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/ExtraPackage.php
@@ -0,0 +1,750 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\Composer;
+use Composer\Json\JsonFile;
+use Composer\Package\BasePackage;
+use Composer\Package\CompletePackage;
+use Composer\Package\Link;
+use Composer\Package\Loader\ArrayLoader;
+use Composer\Package\RootAliasPackage;
+use Composer\Package\RootPackage;
+use Composer\Package\RootPackageInterface;
+use Composer\Package\Version\VersionParser;
+use Composer\Semver\Intervals;
+use UnexpectedValueException;
+
+/**
+ * Processing for a composer.json file that will be merged into
+ * a RootPackageInterface
+ *
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class ExtraPackage
+{
+
+    /**
+     * @var Composer $composer
+     */
+    protected $composer;
+
+    /**
+     * @var Logger $logger
+     */
+    protected $logger;
+
+    /**
+     * @var string $path
+     */
+    protected $path;
+
+    /**
+     * @var array $json
+     */
+    protected $json;
+
+    /**
+     * @var CompletePackage $package
+     */
+    protected $package;
+
+    /**
+     * @var array<string, bool> $mergedRequirements
+     */
+    protected $mergedRequirements = [];
+
+    /**
+     * @var VersionParser $versionParser
+     */
+    protected $versionParser;
+
+    /**
+     * @param string $path Path to composer.json file
+     * @param Composer $composer
+     * @param Logger $logger
+     */
+    public function __construct($path, Composer $composer, Logger $logger)
+    {
+        $this->path = $path;
+        $this->composer = $composer;
+        $this->logger = $logger;
+        $this->json = $this->readPackageJson($path);
+        $this->package = $this->loadPackage($this->json);
+        $this->versionParser = new VersionParser();
+    }
+
+    /**
+     * Get list of additional packages to include if precessing recursively.
+     *
+     * @return array
+     */
+    public function getIncludes()
+    {
+        return isset($this->json['extra']['merge-plugin']['include']) ?
+            $this->fixRelativePaths($this->json['extra']['merge-plugin']['include']) : [];
+    }
+
+    /**
+     * Get list of additional packages to require if precessing recursively.
+     *
+     * @return array
+     */
+    public function getRequires()
+    {
+        return isset($this->json['extra']['merge-plugin']['require']) ?
+            $this->fixRelativePaths($this->json['extra']['merge-plugin']['require']) : [];
+    }
+
+    /**
+     * Get list of merged requirements from this package.
+     *
+     * @return string[]
+     */
+    public function getMergedRequirements()
+    {
+        return array_keys($this->mergedRequirements);
+    }
+
+    /**
+     * Read the contents of a composer.json style file into an array.
+     *
+     * The package contents are fixed up to be usable to create a Package
+     * object by providing dummy "name" and "version" values if they have not
+     * been provided in the file. This is consistent with the default root
+     * package loading behavior of Composer.
+     *
+     * @param string $path
+     * @return array
+     */
+    protected function readPackageJson($path)
+    {
+        $file = new JsonFile($path);
+        $json = $file->read();
+        if (!isset($json['name'])) {
+            $json['name'] = 'merge-plugin/' .
+                strtr($path, DIRECTORY_SEPARATOR, '-');
+        }
+        if (!isset($json['version'])) {
+            $json['version'] = '1.0.0';
+        }
+        return $json;
+    }
+
+    /**
+     * @param array $json
+     * @return CompletePackage
+     */
+    protected function loadPackage(array $json)
+    {
+        $loader = new ArrayLoader();
+        $package = $loader->load($json);
+        // @codeCoverageIgnoreStart
+        if (!$package instanceof CompletePackage) {
+            throw new UnexpectedValueException(
+                'Expected instance of CompletePackage, got ' .
+                get_class($package)
+            );
+        }
+        // @codeCoverageIgnoreEnd
+        return $package;
+    }
+
+    /**
+     * Merge this package into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     * @param PluginState $state
+     */
+    public function mergeInto(RootPackageInterface $root, PluginState $state)
+    {
+        $this->prependRepositories($root);
+
+        $this->mergeRequires('require', $root, $state);
+
+        $this->mergePackageLinks('conflict', $root);
+
+        if ($state->shouldMergeReplace()) {
+            $this->mergePackageLinks('replace', $root);
+        }
+
+        $this->mergePackageLinks('provide', $root);
+
+        $this->mergeSuggests($root);
+
+        $this->mergeAutoload('autoload', $root);
+
+        $this->mergeExtra($root, $state);
+
+        $this->mergeScripts($root, $state);
+
+        if ($state->isDevMode()) {
+            $this->mergeDevInto($root, $state);
+        } else {
+            $this->mergeReferences($root);
+            $this->mergeAliases($root);
+        }
+    }
+
+    /**
+     * Merge just the dev portion into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     * @param PluginState $state
+     */
+    public function mergeDevInto(RootPackageInterface $root, PluginState $state)
+    {
+        $this->mergeRequires('require-dev', $root, $state);
+        $this->mergeAutoload('devAutoload', $root);
+        $this->mergeReferences($root);
+        $this->mergeAliases($root);
+    }
+
+    /**
+     * Add a collection of repositories described by the given configuration
+     * to the given package and the global repository manager.
+     *
+     * @param RootPackageInterface $root
+     */
+    protected function prependRepositories(RootPackageInterface $root)
+    {
+        if (!isset($this->json['repositories'])) {
+            return;
+        }
+        $repoManager = $this->composer->getRepositoryManager();
+        $newRepos = [];
+
+        foreach ($this->json['repositories'] as $repoJson) {
+            if (!isset($repoJson['type'])) {
+                continue;
+            }
+            if ($repoJson['type'] === 'path' && isset($repoJson['url'])) {
+                $repoJson['url'] = $this->fixRelativePaths(array($repoJson['url']))[0];
+            }
+            $this->logger->info("Prepending {$repoJson['type']} repository");
+            $repo = $repoManager->createRepository(
+                $repoJson['type'],
+                $repoJson
+            );
+            $repoManager->prependRepository($repo);
+            $newRepos[] = $repo;
+        }
+
+        $unwrapped = self::unwrapIfNeeded($root, 'setRepositories');
+        $unwrapped->setRepositories(array_merge(
+            $newRepos,
+            $root->getRepositories()
+        ));
+    }
+
+    /**
+     * Merge require or require-dev into a RootPackageInterface
+     *
+     * @param string $type 'require' or 'require-dev'
+     * @param RootPackageInterface $root
+     * @param PluginState $state
+     */
+    protected function mergeRequires(
+        $type,
+        RootPackageInterface $root,
+        PluginState $state
+    ) {
+        $linkType = BasePackage::$supportedLinkTypes[$type];
+        $getter = 'get' . ucfirst($linkType['method']);
+        $setter = 'set' . ucfirst($linkType['method']);
+
+        $requires = $this->package->{$getter}();
+        if (empty($requires)) {
+            return;
+        }
+
+        $this->mergeStabilityFlags($root, $requires);
+
+        $requires = $this->replaceSelfVersionDependencies(
+            $type,
+            $requires,
+            $root
+        );
+
+        $root->{$setter}($this->mergeOrDefer(
+            $type,
+            $root->{$getter}(),
+            $requires,
+            $state
+        ));
+    }
+
+    /**
+     * Merge two collections of package links and collect duplicates for
+     * subsequent processing.
+     *
+     * @param string $type 'require' or 'require-dev'
+     * @param array $origin Primary collection
+     * @param array $merge Additional collection
+     * @param PluginState $state
+     * @return array Merged collection
+     */
+    protected function mergeOrDefer(
+        $type,
+        array $origin,
+        array $merge,
+        PluginState $state
+    ) {
+        if ($state->ignoreDuplicateLinks() && $state->replaceDuplicateLinks()) {
+            $this->logger->warning("Both replace and ignore-duplicates are true. These are mutually exclusive.");
+            $this->logger->warning("Duplicate packages will be ignored.");
+        }
+
+        foreach ($merge as $name => $link) {
+            if (isset($origin[$name])) {
+                if ($state->ignoreDuplicateLinks()) {
+                    $this->logger->info("Ignoring duplicate <comment>{$name}</comment>");
+                    continue;
+                }
+
+                if ($state->replaceDuplicateLinks()) {
+                    $this->logger->info("Replacing <comment>{$name}</comment>");
+                    $this->mergedRequirements[$name] = true;
+                    $origin[$name] = $link;
+                } else {
+                    $this->logger->info("Merging <comment>{$name}</comment>");
+                    $this->mergedRequirements[$name] = true;
+                    $origin[$name] = $this->mergeConstraints($origin[$name], $link, $state);
+                }
+            } else {
+                $this->logger->info("Adding <comment>{$name}</comment>");
+                $this->mergedRequirements[$name] = true;
+                $origin[$name] = $link;
+            }
+        }
+
+        if (!$state->isComposer1()) {
+            Intervals::clear();
+        }
+
+        return $origin;
+    }
+
+    /**
+     * Merge package constraints.
+     *
+     * Adapted from Composer's UpdateCommand::appendConstraintToLink
+     *
+     * @param Link $origin The base package link.
+     * @param Link $merge The related package link to merge.
+     * @param PluginState $state
+     * @return Link Merged link.
+     */
+    protected function mergeConstraints(Link $origin, Link $merge, PluginState $state)
+    {
+        $oldPrettyString = $origin->getConstraint()->getPrettyString();
+        $newPrettyString = $merge->getConstraint()->getPrettyString();
+
+        if ($state->isComposer1()) {
+            $constraintClass = MultiConstraint::class;
+        } else {
+            $constraintClass = \Composer\Semver\Constraint\MultiConstraint::class;
+
+            if (Intervals::isSubsetOf($origin->getConstraint(), $merge->getConstraint())) {
+                return $origin;
+            }
+
+            if (Intervals::isSubsetOf($merge->getConstraint(), $origin->getConstraint())) {
+                return $merge;
+            }
+        }
+
+        $newConstraint = $constraintClass::create([
+            $origin->getConstraint(),
+            $merge->getConstraint()
+        ], true);
+        $newConstraint->setPrettyString($oldPrettyString.', '.$newPrettyString);
+
+        return new Link(
+            $origin->getSource(),
+            $origin->getTarget(),
+            $newConstraint,
+            $origin->getDescription(),
+            $origin->getPrettyConstraint() . ', ' . $newPrettyString
+        );
+    }
+
+    /**
+     * Merge autoload or autoload-dev into a RootPackageInterface
+     *
+     * @param string $type 'autoload' or 'devAutoload'
+     * @param RootPackageInterface $root
+     */
+    protected function mergeAutoload($type, RootPackageInterface $root)
+    {
+        $getter = 'get' . ucfirst($type);
+        $setter = 'set' . ucfirst($type);
+
+        $autoload = $this->package->{$getter}();
+        if (empty($autoload)) {
+            return;
+        }
+
+        $unwrapped = self::unwrapIfNeeded($root, $setter);
+        $unwrapped->{$setter}(array_merge_recursive(
+            $root->{$getter}(),
+            $this->fixRelativePaths($autoload)
+        ));
+    }
+
+    /**
+     * Fix a collection of paths that are relative to this package to be
+     * relative to the base package.
+     *
+     * @param array $paths
+     * @return array
+     */
+    protected function fixRelativePaths(array $paths)
+    {
+        $base = dirname($this->path);
+        $base = ($base === '.') ? '' : "{$base}/";
+
+        array_walk_recursive(
+            $paths,
+            function (&$path) use ($base) {
+                $path = "{$base}{$path}";
+            }
+        );
+        return $paths;
+    }
+
+    /**
+     * Extract and merge stability flags from the given collection of
+     * requires and merge them into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     * @param array $requires
+     */
+    protected function mergeStabilityFlags(
+        RootPackageInterface $root,
+        array $requires
+    ) {
+        $flags = $root->getStabilityFlags();
+        $sf = new StabilityFlags($flags, $root->getMinimumStability());
+
+        $unwrapped = self::unwrapIfNeeded($root, 'setStabilityFlags');
+        $unwrapped->setStabilityFlags(array_merge(
+            $flags,
+            $sf->extractAll($requires)
+        ));
+    }
+
+    /**
+     * Merge package links of the given type into a RootPackageInterface
+     *
+     * @param string $type 'conflict', 'replace' or 'provide'
+     * @param RootPackageInterface $root
+     */
+    protected function mergePackageLinks($type, RootPackageInterface $root)
+    {
+        $linkType = BasePackage::$supportedLinkTypes[$type];
+        $getter = 'get' . ucfirst($linkType['method']);
+        $setter = 'set' . ucfirst($linkType['method']);
+
+        $links = $this->package->{$getter}();
+        if (!empty($links)) {
+            $unwrapped = self::unwrapIfNeeded($root, $setter);
+            // @codeCoverageIgnoreStart
+            if ($root !== $unwrapped) {
+                $this->logger->warning(
+                    'This Composer version does not support ' .
+                    "'{$type}' merging for aliased packages."
+                );
+            }
+            // @codeCoverageIgnoreEnd
+            $unwrapped->{$setter}(array_merge(
+                $root->{$getter}(),
+                $this->replaceSelfVersionDependencies($type, $links, $root)
+            ));
+        }
+    }
+
+    /**
+     * Merge suggested packages into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     */
+    protected function mergeSuggests(RootPackageInterface $root)
+    {
+        $suggests = $this->package->getSuggests();
+        if (!empty($suggests)) {
+            $unwrapped = self::unwrapIfNeeded($root, 'setSuggests');
+            $unwrapped->setSuggests(array_merge(
+                $root->getSuggests(),
+                $suggests
+            ));
+        }
+    }
+
+    /**
+     * Merge extra config into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     * @param PluginState $state
+     */
+    public function mergeExtra(RootPackageInterface $root, PluginState $state)
+    {
+        $extra = $this->package->getExtra();
+        unset($extra['merge-plugin']);
+        if (!$state->shouldMergeExtra() || empty($extra)) {
+            return;
+        }
+
+        $rootExtra = $root->getExtra();
+        $unwrapped = self::unwrapIfNeeded($root, 'setExtra');
+
+        if ($state->replaceDuplicateLinks()) {
+            $unwrapped->setExtra(
+                self::mergeExtraArray($state->shouldMergeExtraDeep(), $rootExtra, $extra)
+            );
+        } else {
+            if (!$state->shouldMergeExtraDeep()) {
+                foreach (array_intersect(
+                    array_keys($extra),
+                    array_keys($rootExtra)
+                ) as $key) {
+                    $this->logger->info(
+                        "Ignoring duplicate <comment>{$key}</comment> in ".
+                        "<comment>{$this->path}</comment> extra config."
+                    );
+                }
+            }
+            $unwrapped->setExtra(
+                self::mergeExtraArray($state->shouldMergeExtraDeep(), $extra, $rootExtra)
+            );
+        }
+    }
+
+    /**
+     * Merge scripts config into a RootPackageInterface
+     *
+     * @param RootPackageInterface $root
+     * @param PluginState $state
+     */
+    public function mergeScripts(RootPackageInterface $root, PluginState $state)
+    {
+        $scripts = $this->package->getScripts();
+        if (!$state->shouldMergeScripts() || empty($scripts)) {
+            return;
+        }
+
+        $rootScripts = $root->getScripts();
+        $unwrapped = self::unwrapIfNeeded($root, 'setScripts');
+
+        if ($state->replaceDuplicateLinks()) {
+            $unwrapped->setScripts(
+                array_merge($rootScripts, $scripts)
+            );
+        } else {
+            $unwrapped->setScripts(
+                array_merge($scripts, $rootScripts)
+            );
+        }
+    }
+
+    /**
+     * Merges two arrays either via arrayMergeDeep or via array_merge.
+     *
+     * @param bool $mergeDeep
+     * @param array $array1
+     * @param array $array2
+     * @return array
+     */
+    public static function mergeExtraArray($mergeDeep, $array1, $array2)
+    {
+        if ($mergeDeep) {
+            return NestedArray::mergeDeep($array1, $array2);
+        }
+
+        return array_merge($array1, $array2);
+    }
+
+    /**
+     * Update Links with a 'self.version' constraint with the root package's
+     * version.
+     *
+     * @param string $type Link type
+     * @param array $links
+     * @param RootPackageInterface $root
+     * @return array
+     */
+    protected function replaceSelfVersionDependencies(
+        $type,
+        array $links,
+        RootPackageInterface $root
+    ) {
+        $linkType = BasePackage::$supportedLinkTypes[$type];
+        $version = $root->getVersion();
+        $prettyVersion = $root->getPrettyVersion();
+        $vp = $this->versionParser;
+
+        $method = 'get' . ucfirst($linkType['method']);
+        $packages = $root->$method();
+
+        return array_map(
+            static function ($link) use ($linkType, $version, $prettyVersion, $vp, $packages) {
+                if ('self.version' === $link->getPrettyConstraint()) {
+                    if (isset($packages[$link->getSource()])) {
+                        /** @var Link $package */
+                        $package = $packages[$link->getSource()];
+                        return new Link(
+                            $link->getSource(),
+                            $link->getTarget(),
+                            $vp->parseConstraints($package->getConstraint()->getPrettyString()),
+                            $linkType['description'],
+                            $package->getPrettyConstraint()
+                        );
+                    }
+
+                    return new Link(
+                        $link->getSource(),
+                        $link->getTarget(),
+                        $vp->parseConstraints($version),
+                        $linkType['description'],
+                        $prettyVersion
+                    );
+                }
+                return $link;
+            },
+            $links
+        );
+    }
+
+    /**
+     * Get a full featured Package from a RootPackageInterface.
+     *
+     * In Composer versions before 599ad77 the RootPackageInterface only
+     * defines a sub-set of operations needed by composer-merge-plugin and
+     * RootAliasPackage only implemented those methods defined by the
+     * interface. Most of the unimplemented methods in RootAliasPackage can be
+     * worked around because the getter methods that are implemented proxy to
+     * the aliased package which we can modify by unwrapping. The exception
+     * being modifying the 'conflicts', 'provides' and 'replaces' collections.
+     * We have no way to actually modify those collections unfortunately in
+     * older versions of Composer.
+     *
+     * @param RootPackageInterface $root
+     * @param string $method Method needed
+     * @return RootPackageInterface|RootPackage
+     */
+    public static function unwrapIfNeeded(
+        RootPackageInterface $root,
+        $method = 'setExtra'
+    ) {
+        // @codeCoverageIgnoreStart
+        if ($root instanceof RootAliasPackage &&
+            !method_exists($root, $method)
+        ) {
+            // Unwrap and return the aliased RootPackage.
+            $root = $root->getAliasOf();
+        }
+        // @codeCoverageIgnoreEnd
+        return $root;
+    }
+
+    protected function mergeAliases(RootPackageInterface $root)
+    {
+        $aliases = [];
+        $unwrapped = self::unwrapIfNeeded($root, 'setAliases');
+        foreach (array('require', 'require-dev') as $linkType) {
+            $linkInfo = BasePackage::$supportedLinkTypes[$linkType];
+            $method = 'get'.ucfirst($linkInfo['method']);
+            $links = [];
+            foreach ($unwrapped->$method() as $link) {
+                $links[$link->getTarget()] = $link->getConstraint()->getPrettyString();
+            }
+            $aliases = $this->extractAliases($links, $aliases);
+        }
+        $unwrapped->setAliases($aliases);
+    }
+
+    /**
+     * Extract aliases from version constraints (dev-branch as 1.0.0).
+     *
+     * @param array $requires
+     * @param array $aliases
+     * @return array
+     * @see RootPackageLoader::extractAliases()
+     */
+    protected function extractAliases(array $requires, array $aliases)
+    {
+        foreach ($requires as $reqName => $reqVersion) {
+            if (preg_match('{^([^,\s#]+)(?:#[^ ]+)? +as +([^,\s]+)$}', $reqVersion, $match)) {
+                $aliases[] = [
+                    'package' => strtolower($reqName),
+                    'version' => $this->versionParser->normalize($match[1], $reqVersion),
+                    'alias' => $match[2],
+                    'alias_normalized' => $this->versionParser->normalize($match[2], $reqVersion),
+                ];
+            } elseif (strpos($reqVersion, ' as ') !== false) {
+                throw new UnexpectedValueException(
+                    'Invalid alias definition in "'.$reqName.'": "'.$reqVersion.'". '
+                    . 'Aliases should be in the form "exact-version as other-exact-version".'
+                );
+            }
+        }
+
+        return $aliases;
+    }
+
+    /**
+     * Update the root packages reference information.
+     *
+     * @param RootPackageInterface $root
+     */
+    protected function mergeReferences(RootPackageInterface $root)
+    {
+        // Merge source reference information for merged packages.
+        // @see RootPackageLoader::load
+        $references = [];
+        $unwrapped = self::unwrapIfNeeded($root, 'setReferences');
+        foreach (['require', 'require-dev'] as $linkType) {
+            $linkInfo = BasePackage::$supportedLinkTypes[$linkType];
+            $method = 'get'.ucfirst($linkInfo['method']);
+            $links = [];
+            foreach ($unwrapped->$method() as $link) {
+                $links[$link->getTarget()] = $link->getConstraint()->getPrettyString();
+            }
+            $references = $this->extractReferences($links, $references);
+        }
+        $unwrapped->setReferences($references);
+    }
+
+    /**
+     * Extract vcs revision from version constraint (dev-master#abc123.
+     *
+     * @param array $requires
+     * @param array $references
+     * @return array
+     * @see RootPackageLoader::extractReferences()
+     */
+    protected function extractReferences(array $requires, array $references)
+    {
+        foreach ($requires as $reqName => $reqVersion) {
+            $reqVersion = preg_replace('{^([^,\s@]+) as .+$}', '$1', $reqVersion);
+            $stabilityName = VersionParser::parseStability($reqVersion);
+            if ($stabilityName === 'dev' &&
+                preg_match('{^[^,\s@]+?#([a-f0-9]+)$}', $reqVersion, $match)
+            ) {
+                $name = strtolower($reqName);
+                $references[$name] = $match[1];
+            }
+        }
+
+        return $references;
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/Logger.php b/vendor/wikimedia/composer-merge-plugin/src/Logger.php
new file mode 100644
index 0000000000000000000000000000000000000000..50bfdd2d38dd9c9c68f20f9cea089d8595234688
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/Logger.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\IO\IOInterface;
+
+/**
+ * Simple logging wrapper for Composer\IO\IOInterface
+ *
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class Logger
+{
+    /**
+     * @var string $name
+     */
+    protected $name;
+
+    /**
+     * @var IOInterface $inputOutput
+     */
+    protected $inputOutput;
+
+    /**
+     * @param string $name
+     * @param IOInterface $io
+     */
+    public function __construct($name, IOInterface $io)
+    {
+        $this->name = $name;
+        $this->inputOutput = $io;
+    }
+
+    /**
+     * Log a debug message
+     *
+     * Messages will be output at the "very verbose" logging level (eg `-vv`
+     * needed on the Composer command).
+     *
+     * @param string $message
+     */
+    public function debug($message)
+    {
+        if ($this->inputOutput->isVeryVerbose()) {
+            $message = "  <info>[{$this->name}]</info> {$message}";
+            $this->log($message);
+        }
+    }
+
+    /**
+     * Log an informative message
+     *
+     * Messages will be output at the "verbose" logging level (eg `-v` needed
+     * on the Composer command).
+     *
+     * @param string $message
+     */
+    public function info($message)
+    {
+        if ($this->inputOutput->isVerbose()) {
+            $message = "  <info>[{$this->name}]</info> {$message}";
+            $this->log($message);
+        }
+    }
+
+    /**
+     * Log a warning message
+     *
+     * @param string $message
+     */
+    public function warning($message)
+    {
+        $message = "  <error>[{$this->name}]</error> {$message}";
+        $this->log($message);
+    }
+
+    /**
+     * Write a message
+     *
+     * @param string $message
+     */
+    public function log($message)
+    {
+        if (method_exists($this->inputOutput, 'writeError')) {
+            $this->inputOutput->writeError($message);
+        } else {
+            // @codeCoverageIgnoreStart
+            // Backwards compatibility for Composer before cb336a5
+            $this->inputOutput->write($message);
+            // @codeCoverageIgnoreEnd
+        }
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/MergePlugin.php b/vendor/wikimedia/composer-merge-plugin/src/MergePlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..a9af5b632d7ca4cec41b1419d1e2ab5ae1a4691f
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/MergePlugin.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\Composer;
+use Composer\DependencyResolver\Operation\InstallOperation;
+use Composer\EventDispatcher\Event as BaseEvent;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\Factory;
+use Composer\Installer;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
+use Composer\IO\IOInterface;
+use Composer\Package\RootPackageInterface;
+use Composer\Plugin\PluginEvents;
+use Composer\Plugin\PluginInterface;
+use Composer\Script\Event as ScriptEvent;
+use Composer\Script\ScriptEvents;
+
+/**
+ * Composer plugin that allows merging multiple composer.json files.
+ *
+ * When installed, this plugin will look for a "merge-plugin" key in the
+ * composer configuration's "extra" section. The value for this key is
+ * a set of options configuring the plugin.
+ *
+ * An "include" setting is required. The value of this setting can be either
+ * a single value or an array of values. Each value is treated as a glob()
+ * pattern identifying additional composer.json style configuration files to
+ * merge into the configuration for the current compser execution.
+ *
+ * The "autoload", "autoload-dev", "conflict", "provide", "replace",
+ * "repositories", "require", "require-dev", and "suggest" sections of the
+ * found configuration files will be merged into the root package
+ * configuration as though they were directly included in the top-level
+ * composer.json file.
+ *
+ * If included files specify conflicting package versions for "require" or
+ * "require-dev", the normal Composer dependency solver process will be used
+ * to attempt to resolve the conflict. Specifying the 'replace' key as true will
+ * change this default behaviour so that the last-defined version of a package
+ * will win, allowing for force-overrides of package defines.
+ *
+ * By default the "extra" section is not merged. This can be enabled by
+ * setitng the 'merge-extra' key to true. In normal mode, when the same key is
+ * found in both the original and the imported extra section, the version in
+ * the original config is used and the imported version is skipped. If
+ * 'replace' mode is active, this behaviour changes so the imported version of
+ * the key is used, replacing the version in the original config.
+ *
+ *
+ * @code
+ * {
+ *     "require": {
+ *         "wikimedia/composer-merge-plugin": "dev-master"
+ *     },
+ *     "extra": {
+ *         "merge-plugin": {
+ *             "include": [
+ *                 "composer.local.json"
+ *             ]
+ *         }
+ *     }
+ * }
+ * @endcode
+ *
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class MergePlugin implements PluginInterface, EventSubscriberInterface
+{
+
+    /**
+     * Official package name
+     */
+    public const PACKAGE_NAME = 'wikimedia/composer-merge-plugin';
+
+    /**
+     * Priority that plugin uses to register callbacks.
+     */
+    private const CALLBACK_PRIORITY = 50000;
+
+    /**
+     * @var Composer $composer
+     */
+    protected $composer;
+
+    /**
+     * @var PluginState $state
+     */
+    protected $state;
+
+    /**
+     * @var Logger $logger
+     */
+    protected $logger;
+
+    /**
+     * Files that have already been fully processed
+     *
+     * @var array<string, bool> $loaded
+     */
+    protected $loaded = [];
+
+    /**
+     * Files that have already been partially processed
+     *
+     * @var array<string, bool> $loadedNoDev
+     */
+    protected $loadedNoDev = [];
+
+    /**
+     * Nested packages to restrict update operations.
+     *
+     * @var array<string, bool> $updateAllowList
+     */
+    protected $updateAllowList = [];
+
+    /**
+     * {@inheritdoc}
+     */
+    public function activate(Composer $composer, IOInterface $io)
+    {
+        $this->composer = $composer;
+        $this->state = new PluginState($this->composer);
+        $this->logger = new Logger('merge-plugin', $io);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function deactivate(Composer $composer, IOInterface $io)
+    {
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function uninstall(Composer $composer, IOInterface $io)
+    {
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function getSubscribedEvents()
+    {
+        return [
+            PluginEvents::INIT =>
+                ['onInit', self::CALLBACK_PRIORITY],
+            PackageEvents::POST_PACKAGE_INSTALL =>
+                ['onPostPackageInstall', self::CALLBACK_PRIORITY],
+            ScriptEvents::POST_INSTALL_CMD =>
+                ['onPostInstallOrUpdate', self::CALLBACK_PRIORITY],
+            ScriptEvents::POST_UPDATE_CMD =>
+                ['onPostInstallOrUpdate', self::CALLBACK_PRIORITY],
+            ScriptEvents::PRE_AUTOLOAD_DUMP =>
+                ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY],
+            ScriptEvents::PRE_INSTALL_CMD =>
+                ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY],
+            ScriptEvents::PRE_UPDATE_CMD =>
+                ['onInstallUpdateOrDump', self::CALLBACK_PRIORITY],
+        ];
+    }
+
+    /**
+     * Get list of packages to restrict update operations.
+     *
+     * @return string[]
+     * @see \Composer\Installer::setUpdateAllowList()
+     */
+    public function getUpdateAllowList()
+    {
+        return array_keys($this->updateAllowList);
+    }
+
+    /**
+     * Handle an event callback for initialization.
+     *
+     * @param BaseEvent $event
+     */
+    public function onInit(BaseEvent $event)
+    {
+        $this->state->loadSettings();
+        // It is not possible to know if the user specified --dev or --no-dev
+        // so assume it is false. The dev section will be merged later when
+        // the other events fire.
+        $this->state->setDevMode(false);
+        $this->mergeFiles($this->state->getIncludes(), false);
+        $this->mergeFiles($this->state->getRequires(), true);
+    }
+
+    /**
+     * Handle an event callback for an install, update or dump command by
+     * checking for "merge-plugin" in the "extra" data and merging package
+     * contents if found.
+     *
+     * @param ScriptEvent $event
+     */
+    public function onInstallUpdateOrDump(ScriptEvent $event)
+    {
+        $this->state->loadSettings();
+        $this->state->setDevMode($event->isDevMode());
+        $this->mergeFiles($this->state->getIncludes(), false);
+        $this->mergeFiles($this->state->getRequires(), true);
+
+        if ($event->getName() === ScriptEvents::PRE_AUTOLOAD_DUMP) {
+            $this->state->setDumpAutoloader(true);
+            $flags = $event->getFlags();
+            if (isset($flags['optimize'])) {
+                $this->state->setOptimizeAutoloader($flags['optimize']);
+            }
+        }
+    }
+
+    /**
+     * Find configuration files matching the configured glob patterns and
+     * merge their contents with the master package.
+     *
+     * @param array $patterns List of files/glob patterns
+     * @param bool $required Are the patterns required to match files?
+     * @throws MissingFileException when required and a pattern returns no
+     *      results
+     */
+    protected function mergeFiles(array $patterns, $required = false)
+    {
+        $root = $this->composer->getPackage();
+
+        $files = array_map(
+            static function ($files, $pattern) use ($required) {
+                if ($required && !$files) {
+                    throw new MissingFileException(
+                        "merge-plugin: No files matched required '{$pattern}'"
+                    );
+                }
+                return $files;
+            },
+            array_map('glob', $patterns),
+            $patterns
+        );
+
+        foreach (array_reduce($files, 'array_merge', []) as $path) {
+            $this->mergeFile($root, $path);
+        }
+    }
+
+    /**
+     * Read a JSON file and merge its contents
+     *
+     * @param RootPackageInterface $root
+     * @param string $path
+     */
+    protected function mergeFile(RootPackageInterface $root, $path)
+    {
+        if (isset($this->loaded[$path]) ||
+            (isset($this->loadedNoDev[$path]) && !$this->state->isDevMode())
+        ) {
+            $this->logger->debug(
+                "Already merged <comment>$path</comment> completely"
+            );
+            return;
+        }
+
+        $package = new ExtraPackage($path, $this->composer, $this->logger);
+
+        if (isset($this->loadedNoDev[$path])) {
+            $this->logger->info(
+                "Loading -dev sections of <comment>{$path}</comment>..."
+            );
+            $package->mergeDevInto($root, $this->state);
+        } else {
+            $this->logger->info("Loading <comment>{$path}</comment>...");
+            $package->mergeInto($root, $this->state);
+        }
+
+        $requirements = $package->getMergedRequirements();
+        if (!empty($requirements)) {
+            $this->updateAllowList = array_replace(
+                $this->updateAllowList,
+                array_fill_keys($requirements, true)
+            );
+        }
+
+        if ($this->state->isDevMode()) {
+            $this->loaded[$path] = true;
+        } else {
+            $this->loadedNoDev[$path] = true;
+        }
+
+        if ($this->state->recurseIncludes()) {
+            $this->mergeFiles($package->getIncludes(), false);
+            $this->mergeFiles($package->getRequires(), true);
+        }
+    }
+
+    /**
+     * Handle an event callback following installation of a new package by
+     * checking to see if the package that was installed was our plugin.
+     *
+     * @param PackageEvent $event
+     */
+    public function onPostPackageInstall(PackageEvent $event)
+    {
+        $op = $event->getOperation();
+        if ($op instanceof InstallOperation) {
+            $package = $op->getPackage()->getName();
+            if ($package === self::PACKAGE_NAME) {
+                $this->logger->info('composer-merge-plugin installed');
+                $this->state->setFirstInstall(true);
+                $this->state->setLocked(
+                    $event->getComposer()->getLocker()->isLocked()
+                );
+            }
+        }
+    }
+
+    /**
+     * Handle an event callback following an install or update command. If our
+     * plugin was installed during the run then trigger an update command to
+     * process any merge-patterns in the current config.
+     *
+     * @param ScriptEvent $event
+     */
+    public function onPostInstallOrUpdate(ScriptEvent $event)
+    {
+        // @codeCoverageIgnoreStart
+        if ($this->state->isFirstInstall()) {
+            $this->state->setFirstInstall(false);
+
+            $requirements = $this->getUpdateAllowList();
+            if (empty($requirements)) {
+                return;
+            }
+
+            $this->logger->log("\n".'<info>Running composer update to apply merge settings</info>');
+
+            $lockBackup = null;
+            $lock = null;
+            if (!$this->state->isComposer1()) {
+                $file = Factory::getComposerFile();
+                $lock = Factory::getLockFile($file);
+                if (file_exists($lock)) {
+                    $lockBackup = file_get_contents($lock);
+                }
+            }
+
+            $config = $this->composer->getConfig();
+            $preferSource = $config->get('preferred-install') === 'source';
+            $preferDist = $config->get('preferred-install') === 'dist';
+
+            $installer = Installer::create(
+                $event->getIO(),
+                // Create a new Composer instance to ensure full processing of
+                // the merged files.
+                Factory::create($event->getIO(), null, false)
+            );
+
+            $installer->setPreferSource($preferSource);
+            $installer->setPreferDist($preferDist);
+            $installer->setDevMode($event->isDevMode());
+            $installer->setDumpAutoloader($this->state->shouldDumpAutoloader());
+            $installer->setOptimizeAutoloader(
+                $this->state->shouldOptimizeAutoloader()
+            );
+
+            $installer->setUpdate(true);
+
+            if ($this->state->isComposer1()) {
+                // setUpdateWhitelist() only exists in composer 1.x. Configure as to run phan against composer 2.x
+                // @phan-suppress-next-line PhanUndeclaredMethod
+                $installer->setUpdateWhitelist($requirements);
+            } else {
+                $installer->setUpdateAllowList($requirements);
+            }
+
+            $status = $installer->run();
+            if (( $status !== 0 ) && $lockBackup && $lock && !$this->state->isComposer1()) {
+                $this->logger->log(
+                    "\n".'<error>'.
+                    'Update to apply merge settings failed, reverting '.$lock.' to its original content.'.
+                    '</error>'
+                );
+                file_put_contents($lock, $lockBackup);
+            }
+        }
+        // @codeCoverageIgnoreEnd
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/MissingFileException.php b/vendor/wikimedia/composer-merge-plugin/src/MissingFileException.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0ef51075327e5b544d8b47de5b8a7b27cfdd288
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/MissingFileException.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use RuntimeException;
+
+/**
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class MissingFileException extends RuntimeException
+{
+}
diff --git a/vendor/wikimedia/composer-merge-plugin/src/MultiConstraint.php b/vendor/wikimedia/composer-merge-plugin/src/MultiConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..94a9954f6aa7764acefa50df01154feed130632a
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/MultiConstraint.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2021 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\Semver\Constraint\ConstraintInterface;
+use Composer\Semver\Constraint\EmptyConstraint;
+use Composer\Semver\Constraint\MultiConstraint as SemverMultiConstraint;
+use function count;
+
+/**
+ * Adapted from Composer's v2 MultiConstraint::create for Composer v1
+ * @link https://github.com/composer/semver/blob/3.2.4/src/Constraint/MultiConstraint.php
+ * @author Chauncey McAskill <chauncey@mcaskill.ca>
+ */
+class MultiConstraint extends SemverMultiConstraint
+{
+    /**
+     * Tries to optimize the constraints as much as possible, meaning
+     * reducing/collapsing congruent constraints etc.
+     * Does not necessarily return a MultiConstraint instance if
+     * things can be reduced to a simple constraint
+     *
+     * @param ConstraintInterface[] $constraints A set of constraints
+     * @param bool                  $conjunctive Whether the constraints should be treated as conjunctive or disjunctive
+     *
+     * @return ConstraintInterface
+     */
+    public static function create(array $constraints, $conjunctive = true)
+    {
+        if (count($constraints) === 0) {
+            // EmptyConstraint only exists in composer 1.x. Configure as to run phan against composer 2.x
+            // @phan-suppress-next-line PhanTypeMismatchReturn, PhanUndeclaredClassMethod
+            return new EmptyConstraint();
+        }
+
+        if (count($constraints) === 1) {
+            return $constraints[0];
+        }
+
+        $optimized = self::optimizeConstraints($constraints, $conjunctive);
+        if ($optimized !== null) {
+            list($constraints, $conjunctive) = $optimized;
+            if (count($constraints) === 1) {
+                return $constraints[0];
+            }
+        }
+
+        return new self($constraints, $conjunctive);
+    }
+
+    /**
+     * @return array|null
+     */
+    private static function optimizeConstraints(array $constraints, $conjunctive)
+    {
+        // parse the two OR groups and if they are contiguous we collapse
+        // them into one constraint
+        // [>= 1 < 2] || [>= 2 < 3] || [>= 3 < 4] => [>= 1 < 4]
+        if (!$conjunctive) {
+            $left = $constraints[0];
+            $mergedConstraints = [];
+            $optimized = false;
+            for ($i = 1, $l = count($constraints); $i < $l; $i++) {
+                $right = $constraints[$i];
+                if ($left instanceof SemverMultiConstraint
+                    && $left->conjunctive
+                    && $right instanceof SemverMultiConstraint
+                    && $right->conjunctive
+                    && count($left->constraints) === 2
+                    && count($right->constraints) === 2
+                    && ($left0 = (string) $left->constraints[0])
+                    && $left0[0] === '>' && $left0[1] === '='
+                    && ($left1 = (string) $left->constraints[1])
+                    && $left1[0] === '<'
+                    && ($right0 = (string) $right->constraints[0])
+                    && $right0[0] === '>' && $right0[1] === '='
+                    && ($right1 = (string) $right->constraints[1])
+                    && $right1[0] === '<'
+                    && substr($left1, 2) === substr($right0, 3)
+                ) {
+                    $optimized = true;
+                    $left = new MultiConstraint(
+                        [
+                            $left->constraints[0],
+                            $right->constraints[1],
+                        ],
+                        true
+                    );
+                } else {
+                    $mergedConstraints[] = $left;
+                    $left = $right;
+                }
+            }
+            if ($optimized) {
+                $mergedConstraints[] = $left;
+                return [$mergedConstraints, false];
+            }
+        }
+
+        // TODO: Here's the place to put more optimizations
+
+        return null;
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/NestedArray.php b/vendor/wikimedia/composer-merge-plugin/src/NestedArray.php
new file mode 100644
index 0000000000000000000000000000000000000000..da82b6d611cb974890a2f06a59cd45d020d7efa9
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/NestedArray.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+/**
+ * Adapted from
+ * http://cgit.drupalcode.org/drupal/tree/core/lib/Drupal/Component/Utility/NestedArray.php
+ * @ f86a4d650d5af0b82a3981e09977055fa63f6f2e
+ */
+class NestedArray
+{
+
+    /**
+     * Merges multiple arrays, recursively, and returns the merged array.
+     *
+     * This function is similar to PHP's array_merge_recursive() function, but
+     * it handles non-array values differently. When merging values that are
+     * not both arrays, the latter value replaces the former rather than
+     * merging with it.
+     *
+     * Example:
+     *
+     * @code
+     * $link_options_1 = ['fragment' => 'x', 'attributes' => ['title' => t('X'), 'class' => ['a', 'b']]];
+     * $link_options_2 = ['fragment' => 'y', 'attributes' => ['title' => t('Y'), 'class' => ['c', 'd']]];
+     *
+     * // This results in ['fragment' => ['x', 'y'], 'attributes' =>
+     * // ['title' => [t('X'), t('Y')], 'class' => ['a', 'b',
+     * // 'c', 'd']]].
+     * $incorrect = array_merge_recursive($link_options_1, $link_options_2);
+     *
+     * // This results in ['fragment' => 'y', 'attributes' =>
+     * // ['title' => t('Y'), 'class' => ['a', 'b', 'c', 'd']]].
+     * $correct = NestedArray::mergeDeep($link_options_1, $link_options_2);
+     * @endcode
+     *
+     * @param mixed ...$params Arrays to merge.
+     *
+     * @return array The merged array.
+     *
+     * @see NestedArray::mergeDeepArray()
+     */
+    public static function mergeDeep(...$params)
+    {
+        return self::mergeDeepArray($params);
+    }
+
+    /**
+     * Merges multiple arrays, recursively, and returns the merged array.
+     *
+     * This function is equivalent to NestedArray::mergeDeep(), except the
+     * input arrays are passed as a single array parameter rather than
+     * a variable parameter list.
+     *
+     * The following are equivalent:
+     * - NestedArray::mergeDeep($a, $b);
+     * - NestedArray::mergeDeepArray([$a, $b]);
+     *
+     * The following are also equivalent:
+     * - call_user_func_array('NestedArray::mergeDeep', $arrays_to_merge);
+     * - NestedArray::mergeDeepArray($arrays_to_merge);
+     *
+     * @param array $arrays
+     *   An arrays of arrays to merge.
+     * @param bool  $preserveIntegerKeys
+     *   (optional) If given, integer keys will be preserved and merged
+     *   instead of appended. Defaults to false.
+     *
+     * @return array
+     *   The merged array.
+     *
+     * @see NestedArray::mergeDeep()
+     */
+    public static function mergeDeepArray(
+        array $arrays,
+        $preserveIntegerKeys = false
+    ) {
+        $result = [];
+        foreach ($arrays as $array) {
+            foreach ($array as $key => $value) {
+                // Renumber integer keys as array_merge_recursive() does
+                // unless $preserveIntegerKeys is set to TRUE. Note that PHP
+                // automatically converts array keys that are integer strings
+                // (e.g., '1') to integers.
+                if (is_int($key) && !$preserveIntegerKeys) {
+                    $result[] = $value;
+                } elseif (isset($result[$key]) &&
+                    is_array($result[$key]) &&
+                    is_array($value)
+                ) {
+                    // Recurse when both values are arrays.
+                    $result[$key] = self::mergeDeepArray(
+                        [$result[$key], $value],
+                        $preserveIntegerKeys
+                    );
+                } else {
+                    // Otherwise, use the latter value, overriding any
+                    // previous value.
+                    $result[$key] = $value;
+                }
+            }
+        }
+        return $result;
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/PluginState.php b/vendor/wikimedia/composer-merge-plugin/src/PluginState.php
new file mode 100644
index 0000000000000000000000000000000000000000..19878834abddc5c3850524b60f1f25f4fea5462e
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/PluginState.php
@@ -0,0 +1,422 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\Composer;
+use Composer\Plugin\PluginInterface;
+
+/**
+ * Mutable plugin state
+ *
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class PluginState
+{
+    /**
+     * @var Composer $composer
+     */
+    protected $composer;
+
+    /**
+     * @var bool $isComposer1
+     */
+    protected $isComposer1;
+
+    /**
+     * @var array $includes
+     */
+    protected $includes = [];
+
+    /**
+     * @var array $requires
+     */
+    protected $requires = [];
+
+    /**
+     * @var bool $devMode
+     */
+    protected $devMode = false;
+
+    /**
+     * @var bool $recurse
+     */
+    protected $recurse = true;
+
+    /**
+     * @var bool $replace
+     */
+    protected $replace = false;
+
+    /**
+     * @var bool $ignore
+     */
+    protected $ignore = false;
+
+    /**
+     * Whether to merge the -dev sections.
+     * @var bool $mergeDev
+     */
+    protected $mergeDev = true;
+
+    /**
+     * Whether to merge the extra section.
+     *
+     * By default, the extra section is not merged and there will be many
+     * cases where the merge of the extra section is performed too late
+     * to be of use to other plugins. When enabled, merging uses one of
+     * two strategies - either 'first wins' or 'last wins'. When enabled,
+     * 'first wins' is the default behaviour. If Replace mode is activated
+     * then 'last wins' is used.
+     *
+     * @var bool $mergeExtra
+     */
+    protected $mergeExtra = false;
+
+    /**
+     * Whether to merge the extra section in a deep / recursive way.
+     *
+     * By default the extra section is merged with array_merge() and duplicate
+     * keys are ignored. When enabled this allows to merge the arrays recursively
+     * using the following rule: Integer keys are merged, while array values are
+     * replaced where the later values overwrite the former.
+     *
+     * This is useful especially for the extra section when plugins use larger
+     * structures like a 'patches' key with the packages as sub-keys and the
+     * patches as values.
+     *
+     * When 'replace' mode is activated the order of array merges is exchanged.
+     *
+     * @var bool $mergeExtraDeep
+     */
+    protected $mergeExtraDeep = false;
+
+    /**
+     * Whether to merge the replace section.
+     *
+     * @var bool $mergeReplace
+     */
+    protected $mergeReplace = true;
+
+    /**
+     * Whether to merge the scripts section.
+     *
+     * @var bool $mergeScripts
+     */
+    protected $mergeScripts = false;
+
+    /**
+     * @var bool $firstInstall
+     */
+    protected $firstInstall = false;
+
+    /**
+     * @var bool $locked
+     */
+    protected $locked = false;
+
+    /**
+     * @var bool $dumpAutoloader
+     */
+    protected $dumpAutoloader = false;
+
+    /**
+     * @var bool $optimizeAutoloader
+     */
+    protected $optimizeAutoloader = false;
+
+    /**
+     * @param Composer $composer
+     */
+    public function __construct(Composer $composer)
+    {
+        $this->composer = $composer;
+        $this->isComposer1 = version_compare(PluginInterface::PLUGIN_API_VERSION, '2.0.0', '<');
+    }
+
+    /**
+     * Test if this plugin runs within Composer 1.
+     *
+     * @return bool
+     */
+    public function isComposer1()
+    {
+        return $this->isComposer1;
+    }
+
+    /**
+     * Load plugin settings
+     */
+    public function loadSettings()
+    {
+        $extra = $this->composer->getPackage()->getExtra();
+        $config = array_merge(
+            [
+                'include' => [],
+                'require' => [],
+                'recurse' => true,
+                'replace' => false,
+                'ignore-duplicates' => false,
+                'merge-dev' => true,
+                'merge-extra' => false,
+                'merge-extra-deep' => false,
+                'merge-replace' => true,
+                'merge-scripts' => false,
+            ],
+            $extra['merge-plugin'] ?? []
+        );
+
+        $this->includes = (is_array($config['include'])) ?
+            $config['include'] : [$config['include']];
+        $this->requires = (is_array($config['require'])) ?
+            $config['require'] : [$config['require']];
+        $this->recurse = (bool)$config['recurse'];
+        $this->replace = (bool)$config['replace'];
+        $this->ignore = (bool)$config['ignore-duplicates'];
+        $this->mergeDev = (bool)$config['merge-dev'];
+        $this->mergeExtra = (bool)$config['merge-extra'];
+        $this->mergeExtraDeep = (bool)$config['merge-extra-deep'];
+        $this->mergeReplace = (bool)$config['merge-replace'];
+        $this->mergeScripts = (bool)$config['merge-scripts'];
+    }
+
+    /**
+     * Get list of filenames and/or glob patterns to include
+     *
+     * @return array
+     */
+    public function getIncludes()
+    {
+        return $this->includes;
+    }
+
+    /**
+     * Get list of filenames and/or glob patterns to require
+     *
+     * @return array
+     */
+    public function getRequires()
+    {
+        return $this->requires;
+    }
+
+    /**
+     * Set the first install flag
+     *
+     * @param bool $flag
+     */
+    public function setFirstInstall($flag)
+    {
+        $this->firstInstall = (bool)$flag;
+    }
+
+    /**
+     * Is this the first time that the plugin has been installed?
+     *
+     * @return bool
+     */
+    public function isFirstInstall()
+    {
+        return $this->firstInstall;
+    }
+
+    /**
+     * Set the locked flag
+     *
+     * @param bool $flag
+     */
+    public function setLocked($flag)
+    {
+        $this->locked = (bool)$flag;
+    }
+
+    /**
+     * Was a lockfile present when the plugin was installed?
+     *
+     * @return bool
+     */
+    public function isLocked()
+    {
+        return $this->locked;
+    }
+
+    /**
+     * Should an update be forced?
+     *
+     * @return true If packages are not locked
+     */
+    public function forceUpdate()
+    {
+        return !$this->locked;
+    }
+
+    /**
+     * Set the devMode flag
+     *
+     * @param bool $flag
+     */
+    public function setDevMode($flag)
+    {
+        $this->devMode = (bool)$flag;
+    }
+
+    /**
+     * Should devMode settings be processed?
+     *
+     * @return bool
+     */
+    public function isDevMode()
+    {
+        return $this->shouldMergeDev() && $this->devMode;
+    }
+
+    /**
+     * Should devMode settings be merged?
+     *
+     * @return bool
+     */
+    public function shouldMergeDev()
+    {
+        return $this->mergeDev;
+    }
+
+    /**
+     * Set the dumpAutoloader flag
+     *
+     * @param bool $flag
+     */
+    public function setDumpAutoloader($flag)
+    {
+        $this->dumpAutoloader = (bool)$flag;
+    }
+
+    /**
+     * Is the autoloader file supposed to be written out?
+     *
+     * @return bool
+     */
+    public function shouldDumpAutoloader()
+    {
+        return $this->dumpAutoloader;
+    }
+
+    /**
+     * Set the optimizeAutoloader flag
+     *
+     * @param bool $flag
+     */
+    public function setOptimizeAutoloader($flag)
+    {
+        $this->optimizeAutoloader = (bool)$flag;
+    }
+
+    /**
+     * Should the autoloader be optimized?
+     *
+     * @return bool
+     */
+    public function shouldOptimizeAutoloader()
+    {
+        return $this->optimizeAutoloader;
+    }
+
+    /**
+     * Should includes be recursively processed?
+     *
+     * @return bool
+     */
+    public function recurseIncludes()
+    {
+        return $this->recurse;
+    }
+
+    /**
+     * Should duplicate links be replaced in a 'last definition wins' order?
+     *
+     * @return bool
+     */
+    public function replaceDuplicateLinks()
+    {
+        return $this->replace;
+    }
+
+    /**
+     * Should duplicate links be ignored?
+     *
+     * @return bool
+     */
+    public function ignoreDuplicateLinks()
+    {
+        return $this->ignore;
+    }
+
+    /**
+     * Should the extra section be merged?
+     *
+     * By default, the extra section is not merged and there will be many
+     * cases where the merge of the extra section is performed too late
+     * to be of use to other plugins. When enabled, merging uses one of
+     * two strategies - either 'first wins' or 'last wins'. When enabled,
+     * 'first wins' is the default behaviour. If Replace mode is activated
+     * then 'last wins' is used.
+     *
+     * @return bool
+     */
+    public function shouldMergeExtra()
+    {
+        return $this->mergeExtra;
+    }
+
+    /**
+     * Should the extra section be merged deep / recursively?
+     *
+     * By default the extra section is merged with array_merge() and duplicate
+     * keys are ignored. When enabled this allows to merge the arrays recursively
+     * using the following rule: Integer keys are merged, while array values are
+     * replaced where the later values overwrite the former.
+     *
+     * This is useful especially for the extra section when plugins use larger
+     * structures like a 'patches' key with the packages as sub-keys and the
+     * patches as values.
+     *
+     * When 'replace' mode is activated the order of array merges is exchanged.
+     *
+     * @return bool
+     */
+    public function shouldMergeExtraDeep()
+    {
+        return $this->mergeExtraDeep;
+    }
+
+    /**
+     * Should the replace section be merged?
+     *
+     * By default, the replace section is merged.
+     *
+     * @return bool
+     */
+    public function shouldMergeReplace()
+    {
+        return $this->mergeReplace;
+    }
+
+    /**
+     * Should the scripts section be merged?
+     *
+     * By default, the scripts section is not merged.
+     *
+     * @return bool
+     */
+    public function shouldMergeScripts()
+    {
+        return $this->mergeScripts;
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/vendor/wikimedia/composer-merge-plugin/src/StabilityFlags.php b/vendor/wikimedia/composer-merge-plugin/src/StabilityFlags.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b29f7abc978cbb645e721f033d7e59c68b1b6f3
--- /dev/null
+++ b/vendor/wikimedia/composer-merge-plugin/src/StabilityFlags.php
@@ -0,0 +1,173 @@
+<?php
+/**
+ * This file is part of the Composer Merge plugin.
+ *
+ * Copyright (C) 2015 Bryan Davis, Wikimedia Foundation, and contributors
+ *
+ * This software may be modified and distributed under the terms of the MIT
+ * license. See the LICENSE file for details.
+ */
+
+namespace Wikimedia\Composer\Merge\V2;
+
+use Composer\Package\BasePackage;
+use Composer\Package\Version\VersionParser;
+
+/**
+ * Adapted from Composer's RootPackageLoader::extractStabilityFlags
+ * @author Bryan Davis <bd808@bd808.com>
+ */
+class StabilityFlags
+{
+
+    /**
+     * @var array Current package name => stability mappings
+     */
+    protected $stabilityFlags;
+
+    /**
+     * @var int Current default minimum stability
+     */
+    protected $minimumStability;
+
+    /**
+     * @var string Regex to extract an explicit stability flag (eg '@dev')
+     */
+    protected $explicitStabilityRe;
+
+    /**
+     * @param array $stabilityFlags Current package name => stability mappings
+     * @param int|string $minimumStability Current default minimum stability
+     */
+    public function __construct(
+        array $stabilityFlags = [],
+        $minimumStability = BasePackage::STABILITY_STABLE
+    ) {
+        $this->stabilityFlags = $stabilityFlags;
+        $this->minimumStability = $this->getStabilityInt((string)$minimumStability);
+        $this->explicitStabilityRe = '/^[^@]*?@(' .
+            implode('|', array_keys(BasePackage::$stabilities)) .
+            ')$/i';
+    }
+
+    /**
+     * Get the stability value for a given string.
+     *
+     * @param string $name Stability name
+     * @return int Stability value
+     */
+    protected function getStabilityInt($name)
+    {
+        $name = VersionParser::normalizeStability($name);
+        return BasePackage::$stabilities[$name] ?? BasePackage::STABILITY_STABLE;
+    }
+
+    /**
+     * Extract and merge stability flags from the given collection of
+     * requires with another collection of stability flags.
+     *
+     * @param array $requires New package name => link mappings
+     * @return array Unified package name => stability mappings
+     */
+    public function extractAll(array $requires)
+    {
+        $flags = [];
+
+        foreach ($requires as $name => $link) {
+            $name = strtolower($name);
+            $version = $link->getPrettyConstraint();
+
+            $stability = $this->getExplicitStability($version);
+
+            if ($stability === null) {
+                $stability = $this->getParsedStability($version);
+            }
+
+            $flags[$name] = max($stability, $this->getCurrentStability($name));
+        }
+
+        // Filter out null stability values
+        return array_filter($flags, function ($v) {
+            return $v !== null;
+        });
+    }
+
+    /**
+     * Extract the most unstable explicit stability (eg '@dev') from a version
+     * specification.
+     *
+     * @param string $version
+     * @return int|null Stability or null if no explict stability found
+     */
+    protected function getExplicitStability($version)
+    {
+        $found = null;
+        $constraints = $this->splitConstraints($version);
+        foreach ($constraints as $constraint) {
+            if (preg_match($this->explicitStabilityRe, $constraint, $match)) {
+                $stability = $this->getStabilityInt($match[1]);
+                $found = max($stability, $found);
+            }
+        }
+        return $found;
+    }
+
+    /**
+     * Split a version specification into a list of version constraints.
+     *
+     * @param string $version
+     * @return array
+     */
+    protected function splitConstraints($version)
+    {
+        $found = [];
+        $orConstraints = preg_split('/\s*\|\|?\s*/', trim($version));
+        foreach ($orConstraints as $constraints) {
+            $andConstraints = preg_split(
+                '/(?<!^|as|[=>< ,]) *(?<!-)[, ](?!-) *(?!,|as|$)/',
+                $constraints
+            );
+            foreach ($andConstraints as $constraint) {
+                $found[] = $constraint;
+            }
+        }
+        return $found;
+    }
+
+    /**
+     * Get the stability of a version
+     *
+     * @param string $version
+     * @return int|null Stability or null if STABLE or less than minimum
+     */
+    protected function getParsedStability($version)
+    {
+        // Drop aliasing if used
+        $version = preg_replace('/^([^,\s@]+) as .+$/', '$1', $version);
+        $stability = $this->getStabilityInt(
+            VersionParser::parseStability($version)
+        );
+
+        if ($stability === BasePackage::STABILITY_STABLE ||
+            $this->minimumStability > $stability
+        ) {
+            // Ignore if 'stable' or more stable than the global
+            // minimum
+            $stability = null;
+        }
+
+        return $stability;
+    }
+
+    /**
+     * Get the current stability of a given package.
+     *
+     * @param string $name
+     * @return int|null Stability of null if not set
+     */
+    protected function getCurrentStability($name)
+    {
+        return $this->stabilityFlags[$name] ?? null;
+    }
+}
+// vim:sw=4:ts=4:sts=4:et:
diff --git a/web/libraries/ckeditor5-anchor-drupal/LICENSE b/web/libraries/ckeditor5-anchor-drupal/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..6a86003330f2158678c2da7a740e93881775f3e6
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/LICENSE
@@ -0,0 +1,7 @@
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/web/libraries/ckeditor5-anchor-drupal/LICENSE.md b/web/libraries/ckeditor5-anchor-drupal/LICENSE.md
new file mode 100644
index 0000000000000000000000000000000000000000..3aa218d01442ea3b2632d87ce11c170a62392749
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/LICENSE.md
@@ -0,0 +1,6 @@
+Software License Agreement
+==========================
+
+Copyright (c) 2023. All rights reserved.
+
+Licensed under the terms of [MIT license](https://opensource.org/licenses/MIT).
diff --git a/web/libraries/ckeditor5-anchor-drupal/README.md b/web/libraries/ckeditor5-anchor-drupal/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..46fa5ea3fbd455ea27ed188624cb9e8c62f8f3fa
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/README.md
@@ -0,0 +1,170 @@
+@northernco/ckeditor5-anchor-drupal
+===================================
+
+This package implements the anchor feature for CKEditor 5. It allows inserting anchor elements (ID field) into the edited content and offers the UI to create and edit them.
+
+This is a Drupal-adapted fork of [the original plugin by bvedad](https://github.com/bvedad/ckeditor5-anchor). 
+
+## Table of contents
+
+* [Developing the package](#developing-the-package)
+* [Available scripts](#available-scripts)
+  * [`start`](#start)
+  * [`test`](#test)
+  * [`lint`](#lint)
+  * [`stylelint`](#stylelint)
+  * [`dll:build`](#dllbuild)
+  * [`dll:serve`](#dllserve)
+  * [`translations:collect`](#translationscollect)
+  * [`translations:download`](#translationsdownload)
+  * [`translations:upload`](#translationsupload)
+* [License](#license)
+
+## Developing the package
+
+To read about the CKEditor 5 framework, visit the [CKEditor5 documentation](https://ckeditor.com/docs/ckeditor5/latest/framework/index.html).
+
+## Available scripts
+
+Npm scripts are a convenient way to provide commands in a project. They are defined in the `package.json` file and shared with other people contributing to the project. It ensures that developers use the same command with the same options (flags).
+
+All the scripts can be executed by running `npm run <script>`. Pre and post commands with matching names will be run for those as well.
+
+The following scripts are available in the package.
+
+### `start`
+
+Starts a HTTP server with the live-reload mechanism that allows previewing and testing plugins available in the package.
+
+When the server has been started, the default browser will open the developer sample. This can be disabled by passing the `--no-open` option to that command.
+
+You can also define the language that will translate the created editor by specifying the `--language [LANG]` option. It defaults to `'en'`.
+
+Examples:
+
+```bash
+# Starts the server and open the browser.
+npm run start
+
+# Disable auto-opening the browser.
+npm run start -- --no-open
+
+# Create the editor with the interface in German.
+npm run start -- --language=de
+```
+
+### `test`
+
+Allows executing unit tests for the package, specified in the `tests/` directory. The command accepts the following modifiers:
+
+* `--coverage` &ndash; to create the code coverage report,
+* `--watch` &ndash; to observe the source files (the command does not end after executing tests),
+* `--source-map` &ndash; to generate source maps of sources,
+* `--verbose` &ndash; to print additional webpack logs.
+
+Examples:
+
+```bash
+# Execute tests.
+npm run test
+
+# Generate code coverage report after each change in the sources.
+npm run test -- --coverage --test
+```
+
+### `lint`
+
+Runs ESLint, which analyzes the code (all `*.js` files) to quickly find problems.
+
+Examples:
+
+```bash
+# Execute eslint.
+npm run lint
+```
+
+### `stylelint`
+
+Similar to the `lint` task, stylelint analyzes the CSS code (`*.css` files in the `theme/` directory) in the package.
+
+Examples:
+
+```bash
+# Execute stylelint.
+npm run stylelint
+```
+
+### `dll:build`
+
+Creates a DLL-compatible package build which can be loaded into an editor using [DLL builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html).
+
+Examples:
+
+```bash
+# Build the DLL file that is ready to publish.
+npm run dll:build
+
+# Build the DLL file and listen to changes in its sources.
+npm run dll:build -- --watch
+```
+
+### `dll:serve`
+
+Creates a simple HTTP server (without the live-reload mechanism) that allows verifying whether the DLL build of the package is compatible with the CKEditor 5 [DLL builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html).
+
+Examples:
+
+```bash
+# Starts the HTTP server and opens the browser.
+npm run dll:serve
+```
+
+### `translations:collect`
+
+Collects translation messages (arguments of the `t()` function) and context files, then validates whether the provided values do not interfere with the values specified in the `@ckeditor/ckeditor5-core` package.
+
+The task may end with an error if one of the following conditions is met:
+
+* Found the `Unused context` error &ndash; entries specified in the `lang/contexts.json` file are not used in source files. They should be removed.
+* Found the `Context is duplicated for the id` error &ndash; some of the entries are duplicated. Consider removing them from the `lang/contexts.json` file, or rewrite them.
+* Found the `Context for the message id is missing` error &ndash; entries specified in source files are not described in the `lang/contexts.json` file. They should be added.
+
+Examples:
+
+```bash
+npm run translations:collect
+```
+
+### `translations:download`
+
+Download translations from the Transifex server. Depending on users' activity in the project, it creates translations files used for building the editor.
+
+The task requires passing the URL to Transifex API. Usually, it matches the following format: `https://www.transifex.com/api/2/project/[PROJECT_SLUG]`.
+
+To avoid passing the `--transifex` option every time when calls the command, you can store it in `package.json`, next to the `ckeditor5-package-tools translations:download` command.
+
+Examples:
+
+```bash
+npm run translations:download -- --transifex [API URL]
+```
+
+### `translations:upload`
+
+Uploads translation messages onto the Transifex server. It allows for the creation of translations into other languages by users using the Transifex platform.
+
+The task requires passing the URL to the Transifex API. Usually, it matches the following format: `https://www.transifex.com/api/2/project/[PROJECT_SLUG]`.
+
+To avoid passing the `--transifex` option every time when you call the command, you can store it in `package.json`, next to the `ckeditor5-package-tools translations:upload` command.
+
+Examples:
+
+```bash
+npm run translations:upload -- --transifex [API URL]
+```
+
+## License
+
+The `@northernco/ckeditor5-anchor-drupal` package is available under [MIT license](https://opensource.org/licenses/MIT).
+
+However, it is the default license of packages created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package and it can be changed.
diff --git a/web/libraries/ckeditor5-anchor-drupal/build/anchor-drupal.js b/web/libraries/ckeditor5-anchor-drupal/build/anchor-drupal.js
new file mode 100644
index 0000000000000000000000000000000000000000..986dd55a212768a4ce7a7880bf53761e05c0d411
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/build/anchor-drupal.js
@@ -0,0 +1,61 @@
+!function(e){const t=e.en=e.en||{};t.dictionary=Object.assign(t.dictionary||{},{Anchor:"Anchor","Anchor name":"Anchor name","Edit anchor":"Edit anchor",Unanchor:"Unanchor"})}(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={})),(()=>{var e={590:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});var o=n(645),i=n.n(o)()((function(e){return e[1]}));i.push([e.id,'.ck-vertical-form .ck-button:after{bottom:-1px;content:"";position:absolute;right:-1px;top:-1px;width:0;z-index:1}.ck-vertical-form .ck-button:focus:after{display:none}@media screen and (max-width:600px){.ck.ck-responsive-form .ck-button:after{bottom:-1px;content:"";position:absolute;right:-1px;top:-1px;width:0;z-index:1}.ck.ck-responsive-form .ck-button:focus:after{display:none}}.ck-vertical-form>.ck-button:nth-last-child(2):after{border-right:1px solid var(--ck-color-base-border)}.ck.ck-responsive-form{padding:var(--ck-spacing-large)}.ck.ck-responsive-form:focus{outline:none}[dir=ltr] .ck.ck-responsive-form>:not(:first-child),[dir=rtl] .ck.ck-responsive-form>:not(:last-child){margin-left:var(--ck-spacing-standard)}@media screen and (max-width:600px){.ck.ck-responsive-form{padding:0;width:calc(var(--ck-input-width)*.8)}.ck.ck-responsive-form .ck-labeled-field-view{margin:var(--ck-spacing-large) var(--ck-spacing-large) 0}.ck.ck-responsive-form .ck-labeled-field-view .ck-input-text{min-width:0;width:100%}.ck.ck-responsive-form .ck-labeled-field-view .ck-labeled-field-view__error{white-space:normal}.ck.ck-responsive-form>.ck-button:nth-last-child(2):after{border-right:1px solid var(--ck-color-base-border)}.ck.ck-responsive-form>.ck-button:last-child,.ck.ck-responsive-form>.ck-button:nth-last-child(2){border-radius:0;margin-top:var(--ck-spacing-large);padding:var(--ck-spacing-standard)}.ck.ck-responsive-form>.ck-button:last-child:not(:focus),.ck.ck-responsive-form>.ck-button:nth-last-child(2):not(:focus){border-top:1px solid var(--ck-color-base-border)}[dir=ltr] .ck.ck-responsive-form>.ck-button:last-child,[dir=ltr] .ck.ck-responsive-form>.ck-button:nth-last-child(2),[dir=rtl] .ck.ck-responsive-form>.ck-button:last-child,[dir=rtl] .ck.ck-responsive-form>.ck-button:nth-last-child(2){margin-left:0}[dir=rtl] .ck.ck-responsive-form>.ck-button:last-child:last-of-type,[dir=rtl] .ck.ck-responsive-form>.ck-button:nth-last-child(2):last-of-type{border-right:1px solid var(--ck-color-base-border)}}',""]);const r=i},17:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});var o=n(645),i=n.n(o)()((function(e){return e[1]}));i.push([e.id,"",""]);const r=i},145:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});var o=n(645),i=n.n(o)()((function(e){return e[1]}));i.push([e.id,'.ck.ck-anchor-actions{display:flex;flex-direction:row;flex-wrap:nowrap}.ck.ck-anchor-actions .ck-anchor-actions__preview{display:inline-block}.ck.ck-anchor-actions .ck-anchor-actions__preview .ck-button__label{overflow:hidden}@media screen and (max-width:600px){.ck.ck-anchor-actions{flex-wrap:wrap}.ck.ck-anchor-actions .ck-anchor-actions__preview{flex-basis:100%}.ck.ck-anchor-actions .ck-button:not(.ck-anchor-actions__preview){flex-basis:50%}}div[contenteditable=false] .ck-anchor{background-size:16px;color:#00f;cursor:auto;padding-left:18px}div[contenteditable=true] .ck-anchor{background:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAAB3RJTUUH4AsCDSEAgw7OCAAAA1lJREFUWMPdl82LHEUYxn/VPTthe5Q1SMgt7GlZRG8KXrwEsgQlmEvwkIsL2YPgvyAEIoFcAvG0Nw8hgroQiHtIRNBLIAhqAlmQfHkIHiQssjg7n131eOjqmeqPnWF24x58oJjp6n7f99dVXdVPI4mpzTmctbg0LTRZi5ybHl9snykTkiicdM5hrcVai8sTW4vt95H0tqTvJP0pyaqotRFMHdC4/9MgpgJQq3+2tvY8F+impCNuMCiOjHPIWtxwiKSPSjFjAH+3k+WcHqyu6vujR/XD8eP65exZ7dy7V77qhaT3bLeLGw5xwyFpu42kz2vyjQGstSMAQFDkeXzxom6AvgZt+PYN6Ib//+zKlTrk3yX9Fnb8uLg4rp/VxOQAcRwLIEkSBHQ7HQDunzvH1sYGCdAAIjIJcIAFBr7vjdVV3lpfh2aTUP3nz/lpeZlnnQ6fSFm8tcbEcRVgYWEBgJ2dHQC+NIbXgLmgeKgQJPUw88DrS0tEzSbbDx/SAWKgA3ycA6SpMXFMo5zQGIOC44YPrisOYILzDeCIB9p+9Ah8/yu+r18T36hLKueYVca3vGjdSNWpAuCcI4rGKQyza5aYCmwURThphhQHUwVAh1i8FuCwVQFw+3gA/18jEEXRvpbhSwM47Cmo7AOSMME+oOC3vD7CzeelAdTJMd7vre+LyLbgfBueBKPS71SAcBpSoEu2xy+vrXHszBnM3Bx/373LH1evst3p0GT8ssphwuLyedLC8BkwpgoQRRE2AOgCb66s8M6dO4Xrjp0+zdKlS7h2m1/Pn+fJrVtEHiQOIJwv3ANOXrtWBIBsztM0HTmQVqulpNUaGYefT50KPcV9SR/4tl52IE8uX9ZN0HXQV75dB22Cdp8+DS9dKTiiCkCS1DmcD22/P3bFwyG220XSu96KjWR7Pb24fVt/bW7K7u6W85xwacqsACdHQWHzVt0OBkh6VdK3E1zlF2m7TVi8FiBJEs3Pz4eBF/a024Hldt792l4vn9r3/VQt2l5vz++IiiVrtVoYY2i32/mKMKE/mKjsjrLllr9VjcHkD5ypLtR6RxS8kmd6PfuldSBDkn8RHZYqI5Cm6dQgEwzlfmHzHI1ywjKAKc3btON9jcCkOzpogWmKphX5rwH+BWxcUbVSlumsAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE2LTExLTAyVDEzOjMzOjAwKzAxOjAw1wGs7QAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNi0xMS0wMlQxMzozMzowMCswMTowMKZcFFEAAAAASUVORK5CYII=") no-repeat 0;background-size:16px;color:#00f;cursor:auto;padding-left:18px}',""]);const r=i},831:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});var o=n(645),i=n.n(o)()((function(e){return e[1]}));i.push([e.id,".ck.ck-anchor-form{display:flex}.ck.ck-anchor-form .ck-label{display:none}@media screen and (max-width:600px){.ck.ck-anchor-form{flex-wrap:wrap}.ck.ck-anchor-form .ck-labeled-field-view{flex-basis:100%}.ck.ck-anchor-form .ck-button{flex-basis:50%}}.ck.ck-anchor-form_layout-vertical{display:block}.ck.ck-anchor-form_layout-vertical .ck-button.ck-button-cancel,.ck.ck-anchor-form_layout-vertical .ck-button.ck-button-save{margin-top:var(--ck-spacing-medium)}",""]);const r=i},645:e=>{"use strict";e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var n=e(t);return t[2]?"@media ".concat(t[2]," {").concat(n,"}"):n})).join("")},t.i=function(e,n,o){"string"==typeof e&&(e=[[null,e,""]]);var i={};if(o)for(var r=0;r<this.length;r++){var s=this[r][0];null!=s&&(i[s]=!0)}for(var a=0;a<e.length;a++){var c=[].concat(e[a]);o&&i[c[0]]||(n&&(c[2]?c[2]="".concat(n," and ").concat(c[2]):c[2]=n),t.push(c))}},t}},379:(e,t,n)=>{"use strict";var o,i=function(){return void 0===o&&(o=Boolean(window&&document&&document.all&&!window.atob)),o},r=function(){var e={};return function(t){if(void 0===e[t]){var n=document.querySelector(t);if(window.HTMLIFrameElement&&n instanceof window.HTMLIFrameElement)try{n=n.contentDocument.head}catch(e){n=null}e[t]=n}return e[t]}}(),s=[];function a(e){for(var t=-1,n=0;n<s.length;n++)if(s[n].identifier===e){t=n;break}return t}function c(e,t){for(var n={},o=[],i=0;i<e.length;i++){var r=e[i],c=t.base?r[0]+t.base:r[0],l=n[c]||0,d="".concat(c," ").concat(l);n[c]=l+1;var u=a(d),h={css:r[1],media:r[2],sourceMap:r[3]};-1!==u?(s[u].references++,s[u].updater(h)):s.push({identifier:d,updater:p(h,t),references:1}),o.push(d)}return o}function l(e){var t=document.createElement("style"),o=e.attributes||{};if(void 0===o.nonce){var i=n.nc;i&&(o.nonce=i)}if(Object.keys(o).forEach((function(e){t.setAttribute(e,o[e])})),"function"==typeof e.insert)e.insert(t);else{var s=r(e.insert||"head");if(!s)throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");s.appendChild(t)}return t}var d,u=(d=[],function(e,t){return d[e]=t,d.filter(Boolean).join("\n")});function h(e,t,n,o){var i=n?"":o.media?"@media ".concat(o.media," {").concat(o.css,"}"):o.css;if(e.styleSheet)e.styleSheet.cssText=u(t,i);else{var r=document.createTextNode(i),s=e.childNodes;s[t]&&e.removeChild(s[t]),s.length?e.insertBefore(r,s[t]):e.appendChild(r)}}function f(e,t,n){var o=n.css,i=n.media,r=n.sourceMap;if(i?e.setAttribute("media",i):e.removeAttribute("media"),r&&"undefined"!=typeof btoa&&(o+="\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(r))))," */")),e.styleSheet)e.styleSheet.cssText=o;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(o))}}var m=null,g=0;function p(e,t){var n,o,i;if(t.singleton){var r=g++;n=m||(m=l(t)),o=h.bind(null,n,r,!1),i=h.bind(null,n,r,!0)}else n=l(t),o=f.bind(null,n,t),i=function(){!function(e){if(null===e.parentNode)return!1;e.parentNode.removeChild(e)}(n)};return o(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;o(e=t)}else i()}}e.exports=function(e,t){(t=t||{}).singleton||"boolean"==typeof t.singleton||(t.singleton=i());var n=c(e=e||[],t);return function(e){if(e=e||[],"[object Array]"===Object.prototype.toString.call(e)){for(var o=0;o<n.length;o++){var i=a(n[o]);s[i].references--}for(var r=c(e,t),l=0;l<n.length;l++){var d=a(n[l]);0===s[d].references&&(s[d].updater(),s.splice(d,1))}n=r}}}},945:(e,t,n)=>{e.exports=n(79)("./src/clipboard.js")},704:(e,t,n)=>{e.exports=n(79)("./src/core.js")},492:(e,t,n)=>{e.exports=n(79)("./src/engine.js")},181:(e,t,n)=>{e.exports=n(79)("./src/typing.js")},273:(e,t,n)=>{e.exports=n(79)("./src/ui.js")},209:(e,t,n)=>{e.exports=n(79)("./src/utils.js")},995:(e,t,n)=>{e.exports=n(79)("./src/widget.js")},79:e=>{"use strict";e.exports=CKEditor5.dll}},t={};function n(o){var i=t[o];if(void 0!==i)return i.exports;var r=t[o]={id:o,exports:{}};return e[o](r,r.exports,n),r.exports}n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var o in t)n.o(t,o)&&!n.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.nc=void 0;var o={};(()=>{"use strict";n.r(o),n.d(o,{Anchor:()=>je});var e=n(704),t=n(492),i=n(181),r=n(945),s=n(209);
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+class a{constructor(){this._definitions=new Set}get length(){return this._definitions.size}add(e){Array.isArray(e)?e.forEach((e=>this._definitions.add(e))):this._definitions.add(e)}getDispatcher(){return e=>{e.on("attribute:anchorId",((e,t,n)=>{if(!n.consumable.test(t.item,"attribute:anchorId"))return;const o=n.writer,i=o.document.selection;for(const e of this._definitions){const r=o.createAttributeElement("a",e.attributes,{priority:5});o.setCustomProperty("anchor",!0,r),e.callback(t.attributeNewValue)?t.item.is("selection")?o.wrap(i.getFirstRange(),r):o.wrap(n.mapper.toViewRange(t.range),r):o.unwrap(n.mapper.toViewRange(t.range),r)}}),{priority:"high"})}}getDispatcherForAnchoredImage(){return e=>{e.on("attribute:anchorId:image",((e,t,n)=>{const o=n.mapper.toViewElement(t.item),i=Array.from(o.getChildren()).find((e=>"a"===e.name));for(const e of this._definitions){const o=(0,s.toMap)(e.attributes);if(e.callback(t.attributeNewValue))for(const[e,t]of o)"class"===e?n.writer.addClass(t,i):n.writer.setAttribute(e,t,i);else for(const[e,t]of o)"class"===e?n.writer.removeClass(t,i):n.writer.removeAttribute(e,i)}}))}}}const c=function(e,t,n){var o=-1,i=e.length;t<0&&(t=-t>i?0:i+t),(n=n>i?i:n)<0&&(n+=i),i=t>n?0:n-t>>>0,t>>>=0;for(var r=Array(i);++o<i;)r[o]=e[o+t];return r};const l=function(e,t,n){var o=e.length;return n=void 0===n?o:n,!t&&n>=o?e:c(e,t,n)};var d=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");const u=function(e){return d.test(e)};const h=function(e){return e.split("")};var f="\\ud800-\\udfff",m="["+f+"]",g="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",p="\\ud83c[\\udffb-\\udfff]",b="[^"+f+"]",w="(?:\\ud83c[\\udde6-\\uddff]){2}",v="[\\ud800-\\udbff][\\udc00-\\udfff]",k="(?:"+g+"|"+p+")"+"?",A="[\\ufe0e\\ufe0f]?",_=A+k+("(?:\\u200d(?:"+[b,w,v].join("|")+")"+A+k+")*"),y="(?:"+[b+g+"?",g,w,v,m].join("|")+")",x=RegExp(p+"(?="+p+")|"+y+_,"g");const V=function(e){return e.match(x)||[]};const I=function(e){return u(e)?V(e):h(e)};const T="object"==typeof global&&global&&global.Object===Object&&global;var S="object"==typeof self&&self&&self.Object===Object&&self;const C=(T||S||Function("return this")()).Symbol;const E=function(e,t){for(var n=-1,o=null==e?0:e.length,i=Array(o);++n<o;)i[n]=t(e[n],n,e);return i};const B=Array.isArray;var F=Object.prototype,O=F.hasOwnProperty,P=F.toString,D=C?C.toStringTag:void 0;const R=function(e){var t=O.call(e,D),n=e[D];try{e[D]=void 0;var o=!0}catch(e){}var i=P.call(e);return o&&(t?e[D]=n:delete e[D]),i};var j=Object.prototype.toString;const M=function(e){return j.call(e)};var U=C?C.toStringTag:void 0;const H=function(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":U&&U in Object(e)?R(e):M(e)};const z=function(e){return null!=e&&"object"==typeof e};const L=function(e){return"symbol"==typeof e||z(e)&&"[object Symbol]"==H(e)};var N=C?C.prototype:void 0,K=N?N.toString:void 0;const Q=function e(t){if("string"==typeof t)return t;if(B(t))return E(t,e)+"";if(L(t))return K?K.call(t):"";var n=t+"";return"0"==n&&1/t==-Infinity?"-0":n};const Z=function(e){return null==e?"":Q(e)};const W=function(e){return function(t){t=Z(t);var n=u(t)?I(t):void 0,o=n?n[0]:t.charAt(0),i=n?l(n,1).join(""):t.slice(1);return o[e]()+i}}("toUpperCase");var q=n(995);
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+const G=/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g,Y=/^(?:(?:https?|ftps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,J=/^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i,X=/^((\w+:(\/{2,})?)|(\W))/i,$="Ctrl+M";function ee(e,{writer:t}){const n=t.createAttributeElement("a",{id:e},{priority:5});return t.addClass("ck-anchor",n),t.setCustomProperty("anchor",!0,n),n}function te(e){return function(e){const t=e.replace(G,"");return t.match(Y)}(e=String(e))?e:"#"}function ne(e,t){return!!e&&(e.is("element","image")&&t.checkAttribute("image","anchorId"))}function oe(e,t){const n=(o=e,J.test(o)?"mailto:":t);var o;const i=!!n&&!X.test(e);return e&&i?n+e:e}
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class ie extends e.Command{constructor(e){super(e),this.manualDecorators=new s.Collection,this.automaticDecorators=new a}restoreManualDecoratorStates(){for(const e of this.manualDecorators)e.value=this._getDecoratorStateFromModel(e.id)}refresh(){const e=this.editor.model,t=e.document,n=(0,s.first)(t.selection.getSelectedBlocks());ne(n,e.schema)?(this.value=n.getAttribute("anchorId"),this.isEnabled=e.schema.checkAttribute(n,"anchorId")):(this.value=t.selection.getAttribute("anchorId"),this.isEnabled=e.schema.checkAttributeInSelection(t.selection,"anchorId"));for(const e of this.manualDecorators)e.value=this._getDecoratorStateFromModel(e.id)}execute(e,t={}){const n=this.editor.model,o=n.document.selection,r=[],a=[];for(const e in t)t[e]?r.push(e):a.push(e);n.change((t=>{if(o.isCollapsed){const c=o.getFirstPosition();if(o.hasAttribute("anchorId")){const s=(0,i.findAttributeRange)(c,"anchorId",o.getAttribute("anchorId"),n);t.setAttribute("anchorId",e,s),r.forEach((e=>{t.setAttribute(e,!0,s)})),a.forEach((e=>{t.removeAttribute(e,s)})),t.setSelection(t.createPositionAfter(s.end.nodeBefore))}else if(""!==e){const i=(0,s.toMap)(o.getAttributes());i.set("anchorId",e),r.forEach((e=>{i.set(e,!0)}));const{end:a}=n.insertContent(t.createElement("anchor",i),c);t.setSelection(a)}["anchorId",...r,...a].forEach((e=>{t.removeSelectionAttribute(e)}))}else if("anchor"===o.getSelectedElement()?.name){const o=t.createElement("anchor",{anchorId:e});n.insertObject(o,null,null,{setSelection:"on"})}else{const i=n.schema.getValidRanges(o.getRanges(),"anchorId"),s=[];for(const e of o.getSelectedBlocks())n.schema.checkAttribute(e,"anchorId")&&s.push(t.createRangeOn(e));const c=s.slice();for(const e of i)this._isRangeToUpdate(e,s)&&c.push(e);for(const n of c)t.setAttribute("anchorId",e,n),r.forEach((e=>{t.setAttribute(e,!0,n)})),a.forEach((e=>{t.removeAttribute(e,n)}))}}))}_getDecoratorStateFromModel(e){const t=this.editor.model,n=t.document,o=(0,s.first)(n.selection.getSelectedBlocks());return ne(o,t.schema)?o.getAttribute(e):n.selection.getAttribute(e)}_isRangeToUpdate(e,t){for(const n of t)if(n.containsRange(e))return!1;return!0}}
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class re extends e.Command{refresh(){const e=this.editor.model,t=e.document,n=(0,s.first)(t.selection.getSelectedBlocks());ne(n,e.schema)?this.isEnabled=e.schema.checkAttribute(n,"anchorId"):this.isEnabled=e.schema.checkAttributeInSelection(t.selection,"anchorId")}execute(){const e=this.editor,t=this.editor.model,n=t.document.selection,o=e.commands.get("anchor");t.change((e=>{const r=n.isCollapsed?[(0,i.findAttributeRange)(n.getFirstPosition(),"anchorId",n.getAttribute("anchorId"),t)]:t.schema.getValidRanges(n.getRanges(),"anchorId");for(const t of r)if(e.removeAttribute("anchorId",t),o)for(const n of o.manualDecorators)e.removeAttribute(n.id,t);"anchor"===n.getSelectedElement()?.name&&t.deleteContent(n)}))}}
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class se{constructor({id:e,label:t,attributes:n,defaultValue:o}){this.id=e,this.set("value"),this.defaultValue=o,this.label=t,this.attributes=n}}
+/**
+ * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+function ae(e,t,n,o){return o.createRange(ce(e,t,n,!0,o),ce(e,t,n,!1,o))}function ce(e,t,n,o,i){let r=e.textNode||(o?e.nodeBefore:e.nodeAfter),s=null;for(;r&&r.getAttribute(t)==n;)s=r,r=o?r.previousSibling:r.nextSibling;return s?i.createPositionAt(s,o?"before":"after"):e}(0,s.mix)(se,s.ObservableMixin);const le=
+/**
+ * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+function(){try{return navigator.userAgent.toLowerCase()}catch(e){return""}}();de(le),function(e){e.indexOf("windows")}(le),function(e){e.match(/gecko\/\d+/)}(le),function(e){e.indexOf(" applewebkit/")>-1&&e.indexOf("chrome")}(le),function(e){!!e.match(/iphone|ipad/i)||de(e)&&navigator.maxTouchPoints}(le),function(e){e.indexOf("android")}(le),function(e){e.indexOf("chrome/")>-1&&e.indexOf("edge/")}(le),function(){let e=!1;try{e=0==="ć".search(new RegExp("[\\p{L}]","u"))}catch(e){}}();function de(e){return e.indexOf("macintosh")>-1}
+/**
+ * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+const ue=function(){const e={arrowleft:37,arrowup:38,arrowright:39,arrowdown:40,backspace:8,delete:46,enter:13,space:32,esc:27,tab:9,ctrl:1114112,shift:2228224,alt:4456448,cmd:8912896};for(let t=65;t<=90;t++){e[String.fromCharCode(t).toLowerCase()]=t}for(let t=48;t<=57;t++)e[t-48]=t;for(let t=112;t<=123;t++)e["f"+(t-111)]=t;for(const t of"`-=[];',./\\")e[t]=t.charCodeAt(0);return e}();Object.fromEntries(Object.entries(ue).map((([e,t])=>[t,e.charAt(0).toUpperCase()+e.slice(1)])));var he=n(379),fe=n.n(he),me=n(17),ge={injectType:"singletonStyleTag",attributes:{"data-cke":!0},insert:"head",singleton:!0};fe()(me.Z,ge);me.Z.locals;const pe="automatic",be=/^(https?:)?\/\//;class we extends e.Plugin{static get pluginName(){return"AnchorEditing"}static get requires(){return[i.TwoStepCaretMovement,i.Input,r.Clipboard]}constructor(e){super(e),e.config.define("anchor",{addTargetToExternalAnchors:!1})}init(){const e=this.editor;e.model.schema.extend("$text",{allowAttributes:"anchorId"}),e.model.schema.register("anchor",{allowContentOf:"$inlineObject",allowWhere:"$inlineObject",inheritTypesFrom:"$inlineObject",allowAttributes:["class","id","anchorId"]}),e.conversion.for("dataDowncast").attributeToElement({model:{name:"$text",key:"anchorId"},view:ee}),e.conversion.for("dataDowncast").elementToElement({model:"anchor",view:(e,t)=>function(e,{writer:t}){let n=null;return n=t.createEmptyElement("a",{id:e}),t.addClass("ck-anchor",n),t.setCustomProperty("anchor",!0,n),n}(e.getAttribute("anchorId"),t)}),e.conversion.for("editingDowncast").attributeToElement({model:"anchorId",view:(e,t)=>e?ee(te(e),t):null}),e.conversion.for("editingDowncast").elementToElement({model:"anchor",view:(e,t)=>function(e,{writer:t}){const n=t.createContainerElement("span",{class:"ck-anchor-placeholder"},[t.createText(`[INVISIBLE ANCHOR: ${e}]`)]);return(0,q.toWidget)(n,t)}(e.getAttribute("anchorId"),t)}),e.conversion.for("upcast").elementToAttribute({view:{name:"a",attributes:{id:!0,href:!1}},model:{key:"anchorId",value:e=>{if(!(e.childCount<1))return e.getAttribute("id")}}}),e.conversion.for("upcast").elementToElement({view:{name:"a",attributes:{id:!0}},model:(e,{writer:t})=>{if(!(e.childCount>0))return t.createElement("anchor",{anchorId:e.getAttribute("id")})}}),e.commands.add("anchor",new ie(e)),e.commands.add("unanchor",new re(e));const t=function(e,t){const n={};return t.forEach((e=>(e.label&&n[e.label]&&(e.label=n[e.label]),e))),t}(e.t,function(e){const t=[];if(e)for(const[n,o]of Object.entries(e)){const e=Object.assign({},o,{id:`anchor${W(n)}`});t.push(e)}return t}(e.config.get("anchor.decorators")));this._enableAutomaticDecorators(t.filter((e=>e.mode===pe))),this._enableManualDecorators(t.filter((e=>"manual"===e.mode)));e.plugins.get(i.TwoStepCaretMovement).registerAttribute("anchorId"),(0,i.inlineHighlight)(e,"anchorId","a","ck-anchor_selected"),this._enableInsertContentSelectionAttributesFixer(),this._enableClickingAfterAnchor(),this._enableTypingOverAnchor(),this._handleDeleteContentAfterAnchor(),e.editing.mapper.on("viewToModelPosition",(0,q.viewToModelPositionOutsideModelElement)(e.model,(e=>e.hasClass("ck-anchor-placeholder"))))}_enableAutomaticDecorators(e){const t=this.editor,n=t.commands.get("anchor").automaticDecorators;t.config.get("anchor.addTargetToExternalAnchors")&&n.add({id:"anchorIsExternal",mode:pe,callback:e=>be.test(e),attributes:{target:"_blank",rel:"noopener noreferrer"}}),n.add(e),n.length&&t.conversion.for("downcast").add(n.getDispatcher())}_enableManualDecorators(e){if(!e.length)return;const t=this.editor,n=t.commands.get("anchor").manualDecorators;e.forEach((e=>{t.model.schema.extend("$text",{allowAttributes:e.id}),n.add(new se(e)),t.conversion.for("downcast").attributeToElement({model:e.id,view:(t,{writer:o})=>{if(t){const t=n.get(e.id).attributes,i=o.createAttributeElement("a",t,{priority:5});return o.setCustomProperty("anchor",!0,i),i}}}),t.conversion.for("upcast").elementToAttribute({view:{name:"a",attributes:n.get(e.id).attributes},model:{key:e.id}})}))}_enableInsertContentSelectionAttributesFixer(){const e=this.editor,t=e.model,n=t.document.selection,o=e.commands.get("anchor");this.listenTo(t,"insertContent",(()=>{const e=n.anchor.nodeBefore,i=n.anchor.nodeAfter;n.hasAttribute("anchorId")&&e&&e.hasAttribute("anchorId")&&(i&&i.hasAttribute("anchorId")||t.change((e=>{ve(e,o.manualDecorators)})))}),{priority:"low"})}_enableClickingAfterAnchor(){const e=this.editor,n=e.commands.get("anchor");e.editing.view.addObserver(t.MouseObserver);let o=!1;this.listenTo(e.editing.view.document,"mousedown",(()=>{o=!0})),this.listenTo(e.editing.view.document,"selectionChange",(()=>{if(!o)return;o=!1;const t=e.model.document.selection;if(!t.isCollapsed)return;if(!t.hasAttribute("anchorId"))return;const i=t.getFirstPosition(),r=ae(i,"anchorId",t.getAttribute("anchorId"),e.model);(i.isTouching(r.start)||i.isTouching(r.end))&&e.model.change((e=>{ve(e,n.manualDecorators)}))}))}_enableTypingOverAnchor(){const e=this.editor,t=e.editing.view;let n,o;this.listenTo(t.document,"delete",(()=>{o=!0}),{priority:"high"}),this.listenTo(e.model,"deleteContent",(()=>{const t=e.model.document.selection;t.isCollapsed||(o?o=!1:ke(e)&&function(e){const t=e.document.selection,n=t.getFirstPosition(),o=t.getLastPosition(),i=n.nodeAfter;if(!i)return!1;if(!i.is("$text"))return!1;if(!i.hasAttribute("anchorId"))return!1;const r=o.textNode||o.nodeBefore;if(i===r)return!0;return ae(n,"anchorId",i.getAttribute("anchorId"),e).containsRange(e.createRange(n,o),!0)}(e.model)&&(n=t.getAttributes()))}),{priority:"high"}),this.listenTo(e.model,"insertContent",((t,[i])=>{o=!1,ke(e)&&n&&(e.model.change((e=>{for(const[t,o]of n)e.setAttribute(t,o,i)})),n=null)}),{priority:"high"})}_handleDeleteContentAfterAnchor(){const e=this.editor,t=e.model,n=t.document.selection,o=e.editing.view,i=e.commands.get("anchor");let r=!1,s=!1;this.listenTo(o.document,"delete",((e,t)=>{s=t.domEvent.keyCode===ue.backspace}),{priority:"high"}),this.listenTo(t,"deleteContent",(()=>{r=!1;const e=n.getFirstPosition(),o=n.getAttribute("anchorId");if(!o)return;const i=ae(e,"anchorId",o,t);r=i.containsPosition(e)||i.end.isEqual(e)}),{priority:"high"}),this.listenTo(t,"deleteContent",(()=>{s&&(s=!1,r||e.model.enqueueChange((e=>{ve(e,i.manualDecorators)})))}),{priority:"low"})}}function ve(e,t){e.removeSelectionAttribute("anchorId");for(const n of t)e.removeSelectionAttribute(n.id)}function ke(e){return e.model.change((e=>e.batch)).isTyping}var Ae=n(273);var _e=n(831),ye={injectType:"singletonStyleTag",attributes:{"data-cke":!0},insert:"head",singleton:!0};fe()(_e.Z,ye);_e.Z.locals;var xe=n(590),Ve={injectType:"singletonStyleTag",attributes:{"data-cke":!0},insert:"head",singleton:!0};fe()(xe.Z,Ve);xe.Z.locals;
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class Ie extends Ae.View{constructor(e,t){super(e);const n=e.t;this.focusTracker=new s.FocusTracker,this.keystrokes=new s.KeystrokeHandler,this.urlInputView=this._createUrlInput(),this.saveButtonView=this._createButton(n("Save"),'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.972 16.615a.997.997 0 0 1-.744-.292l-4.596-4.596a1 1 0 1 1 1.414-1.414l3.926 3.926 9.937-9.937a1 1 0 0 1 1.414 1.415L7.717 16.323a.997.997 0 0 1-.745.292z"/></svg>',"ck-button-save"),this.saveButtonView.type="submit",this.cancelButtonView=this._createButton(n("Cancel"),'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.591 10.177 4.243 4.242a1 1 0 0 1-1.415 1.415l-4.242-4.243-4.243 4.243a1 1 0 0 1-1.414-1.415l4.243-4.242L4.52 5.934A1 1 0 0 1 5.934 4.52l4.243 4.243 4.242-4.243a1 1 0 1 1 1.415 1.414l-4.243 4.243z"/></svg>',"ck-button-cancel","cancel"),this._manualDecoratorSwitches=this._createManualDecoratorSwitches(t),this.children=this._createFormChildren(t.manualDecorators),this._focusables=new Ae.ViewCollection,this._focusCycler=new Ae.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}});const o=["ck","ck-anchor-form","ck-responsive-form"];t.manualDecorators.length&&o.push("ck-anchor-form_layout-vertical","ck-vertical-form"),this.setTemplate({tag:"form",attributes:{class:o,tabindex:"-1"},children:this.children}),(0,Ae.injectCssTransitionDisabler)(this)}getDecoratorSwitchesState(){return Array.from(this._manualDecoratorSwitches).reduce(((e,t)=>(e[t.name]=t.isOn,e)),{})}render(){super.render(),(0,Ae.submitHandler)({view:this});[this.urlInputView,...this._manualDecoratorSwitches,this.saveButtonView,this.cancelButtonView].forEach((e=>{this._focusables.add(e),this.focusTracker.add(e.element)})),this.keystrokes.listenTo(this.element)}focus(){this._focusCycler.focusFirst()}_createUrlInput(){const e=this.locale.t,t=new Ae.LabeledFieldView(this.locale,Ae.createLabeledInputText);return t.label=e("Anchor name"),t}_createButton(e,t,n,o){const i=new Ae.ButtonView(this.locale);return i.set({label:e,icon:t,tooltip:!0}),i.extendTemplate({attributes:{class:n}}),o&&i.delegate("execute").to(this,o),i}_createManualDecoratorSwitches(e){const t=this.createCollection();for(const n of e.manualDecorators){const o=new Ae.SwitchButtonView(this.locale);o.set({name:n.id,label:n.label,withText:!0}),o.bind("isOn").toMany([n,e],"value",((e,t)=>void 0===t&&void 0===e?n.defaultValue:e)),o.on("execute",(()=>{n.set("value",!o.isOn)})),t.add(o)}return t}_createFormChildren(e){const t=this.createCollection();if(t.add(this.urlInputView),e.length){const e=new Ae.View;e.setTemplate({tag:"ul",children:this._manualDecoratorSwitches.map((e=>({tag:"li",children:[e],attributes:{class:["ck","ck-list__item"]}}))),attributes:{class:["ck","ck-reset","ck-list"]}}),t.add(e)}return t.add(this.saveButtonView),t.add(this.cancelButtonView),t}}var Te=n(145),Se={injectType:"singletonStyleTag",attributes:{"data-cke":!0},insert:"head",singleton:!0};fe()(Te.Z,Se);Te.Z.locals;
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class Ce extends Ae.View{constructor(t){super(t);const n=t.t;this.focusTracker=new s.FocusTracker,this.keystrokes=new s.KeystrokeHandler,this.unanchorButtonView=this._createButton(n("Unanchor"),'<?xml version="1.0" standalone="no"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"\n "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">\n<svg version="1.0"\n    xmlns="http://www.w3.org/2000/svg" width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000" preserveAspectRatio="xMidYMid meet">\n    <metadata>\nCreated by potrace 1.16, written by Peter Selinger 2001-2019\n    </metadata>\n    <g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">\n        <path d="M4255 5006 c-204 -208 -399 -316 -686 -380 -261 -58 -568 -60 -1104\n-7 -170 17 -404 36 -519 43 -593 32 -1000 -83 -1307 -370 l-72 -68 21 -64 c12\n-36 175 -616 363 -1290 188 -674 343 -1227 344 -1228 2 -2 44 31 94 75 201\n175 418 276 711 330 79 15 149 18 410 17 267 0 354 -4 575 -27 143 -15 318\n-32 390 -37 192 -16 529 -14 666 4 349 47 616 174 843 400 95 95 124 139 132\n198 11 83 7 88 -133 163 -250 133 -497 302 -731 501 -62 53 -115 106 -118 119\n-3 12 17 114 45 226 97 396 173 780 227 1157 19 134 34 268 32 296 -3 51 -3\n51 -38 53 -32 1 -44 -7 -145 -111z"/>\n        <path d="M152 4245 c-97 -30 -151 -104 -152 -204 0 -42 126 -506 541 -1990\n297 -1065 548 -1951 556 -1969 26 -54 77 -82 149 -82 159 0 274 80 274 191 0\n35 -1083 3917 -1105 3959 -21 41 -81 87 -131 99 -54 14 -78 13 -132 -4z"/>\n        <path d="M3845 1352 c-16 -11 -47 -38 -67 -60 -33 -36 -38 -49 -38 -89 l0 -47\n212 -213 212 -213 -212 -213 -212 -213 0 -47 c0 -42 5 -53 42 -93 51 -55 85\n-74 134 -74 36 0 50 12 251 212 l213 212 213 -212 c201 -200 215 -212 251\n-212 49 0 83 19 134 74 37 40 42 51 42 93 l0 47 -212 213 -212 213 212 213\n212 213 0 47 c0 42 -5 53 -42 93 -51 55 -85 74 -134 74 -36 0 -50 -12 -251\n-212 l-213 -212 -213 212 c-205 204 -214 212 -253 212 -21 0 -52 -8 -69 -18z"/>\n    </g>\n</svg>',"unanchor"),this.editButtonView=this._createButton(n("Edit anchor"),e.icons.pencil,"edit"),this._focusables=new Ae.ViewCollection,this._focusCycler=new Ae.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}}),this.setTemplate({tag:"div",attributes:{class:["ck","ck-anchor-actions","ck-responsive-form"],tabindex:"-1"},children:[this.editButtonView,this.unanchorButtonView]})}render(){super.render();[this.editButtonView,this.unanchorButtonView].forEach((e=>{this._focusables.add(e),this.focusTracker.add(e.element)})),this.keystrokes.listenTo(this.element)}focus(){this._focusCycler.focusFirst()}_createButton(e,t,n){const o=new Ae.ButtonView(this.locale);return o.set({label:e,icon:t,tooltip:!0}),o.delegate("execute").to(this,n),o}}const Ee="anchor-ui";class Be extends e.Plugin{static get requires(){return[Ae.ContextualBalloon]}static get pluginName(){return"AnchorUI"}init(){const e=this.editor;e.editing.view.addObserver(t.ClickObserver),this.actionsView=this._createActionsView(),this.formView=this._createFormView(),this._balloon=e.plugins.get(Ae.ContextualBalloon),this._createToolbarAnchorButton(),this._enableUserBalloonInteractions(),e.conversion.for("editingDowncast").markerToHighlight({model:Ee,view:{classes:["ck-fake-anchor-selection"]}}),e.conversion.for("editingDowncast").markerToElement({model:Ee,view:{name:"span",classes:["ck-fake-anchor-selection","ck-fake-anchor-selection_collapsed"]}})}destroy(){super.destroy(),this.formView.destroy()}_createActionsView(){const e=this.editor,t=new Ce(e.locale),n=e.commands.get("anchor"),o=e.commands.get("unanchor");return t.bind("id").to(n,"value"),t.editButtonView.bind("isEnabled").to(n),t.unanchorButtonView.bind("isEnabled").to(o),this.listenTo(t,"edit",(()=>{this._addFormView()})),this.listenTo(t,"unanchor",(()=>{e.execute("unanchor"),this._hideUI()})),t.keystrokes.set("Esc",((e,t)=>{this._hideUI(),t()})),t.keystrokes.set($,((e,t)=>{this._addFormView(),t()})),t}_createFormView(){const e=this.editor,t=e.commands.get("anchor"),n=e.config.get("anchor.defaultProtocol"),o=new Ie(e.locale,t);return o.urlInputView.fieldView.bind("value").to(t,"value"),o.urlInputView.bind("isReadOnly").to(t,"isEnabled",(e=>!e)),o.saveButtonView.bind("isEnabled").to(t),this.listenTo(o,"submit",(()=>{const{value:t}=o.urlInputView.fieldView.element,i=oe(t,n);e.execute("anchor",i,o.getDecoratorSwitchesState()),this._closeFormView()})),this.listenTo(o,"cancel",(()=>{this._closeFormView()})),o.keystrokes.set("Esc",((e,t)=>{this._closeFormView(),t()})),o}_createToolbarAnchorButton(){const e=this.editor,t=e.commands.get("anchor"),n=e.t;e.keystrokes.set($,((e,n)=>{n(),t.isEnabled&&this._showUI(!0)})),e.ui.componentFactory.add("anchor",(e=>{const o=new Ae.ButtonView(e);return o.isEnabled=!0,o.label=n("Anchor"),o.icon='<svg height="512" viewBox="0 0 58 58" width="512"\n    xmlns="http://www.w3.org/2000/svg">\n    <g id="Page-1" fill="none" fill-rule="evenodd">\n        <g id="037---Waypoint-Flag" fill="rgb(0,0,0)" fill-rule="nonzero" transform="translate(0 -1)">\n            <path id="Shape" d="m14.678 58.9507 1.0678-.2984c1.0270794-.287091 1.6269982-1.3523947 1.34-2.3795l-12.2083-43.6888c-.17227193-.6165569-.58242107-1.139423-1.14021438-1.4535673-.5577933-.3141444-1.21753647-.3938324-1.83408562-.2215327l-.1379.0385c-1.28397381.3587434-2.0340279 1.6904218-1.6753 2.9744l12.2086 43.6888c.2870014 1.0271063 1.3522895 1.6270863 2.3794 1.3401z"/>\n            <path id="Shape" d="m57.67 28.42c-3.8715209-1.930437-7.4530885-4.3944478-10.64-7.32-.2678864-.245221-.3726619-.6216366-.27-.97 1.579074-5.9738125 2.7517572-12.04771023 3.51-18.18.12-1.02-.43-1.32-1.01-.62-11.38 13.61-31.07-2.49-42.79 9.88.14070884.2634479.25140182.5418575.33.83l7.92 28.36c11.74-12.22 31.36 3.78 42.72-9.8.58-.7.69-1.98.23-2.18z"/>\n        </g>\n    </g>\n</svg>',o.keystroke=$,o.tooltip=!0,o.isToggleable=!0,o.bind("isEnabled").to(t,"isEnabled"),o.bind("isOn").to(t,"value",(e=>!!e)),this.listenTo(o,"execute",(()=>this._showUI(!0))),o}))}_enableUserBalloonInteractions(){const e=this.editor.editing.view.document;this.listenTo(e,"click",(()=>{this._getSelectedAnchorElement()&&this._showUI()})),this.editor.keystrokes.set("Tab",((e,t)=>{this._areActionsVisible&&!this.actionsView.focusTracker.isFocused&&(this.actionsView.focus(),t())}),{priority:"high"}),this.editor.keystrokes.set("Esc",((e,t)=>{this._isUIVisible&&(this._hideUI(),t())})),(0,Ae.clickOutsideHandler)({emitter:this.formView,activator:()=>this._isUIInPanel,contextElements:[this._balloon.view.element],callback:()=>this._hideUI()})}_addActionsView(){this._areActionsInPanel||this._balloon.add({view:this.actionsView,position:this._getBalloonPositionData()})}_addFormView(){if(this._isFormInPanel)return;const e=this.editor.commands.get("anchor");this.formView.disableCssTransitions(),this._balloon.add({view:this.formView,position:this._getBalloonPositionData()}),this._balloon.visibleView===this.formView&&this.formView.urlInputView.fieldView.select(),this.formView.enableCssTransitions(),this.formView.urlInputView.fieldView.element.value=e.value||""}_closeFormView(){const e=this.editor.commands.get("anchor");e.restoreManualDecoratorStates(),void 0!==e.value?this._removeFormView():this._hideUI()}_removeFormView(){this._isFormInPanel&&(this.formView.saveButtonView.focus(),this._balloon.remove(this.formView),this.editor.editing.view.focus(),this._hideFakeVisualSelection())}_showUI(e=!1){this._getSelectedAnchorElement()?(this._areActionsVisible?this._addFormView():this._addActionsView(),e&&this._balloon.showStack("main")):(this._showFakeVisualSelection(),this._addActionsView(),e&&this._balloon.showStack("main"),this._addFormView()),this._startUpdatingUI()}_hideUI(){if(!this._isUIInPanel)return;const e=this.editor;this.stopListening(e.ui,"update"),this.stopListening(this._balloon,"change:visibleView"),e.editing.view.focus(),this._removeFormView(),this._balloon.remove(this.actionsView),this._hideFakeVisualSelection()}_startUpdatingUI(){const e=this.editor,t=e.editing.view.document;let n=this._getSelectedAnchorElement(),o=r();const i=()=>{const e=this._getSelectedAnchorElement(),t=r();n&&!e||!n&&t!==o?this._hideUI():this._isUIVisible&&this._balloon.updatePosition(this._getBalloonPositionData()),n=e,o=t};function r(){return t.selection.focus.getAncestors().reverse().find((e=>e.is("element")))}this.listenTo(e.ui,"update",i),this.listenTo(this._balloon,"change:visibleView",i)}get _isFormInPanel(){return this._balloon.hasView(this.formView)}get _areActionsInPanel(){return this._balloon.hasView(this.actionsView)}get _areActionsVisible(){return this._balloon.visibleView===this.actionsView}get _isUIInPanel(){return this._isFormInPanel||this._areActionsInPanel}get _isUIVisible(){return this._balloon.visibleView==this.formView||this._areActionsVisible}_getBalloonPositionData(){const e=this.editor.editing.view,t=this.editor.model,n=e.document;let o=null;if(t.markers.has(Ee)){const t=Array.from(this.editor.editing.mapper.markerNameToElements(Ee)),n=e.createRange(e.createPositionBefore(t[0]),e.createPositionAfter(t[t.length-1]));o=e.domConverter.viewRangeToDom(n)}else{const t=this._getSelectedAnchorElement(),i=n.selection.getFirstRange();o=t?e.domConverter.mapViewToDom(t):e.domConverter.viewRangeToDom(i)}return{target:o}}_getSelectedAnchorElement(){const e=this.editor.editing.view,t=e.document.selection;if(t.isCollapsed)return Fe(t.getFirstPosition());{const n=t.getFirstRange().getTrimmed(),o=Fe(n.start),i=Fe(n.end);return o&&o==i&&e.createRangeIn(o).getTrimmed().isEqual(n)?o:null}}_showFakeVisualSelection(){const e=this.editor.model;e.change((t=>{const n=e.document.selection.getFirstRange();if(e.markers.has(Ee))t.updateMarker(Ee,{range:n});else if(n.start.isAtEnd){const o=Oe(n,e.document.selection.focus,t);t.addMarker(Ee,{usingOperation:!1,affectsData:!1,range:o})}else t.addMarker(Ee,{usingOperation:!1,affectsData:!1,range:n})}))}_hideFakeVisualSelection(){const e=this.editor.model;e.markers.has(Ee)&&e.change((e=>{e.removeMarker(Ee)}))}}function Fe(e){return e.getAncestors().find((e=>{return(t=e).is("attributeElement")&&!!t.getCustomProperty("anchor");var t}))}function Oe(e,t,n){const o=[e.start.path[0]+1,0],i=n.createPositionFromPath(e.start.root,o,"toNext"),r=n.createRange(i,e.end);return r.start.path[0]>e.end.path[0]?n.createRange(t):i.isAtStart&&i.isAtEnd?Oe(r,t,n):r}
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+const Pe=new RegExp("(^|\\s)(#\\S+)");class De extends e.Plugin{static get pluginName(){return"AutoAnchor"}init(){const e=this.editor.model.document.selection;e.on("change:range",(()=>{this.isEnabled=!e.anchor.parent.is("element","codeBlock")})),this._enableTypingHandling()}afterInit(){this._enableEnterHandling(),this._enableShiftEnterHandling()}_enableTypingHandling(){const e=this.editor,t=new i.TextWatcher(e.model,(e=>{if(!function(e){return e.length>4&&" "===e[e.length-1]&&" "!==e[e.length-2]}(e))return;const t=Re(e.substr(0,e.length-1));return t?{url:t}:void 0}));t.on("matched:data",((t,n)=>{const{batch:o,range:i,url:r}=n;if(!o.isTyping)return;const s=i.end.getShiftedBy(-1),a=s.getShiftedBy(-r.length),c=e.model.createRange(a,s);this._applyAutoAnchor(r,c)})),t.bind("isEnabled").to(this)}_enableEnterHandling(){const e=this.editor,t=e.model,n=e.commands.get("enter");n&&n.on("execute",(()=>{const e=t.document.selection.getFirstPosition();if(!e.parent.previousSibling)return;const n=t.createRangeIn(e.parent.previousSibling);this._checkAndApplyAutoAnchorOnRange(n)}))}_enableShiftEnterHandling(){const e=this.editor,t=e.model,n=e.commands.get("shiftEnter");n&&n.on("execute",(()=>{const e=t.document.selection.getFirstPosition(),n=t.createRange(t.createPositionAt(e.parent,0),e.getShiftedBy(-1));this._checkAndApplyAutoAnchorOnRange(n)}))}_checkAndApplyAutoAnchorOnRange(e){const t=this.editor.model,{text:n,range:o}=(0,i.getLastTextLine)(e,t),r=Re(n);if(r){const e=t.createRange(o.end.getShiftedBy(-r.length),o.end);this._applyAutoAnchor(r,e)}}_applyAutoAnchor(e,t){const n=this.editor.model;this.isEnabled&&function(e,t){return t.schema.checkAttributeInSelection(t.createSelection(e),"anchorId")}(t,n)&&n.enqueueChange((n=>{const o=this.editor.config.get("anchor.defaultProtocol"),i=oe(e,o);n.setAttribute("linkHref",i,t)}))}}function Re(e){const t=Pe.exec(e);return t?t[2]:null}
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+class je extends e.Plugin{static get requires(){return[we,Be,De]}static get pluginName(){return"Anchor"}}})(),(window.CKEditor5=window.CKEditor5||{}).anchorDrupal=o})();
\ No newline at end of file
diff --git a/web/libraries/ckeditor5-anchor-drupal/ckeditor5-metadata.json b/web/libraries/ckeditor5-anchor-drupal/ckeditor5-metadata.json
new file mode 100644
index 0000000000000000000000000000000000000000..c9c764da68851e36a0074f67c748661eec1ad419
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/ckeditor5-metadata.json
@@ -0,0 +1,17 @@
+{
+  "plugins": [
+    {
+      "name": "Anchor",
+      "className": "Anchor",
+      "description": "Adds text to the editor.",
+      "path": "src/anchor.js",
+      "uiComponents": [
+        {
+          "name": "anchor",
+          "type": "Button",
+          "iconPath": "theme/icons/anchor.svg"
+        }
+      ]
+    }
+  ]
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/lang/contexts.json b/web/libraries/ckeditor5-anchor-drupal/lang/contexts.json
new file mode 100644
index 0000000000000000000000000000000000000000..945284de20396efb6b6e04506fca77c4e9ae8d79
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/lang/contexts.json
@@ -0,0 +1,8 @@
+{
+	"Unanchor": "Toolbar button tooltip for the Unanchor feature.",
+	"Anchor": "Toolbar button tooltip for the Anchor feature.",
+	"Anchor name": "Label for the input in the Anchor name editing balloon.",
+	"Anchor image": "Label for the image anchor button.",
+	"Edit anchor": "Button opening the Anchor name editing balloon.",
+	"This anchor has no name": "Label explaining that a anchor has no URL set (the URL is empty)."
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/lang/translations/en.po b/web/libraries/ckeditor5-anchor-drupal/lang/translations/en.po
new file mode 100644
index 0000000000000000000000000000000000000000..930881c9c67777811e57fece8d6261d467904011
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/lang/translations/en.po
@@ -0,0 +1,41 @@
+# Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+#
+#                                     !!! IMPORTANT !!!
+#
+#         Before you edit this file, please keep in mind that contributing to the project
+#                translations is possible ONLY via the Transifex online service.
+#
+#         To submit your translations, visit https://www.transifex.com/ckeditor/ckeditor5.
+#
+#                   To learn more, check out the official contributor's guide:
+#     https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html
+#
+msgid ""
+msgstr ""
+"Language: \n"
+"Language-Team: \n"
+"Plural-Forms: \n"
+
+msgctxt "Toolbar button tooltip for the Unanchor feature."
+msgid "Unanchor"
+msgstr "Unanchor"
+
+msgctxt "Toolbar button tooltip for the Anchor feature."
+msgid "Anchor"
+msgstr "Anchor"
+
+msgctxt "Label for the input in the Anchor name editing balloon."
+msgid "Anchor name"
+msgstr "Anchor name"
+
+msgctxt "Label for the image anchor button."
+msgid "Anchor image"
+msgstr "Anchor image"
+
+msgctxt "Button opening the Anchor name editing balloon."
+msgid "Edit anchor"
+msgstr "Edit anchor"
+
+msgctxt "Label explaining that a anchor has no URL set (the URL is empty)."
+msgid "This anchor has no name"
+msgstr "This anchor has no name"
diff --git a/web/libraries/ckeditor5-anchor-drupal/package.json b/web/libraries/ckeditor5-anchor-drupal/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..d3a41fc3e1d013978bb9a8720540e453ecf9cb0d
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/package.json
@@ -0,0 +1,85 @@
+{
+  "name": "@northernco/ckeditor5-anchor-drupal",
+  "version": "0.4.0",
+  "description": "Drupal CKEditor 5 integration",
+  "keywords": [
+    "ckeditor",
+    "ckeditor5",
+    "ckeditor 5",
+    "ckeditor5-feature",
+    "ckeditor5-plugin",
+    "ckeditor5-dll",
+    "ckeditor5-package-generator"
+  ],
+  "main": "src/index.js",
+  "license": "MIT",
+  "engines": {
+    "node": ">=14.0.0",
+    "npm": ">=5.7.1"
+  },
+  "files": [
+    "lang",
+    "src",
+    "theme",
+    "build",
+    "ckeditor5-metadata.json"
+  ],
+  "devDependencies": {
+    "@ckeditor/ckeditor5-autoformat": ">=37.0.1",
+    "@ckeditor/ckeditor5-basic-styles": ">=37.0.1",
+    "@ckeditor/ckeditor5-block-quote": ">=37.0.1",
+    "@ckeditor/ckeditor5-code-block": ">=37.0.1",
+    "@ckeditor/ckeditor5-core": ">=37.0.1",
+    "@ckeditor/ckeditor5-editor-classic": ">=37.0.1",
+    "@ckeditor/ckeditor5-essentials": ">=37.0.1",
+    "@ckeditor/ckeditor5-heading": ">=37.0.1",
+    "@ckeditor/ckeditor5-html-support": ">=37.0.1",
+    "@ckeditor/ckeditor5-image": ">=37.0.1",
+    "@ckeditor/ckeditor5-indent": ">=37.0.1",
+    "@ckeditor/ckeditor5-inspector": ">=4.1.0",
+    "@ckeditor/ckeditor5-link": ">=37.0.1",
+    "@ckeditor/ckeditor5-list": ">=37.0.1",
+    "@ckeditor/ckeditor5-media-embed": ">=37.0.1",
+    "@ckeditor/ckeditor5-package-tools": "^1.0.0-beta.10",
+    "@ckeditor/ckeditor5-paragraph": ">=37.0.1",
+    "@ckeditor/ckeditor5-table": ">=37.0.1",
+    "@ckeditor/ckeditor5-theme-lark": ">=37.0.1",
+    "@ckeditor/ckeditor5-upload": ">=37.0.1",
+    "ckeditor5": ">=37.0.1",
+    "eslint": "^7.32.0",
+    "eslint-config-ckeditor5": ">=4.4.0",
+    "http-server": "^14.1.0",
+    "husky": "^4.2.5",
+    "lint-staged": "^10.2.6",
+    "stylelint": "^13.13.1",
+    "stylelint-config-ckeditor5": ">=4.4.0"
+  },
+  "peerDependencies": {
+    "ckeditor5": ">=37.0.1"
+  },
+  "scripts": {
+    "dll:build": "ckeditor5-package-tools dll:build",
+    "dll:serve": "http-server ./ -o sample/dll.html",
+    "lint": "eslint \"**/*.js\" --quiet",
+    "start": "ckeditor5-package-tools start",
+    "stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
+    "test": "ckeditor5-package-tools test",
+    "prepare": "npm run dll:build",
+    "translations:collect": "ckeditor5-package-tools translations:collect",
+    "translations:download": "ckeditor5-package-tools translations:download",
+    "translations:upload": "ckeditor5-package-tools translations:upload"
+  },
+  "lint-staged": {
+    "**/*.js": [
+      "eslint --quiet"
+    ],
+    "**/*.css": [
+      "stylelint --quiet --allow-empty-input"
+    ]
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  }
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchor.js b/web/libraries/ckeditor5-anchor-drupal/src/anchor.js
new file mode 100644
index 0000000000000000000000000000000000000000..86b2c3188a0e98b7d361d8e40dcf465dc224b082
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchor.js
@@ -0,0 +1,213 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchor
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import AnchorEditing from './anchorediting';
+import AnchorUI from './anchorui';
+import AutoAnchor from './autoanchor';
+
+/**
+ * The anchor plugin.
+ *
+ * This is a "glue" plugin that loads the {@link module:anchor/anchorediting~AnchorEditing anchor editing feature}
+ * and {@link module:anchor/anchorui~AnchorUI anchor UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class Anchor extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ AnchorEditing, AnchorUI, AutoAnchor ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'Anchor';
+	}
+}
+
+/**
+ * The configuration of the {@link module:anchor/anchor~Anchor} feature.
+ *
+ * Read more in {@link module:anchor/anchor~AnchorConfig}.
+ *
+ * @member {module:anchor/anchor~AnchorConfig} module:core/editor/editorconfig~EditorConfig#anchor
+ */
+
+/**
+ * The configuration of the {@link module:anchor/anchor~Anchor anchor feature}.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				anchor:  ... // Anchor feature configuration.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ * @interface AnchorConfig
+ */
+
+/**
+ * When set, the editor will add the given protocol to the anchor when the user creates a anchor without one.
+ * For example, when the user is creating a anchor and types `ckeditor.com` in the anchor form input, during anchor submission
+ * the editor will automatically add the `http://` protocol, so the anchor will look as follows: `http://ckeditor.com`.
+ *
+ * The feature also provides email address auto-detection. When you submit `hello@example.com`,
+ * the plugin will automatically change it to `mailto:hello@example.com`.
+ *
+ * 		ClassicEditor
+ *			.create( editorElement, {
+ * 				anchor: {
+ * 					defaultProtocol: 'http://'
+ * 				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * **NOTE:** If no configuration is provided, the editor will not auto-fix the anchors.
+ *
+ * @member {String} module:anchor/anchor~AnchorConfig#defaultProtocol
+ */
+
+/**
+ * When set to `true`, the `target="blank"` and `rel="noopener noreferrer"` attributes are automatically added to all external anchors
+ * in the editor. "External anchors" are all anchors in the editor content starting with `http`, `https`, or `//`.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				anchor: {
+ *					addTargetToExternalAnchors: true
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * Internally, this option activates a predefined {@link module:anchor/anchor~AnchorConfig#decorators automatic anchor decorator}
+ * that extends all external anchors with the `target` and `rel` attributes.
+ *
+ * **Note**: To control the `target` and `rel` attributes of specific anchors in the edited content, a dedicated
+ * {@link module:anchor/anchor~AnchorDecoratorManualDefinition manual} decorator must be defined in the
+ * {@link module:anchor/anchor~AnchorConfig#decorators `config.anchor.decorators`} array. In such scenario,
+ * the `config.anchor.addTargetToExternalAnchors` option should remain `undefined` or `false` to not interfere with the manual decorator.
+ *
+ * It is possible to add other {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic}
+ * or {@link module:anchor/anchor~AnchorDecoratorManualDefinition manual} anchor decorators when this option is active.
+ *
+ * More information about decorators can be found in the {@link module:anchor/anchor~AnchorConfig#decorators decorators configuration}
+ * reference.
+ *
+ * @default false
+ * @member {Boolean} module:anchor/anchor~AnchorConfig#addTargetToExternalAnchors
+ */
+
+/**
+ * Decorators provide an easy way to configure and manage additional anchor attributes in the editor content. There are
+ * two types of anchor decorators:
+ *
+ * * {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition Automatic} &ndash; They match anchors against pre–defined rules and
+ * manage their attributes based on the results.
+ * * {@link module:anchor/anchor~AnchorDecoratorManualDefinition Manual} &ndash; They allow users to control anchor attributes individually,
+ *  using the editor UI.
+ *
+ * Anchor decorators are defined as objects with key-value pairs, where the key is the name provided for a given decorator and the
+ * value is the decorator definition.
+ *
+ * The name of the decorator also corresponds to the {@ganchor framework/guides/architecture/editing-engine#text-attributes text attribute}
+ * in the model. For instance, the `isExternal` decorator below is represented as a `anchorIsExternal` attribute in the model.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				anchor: {
+ *					decorators: {
+ *						isExternal: {
+ *							mode: 'automatic',
+ *							callback: url => url.startsWith( 'http://' ),
+ *							attributes: {
+ *								target: '_blank',
+ *								rel: 'noopener noreferrer'
+ *							}
+ *						}
+ *					}
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * To learn more about the configuration syntax, check out the {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic}
+ * and {@link module:anchor/anchor~AnchorDecoratorManualDefinition manual} decorator option reference.
+ *
+ * **Warning:** Currently, anchor decorators work independently of one another and no conflict resolution mechanism exists.
+ * For example, configuring the `target` attribute using both an automatic and a manual decorator at the same time could end up with
+ * quirky results. The same applies if multiple manual or automatic decorators were defined for the same attribute.
+ *
+ * **Note**: Since the `target` attribute management for external anchors is a common use case, there is a predefined automatic decorator
+ * dedicated for that purpose which can be enabled by turning a single option on. Check out the
+ * {@link module:anchor/anchor~AnchorConfig#addTargetToExternalAnchors `config.anchor.addTargetToExternalAnchors`}
+ * configuration description to learn more.
+ *
+ * See also the {@ganchor features/anchor#custom-anchor-attributes-decorators anchor feature guide} for more information.
+ *
+ * @member {Object.<String, module:anchor/anchor~AnchorDecoratorDefinition>} module:anchor/anchor~AnchorConfig#decorators
+ */
+
+/**
+ * A anchor decorator definition. Two types implement this defition:
+ *
+ * * {@link module:anchor/anchor~AnchorDecoratorManualDefinition}
+ * * {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition}
+ *
+ * Refer to their document for more information about available options or to the
+ * {@ganchor features/anchor#custom-anchor-attributes-decorators anchor feature guide} for general information.
+ *
+ * @interface AnchorDecoratorDefinition
+ */
+
+/**
+ * Anchor decorator type.
+ *
+ * Check out the {@ganchor features/anchor#custom-anchor-attributes-decorators anchor feature guide} for more information.
+ *
+ * @member {'manual'|'automatic'} module:anchor/anchor~AnchorDecoratorDefinition#mode
+ */
+
+/**
+ * Describes an automatic {@link module:anchor/anchor~AnchorConfig#decorators anchor decorator}. This decorator type matches
+ * all anchors in the editor content against a function that decides whether the anchor should receive a pre–defined set of attributes.
+ *
+ * It takes an object with key-value pairs of attributes and a callback function that must return a Boolean value based on the anchor's
+ * `id` (URL). When the callback returns `true`, attributes are applied to the anchor.
+ *
+ * For example, to add the `target="_blank"` attribute to all anchors in the editor starting with `http://`, the
+ * configuration could look like this:
+ *
+ *		{
+ *			mode: 'automatic',
+ *			callback: url => url.startsWith( 'http://' ),
+ *			attributes: {
+ *				target: '_blank'
+ *			}
+ *		}
+ *
+ * **Note**: Since the `target` attribute management for external anchors is a common use case, there is a predefined automatic decorator
+ * dedicated for that purpose that can be enabled by turning a single option on. Check out the
+ * {@link module:anchor/anchor~AnchorConfig#addTargetToExternalAnchors `config.anchor.addTargetToExternalAnchors`}
+ * configuration description to learn more.
+ *
+ * @typedef {Object} module:anchor/anchor~AnchorDecoratorAutomaticDefinition
+ * @property {'automatic'} mode Anchor decorator type. It is `'automatic'` for all automatic decorators.
+ * @property {Function} callback Takes a `url` as a parameter and returns `true` if the `attributes` should be applied to the anchor.
+ * @property {Object} attributes Key-value pairs used as anchor attributes added to the output during the
+ * {@ganchor framework/guides/architecture/editing-engine#conversion downcasting}.
+ * Attributes should follow the {@link module:engine/view/elementdefinition~ElementDefinition} syntax.
+ */
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorcommand.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorcommand.js
new file mode 100644
index 0000000000000000000000000000000000000000..5e876d826cb9d3658386f6fea11f1d860f32936d
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorcommand.js
@@ -0,0 +1,296 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorcommand
+ */
+
+import { Command } from 'ckeditor5/src/core';
+import { findAttributeRange } from 'ckeditor5/src/typing';
+import { toMap } from 'ckeditor5/src/utils';
+import { Collection } from 'ckeditor5/src/utils';
+import { first } from 'ckeditor5/src/utils';
+import AutomaticDecorators from './utils/automaticdecorators';
+import { isImageAllowed } from './utils';
+
+/**
+ * The anchor command. It is used by the {@link module:anchor/anchor~Anchor anchor feature}.
+ *
+ * @extends module:core/command~Command
+ */
+export default class AnchorCommand extends Command {
+	/**
+	 * The value of the `'anchorId'` attribute if the start of the selection is located in a node with this attribute.
+	 *
+	 * @observable
+	 * @readonly
+	 * @member {Object|undefined} #value
+	 */
+
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A collection of {@link module:anchor/utils~ManualDecorator manual decorators}
+		 * corresponding to the {@link module:anchor/anchor~AnchorConfig#decorators decorator configuration}.
+		 *
+		 * You can consider it a model with states of manual decorators added to the currently selected anchor.
+		 *
+		 * @readonly
+		 * @type {module:utils/collection~Collection}
+		 */
+		this.manualDecorators = new Collection();
+
+		/**
+		 * An instance of the helper that ties together all {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition}
+		 * that are used by the {@ganchor features/anchor anchor} and the {@ganchor features/image#anchoring-images anchoring images} features.
+		 *
+		 * @readonly
+		 * @type {module:anchor/utils~AutomaticDecorators}
+		 */
+		this.automaticDecorators = new AutomaticDecorators();
+	}
+
+	/**
+	 * Synchronizes the state of {@link #manualDecorators} with the currently present elements in the model.
+	 */
+	restoreManualDecoratorStates() {
+		for ( const manualDecorator of this.manualDecorators ) {
+			manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `AnchorImage` plugin. If the selection contains an element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
+			this.value = selectedElement.getAttribute( 'anchorId' );
+			this.isEnabled = model.schema.checkAttribute( selectedElement, 'anchorId' );
+		} else {
+			this.value = doc.selection.getAttribute( 'anchorId' );
+			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'anchorId' );
+		}
+
+		for ( const manualDecorator of this.manualDecorators ) {
+			manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
+		}
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * When the selection is non-collapsed, the `anchorId` attribute will be applied to nodes inside the selection, but only to
+	 * those nodes where the `anchorId` attribute is allowed (disallowed nodes will be omitted).
+	 *
+	 * When the selection is collapsed and is not inside the text with the `anchorId` attribute, a
+	 * new {@link module:engine/model/text~Text text node} with the `anchorId` attribute will be inserted in place of the caret, but
+	 * only if such element is allowed in this place. The `_data` of the inserted text will equal the `id` parameter.
+	 * The selection will be updated to wrap the just inserted text node.
+	 *
+	 * When the selection is collapsed and inside the text with the `anchorId` attribute, the attribute value will be updated.
+	 *
+	 * # Decorators and model attribute management
+	 *
+	 * There is an optional argument to this command that applies or removes model
+	 * {@ganchor framework/guides/architecture/editing-engine#text-attributes text attributes} brought by
+	 * {@link module:anchor/utils~ManualDecorator manual anchor decorators}.
+	 *
+	 * Text attribute names in the model correspond to the entries in the {@link module:anchor/anchor~AnchorConfig#decorators configuration}.
+	 * For every decorator configured, a model text attribute exists with the "anchor" prefix. For example, a `'anchorMyDecorator'` attribute
+	 * corresponds to `'myDecorator'` in the configuration.
+	 *
+	 * To learn more about anchor decorators, check out the {@link module:anchor/anchor~AnchorConfig#decorators `config.anchor.decorators`}
+	 * documentation.
+	 *
+	 * Here is how to manage decorator attributes with the anchor command:
+	 *
+	 *		const anchorCommand = editor.commands.get( 'anchor' );
+	 *
+	 *		// Adding a new decorator attribute.
+	 *		anchorCommand.execute( 'http://example.com', {
+	 *			anchorIsExternal: true
+	 *		} );
+	 *
+	 *		// Removing a decorator attribute from the selection.
+	 *		anchorCommand.execute( 'http://example.com', {
+	 *			anchorIsExternal: false
+	 *		} );
+	 *
+	 *		// Adding multiple decorator attributes at the same time.
+	 *		anchorCommand.execute( 'http://example.com', {
+	 *			anchorIsExternal: true
+	 *		} );
+	 *
+	 *		// Removing and adding decorator attributes at the same time.
+	 *		anchorCommand.execute( 'http://example.com', {
+	 *			anchorIsExternal: false,
+	 *			anchorFoo: true
+	 *		} );
+	 *
+	 * **Note**: If the decorator attribute name is not specified, its state remains untouched.
+	 *
+	 * **Note**: {@link module:anchor/unanchorcommand~UnanchorCommand#execute `UnanchorCommand#execute()`} removes all
+	 * decorator attributes.
+	 *
+	 * @fires execute
+	 * @param {String} id Anchor destination.
+	 * @param {Object} [manualDecoratorIds={}] The information about manual decorator attributes to be applied or removed upon execution.
+	 */
+	execute( id, manualDecoratorIds = {} ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		// Stores information about manual decorators to turn them on/off when command is applied.
+		const truthyManualDecorators = [];
+		const falsyManualDecorators = [];
+
+		for ( const name in manualDecoratorIds ) {
+			if ( manualDecoratorIds[ name ] ) {
+				truthyManualDecorators.push( name );
+			} else {
+				falsyManualDecorators.push( name );
+			}
+		}
+
+		model.change( writer => {
+			// If selection is collapsed then update selected anchor or insert new one at the place of caret.
+			if ( selection.isCollapsed ) {
+				const position = selection.getFirstPosition();
+
+				// When selection is inside text with `anchorId` attribute.
+				if ( selection.hasAttribute( 'anchorId' ) ) {
+					// Then update `anchorId` value.
+					const anchorRange = findAttributeRange( position, 'anchorId', selection.getAttribute( 'anchorId' ), model );
+
+					writer.setAttribute( 'anchorId', id, anchorRange );
+
+					truthyManualDecorators.forEach( item => {
+						writer.setAttribute( item, true, anchorRange );
+					} );
+
+					falsyManualDecorators.forEach( item => {
+						writer.removeAttribute( item, anchorRange );
+					} );
+
+					// Put the selection at the end of the updated anchor.
+					writer.setSelection( writer.createPositionAfter( anchorRange.end.nodeBefore ) );
+				}
+				// If not then insert text node with `anchorId` attribute in place of caret.
+				// However, since selection in collapsed, attribute value will be used as data for text node.
+				// So, if `id` is empty, do not create text node.
+				else if ( id !== '' ) {
+					const attributes = toMap( selection.getAttributes() );
+					attributes.set('anchorId', id);
+
+					truthyManualDecorators.forEach( item => {
+						attributes.set( item, true );
+					} );
+
+					const { end: positionAfter } = model.insertContent( writer.createElement( 'anchor', attributes ), position );
+
+					// Put the selection at the end of the inserted anchor.
+					// Using end of range returned from insertContent in case nodes with the same attributes got merged.
+					writer.setSelection( positionAfter );
+				}
+
+				// Remove the `anchorId` attribute and all anchor decorators from the selection.
+				// It stops adding a new content into the anchor element.
+				[ 'anchorId', ...truthyManualDecorators, ...falsyManualDecorators ].forEach( item => {
+					writer.removeSelectionAttribute( item );
+				} );
+			} else if (selection.getSelectedElement()?.name === 'anchor') {
+				// Replace an invisible anchor.
+				const anchor = writer.createElement('anchor', {
+					anchorId: id
+				});
+				model.insertObject(anchor, null, null, { setSelection: 'on' });
+			} else {
+				// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
+				// omitting nodes where the `anchorId` attribute is disallowed.
+				const ranges = model.schema.getValidRanges( selection.getRanges(), 'anchorId' );
+
+				// But for the first, check whether the `anchorId` attribute is allowed on selected blocks (e.g. the "image" element).
+				const allowedRanges = [];
+
+				for ( const element of selection.getSelectedBlocks() ) {
+					if ( model.schema.checkAttribute( element, 'anchorId' ) ) {
+						allowedRanges.push( writer.createRangeOn( element ) );
+					}
+				}
+
+				// Ranges that accept the `anchorId` attribute. Since we will iterate over `allowedRanges`, let's clone it.
+				const rangesToUpdate = allowedRanges.slice();
+
+				// For all selection ranges we want to check whether given range is inside an element that accepts the `anchorId` attribute.
+				// If so, we don't want to propagate applying the attribute to its children.
+				for ( const range of ranges ) {
+					if ( this._isRangeToUpdate( range, allowedRanges ) ) {
+						rangesToUpdate.push( range );
+					}
+				}
+
+				for ( const range of rangesToUpdate ) {
+					writer.setAttribute( 'anchorId', id, range );
+
+					truthyManualDecorators.forEach( item => {
+						writer.setAttribute( item, true, range );
+					} );
+
+					falsyManualDecorators.forEach( item => {
+						writer.removeAttribute( item, range );
+					} );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Provides information whether a decorator with a given name is present in the currently processed selection.
+	 *
+	 * @private
+	 * @param {String} decoratorName The name of the manual decorator used in the model
+	 * @returns {Boolean} The information whether a given decorator is currently present in the selection.
+	 */
+	_getDecoratorStateFromModel( decoratorName ) {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `AnchorImage` plugin. If the selection contains an element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
+			return selectedElement.getAttribute( decoratorName );
+		}
+
+		return doc.selection.getAttribute( decoratorName );
+	}
+
+	/**
+	 * Checks whether specified `range` is inside an element that accepts the `anchorId` attribute.
+	 *
+	 * @private
+	 * @param {module:engine/view/range~Range} range A range to check.
+	 * @param {Array.<module:engine/view/range~Range>} allowedRanges An array of ranges created on elements where the attribute is accepted.
+	 * @returns {Boolean}
+	 */
+	_isRangeToUpdate( range, allowedRanges ) {
+		for ( const allowedRange of allowedRanges ) {
+			// A range is inside an element that will have the `anchorId` attribute. Do not modify its nodes.
+			if ( allowedRange.containsRange( range ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorediting.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorediting.js
new file mode 100644
index 0000000000000000000000000000000000000000..050547bf7b3d19ec642d061242eb83830d3f5738
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorediting.js
@@ -0,0 +1,642 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorediting
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { MouseObserver } from 'ckeditor5/src/engine';
+import { TwoStepCaretMovement } from 'ckeditor5/src/typing';
+import { inlineHighlight } from 'ckeditor5/src/typing';
+import { Input } from 'ckeditor5/src/typing';
+import { Clipboard } from 'ckeditor5/src/clipboard';
+import AnchorCommand from './anchorcommand';
+import UnanchorCommand from './unanchorcommand';
+import ManualDecorator from './utils/manualdecorator';
+import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { viewToModelPositionOutsideModelElement } from "ckeditor5/src/widget";
+
+import {
+	createAnchorElement,
+	createEmptyAnchorElement, createEmptyPlaceholderAnchorElement,
+	ensureSafeUrl,
+	getLocalizedDecorators,
+	normalizeDecorators
+} from './utils';
+
+import '../theme/anchor.css';
+
+const HIGHLIGHT_CLASS = 'ck-anchor_selected';
+const DECORATOR_AUTOMATIC = 'automatic';
+const DECORATOR_MANUAL = 'manual';
+const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
+
+/**
+ * The anchor engine feature.
+ *
+ * It introduces the `anchorId="url"` attribute in the model which renders to the view as a `<a id="url">` element
+ * as well as `'anchor'` and `'unanchor'` commands.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AnchorEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AnchorEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		// Clipboard is required for handling cut and paste events while typing over the anchor.
+		return [ TwoStepCaretMovement, Input, Clipboard ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'anchor', {
+			addTargetToExternalAnchors: false
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		// Allow anchor attribute on all inline nodes.
+		editor.model.schema.extend( '$text', { allowAttributes: 'anchorId' } );
+		editor.model.schema.register('anchor', {
+			allowContentOf: '$inlineObject',
+			allowWhere: '$inlineObject',
+			inheritTypesFrom: '$inlineObject',
+			allowAttributes: [ 'class', 'id', 'anchorId' ]
+		});
+
+		editor.conversion.for( 'dataDowncast' )
+			.attributeToElement( {
+				model: {
+					name: '$text',
+					key: 'anchorId',
+				},
+				view: createAnchorElement,
+			});
+		editor.conversion.for('dataDowncast').elementToElement({
+			model: 'anchor',
+			view: (modelItem, viewWriter) => {
+				return createEmptyAnchorElement( modelItem.getAttribute('anchorId'), viewWriter);
+			}
+		});
+
+		editor.conversion.for( 'editingDowncast' )
+			.attributeToElement( { model: 'anchorId', view: ( id, conversionApi ) => {
+				if (id) {
+					return createAnchorElement( ensureSafeUrl( id ), conversionApi );
+				}
+				else {
+					return null;
+				}
+			} } );
+		editor.conversion.for('editingDowncast').elementToElement({
+			model: 'anchor',
+			view: (modelItem, viewWriter) => {
+				return createEmptyPlaceholderAnchorElement( modelItem.getAttribute('anchorId'), viewWriter, true);
+			}
+		});
+
+		editor.conversion.for( 'upcast' )
+			.elementToAttribute( {
+				view: {
+					name: 'a',
+					attributes: {
+						id: true,
+						href: false
+					}
+				},
+				model: {
+					key: 'anchorId',
+					value: viewElement => {
+						if (viewElement.childCount < 1) {
+							return;
+						}
+
+						return viewElement.getAttribute( 'id' );
+					}
+				}
+			} );
+
+		editor.conversion.for( 'upcast' )
+			.elementToElement( {
+				view: {
+					name: 'a',
+					attributes: {
+						id: true,
+					}
+				},
+				model: ( viewElement, { writer } ) => {
+					if (viewElement.childCount > 0) {
+						return;
+					}
+
+					return writer.createElement( 'anchor', { anchorId: viewElement.getAttribute('id') } );
+				}
+			} );
+
+		// Create anchoring commands.
+		editor.commands.add( 'anchor', new AnchorCommand( editor ) );
+		editor.commands.add( 'unanchor', new UnanchorCommand( editor ) );
+
+		const anchorDecorators = getLocalizedDecorators( editor.t, normalizeDecorators( editor.config.get( 'anchor.decorators' ) ) );
+
+		this._enableAutomaticDecorators( anchorDecorators.filter( item => item.mode === DECORATOR_AUTOMATIC ) );
+		this._enableManualDecorators( anchorDecorators.filter( item => item.mode === DECORATOR_MANUAL ) );
+
+		// Enable two-step caret movement for `anchorId` attribute.
+		const twoStepCaretMovementPlugin = editor.plugins.get( TwoStepCaretMovement );
+		twoStepCaretMovementPlugin.registerAttribute( 'anchorId' );
+
+		// Setup highlight over selected anchor.
+		inlineHighlight( editor, 'anchorId', 'a', HIGHLIGHT_CLASS );
+
+		// Change the attributes of the selection in certain situations after the anchor was inserted into the document.
+		this._enableInsertContentSelectionAttributesFixer();
+
+		// Handle a click at the beginning/end of a anchor element.
+		this._enableClickingAfterAnchor();
+
+		// Handle typing over the anchor.
+		this._enableTypingOverAnchor();
+
+		// Handle removing the content after the anchor element.
+		this._handleDeleteContentAfterAnchor();
+
+		editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.hasClass( 'ck-anchor-placeholder' ) )
+		);
+	}
+
+	/**
+	 * Processes an array of configured {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic decorators}
+	 * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}
+	 * for each one of them. Downcast dispatchers are obtained using the
+	 * {@link module:anchor/utils~AutomaticDecorators#getDispatcher} method.
+	 *
+	 * **Note**: This method also activates the automatic external anchor decorator if enabled with
+	 * {@link module:anchor/anchor~AnchorConfig#addTargetToExternalAnchors `config.anchor.addTargetToExternalAnchors`}.
+	 *
+	 * @private
+	 * @param {Array.<module:anchor/anchor~AnchorDecoratorAutomaticDefinition>} automaticDecoratorDefinitions
+	 */
+	_enableAutomaticDecorators( automaticDecoratorDefinitions ) {
+		const editor = this.editor;
+		// Store automatic decorators in the command instance as we do the same with manual decorators.
+		// Thanks to that, `AnchorImageEditing` plugin can re-use the same definitions.
+		const command = editor.commands.get( 'anchor' );
+		const automaticDecorators = command.automaticDecorators;
+
+		// Adds a default decorator for external anchors.
+		if ( editor.config.get( 'anchor.addTargetToExternalAnchors' ) ) {
+			automaticDecorators.add( {
+				id: 'anchorIsExternal',
+				mode: DECORATOR_AUTOMATIC,
+				callback: url => EXTERNAL_LINKS_REGEXP.test( url ),
+				attributes: {
+					target: '_blank',
+					rel: 'noopener noreferrer'
+				}
+			} );
+		}
+
+		automaticDecorators.add( automaticDecoratorDefinitions );
+
+		if ( automaticDecorators.length ) {
+			editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcher() );
+		}
+	}
+
+	/**
+	 * Processes an array of configured {@link module:anchor/anchor~AnchorDecoratorManualDefinition manual decorators},
+	 * transforms them into {@link module:anchor/utils~ManualDecorator} instances and stores them in the
+	 * {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators} collection (a model for manual decorators state).
+	 *
+	 * Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attribute-to-element}
+	 * converter for each manual decorator and extends the {@link module:engine/model/schema~Schema model's schema}
+	 * with adequate model attributes.
+	 *
+	 * @private
+	 * @param {Array.<module:anchor/anchor~AnchorDecoratorManualDefinition>} manualDecoratorDefinitions
+	 */
+	_enableManualDecorators( manualDecoratorDefinitions ) {
+		if ( !manualDecoratorDefinitions.length ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const command = editor.commands.get( 'anchor' );
+		const manualDecorators = command.manualDecorators;
+
+		manualDecoratorDefinitions.forEach( decorator => {
+			editor.model.schema.extend( '$text', { allowAttributes: decorator.id } );
+
+			// Keeps reference to manual decorator to decode its name to attributes during downcast.
+			manualDecorators.add( new ManualDecorator( decorator ) );
+
+			editor.conversion.for( 'downcast' ).attributeToElement( {
+				model: decorator.id,
+				view: ( manualDecoratorName, { writer } ) => {
+					if ( manualDecoratorName ) {
+						const attributes = manualDecorators.get( decorator.id ).attributes;
+						const element = writer.createAttributeElement( 'a', attributes, { priority: 5 } );
+						writer.setCustomProperty( 'anchor', true, element );
+
+						return element;
+					}
+				} } );
+
+			editor.conversion.for( 'upcast' ).elementToAttribute( {
+				view: {
+					name: 'a',
+					attributes: manualDecorators.get( decorator.id ).attributes
+				},
+				model: {
+					key: decorator.id
+				}
+			} );
+		} );
+	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
+	 * selection attributes if the selection is at the end of a anchor after inserting the content.
+	 *
+	 * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the
+	 * `anchorId` attribute of the selection and they can type a "clean" (`anchorId`–less) text right away.
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/6053.
+	 *
+	 * @private
+	 */
+	_enableInsertContentSelectionAttributesFixer() {
+		const editor = this.editor;
+		const model = editor.model;
+		const selection = model.document.selection;
+		const anchorCommand = editor.commands.get( 'anchor' );
+
+		this.listenTo( model, 'insertContent', () => {
+			const nodeBefore = selection.anchor.nodeBefore;
+			const nodeAfter = selection.anchor.nodeAfter;
+
+			// NOTE: ↰ and ↱ represent the gravity of the selection.
+
+			// The only truly valid case is:
+			//
+			//		                                 ↰
+			//		...<$text anchorId="foo">INSERTED[]</$text>
+			//
+			// If the selection is not "trapped" by the `anchorId` attribute after inserting, there's nothing
+			// to fix there.
+			if ( !selection.hasAttribute( 'anchorId' ) ) {
+				return;
+			}
+
+			// Filter out the following case where a anchor with the same id (e.g. <a id="foo">INSERTED</a>) is inserted
+			// in the middle of an existing anchor:
+			//
+			// Before insertion:
+			//		                       ↰
+			//		<$text anchorId="foo">l[]ink</$text>
+			//
+			// Expected after insertion:
+			//		                               ↰
+			//		<$text anchorId="foo">lINSERTED[]ink</$text>
+			//
+			if ( !nodeBefore ) {
+				return;
+			}
+
+			// Filter out the following case where the selection has the "anchorId" attribute because the
+			// gravity is overridden and some text with another attribute (e.g. <b>INSERTED</b>) is inserted:
+			//
+			// Before insertion:
+			//
+			//		                       ↱
+			//		<$text anchorId="foo">[]anchor</$text>
+			//
+			// Expected after insertion:
+			//
+			//		                                                          ↱
+			//		<$text bold="true">INSERTED</$text><$text anchorId="foo">[]anchor</$text>
+			//
+			if ( !nodeBefore.hasAttribute( 'anchorId' ) ) {
+				return;
+			}
+
+			// Filter out the following case where a anchor is a inserted in the middle (or before) another anchor
+			// (different URLs, so they will not merge). In this (let's say weird) case, we can leave the selection
+			// attributes as they are because the user will end up writing in one anchor or another anyway.
+			//
+			// Before insertion:
+			//
+			//		                       ↰
+			//		<$text anchorId="foo">l[]ink</$text>
+			//
+			// Expected after insertion:
+			//
+			//		                                                             ↰
+			//		<$text anchorId="foo">l</$text><$text anchorId="bar">INSERTED[]</$text><$text anchorId="foo">ink</$text>
+			//
+			if ( nodeAfter && nodeAfter.hasAttribute( 'anchorId' ) ) {
+				return;
+			}
+
+			model.change( writer => {
+				removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators );
+			} );
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and
+	 * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a anchor node
+	 * if clicked at the beginning/ending of the anchor.
+	 *
+	 * The purpose of this action is to allow typing around the anchor node directly after a click.
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/1016.
+	 *
+	 * @private
+	 */
+	_enableClickingAfterAnchor() {
+		const editor = this.editor;
+		const anchorCommand = editor.commands.get( 'anchor' );
+
+		editor.editing.view.addObserver( MouseObserver );
+
+		let clicked = false;
+
+		// Detect the click.
+		this.listenTo( editor.editing.view.document, 'mousedown', () => {
+			clicked = true;
+		} );
+
+		// When the selection has changed...
+		this.listenTo( editor.editing.view.document, 'selectionChange', () => {
+			if ( !clicked ) {
+				return;
+			}
+
+			// ...and it was caused by the click...
+			clicked = false;
+
+			const selection = editor.model.document.selection;
+
+			// ...and no text is selected...
+			if ( !selection.isCollapsed ) {
+				return;
+			}
+
+			// ...and clicked text is the anchor...
+			if ( !selection.hasAttribute( 'anchorId' ) ) {
+				return;
+			}
+
+			const position = selection.getFirstPosition();
+			const anchorRange = findAttributeRange( position, 'anchorId', selection.getAttribute( 'anchorId' ), editor.model );
+
+			// ...check whether clicked start/end boundary of the anchor.
+			// If so, remove the `anchorId` attribute.
+			if ( position.isTouching( anchorRange.start ) || position.isTouching( anchorRange.end ) ) {
+				editor.model.change( writer => {
+					removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators );
+				} );
+			}
+		} );
+	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
+	 * and checks whether typing over the anchor. If so, attributes of removed text are preserved and applied to the inserted text.
+	 *
+	 * The purpose of this action is to allow modifying a text without loosing the `anchorId` attribute (and other).
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/4762.
+	 *
+	 * @private
+	 */
+	_enableTypingOverAnchor() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+
+		// Selection attributes when started typing over the anchor.
+		let selectionAttributes;
+
+		// Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
+		let deletedContent;
+
+		// Detect pressing `Backspace` / `Delete`.
+		this.listenTo( view.document, 'delete', () => {
+			deletedContent = true;
+		}, { priority: 'high' } );
+
+		// Listening to `model#deleteContent` allows detecting whether selected content was a anchor.
+		// If so, before removing the element, we will copy its attributes.
+		this.listenTo( editor.model, 'deleteContent', () => {
+			const selection = editor.model.document.selection;
+
+			// Copy attributes only if anything is selected.
+			if ( selection.isCollapsed ) {
+				return;
+			}
+
+			// When the content was deleted, do not preserve attributes.
+			if ( deletedContent ) {
+				deletedContent = false;
+
+				return;
+			}
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( shouldCopyAttributes( editor.model ) ) {
+				selectionAttributes = selection.getAttributes();
+			}
+		}, { priority: 'high' } );
+
+		// Listening to `model#insertContent` allows detecting the content insertion.
+		// We want to apply attributes that were removed while typing over the anchor.
+		this.listenTo( editor.model, 'insertContent', ( evt, [ element ] ) => {
+			deletedContent = false;
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( !selectionAttributes ) {
+				return;
+			}
+
+			editor.model.change( writer => {
+				for ( const [ attribute, value ] of selectionAttributes ) {
+					writer.setAttribute( attribute, value, element );
+				}
+			} );
+
+			selectionAttributes = null;
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether
+	 * removing a content right after the "anchorId" attribute.
+	 *
+	 * If so, the selection should not preserve the `anchorId` attribute. However, if
+	 * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and
+	 * the selection has the "anchorId" attribute due to overriden gravity (at the end), the `anchorId` attribute should stay untouched.
+	 *
+	 * The purpose of this action is to allow removing the anchor text and keep the selection outside the anchor.
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/7521.
+	 *
+	 * @private
+	 */
+	_handleDeleteContentAfterAnchor() {
+		const editor = this.editor;
+		const model = editor.model;
+		const selection = model.document.selection;
+		const view = editor.editing.view;
+		const anchorCommand = editor.commands.get( 'anchor' );
+
+		// A flag whether attributes `anchorId` attribute should be preserved.
+		let shouldPreserveAttributes = false;
+
+		// A flag whether the `Backspace` key was pressed.
+		let hasBackspacePressed = false;
+
+		// Detect pressing `Backspace`.
+		this.listenTo( view.document, 'delete', ( evt, data ) => {
+			hasBackspacePressed = data.domEvent.keyCode === keyCodes.backspace;
+		}, { priority: 'high' } );
+
+		// Before removing the content, check whether the selection is inside a anchor or at the end of anchor but with 2-SCM enabled.
+		// If so, we want to preserve anchor attributes.
+		this.listenTo( model, 'deleteContent', () => {
+			// Reset the state.
+			shouldPreserveAttributes = false;
+
+			const position = selection.getFirstPosition();
+			const anchorId = selection.getAttribute( 'anchorId' );
+
+			if ( !anchorId ) {
+				return;
+			}
+
+			const anchorRange = findAttributeRange( position, 'anchorId', anchorId, model );
+
+			// Preserve `anchorId` attribute if the selection is in the middle of the anchor or
+			// the selection is at the end of the anchor and 2-SCM is activated.
+			shouldPreserveAttributes = anchorRange.containsPosition( position ) || anchorRange.end.isEqual( position );
+		}, { priority: 'high' } );
+
+		// After removing the content, check whether the current selection should preserve the `anchorId` attribute.
+		this.listenTo( model, 'deleteContent', () => {
+			// If didn't press `Backspace`.
+			if ( !hasBackspacePressed ) {
+				return;
+			}
+
+			hasBackspacePressed = false;
+
+			// Disable the mechanism if inside a anchor (`<$text url="foo">F[]oo</$text>` or <$text url="foo">Foo[]</$text>`).
+			if ( shouldPreserveAttributes ) {
+				return;
+			}
+
+			// Use `model.enqueueChange()` in order to execute the callback at the end of the changes process.
+			editor.model.enqueueChange( writer => {
+				removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators );
+			} );
+		}, { priority: 'low' } );
+	}
+}
+
+// Make the selection free of anchor-related model attributes.
+// All anchor-related model attributes start with "anchor". That includes not only "anchorId"
+// but also all decorator attributes (they have dynamic names).
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:utils/collection~Collection} manualDecorators
+function removeAnchorAttributesFromSelection( writer, manualDecorators ) {
+	writer.removeSelectionAttribute( 'anchorId' );
+
+	for ( const decorator of manualDecorators ) {
+		writer.removeSelectionAttribute( decorator.id );
+	}
+}
+
+// Checks whether selection's attributes should be copied to the new inserted text.
+//
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean}
+function shouldCopyAttributes( model ) {
+	const selection = model.document.selection;
+	const firstPosition = selection.getFirstPosition();
+	const lastPosition = selection.getLastPosition();
+	const nodeAtFirstPosition = firstPosition.nodeAfter;
+
+	// The text anchor node does not exist...
+	if ( !nodeAtFirstPosition ) {
+		return false;
+	}
+
+	// ...or it isn't the text node...
+	if ( !nodeAtFirstPosition.is( '$text' ) ) {
+		return false;
+	}
+
+	// ...or isn't the anchor.
+	if ( !nodeAtFirstPosition.hasAttribute( 'anchorId' ) ) {
+		return false;
+	}
+
+	// `textNode` = the position is inside the anchor element.
+	// `nodeBefore` = the position is at the end of the anchor element.
+	const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore;
+
+	// If both references the same node selection contains a single text node.
+	if ( nodeAtFirstPosition === nodeAtLastPosition ) {
+		return true;
+	}
+
+	// If nodes are not equal, maybe the anchor nodes has defined additional attributes inside.
+	// First, we need to find the entire anchor range.
+	const anchorRange = findAttributeRange( firstPosition, 'anchorId', nodeAtFirstPosition.getAttribute( 'anchorId' ), model );
+
+	// Then we can check whether selected range is inside the found anchor range. If so, attributes should be preserved.
+	return anchorRange.containsRange( model.createRange( firstPosition, lastPosition ), true );
+}
+
+// Checks whether provided changes were caused by typing.
+//
+// @params {module:core/editor/editor~Editor} editor
+// @returns {Boolean}
+function isTyping( editor ) {
+	const currentBatch = editor.model.change( writer => writer.batch );
+	return currentBatch.isTyping;
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorimage.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorimage.js
new file mode 100644
index 0000000000000000000000000000000000000000..be06897462f1a9f1c7b96517755e41bb54b3bca4
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorimage.js
@@ -0,0 +1,38 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorimage
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { AnchorImageEditing } from './anchorimageediting';
+import { AnchorImageUI } from './anchorimageui';
+
+import '../theme/anchorimage.css';
+
+/**
+ * The `AnchorImage` plugin.
+ *
+ * This is a "glue" plugin that loads the {@link module:anchor/anchorimageediting~AnchorImageEditing anchor image editing feature}
+ * and {@link module:anchor/anchorimageui~AnchorImageUI anchor image UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AnchorImage extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ AnchorImageEditing, AnchorImageUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AnchorImage';
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorimageediting.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorimageediting.js
new file mode 100644
index 0000000000000000000000000000000000000000..dfcd2ad174ffdfcc428dcd21381de2d5aae8cee8
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorimageediting.js
@@ -0,0 +1,276 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorimageediting
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { ImageEditing } from 'ckeditor5/src/image';
+import { Matcher } from 'ckeditor5/src/engine';
+import { toMap } from 'ckeditor5/src/utils';
+import { AnchorEditing } from './anchorediting';
+
+import anchorIcon from '../theme/icons/anchor.svg';
+
+/**
+ * The anchor image engine feature.
+ *
+ * It accepts the `anchorId="url"` attribute in the model for the {@link module:image/image~Image `<image>`} element
+ * which allows anchoring images.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AnchorImageEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageEditing, AnchorEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AnchorImageEditing';
+	}
+
+	init() {
+		const editor = this.editor;
+
+		editor.model.schema.extend( 'image', { allowAttributes: [ 'anchorId' ] } );
+
+		editor.conversion.for( 'upcast' ).add( upcastAnchor() );
+		editor.conversion.for( 'editingDowncast' ).add( downcastImageAnchor( { attachIconIndicator: true } ) );
+		editor.conversion.for( 'dataDowncast' ).add( downcastImageAnchor( { attachIconIndicator: false } ) );
+
+		// Definitions for decorators are provided by the `anchor` command and the `AnchorEditing` plugin.
+		this._enableAutomaticDecorators();
+		this._enableManualDecorators();
+	}
+
+	/**
+	 * Processes {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic decorators} definitions and
+	 * attaches proper converters that will work when anchoring an image.`
+	 *
+	 * @private
+	 */
+	_enableAutomaticDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'anchor' );
+		const automaticDecorators = command.automaticDecorators;
+
+		if ( automaticDecorators.length ) {
+			editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcherForAnchoredImage() );
+		}
+	}
+
+	/**
+	 * Processes transformed {@link module:anchor/utils~ManualDecorator} instances and attaches proper converters
+	 * that will work when anchoring an image.
+	 *
+	 * @private
+	 */
+	_enableManualDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'anchor' );
+		const manualDecorators = command.manualDecorators;
+
+		for ( const decorator of command.manualDecorators ) {
+			editor.model.schema.extend( 'image', { allowAttributes: decorator.id } );
+			editor.conversion.for( 'downcast' ).add( downcastImageAnchorManualDecorator( manualDecorators, decorator ) );
+			editor.conversion.for( 'upcast' ).add( upcastImageAnchorManualDecorator( manualDecorators, decorator ) );
+		}
+	}
+}
+
+// Returns a converter that consumes the 'id' attribute if a anchor contains an image.
+//
+// @private
+// @returns {Function}
+function upcastAnchor() {
+	return dispatcher => {
+		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+			const viewAnchor = data.viewItem;
+			const imageInAnchor = getFirstImage( viewAnchor );
+
+			if ( !imageInAnchor ) {
+				return;
+			}
+
+			// There's an image inside an <a> element - we consume it so it won't be picked up by the Anchor plugin.
+			const consumableAttributes = { attributes: [ 'id' ] };
+
+			// Consume the `id` attribute so the default one will not convert it to $text attribute.
+			if ( !conversionApi.consumable.consume( viewAnchor, consumableAttributes ) ) {
+				// Might be consumed by something else - i.e. other converter with priority=highest - a standard check.
+				return;
+			}
+
+			const anchorId = viewAnchor.getAttribute( 'id' );
+
+			// Missing the 'id' attribute.
+			if ( !anchorId ) {
+				return;
+			}
+
+			// A full definition of the image feature.
+			// figure > a > img: parent of the view anchor element is an image element (figure).
+			let modelElement = data.modelCursor.parent;
+
+			if ( !modelElement.is( 'element', 'image' ) ) {
+				// a > img: parent of the view anchor is not the image (figure) element. We need to convert it manually.
+				const conversionResult = conversionApi.convertItem( imageInAnchor, data.modelCursor );
+
+				// Set image range as conversion result.
+				data.modelRange = conversionResult.modelRange;
+
+				// Continue conversion where image conversion ends.
+				data.modelCursor = conversionResult.modelCursor;
+
+				modelElement = data.modelCursor.nodeBefore;
+			}
+
+			if ( modelElement && modelElement.is( 'element', 'image' ) ) {
+				// Set the anchorId attribute from anchor element on model image element.
+				conversionApi.writer.setAttribute( 'anchorId', anchorId, modelElement );
+			}
+		}, { priority: 'high' } );
+		// Using the same priority that `upcastImageAnchorManualDecorator()` converter guarantees
+		// that manual decorators will decorate the proper element.
+	};
+}
+
+// Return a converter that adds the `<a>` element to data.
+//
+// @private
+// @params {Object} options
+// @params {Boolean} options.attachIconIndicator=false If set to `true`, an icon that informs about the anchored image will be added.
+// @returns {Function}
+function downcastImageAnchor( options ) {
+	return dispatcher => {
+		dispatcher.on( 'attribute:anchorId:image', ( evt, data, conversionApi ) => {
+			// The image will be already converted - so it will be present in the view.
+			const viewFigure = conversionApi.mapper.toViewElement( data.item );
+			const writer = conversionApi.writer;
+
+			// But we need to check whether the anchor element exists.
+			const anchorInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+			let anchorIconIndicator;
+
+			if ( options.attachIconIndicator ) {
+				// Create an icon indicator for a anchored image.
+				anchorIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-anchor-image_icon' }, function( domDocument ) {
+					const domElement = this.toDomElement( domDocument );
+					domElement.innerHTML = anchorIcon;
+
+					return domElement;
+				} );
+			}
+
+			// If so, update the attribute if it's defined or remove the entire anchor if the attribute is empty.
+			if ( anchorInImage ) {
+				if ( data.attributeNewValue ) {
+					writer.setAttribute( 'id', data.attributeNewValue, anchorInImage );
+				} else {
+					const viewImage = Array.from( anchorInImage.getChildren() ).find( child => child.name === 'img' );
+
+					writer.move( writer.createRangeOn( viewImage ), writer.createPositionAt( viewFigure, 0 ) );
+					writer.remove( anchorInImage );
+				}
+			} else {
+				// But if it does not exist. Let's wrap already converted image by newly created anchor element.
+				// 1. Create an empty anchor element.
+				const anchorElement = writer.createContainerElement( 'a', { id: data.attributeNewValue } );
+
+				// 2. Insert anchor inside the associated image.
+				writer.insert( writer.createPositionAt( viewFigure, 0 ), anchorElement );
+
+				// 3. Move the image to the anchor.
+				writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( anchorElement, 0 ) );
+
+				// 4. Inset the anchored image icon indicator while downcast to editing.
+				if ( anchorIconIndicator ) {
+					writer.insert( writer.createPositionAt( anchorElement, 'end' ), anchorIconIndicator );
+				}
+			}
+		} );
+	};
+}
+
+// Returns a converter that decorates the `<a>` element when the image is the anchor label.
+//
+// @private
+// @returns {Function}
+function downcastImageAnchorManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( `attribute:${ decorator.id }:image`, ( evt, data, conversionApi ) => {
+			const attributes = manualDecorators.get( decorator.id ).attributes;
+
+			const viewFigure = conversionApi.mapper.toViewElement( data.item );
+			const anchorInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+			for ( const [ key, val ] of toMap( attributes ) ) {
+				conversionApi.writer.setAttribute( key, val, anchorInImage );
+			}
+		} );
+	};
+}
+
+// Returns a converter that checks whether manual decorators should be applied to the anchor.
+//
+// @private
+// @returns {Function}
+function upcastImageAnchorManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+			const viewAnchor = data.viewItem;
+			const imageInAnchor = getFirstImage( viewAnchor );
+
+			// We need to check whether an image is inside a anchor because the converter handles
+			// only manual decorators for anchored images. See #7975.
+			if ( !imageInAnchor ) {
+				return;
+			}
+
+			const consumableAttributes = {
+				attributes: manualDecorators.get( decorator.id ).attributes
+			};
+
+			const matcher = new Matcher( consumableAttributes );
+			const result = matcher.match( viewAnchor );
+
+			// The anchor element does not have required attributes or/and proper values.
+			if ( !result ) {
+				return;
+			}
+
+			// Check whether we can consume those attributes.
+			if ( !conversionApi.consumable.consume( viewAnchor, result.match ) ) {
+				return;
+			}
+
+			// At this stage we can assume that we have the `<image>` element.
+			// `nodeBefore` comes after conversion: `<a><img></a>`.
+			// `parent` comes with full image definition: `<figure><a><img></a></figure>.
+			// See the body of the `upcastAnchor()` function.
+			const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
+
+			conversionApi.writer.setAttribute( decorator.id, true, modelElement );
+		}, { priority: 'high' } );
+		// Using the same priority that `upcastAnchor()` converter guarantees that the anchored image was properly converted.
+	};
+}
+
+// Returns the first image in a given view element.
+//
+// @private
+// @param {module:engine/view/element~Element}
+// @returns {module:engine/view/element~Element|undefined}
+function getFirstImage( viewElement ) {
+	return Array.from( viewElement.getChildren() ).find( child => child.name === 'img' );
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorimageui.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorimageui.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f922e4264926dad090554008e28f4bba16cb076
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorimageui.js
@@ -0,0 +1,120 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorimageui
+ */
+
+import { ButtonView } from 'ckeditor5/src/button';
+import { Plugin } from 'ckeditor5/src/core';
+import { Image } from 'ckeditor5/src/image';
+import { AnchorUI } from './anchorui';
+import { AnchorEditing } from './anchorediting';
+import { isImageWidget } from 'ckeditor5/src/image';
+import { LINK_KEYSTROKE } from './utils';
+
+import anchorIcon from '../theme/icons/anchor.svg';
+
+/**
+ * The anchor image UI plugin.
+ *
+ * This plugin provides the `'anchorImage'` button that can be displayed in the {@link module:image/imagetoolbar~ImageToolbar}.
+ * It can be used to wrap images in anchors.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AnchorImageUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Image, AnchorEditing, AnchorUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AnchorImageUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+
+		this.listenTo( viewDocument, 'click', ( evt, data ) => {
+			const hasAnchor = isImageAnchored( viewDocument.selection.getSelectedElement() );
+
+			if ( hasAnchor ) {
+				data.preventDefault();
+			}
+		} );
+
+		this._createToolbarAnchorImageButton();
+	}
+
+	/**
+	 * Creates a `AnchorImageUI` button view.
+	 *
+	 * Clicking this button shows a {@link module:anchor/anchorui~AnchorUI#_balloon} attached to the selection.
+	 * When an image is already anchored, the view shows {@link module:anchor/anchorui~AnchorUI#actionsView} or
+	 * {@link module:anchor/anchorui~AnchorUI#formView} if it is not.
+	 *
+	 * @private
+	 */
+	_createToolbarAnchorImageButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'anchorImage', locale => {
+			const button = new ButtonView( locale );
+			const plugin = editor.plugins.get( 'AnchorUI' );
+			const anchorCommand = editor.commands.get( 'anchor' );
+
+			button.set( {
+				isEnabled: true,
+				label: t( 'Anchor image' ),
+				icon: anchorIcon,
+				keystroke: LINK_KEYSTROKE,
+				tooltip: true,
+				isToggleable: true
+			} );
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( anchorCommand, 'isEnabled' );
+			button.bind( 'isOn' ).to( anchorCommand, 'value', value => !!value );
+
+			// Show the actionsView or formView (both from AnchorUI) on button click depending on whether the image is anchored already.
+			this.listenTo( button, 'execute', () => {
+				const hasAnchor = isImageAnchored( editor.editing.view.document.selection.getSelectedElement() );
+
+				if ( hasAnchor ) {
+					plugin._addActionsView();
+				} else {
+					plugin._showUI( true );
+				}
+			} );
+
+			return button;
+		} );
+	}
+}
+
+// A helper function that checks whether the element is a anchored image.
+//
+// @param {module:engine/model/element~Element} element
+// @returns {Boolean}
+function isImageAnchored( element ) {
+	const isImage = element && isImageWidget( element );
+
+	if ( !isImage ) {
+		return false;
+	}
+
+	return element.getChild( 0 ).is( 'element', 'a' );
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/anchorui.js b/web/libraries/ckeditor5-anchor-drupal/src/anchorui.js
new file mode 100644
index 0000000000000000000000000000000000000000..922f831a7837fca1b7265dd7223de0807546bb85
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/anchorui.js
@@ -0,0 +1,733 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/anchorui
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { ClickObserver } from 'ckeditor5/src/engine';
+import { addAnchorProtocolIfApplicable, isAnchorElement, LINK_KEYSTROKE } from './utils';
+
+import { ContextualBalloon } from 'ckeditor5/src/ui';
+
+import { clickOutsideHandler } from 'ckeditor5/src/ui';
+
+import { ButtonView } from 'ckeditor5/src/ui';
+import AnchorFormView from './ui/anchorformview';
+import AnchorActionsView from './ui/anchoractionsview';
+
+import anchorIcon from '../theme/icons/anchor.svg';
+
+const VISUAL_SELECTION_MARKER_NAME = 'anchor-ui';
+
+/**
+ * The anchor UI plugin. It introduces the `'anchor'` and `'unanchor'` buttons and support for the <kbd>Ctrl+M</kbd> keystroke.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AnchorUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AnchorUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.editing.view.addObserver( ClickObserver );
+
+		/**
+		 * The actions view displayed inside of the balloon.
+		 *
+		 * @member {module:anchor/ui/anchoractionsview~AnchorActionsView}
+		 */
+		this.actionsView = this._createActionsView();
+
+		/**
+		 * The form view displayed inside the balloon.
+		 *
+		 * @member {module:anchor/ui/anchorformview~AnchorFormView}
+		 */
+		this.formView = this._createFormView();
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = editor.plugins.get( ContextualBalloon );
+
+		// Create toolbar buttons.
+		this._createToolbarAnchorButton();
+
+		// Attach lifecycle actions to the the balloon.
+		this._enableUserBalloonInteractions();
+
+		// Renders a fake visual selection marker on an expanded selection.
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
+			model: VISUAL_SELECTION_MARKER_NAME,
+			view: {
+				classes: [ 'ck-fake-anchor-selection' ]
+			}
+		} );
+
+		// Renders a fake visual selection marker on a collapsed selection.
+		editor.conversion.for( 'editingDowncast' ).markerToElement( {
+			model: VISUAL_SELECTION_MARKER_NAME,
+			view: {
+				name: 'span',
+				classes: [ 'ck-fake-anchor-selection', 'ck-fake-anchor-selection_collapsed' ]
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		// Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
+		this.formView.destroy();
+	}
+
+	/**
+	 * Creates the {@link module:anchor/ui/anchoractionsview~AnchorActionsView} instance.
+	 *
+	 * @private
+	 * @returns {module:anchor/ui/anchoractionsview~AnchorActionsView} The anchor actions view instance.
+	 */
+	_createActionsView() {
+		const editor = this.editor;
+		const actionsView = new AnchorActionsView( editor.locale );
+		const anchorCommand = editor.commands.get( 'anchor' );
+		const unanchorCommand = editor.commands.get( 'unanchor' );
+
+		actionsView.bind( 'id' ).to( anchorCommand, 'value' );
+		actionsView.editButtonView.bind( 'isEnabled' ).to( anchorCommand );
+		actionsView.unanchorButtonView.bind( 'isEnabled' ).to( unanchorCommand );
+
+		// Execute unanchor command after clicking on the "Edit" button.
+		this.listenTo( actionsView, 'edit', () => {
+			this._addFormView();
+		} );
+
+		// Execute unanchor command after clicking on the "Unanchor" button.
+		this.listenTo( actionsView, 'unanchor', () => {
+			editor.execute( 'unanchor' );
+			this._hideUI();
+		} );
+
+		// Close the panel on esc key press when the **actions have focus**.
+		actionsView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideUI();
+			cancel();
+		} );
+
+		// Open the form view on Ctrl+M when the **actions have focus**..
+		actionsView.keystrokes.set( LINK_KEYSTROKE, ( data, cancel ) => {
+			this._addFormView();
+			cancel();
+		} );
+
+		return actionsView;
+	}
+
+	/**
+	 * Creates the {@link module:anchor/ui/anchorformview~AnchorFormView} instance.
+	 *
+	 * @private
+	 * @returns {module:anchor/ui/anchorformview~AnchorFormView} The anchor form view instance.
+	 */
+	_createFormView() {
+		const editor = this.editor;
+		const anchorCommand = editor.commands.get( 'anchor' );
+		const defaultProtocol = editor.config.get( 'anchor.defaultProtocol' );
+
+		const formView = new AnchorFormView( editor.locale, anchorCommand );
+
+		formView.urlInputView.fieldView.bind( 'value' ).to( anchorCommand, 'value' );
+
+		// Form elements should be read-only when corresponding commands are disabled.
+		formView.urlInputView.bind( 'isReadOnly' ).to( anchorCommand, 'isEnabled', value => !value );
+		formView.saveButtonView.bind( 'isEnabled' ).to( anchorCommand );
+
+		// Execute anchor command after clicking the "Save" button.
+		this.listenTo( formView, 'submit', () => {
+			const { value } = formView.urlInputView.fieldView.element;
+			const parsedUrl = addAnchorProtocolIfApplicable( value, defaultProtocol );
+			editor.execute( 'anchor', parsedUrl, formView.getDecoratorSwitchesState() );
+			this._closeFormView();
+		} );
+
+		// Hide the panel after clicking the "Cancel" button.
+		this.listenTo( formView, 'cancel', () => {
+			this._closeFormView();
+		} );
+
+		// Close the panel on esc key press when the **form has focus**.
+		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._closeFormView();
+			cancel();
+		} );
+
+		return formView;
+	}
+
+	/**
+	 * Creates a toolbar Anchor button. Clicking this button will show
+	 * a {@link #_balloon} attached to the selection.
+	 *
+	 * @private
+	 */
+	_createToolbarAnchorButton() {
+		const editor = this.editor;
+		const anchorCommand = editor.commands.get( 'anchor' );
+		const t = editor.t;
+
+		// Handle the `Ctrl+M` keystroke and show the panel.
+		editor.keystrokes.set( LINK_KEYSTROKE, ( keyEvtData, cancel ) => {
+			// Prevent focusing the search bar in FF, Chrome and Edge. See https://github.com/ckeditor/ckeditor5/issues/4811.
+			cancel();
+
+			if ( anchorCommand.isEnabled ) {
+				this._showUI( true );
+			}
+		} );
+
+		editor.ui.componentFactory.add( 'anchor', locale => {
+			const button = new ButtonView( locale );
+
+			button.isEnabled = true;
+			button.label = t( 'Anchor' );
+			button.icon = anchorIcon;
+			button.keystroke = LINK_KEYSTROKE;
+			button.tooltip = true;
+			button.isToggleable = true;
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( anchorCommand, 'isEnabled' );
+			button.bind( 'isOn' ).to( anchorCommand, 'value', value => !!value );
+
+			// Show the panel on button click.
+			this.listenTo( button, 'execute', () => this._showUI( true ) );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * Attaches actions that control whether the balloon panel containing the
+	 * {@link #formView} is visible or not.
+	 *
+	 * @private
+	 */
+	_enableUserBalloonInteractions() {
+		const viewDocument = this.editor.editing.view.document;
+
+		// Handle click on view document and show panel when selection is placed inside the anchor element.
+		// Keep panel open until selection will be inside the same anchor element.
+		this.listenTo( viewDocument, 'click', () => {
+			const parentAnchor = this._getSelectedAnchorElement();
+
+			if ( parentAnchor ) {
+				// Then show panel but keep focus inside editor editable.
+				this._showUI();
+			}
+		} );
+
+		// Focus the form if the balloon is visible and the Tab key has been pressed.
+		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) {
+				this.actionsView.focus();
+				cancel();
+			}
+		}, {
+			// Use the high priority because the anchor UI navigation is more important
+			// than other feature's actions, e.g. list indentation.
+			// https://github.com/ckeditor/ckeditor5-anchor/issues/146
+			priority: 'high'
+		} );
+
+		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
+		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( this._isUIVisible ) {
+				this._hideUI();
+				cancel();
+			}
+		} );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: this.formView,
+			activator: () => this._isUIInPanel,
+			contextElements: [ this._balloon.view.element ],
+			callback: () => this._hideUI()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #actionsView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addActionsView() {
+		if ( this._areActionsInPanel ) {
+			return;
+		}
+
+		this._balloon.add( {
+			view: this.actionsView,
+			position: this._getBalloonPositionData()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #formView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addFormView() {
+		if ( this._isFormInPanel ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const anchorCommand = editor.commands.get( 'anchor' );
+
+		this.formView.disableCssTransitions();
+
+		this._balloon.add( {
+			view: this.formView,
+			position: this._getBalloonPositionData()
+		} );
+
+		// Select input when form view is currently visible.
+		if ( this._balloon.visibleView === this.formView ) {
+			this.formView.urlInputView.fieldView.select();
+		}
+
+		this.formView.enableCssTransitions();
+
+		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
+		// the command. If the user typed in the input, then canceled the balloon (`urlInputView.fieldView#value` stays
+		// unaltered) and re-opened it without changing the value of the anchor command (e.g. because they
+		// clicked the same anchor), they would see the old value instead of the actual value of the command.
+		// https://github.com/ckeditor/ckeditor5-anchor/issues/78
+		// https://github.com/ckeditor/ckeditor5-anchor/issues/123
+		this.formView.urlInputView.fieldView.element.value = anchorCommand.value || '';
+	}
+
+	/**
+	 * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is
+	 * decided upon the anchor command value (which has a value if the document selection is in the anchor).
+	 *
+	 * Additionally, if any {@link module:anchor/anchor~AnchorConfig#decorators} are defined in the editor configuration, the state of
+	 * switch buttons responsible for manual decorator handling is restored.
+	 *
+	 * @private
+	 */
+	_closeFormView() {
+		const anchorCommand = this.editor.commands.get( 'anchor' );
+
+		// Restore manual decorator states to represent the current model state. This case is important to reset the switch buttons
+		// when the user cancels the editing form.
+		anchorCommand.restoreManualDecoratorStates();
+
+		if ( anchorCommand.value !== undefined ) {
+			this._removeFormView();
+		} else {
+			this._hideUI();
+		}
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_removeFormView() {
+		if ( this._isFormInPanel ) {
+			// Blur the input element before removing it from DOM to prevent issues in some browsers.
+			// See https://github.com/ckeditor/ckeditor5/issues/1501.
+			this.formView.saveButtonView.focus();
+
+			this._balloon.remove( this.formView );
+
+			// Because the form has an input which has focus, the focus must be brought back
+			// to the editor. Otherwise, it would be lost.
+			this.editor.editing.view.focus();
+
+			this._hideFakeVisualSelection();
+		}
+	}
+
+	/**
+	 * Shows the correct UI type. It is either {@link #formView} or {@link #actionsView}.
+	 *
+	 * @param {Boolean} forceVisible
+	 * @private
+	 */
+	_showUI( forceVisible = false ) {
+		// When there's no anchor under the selection, go straight to the editing UI.
+		if ( !this._getSelectedAnchorElement() ) {
+			// Show visual selection on a text without a anchor when the contextual balloon is displayed.
+			// See https://github.com/ckeditor/ckeditor5/issues/4721.
+			this._showFakeVisualSelection();
+
+			this._addActionsView();
+
+			// Be sure panel with anchor is visible.
+			if ( forceVisible ) {
+				this._balloon.showStack( 'main' );
+			}
+
+			this._addFormView();
+		}
+		// If there's a anchor under the selection...
+		else {
+			// Go to the editing UI if actions are already visible.
+			if ( this._areActionsVisible ) {
+				this._addFormView();
+			}
+			// Otherwise display just the actions UI.
+			else {
+				this._addActionsView();
+			}
+
+			// Be sure panel with anchor is visible.
+			if ( forceVisible ) {
+				this._balloon.showStack( 'main' );
+			}
+		}
+
+		// Begin responding to ui#update once the UI is added.
+		this._startUpdatingUI();
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * See {@link #_addFormView}, {@link #_addActionsView}.
+	 *
+	 * @protected
+	 */
+	_hideUI() {
+		if ( !this._isUIInPanel ) {
+			return;
+		}
+
+		const editor = this.editor;
+
+		this.stopListening( editor.ui, 'update' );
+		this.stopListening( this._balloon, 'change:visibleView' );
+
+		// Make sure the focus always gets back to the editable _before_ removing the focused form view.
+		// Doing otherwise causes issues in some browsers. See https://github.com/ckeditor/ckeditor5-anchor/issues/193.
+		editor.editing.view.focus();
+
+		// Remove form first because it's on top of the stack.
+		this._removeFormView();
+
+		// Then remove the actions view because it's beneath the form.
+		this._balloon.remove( this.actionsView );
+
+		this._hideFakeVisualSelection();
+	}
+
+	/**
+	 * Makes the UI react to the {@link module:core/editor/editorui~EditorUI#event:update} event to
+	 * reposition itself when the editor UI should be refreshed.
+	 *
+	 * See: {@link #_hideUI} to learn when the UI stops reacting to the `update` event.
+	 *
+	 * @protected
+	 */
+	_startUpdatingUI() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+
+		let prevSelectedAnchor = this._getSelectedAnchorElement();
+		let prevSelectionParent = getSelectionParent();
+
+		const update = () => {
+			const selectedAnchor = this._getSelectedAnchorElement();
+			const selectionParent = getSelectionParent();
+
+			// Hide the panel if:
+			//
+			// * the selection went out of the EXISTING anchor element. E.g. user moved the caret out
+			//   of the anchor,
+			// * the selection went to a different parent when creating a NEW anchor. E.g. someone
+			//   else modified the document.
+			// * the selection has expanded (e.g. displaying anchor actions then pressing SHIFT+Right arrow).
+			//
+			// Note: #_getSelectedAnchorElement will return a anchor for a non-collapsed selection only
+			// when fully selected.
+			if ( ( prevSelectedAnchor && !selectedAnchor ) ||
+				( !prevSelectedAnchor && selectionParent !== prevSelectionParent ) ) {
+				this._hideUI();
+			}
+			// Update the position of the panel when:
+			//  * anchor panel is in the visible stack
+			//  * the selection remains in the original anchor element,
+			//  * there was no anchor element in the first place, i.e. creating a new anchor
+			else if ( this._isUIVisible ) {
+				// If still in a anchor element, simply update the position of the balloon.
+				// If there was no anchor (e.g. inserting one), the balloon must be moved
+				// to the new position in the editing view (a new native DOM range).
+				this._balloon.updatePosition( this._getBalloonPositionData() );
+			}
+
+			prevSelectedAnchor = selectedAnchor;
+			prevSelectionParent = selectionParent;
+		};
+
+		function getSelectionParent() {
+			return viewDocument.selection.focus.getAncestors()
+				.reverse()
+				.find( node => node.is( 'element' ) );
+		}
+
+		this.listenTo( editor.ui, 'update', update );
+		this.listenTo( this._balloon, 'change:visibleView', update );
+	}
+
+	/**
+	 * Returns `true` when {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isFormInPanel() {
+		return this._balloon.hasView( this.formView );
+	}
+
+	/**
+	 * Returns `true` when {@link #actionsView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsInPanel() {
+		return this._balloon.hasView( this.actionsView );
+	}
+
+	/**
+	 * Returns `true` when {@link #actionsView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsVisible() {
+		return this._balloon.visibleView === this.actionsView;
+	}
+
+	/**
+	 * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIInPanel() {
+		return this._isFormInPanel || this._areActionsInPanel;
+	}
+
+	/**
+	 * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIVisible() {
+		const visibleView = this._balloon.visibleView;
+
+		return visibleView == this.formView || this._areActionsVisible;
+	}
+
+	/**
+	 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
+	 * to the target element or selection.
+	 *
+	 * If the selection is collapsed and inside a anchor element, the panel will be attached to the
+	 * entire anchor element. Otherwise, it will be attached to the selection.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPositionData() {
+		const view = this.editor.editing.view;
+		const model = this.editor.model;
+		const viewDocument = view.document;
+		let target = null;
+
+		if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) {
+			// There are cases when we highlight selection using a marker (#7705, #4721).
+			const markerViewElements = Array.from( this.editor.editing.mapper.markerNameToElements( VISUAL_SELECTION_MARKER_NAME ) );
+			const newRange = view.createRange(
+				view.createPositionBefore( markerViewElements[ 0 ] ),
+				view.createPositionAfter( markerViewElements[ markerViewElements.length - 1 ] )
+			);
+
+			target = view.domConverter.viewRangeToDom( newRange );
+		} else {
+			const targetAnchor = this._getSelectedAnchorElement();
+			const range = viewDocument.selection.getFirstRange();
+
+			target = targetAnchor ?
+				// When selection is inside anchor element, then attach panel to this element.
+				view.domConverter.mapViewToDom( targetAnchor ) :
+				// Otherwise attach panel to the selection.
+				view.domConverter.viewRangeToDom( range );
+		}
+
+		return { target };
+	}
+
+	/**
+	 * Returns the anchor {@link module:engine/view/attributeelement~AttributeElement} under
+	 * the {@link module:engine/view/document~Document editing view's} selection or `null`
+	 * if there is none.
+	 *
+	 * **Note**: For a non–collapsed selection, the anchor element is only returned when **fully**
+	 * selected and the **only** element within the selection boundaries.
+	 *
+	 * @private
+	 * @returns {module:engine/view/attributeelement~AttributeElement|null}
+	 */
+	_getSelectedAnchorElement() {
+		const view = this.editor.editing.view;
+		const selection = view.document.selection;
+
+		if ( selection.isCollapsed ) {
+			return findAnchorElementAncestor( selection.getFirstPosition() );
+		} else {
+			// The range for fully selected anchor is usually anchored in adjacent text nodes.
+			// Trim it to get closer to the actual anchor element.
+			const range = selection.getFirstRange().getTrimmed();
+			const startAnchor = findAnchorElementAncestor( range.start );
+			const endAnchor = findAnchorElementAncestor( range.end );
+
+			if ( !startAnchor || startAnchor != endAnchor ) {
+				return null;
+			}
+
+			// Check if the anchor element is fully selected.
+			if ( view.createRangeIn( startAnchor ).getTrimmed().isEqual( range ) ) {
+				return startAnchor;
+			} else {
+				return null;
+			}
+		}
+	}
+
+	/**
+	 * Displays a fake visual selection when the contextual balloon is displayed.
+	 *
+	 * This adds a 'anchor-ui' marker into the document that is rendered as a highlight on selected text fragment.
+	 *
+	 * @private
+	 */
+	_showFakeVisualSelection() {
+		const model = this.editor.model;
+
+		model.change( writer => {
+			const range = model.document.selection.getFirstRange();
+
+			if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) {
+				writer.updateMarker( VISUAL_SELECTION_MARKER_NAME, { range } );
+			} else {
+				if ( range.start.isAtEnd ) {
+					const focus = model.document.selection.focus;
+					const nextValidRange = getNextValidRange( range, focus, writer );
+
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range: nextValidRange
+					} );
+				} else {
+					writer.addMarker( VISUAL_SELECTION_MARKER_NAME, {
+						usingOperation: false,
+						affectsData: false,
+						range
+					} );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Hides the fake visual selection created in {@link #_showFakeVisualSelection}.
+	 *
+	 * @private
+	 */
+	_hideFakeVisualSelection() {
+		const model = this.editor.model;
+
+		if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) {
+			model.change( writer => {
+				writer.removeMarker( VISUAL_SELECTION_MARKER_NAME );
+			} );
+		}
+	}
+}
+
+// Returns a anchor element if there's one among the ancestors of the provided `Position`.
+//
+// @private
+// @param {module:engine/view/position~Position} View position to analyze.
+// @returns {module:engine/view/attributeelement~AttributeElement|null} Anchor element at the position or null.
+function findAnchorElementAncestor( position ) {
+	return position.getAncestors().find( ancestor => isAnchorElement( ancestor ) );
+}
+
+// Returns next valid range for the fake visual selection marker.
+//
+// @private
+// @param {module:engine/model/range~Range} range Current range.
+// @param {module:engine/model/position~Position} focus Selection focus.
+// @param {module:engine/model/writer~Writer} writer Writer.
+// @returns {module:engine/model/range~Range} New valid range for the fake visual selection marker.
+function getNextValidRange( range, focus, writer ) {
+	const nextStartPath = [ range.start.path[ 0 ] + 1, 0 ];
+	const nextStartPosition = writer.createPositionFromPath( range.start.root, nextStartPath, 'toNext' );
+	const nextRange = writer.createRange( nextStartPosition, range.end );
+
+	// Block creating a potential next valid range over the current range end.
+	if ( nextRange.start.path[ 0 ] > range.end.path[ 0 ] ) {
+		return writer.createRange( focus );
+	}
+
+	if ( nextStartPosition.isAtStart && nextStartPosition.isAtEnd ) {
+		return getNextValidRange( nextRange, focus, writer );
+	}
+
+	return nextRange;
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/autoanchor.js b/web/libraries/ckeditor5-anchor-drupal/src/autoanchor.js
new file mode 100644
index 0000000000000000000000000000000000000000..dd0d14bf0e27ef49caaca60f7b6fa7586441e127
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/autoanchor.js
@@ -0,0 +1,216 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/autoanchor
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { TextWatcher } from 'ckeditor5/src/typing';
+import { getLastTextLine } from 'ckeditor5/src/typing';
+import { addAnchorProtocolIfApplicable } from './utils';
+
+const MIN_LINK_LENGTH_WITH_SPACE_AT_END = 4; // Ie: "t.co " (length 5).
+
+const URL_REG_EXP = new RegExp(
+	// Group 1: Line start or after a space.
+	'(^|\\s)' +
+	// Group 2: Detected anchor (begin with #, follows HTML5 restrictions on
+  // allowed values).
+	'(#\\S+)'
+);
+
+const URL_GROUP_IN_MATCH = 2;
+
+/**
+ * The autoanchor plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AutoAnchor extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AutoAnchor';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		selection.on( 'change:range', () => {
+			// Disable plugin when selection is inside a code block.
+			this.isEnabled = !selection.anchor.parent.is( 'element', 'codeBlock' );
+		} );
+
+		this._enableTypingHandling();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		this._enableEnterHandling();
+		this._enableShiftEnterHandling();
+	}
+
+	/**
+	 * Enables autoanchoring on typing.
+	 *
+	 * @private
+	 */
+	_enableTypingHandling() {
+		const editor = this.editor;
+
+		const watcher = new TextWatcher( editor.model, text => {
+			// 1. Detect <kbd>Space</kbd> after a text with a potential anchor.
+			if ( !isSingleSpaceAtTheEnd( text ) ) {
+				return;
+			}
+
+			// 2. Check text before last typed <kbd>Space</kbd>.
+			const url = getUrlAtTextEnd( text.substr( 0, text.length - 1 ) );
+
+			if ( url ) {
+				return { url };
+			}
+		} );
+
+		watcher.on( 'matched:data', ( evt, data ) => {
+			const { batch, range, url } = data;
+
+			if ( !batch.isTyping ) {
+				return;
+			}
+
+			const anchorEnd = range.end.getShiftedBy( -1 ); // Executed after a space character.
+			const anchorStart = anchorEnd.getShiftedBy( -url.length );
+
+			const anchorRange = editor.model.createRange( anchorStart, anchorEnd );
+
+			this._applyAutoAnchor( url, anchorRange );
+		} );
+
+		watcher.bind( 'isEnabled' ).to( this );
+	}
+
+	/**
+	 * Enables autoanchoring on the <kbd>Enter</kbd> key.
+	 *
+	 * @private
+	 */
+	_enableEnterHandling() {
+		const editor = this.editor;
+		const model = editor.model;
+		const enterCommand = editor.commands.get( 'enter' );
+
+		if ( !enterCommand ) {
+			return;
+		}
+
+		enterCommand.on( 'execute', () => {
+			const position = model.document.selection.getFirstPosition();
+
+			if ( !position.parent.previousSibling ) {
+				return;
+			}
+
+			const rangeToCheck = model.createRangeIn( position.parent.previousSibling );
+
+			this._checkAndApplyAutoAnchorOnRange( rangeToCheck );
+		} );
+	}
+
+	/**
+	 * Enables autoanchoring on the <kbd>Shift</kbd>+<kbd>Enter</kbd> keyboard shortcut.
+	 *
+	 * @private
+	 */
+	_enableShiftEnterHandling() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		const shiftEnterCommand = editor.commands.get( 'shiftEnter' );
+
+		if ( !shiftEnterCommand ) {
+			return;
+		}
+
+		shiftEnterCommand.on( 'execute', () => {
+			const position = model.document.selection.getFirstPosition();
+
+			const rangeToCheck = model.createRange(
+				model.createPositionAt( position.parent, 0 ),
+				position.getShiftedBy( -1 )
+			);
+
+			this._checkAndApplyAutoAnchorOnRange( rangeToCheck );
+		} );
+	}
+
+	/**
+	 * Checks if the passed range contains a anchorable text.
+	 *
+	 * @param {module:engine/model/range~Range} rangeToCheck
+	 * @private
+	 */
+	_checkAndApplyAutoAnchorOnRange( rangeToCheck ) {
+		const model = this.editor.model;
+		const { text, range } = getLastTextLine( rangeToCheck, model );
+
+		const url = getUrlAtTextEnd( text );
+
+		if ( url ) {
+			const anchorRange = model.createRange(
+				range.end.getShiftedBy( -url.length ),
+				range.end
+			);
+
+			this._applyAutoAnchor( url, anchorRange );
+		}
+	}
+
+	/**
+	 * Applies a anchor on a given range.
+	 *
+	 * @param {String} url The URL to anchor.
+	 * @param {module:engine/model/range~Range} range The text range to apply the anchor attribute to.
+	 * @private
+	 */
+	_applyAutoAnchor( anchor, range ) {
+		const model = this.editor.model;
+
+		if ( !this.isEnabled || !isAnchorAllowedOnRange( range, model ) ) {
+			return;
+		}
+
+		// Enqueue change to make undo step.
+		model.enqueueChange( writer => {
+			const defaultProtocol = this.editor.config.get( 'anchor.defaultProtocol' );
+			const parsedUrl = addAnchorProtocolIfApplicable( anchor, defaultProtocol );
+      // Create a link to an anchor with the parsed value.
+			writer.setAttribute( 'linkHref', parsedUrl, range );
+		} );
+	}
+}
+
+// Check if text should be evaluated by the plugin in order to reduce number of RegExp checks on whole text.
+function isSingleSpaceAtTheEnd( text ) {
+	return text.length > MIN_LINK_LENGTH_WITH_SPACE_AT_END && text[ text.length - 1 ] === ' ' && text[ text.length - 2 ] !== ' ';
+}
+
+function getUrlAtTextEnd( text ) {
+	const match = URL_REG_EXP.exec( text );
+
+	return match ? match[ URL_GROUP_IN_MATCH ] : null;
+}
+
+function isAnchorAllowedOnRange( range, model ) {
+	return model.schema.checkAttributeInSelection( model.createSelection( range ), 'anchorId' );
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/index.js b/web/libraries/ckeditor5-anchor-drupal/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3996c2bbee0ebb9f2812295c2edd935b8aaef8ba
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/index.js
@@ -0,0 +1,10 @@
+/**
+ * @file The build process always expects an index.js file. Anything exported
+ * here will be recognized by CKEditor 5 as an available plugin. Multiple
+ * plugins can be exported in this one file.
+ *
+ * I.e. this file's purpose is to make plugin(s) discoverable.
+ */
+// cSpell:ignore simplebox
+
+export { default as Anchor } from './anchor';
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/ui/anchoractionsview.js b/web/libraries/ckeditor5-anchor-drupal/src/ui/anchoractionsview.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e14ec9fbc6c5ad96cab29add542fc14ec432cfa
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/ui/anchoractionsview.js
@@ -0,0 +1,183 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/ui/anchoractionsview
+ */
+
+import { View } from 'ckeditor5/src/ui';
+import { ViewCollection } from 'ckeditor5/src/ui';
+
+import { ButtonView } from 'ckeditor5/src/ui';
+
+import { FocusTracker } from 'ckeditor5/src/utils';
+import { FocusCycler } from 'ckeditor5/src/ui';
+import { KeystrokeHandler } from 'ckeditor5/src/utils';
+
+import unanchorIcon from '../../theme/icons/unanchor.svg';
+import { icons } from 'ckeditor5/src/core';
+import '../../theme/anchoractions.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
+
+/**
+ * The anchor actions view class. This view displays the anchor preview, allows
+ * unanchoring or editing the anchor.
+ *
+ * @extends module:ui/view~View
+ */
+export default class AnchorActionsView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const t = locale.t;
+
+		/**
+		 * Tracks information about DOM focus in the actions.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * The unanchor button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.unanchorButtonView = this._createButton( t( 'Unanchor' ), unanchorIcon, 'unanchor' );
+
+		/**
+		 * The edit anchor button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.editButtonView = this._createButton( t( 'Edit anchor' ), icons.pencil, 'edit' );
+
+		/**
+		 * A collection of views that can be focused in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		this.setTemplate( {
+			tag: 'div',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-anchor-actions',
+					'ck-responsive-form'
+				],
+
+				// https://github.com/ckeditor/ckeditor5-anchor/issues/90
+				tabindex: '-1'
+			},
+
+			children: [
+				this.editButtonView,
+				this.unanchorButtonView
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		const childViews = [
+			this.editButtonView,
+			this.unanchorButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the actions.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button icon.
+	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createButton( label, icon, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		button.delegate( 'execute' ).to( this, eventName );
+
+		return button;
+	}
+}
+
+/**
+ * Fired when the {@link #editButtonView} is clicked.
+ *
+ * @event edit
+ */
+
+/**
+ * Fired when the {@link #unanchorButtonView} is clicked.
+ *
+ * @event unanchor
+ */
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/ui/anchorformview.js b/web/libraries/ckeditor5-anchor-drupal/src/ui/anchorformview.js
new file mode 100644
index 0000000000000000000000000000000000000000..4f6bfeab33663505e7077f4e37e4dc00c8b2b529
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/ui/anchorformview.js
@@ -0,0 +1,353 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/ui/anchorformview
+ */
+
+import { View } from 'ckeditor5/src/ui';
+import { ViewCollection } from 'ckeditor5/src/ui';
+
+import { ButtonView } from 'ckeditor5/src/ui';
+import { SwitchButtonView } from 'ckeditor5/src/ui';
+
+import { LabeledFieldView } from 'ckeditor5/src/ui';
+import { createLabeledInputText } from 'ckeditor5/src/ui';
+import { injectCssTransitionDisabler } from 'ckeditor5/src/ui';
+
+import { submitHandler } from 'ckeditor5/src/ui';
+import { FocusTracker } from 'ckeditor5/src/utils';
+import { FocusCycler } from 'ckeditor5/src/ui';
+import { KeystrokeHandler } from 'ckeditor5/src/utils';
+
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+import '../../theme/anchorform.css';
+import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css';
+
+/**
+ * The anchor form view controller class.
+ *
+ * See {@link module:anchor/ui/anchorformview~AnchorFormView}.
+ *
+ * @extends module:ui/view~View
+ */
+export default class AnchorFormView extends View {
+	/**
+	 * Creates an instance of the {@link module:anchor/ui/anchorformview~AnchorFormView} class.
+	 *
+	 * Also see {@link #render}.
+	 *
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
+	 * @param {module:anchor/anchorcommand~AnchorCommand} anchorCommand Reference to {@link module:anchor/anchorcommand~AnchorCommand}.
+	 * @param {String} [protocol] A value of a protocol to be displayed in the input's placeholder.
+	 */
+	constructor( locale, anchorCommand ) {
+		super( locale );
+
+		const t = locale.t;
+
+		/**
+		 * Tracks information about DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * The URL input view.
+		 *
+		 * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
+		 */
+		this.urlInputView = this._createUrlInput();
+
+		/**
+		 * The Save button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
+		this.saveButtonView.type = 'submit';
+
+		/**
+		 * The Cancel button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
+
+		/**
+		 * A collection of {@link module:ui/button/switchbuttonview~SwitchButtonView},
+		 * which corresponds to {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators manual decorators}
+		 * configured in the editor.
+		 *
+		 * @private
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this._manualDecoratorSwitches = this._createManualDecoratorSwitches( anchorCommand );
+
+		/**
+		 * A collection of child views in the form.
+		 *
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this._createFormChildren( anchorCommand.manualDecorators );
+
+		/**
+		 * A collection of views that can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		const classList = [ 'ck', 'ck-anchor-form', 'ck-responsive-form' ];
+
+		if ( anchorCommand.manualDecorators.length ) {
+			classList.push( 'ck-anchor-form_layout-vertical', 'ck-vertical-form' );
+		}
+
+		this.setTemplate( {
+			tag: 'form',
+
+			attributes: {
+				class: classList,
+
+				// https://github.com/ckeditor/ckeditor5-anchor/issues/90
+				tabindex: '-1'
+			},
+
+			children: this.children
+		} );
+
+		injectCssTransitionDisabler( this );
+	}
+
+	/**
+	 * Obtains the state of the {@link module:ui/button/switchbuttonview~SwitchButtonView switch buttons} representing
+	 * {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators manual anchor decorators}
+	 * in the {@link module:anchor/ui/anchorformview~AnchorFormView}.
+	 *
+	 * @returns {Object.<String,Boolean>} Key-value pairs, where the key is the name of the decorator and the value is
+	 * its state.
+	 */
+	getDecoratorSwitchesState() {
+		return Array.from( this._manualDecoratorSwitches ).reduce( ( accumulator, switchButton ) => {
+			accumulator[ switchButton.name ] = switchButton.isOn;
+			return accumulator;
+		}, {} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		submitHandler( {
+			view: this
+		} );
+
+		const childViews = [
+			this.urlInputView,
+			...this._manualDecoratorSwitches,
+			this.saveButtonView,
+			this.cancelButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Creates a labeled input view.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} Labeled field view instance.
+	 */
+	_createUrlInput() {
+		const t = this.locale.t;
+		const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText );
+
+		labeledInput.label = t( 'Anchor name' );
+
+		return labeledInput;
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button icon.
+	 * @param {String} className The additional button CSS class name.
+	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createButton( label, icon, className, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		button.extendTemplate( {
+			attributes: {
+				class: className
+			}
+		} );
+
+		if ( eventName ) {
+			button.delegate( 'execute' ).to( this, eventName );
+		}
+
+		return button;
+	}
+
+	/**
+	 * Populates {@link module:ui/viewcollection~ViewCollection} of {@link module:ui/button/switchbuttonview~SwitchButtonView}
+	 * made based on {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators}.
+	 *
+	 * @private
+	 * @param {module:anchor/anchorcommand~AnchorCommand} anchorCommand A reference to the anchor command.
+	 * @returns {module:ui/viewcollection~ViewCollection} of switch buttons.
+	 */
+	_createManualDecoratorSwitches( anchorCommand ) {
+		const switches = this.createCollection();
+
+		for ( const manualDecorator of anchorCommand.manualDecorators ) {
+			const switchButton = new SwitchButtonView( this.locale );
+
+			switchButton.set( {
+				name: manualDecorator.id,
+				label: manualDecorator.label,
+				withText: true
+			} );
+
+			switchButton.bind( 'isOn' ).toMany( [ manualDecorator, anchorCommand ], 'value', ( decoratorValue, commandValue ) => {
+				return commandValue === undefined && decoratorValue === undefined ? manualDecorator.defaultValue : decoratorValue;
+			} );
+
+			switchButton.on( 'execute', () => {
+				manualDecorator.set( 'value', !switchButton.isOn );
+			} );
+
+			switches.add( switchButton );
+		}
+
+		return switches;
+	}
+
+	/**
+	 * Populates the {@link #children} collection of the form.
+	 *
+	 * If {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators manual decorators} are configured in the editor, it creates an
+	 * additional `View` wrapping all {@link #_manualDecoratorSwitches} switch buttons corresponding
+	 * to these decorators.
+	 *
+	 * @private
+	 * @param {module:utils/collection~Collection} manualDecorators A reference to
+	 * the collection of manual decorators stored in the anchor command.
+	 * @returns {module:ui/viewcollection~ViewCollection} The children of anchor form view.
+	 */
+	_createFormChildren( manualDecorators ) {
+		const children = this.createCollection();
+
+		children.add( this.urlInputView );
+
+		if ( manualDecorators.length ) {
+			const additionalButtonsView = new View();
+
+			additionalButtonsView.setTemplate( {
+				tag: 'ul',
+				children: this._manualDecoratorSwitches.map( switchButton => ( {
+					tag: 'li',
+					children: [ switchButton ],
+					attributes: {
+						class: [
+							'ck',
+							'ck-list__item'
+						]
+					}
+				} ) ),
+				attributes: {
+					class: [
+						'ck',
+						'ck-reset',
+						'ck-list'
+					]
+				}
+			} );
+			children.add( additionalButtonsView );
+		}
+
+		children.add( this.saveButtonView );
+		children.add( this.cancelButtonView );
+
+		return children;
+	}
+}
+
+/**
+ * Fired when the form view is submitted (when one of the children triggered the submit event),
+ * for example with a click on {@link #saveButtonView}.
+ *
+ * @event submit
+ */
+
+/**
+ * Fired when the form view is canceled, for example with a click on {@link #cancelButtonView}.
+ *
+ * @event cancel
+ */
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/unanchorcommand.js b/web/libraries/ckeditor5-anchor-drupal/src/unanchorcommand.js
new file mode 100644
index 0000000000000000000000000000000000000000..467a63757d1203dd4e556ae816c7b4d6b317e7de
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/unanchorcommand.js
@@ -0,0 +1,86 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/unanchorcommand
+ */
+
+import { Command } from 'ckeditor5/src/core';
+import { findAttributeRange } from 'ckeditor5/src/typing';
+import { first } from 'ckeditor5/src/utils';
+import { isImageAllowed } from './utils';
+
+/**
+ * The unanchor command. It is used by the {@link module:anchor/anchor~Anchor anchor plugin}.
+ *
+ * @extends module:core/command~Command
+ */
+export default class UnanchorCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `AnchorImage` plugin. If the selection contains an image element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
+			this.isEnabled = model.schema.checkAttribute( selectedElement, 'anchorId' );
+		} else {
+			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'anchorId' );
+		}
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * When the selection is collapsed, it removes the `anchorId` attribute from each node with the same `anchorId` attribute value.
+	 * When the selection is non-collapsed, it removes the `anchorId` attribute from each node in selected ranges.
+	 *
+	 * # Decorators
+	 *
+	 * If {@link module:anchor/anchor~AnchorConfig#decorators `config.anchor.decorators`} is specified,
+	 * all configured decorators are removed together with the `anchorId` attribute.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const editor = this.editor;
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const anchorCommand = editor.commands.get( 'anchor' );
+
+		model.change( writer => {
+			// Get ranges to unanchor.
+			const rangesToUnanchor = selection.isCollapsed ?
+				[ findAttributeRange(
+					selection.getFirstPosition(),
+					'anchorId',
+					selection.getAttribute( 'anchorId' ),
+					model
+				) ] :
+				model.schema.getValidRanges( selection.getRanges(), 'anchorId' );
+
+			// Remove `anchorId` attribute from specified ranges.
+			for ( const range of rangesToUnanchor ) {
+				writer.removeAttribute( 'anchorId', range );
+				// If there are registered custom attributes, then remove them during unanchor.
+				if ( anchorCommand ) {
+					for ( const manualDecorator of anchorCommand.manualDecorators ) {
+						writer.removeAttribute( manualDecorator.id, range );
+					}
+				}
+			}
+
+			// Remove an invisible anchor.
+			if (selection.getSelectedElement()?.name === 'anchor') {
+				model.deleteContent(selection);
+			}
+		} );
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/utils.js b/web/libraries/ckeditor5-anchor-drupal/src/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..7ae26f007a077841a6dcc7b16eca9c6f6400a8a4
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/utils.js
@@ -0,0 +1,205 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/utils
+ */
+
+import { upperFirst } from 'lodash-es';
+import { toWidget } from "ckeditor5/src/widget";
+
+const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
+const SAFE_URL = /^(?:(?:https?|ftps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;
+
+// Simplified email test - should be run over previously found URL.
+const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;
+
+// The regex checks for the protocol syntax ('xxxx://' or 'xxxx:')
+// or non-word characters at the beginning of the anchor ('/', '#' etc.).
+const PROTOCOL_REG_EXP = /^((\w+:(\/{2,})?)|(\W))/i;
+
+/**
+ * A keystroke used by the {@link module:anchor/anchorui~AnchorUI anchor UI feature}.
+ */
+export const LINK_KEYSTROKE = 'Ctrl+M';
+
+/**
+ * Returns `true` if a given view node is the anchor element.
+ *
+ * @param {module:engine/view/node~Node} node
+ * @returns {Boolean}
+ */
+export function isAnchorElement( node ) {
+	return (
+        node.is('attributeElement') && !!node.getCustomProperty( 'anchor' )
+    );
+}
+
+/**
+ * Creates a anchor {@link module:engine/view/attributeelement~AttributeElement} with the provided `id` attribute.
+ *
+ * @param {String} id
+ * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
+ * @returns {module:engine/view/attributeelement~AttributeElement}
+ */
+export function createAnchorElement( id, { writer } ) {
+	// Priority 5 - https://github.com/ckeditor/ckeditor5-anchor/issues/121.
+	const anchorElement = writer.createAttributeElement( 'a', { id }, { priority: 5 } );
+	writer.addClass("ck-anchor", anchorElement);
+	writer.setCustomProperty( 'anchor', true, anchorElement );
+
+	return anchorElement;
+}
+
+/**
+ * Creates an empty anchor {@link module:engine/view/emptyelement~EmptyElement} with the provided `id` attribute.
+ *
+ * @param {String} id
+ * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
+ * @returns {module:engine/view/emptyelement~EmptyElement}
+ */
+export function createEmptyAnchorElement( id, { writer } ) {
+	let anchorElement = null;
+	anchorElement = writer.createEmptyElement( 'a', { id });
+
+	writer.addClass("ck-anchor", anchorElement);
+	writer.setCustomProperty( 'anchor', true, anchorElement );
+
+	return anchorElement;
+}
+
+/**
+ * Creates an SVG placeholder {@link module:engine/view/emptyelement~EmptyElement} with the provided `id` attribute.
+ *
+ * @param {String} anchorId
+ * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
+ * @returns {module:engine/view/emptyelement~EmptyElement}
+ */
+export function createEmptyPlaceholderAnchorElement( anchorId, { writer } ) {
+	const anchorElement = writer.createContainerElement('span', {
+		class: 'ck-anchor-placeholder',
+	}, [writer.createText(`[INVISIBLE ANCHOR: ${anchorId}]`)]);
+	return toWidget(anchorElement, writer );
+}
+
+/**
+ * Returns a safe URL based on a given value.
+ *
+ * A URL is considered safe if it is safe for the user (does not contain any malicious code).
+ *
+ * If a URL is considered unsafe, a simple `"#"` is returned.
+ *
+ * @protected
+ * @param {*} url
+ * @returns {String} Safe URL.
+ */
+export function ensureSafeUrl( url ) {
+	url = String( url );
+
+	return isSafeUrl( url ) ? url : '#';
+}
+
+// Checks whether the given URL is safe for the user (does not contain any malicious code).
+//
+// @param {String} url URL to check.
+function isSafeUrl( url ) {
+	const normalizedUrl = url.replace( ATTRIBUTE_WHITESPACES, '' );
+
+	return normalizedUrl.match( SAFE_URL );
+}
+
+/**
+ * Returns the {@link module:anchor/anchor~AnchorConfig#decorators `config.anchor.decorators`} configuration processed
+ * to respect the locale of the editor, i.e. to display the {@link module:anchor/anchor~AnchorDecoratorManualDefinition label}
+ * in the correct language.
+ *
+ * **Note**: Only the few most commonly used labels are translated automatically. Other labels should be manually
+ * translated in the {@link module:anchor/anchor~AnchorConfig#decorators `config.anchor.decorators`} configuration.
+ *
+ * @param {module:utils/locale~Locale#t} t shorthand for {@link module:utils/locale~Locale#t Locale#t}
+ * @param {Array.<module:anchor/anchor~AnchorDecoratorDefinition>} The decorator reference
+ * where the label values should be localized.
+ * @returns {Array.<module:anchor/anchor~AnchorDecoratorDefinition>}
+ */
+export function getLocalizedDecorators( t, decorators ) {
+	const localizedDecoratorsLabels = {};
+
+	decorators.forEach( decorator => {
+		if ( decorator.label && localizedDecoratorsLabels[ decorator.label ] ) {
+			decorator.label = localizedDecoratorsLabels[ decorator.label ];
+		}
+		return decorator;
+	} );
+
+	return decorators;
+}
+
+/**
+ * Converts an object with defined decorators to a normalized array of decorators. The `id` key is added for each decorator and
+ * is used as the attribute's name in the model.
+ *
+ * @param {Object.<String, module:anchor/anchor~AnchorDecoratorDefinition>} decorators
+ * @returns {Array.<module:anchor/anchor~AnchorDecoratorDefinition>}
+ */
+export function normalizeDecorators( decorators ) {
+	const retArray = [];
+
+	if ( decorators ) {
+		for ( const [ key, value ] of Object.entries( decorators ) ) {
+			const decorator = Object.assign(
+				{},
+				value,
+				{ id: `anchor${ upperFirst( key ) }` }
+			);
+			retArray.push( decorator );
+		}
+	}
+
+	return retArray;
+}
+
+/**
+ * Returns `true` if the specified `element` is an image and it can be anchored (the element allows having the `anchorId` attribute).
+ *
+ * @params {module:engine/model/element~Element|null} element
+ * @params {module:engine/model/schema~Schema} schema
+ * @returns {Boolean}
+ */
+export function isImageAllowed( element, schema ) {
+	if ( !element ) {
+		return false;
+	}
+
+	return element.is( 'element', 'image' ) && schema.checkAttribute( 'image', 'anchorId' );
+}
+
+/**
+ * Returns `true` if the specified `value` is an email.
+ *
+ * @params {String} value
+ * @returns {Boolean}
+ */
+export function isEmail( value ) {
+	return EMAIL_REG_EXP.test( value );
+}
+
+/**
+ * Adds the protocol prefix to the specified `anchor` when:
+ *
+ * * it does not contain it already, and there is a {@link module:anchor/anchor~AnchorConfig#defaultProtocol `defaultProtocol` }
+ * configuration value provided,
+ * * or the anchor is an email address.
+ *
+ *
+ * @params {String} anchor
+ * @params {String} defaultProtocol
+ * @returns {Boolean}
+ */
+export function addAnchorProtocolIfApplicable( anchor, defaultProtocol ) {
+	const protocol = isEmail( anchor ) ? 'mailto:' : defaultProtocol;
+	const isProtocolNeeded = !!protocol && !PROTOCOL_REG_EXP.test( anchor );
+
+	return anchor && isProtocolNeeded ? protocol + anchor : anchor;
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/utils/automaticdecorators.js b/web/libraries/ckeditor5-anchor-drupal/src/utils/automaticdecorators.js
new file mode 100644
index 0000000000000000000000000000000000000000..b0a038477535d5ac9d02e3f3646107cc2a2300b3
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/utils/automaticdecorators.js
@@ -0,0 +1,128 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module anchor/utils
+ */
+
+import { toMap } from 'ckeditor5/src/utils';
+
+/**
+ * Helper class that ties together all {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition} and provides
+ * the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatchers} for them.
+ */
+export default class AutomaticDecorators {
+	constructor() {
+		/**
+		 * Stores the definition of {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic decorators}.
+		 * This data is used as a source for a downcast dispatcher to create a proper conversion to output data.
+		 *
+		 * @private
+		 * @type {Set}
+		 */
+		this._definitions = new Set();
+	}
+
+	/**
+	 * Gives information about the number of decorators stored in the {@link module:anchor/utils~AutomaticDecorators} instance.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Number}
+	 */
+	get length() {
+		return this._definitions.size;
+	}
+
+	/**
+	 * Adds automatic decorator objects or an array with them to be used during downcasting.
+	 *
+	 * @param {module:anchor/anchor~AnchorDecoratorAutomaticDefinition|Array.<module:anchor/anchor~AnchorDecoratorAutomaticDefinition>} item
+	 * A configuration object of automatic rules for decorating anchors. It might also be an array of such objects.
+	 */
+	add( item ) {
+		if ( Array.isArray( item ) ) {
+			item.forEach( item => this._definitions.add( item ) );
+		} else {
+			this._definitions.add( item );
+		}
+	}
+
+	/**
+	 * Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method.
+	 *
+	 * @returns {Function} A dispatcher function used as conversion helper
+	 * in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
+	 */
+	getDispatcher() {
+		return dispatcher => {
+			dispatcher.on( 'attribute:anchorId', ( evt, data, conversionApi ) => {
+				// There is only test as this behavior decorates anchors and
+				// it is run before dispatcher which actually consumes this node.
+				// This allows on writing own dispatcher with highest priority,
+				// which blocks both native converter and this additional decoration.
+				if ( !conversionApi.consumable.test( data.item, 'attribute:anchorId' ) ) {
+					return;
+				}
+				const viewWriter = conversionApi.writer;
+				const viewSelection = viewWriter.document.selection;
+
+				for ( const item of this._definitions ) {
+					const viewElement = viewWriter.createAttributeElement( 'a', item.attributes, {
+						priority: 5
+					} );
+					viewWriter.setCustomProperty( 'anchor', true, viewElement );
+					if ( item.callback( data.attributeNewValue ) ) {
+						if ( data.item.is( 'selection' ) ) {
+							viewWriter.wrap( viewSelection.getFirstRange(), viewElement );
+						} else {
+							viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), viewElement );
+						}
+					} else {
+						viewWriter.unwrap( conversionApi.mapper.toViewRange( data.range ), viewElement );
+					}
+				}
+			}, { priority: 'high' } );
+		};
+	}
+
+	/**
+	 * Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method
+	 * when anchoring images.
+	 *
+	 * @returns {Function} A dispatcher function used as conversion helper
+	 * in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
+	 */
+	getDispatcherForAnchoredImage() {
+		return dispatcher => {
+			dispatcher.on( 'attribute:anchorId:image', ( evt, data, conversionApi ) => {
+				const viewFigure = conversionApi.mapper.toViewElement( data.item );
+				const anchorInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+				for ( const item of this._definitions ) {
+					const attributes = toMap( item.attributes );
+
+					if ( item.callback( data.attributeNewValue ) ) {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.addClass( val, anchorInImage );
+							} else {
+								conversionApi.writer.setAttribute( key, val, anchorInImage );
+							}
+						}
+					} else {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.removeClass( val, anchorInImage );
+							} else {
+								conversionApi.writer.removeAttribute( key, anchorInImage );
+							}
+						}
+					}
+				}
+			} );
+		};
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/src/utils/manualdecorator.js b/web/libraries/ckeditor5-anchor-drupal/src/utils/manualdecorator.js
new file mode 100644
index 0000000000000000000000000000000000000000..44bcda05d7fa8edc8b97120d833487ae5804367e
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/src/utils/manualdecorator.js
@@ -0,0 +1,72 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module anchor/utils
+ */
+
+import { ObservableMixin } from 'ckeditor5/src/utils';
+import { mix } from 'ckeditor5/src/utils';
+
+/**
+ * Helper class that stores manual decorators with observable {@link module:anchor/utils~ManualDecorator#value}
+ * to support integration with the UI state. An instance of this class is a model with the state of individual manual decorators.
+ * These decorators are kept as collections in {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators}.
+ *
+ * @mixes module:utils/observablemixin~ObservableMixin
+ */
+export default class ManualDecorator {
+	/**
+	 * Creates a new instance of {@link module:anchor/utils~ManualDecorator}.
+	 *
+	 * @param {Object} config
+	 * @param {String} config.id The name of the attribute used in the model that represents a given manual decorator.
+	 * For example: `'anchorIsExternal'`.
+	 * @param {String} config.label The label used in the user interface to toggle the manual decorator.
+	 * @param {Object} config.attributes A set of attributes added to output data when the decorator is active for a specific anchor.
+	 * Attributes should keep the format of attributes defined in {@link module:engine/view/elementdefinition~ElementDefinition}.
+	 * @param {Boolean} [config.defaultValue] Controls whether the decorator is "on" by default.
+	 */
+	constructor( { id, label, attributes, defaultValue } ) {
+		/**
+		 * An ID of a manual decorator which is the name of the attribute in the model, for example: 'anchorManualDecorator0'.
+		 *
+		 * @type {String}
+		 */
+		this.id = id;
+
+		/**
+		 * The value of the current manual decorator. It reflects its state from the UI.
+		 *
+		 * @observable
+		 * @member {Boolean} module:anchor/utils~ManualDecorator#value
+		 */
+		this.set( 'value' );
+
+		/**
+		 * The default value of manual decorator.
+		 *
+		 * @type {Boolean}
+		 */
+		this.defaultValue = defaultValue;
+
+		/**
+		 * The label used in the user interface to toggle the manual decorator.
+		 *
+		 * @type {String}
+		 */
+		this.label = label;
+
+		/**
+		 * A set of attributes added to downcasted data when the decorator is activated for a specific anchor.
+		 * Attributes should be added in a form of attributes defined in {@link module:engine/view/elementdefinition~ElementDefinition}.
+		 *
+		 * @type {Object}
+		 */
+		this.attributes = attributes;
+	}
+}
+
+mix( ManualDecorator, ObservableMixin );
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/anchor.css b/web/libraries/ckeditor5-anchor-drupal/theme/anchor.css
new file mode 100644
index 0000000000000000000000000000000000000000..12134c3b688734bc5ee0d61dba225e7ddf725616
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/anchor.css
@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/anchoractions.css b/web/libraries/ckeditor5-anchor-drupal/theme/anchoractions.css
new file mode 100644
index 0000000000000000000000000000000000000000..6405683dae0303c02c94ee2373008693d70f24af
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/anchoractions.css
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
+
+.ck.ck-anchor-actions {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+
+	& .ck-anchor-actions__preview {
+		display: inline-block;
+
+		& .ck-button__label {
+			overflow: hidden;
+		}
+	}
+
+	@mixin ck-media-phone {
+		flex-wrap: wrap;
+
+		& .ck-anchor-actions__preview {
+			flex-basis: 100%;
+		}
+
+		& .ck-button:not(.ck-anchor-actions__preview) {
+			flex-basis: 50%;
+		}
+	}
+}
+
+div[contenteditable=false] .ck-anchor {
+	background-size: 16px;
+	padding-left: 18px;
+	color: #00f;
+	cursor: auto;
+}
+
+div[contenteditable=true] .ck-anchor {
+	background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAAB3RJTUUH4AsCDSEAgw7OCAAAA1lJREFUWMPdl82LHEUYxn/VPTthe5Q1SMgt7GlZRG8KXrwEsgQlmEvwkIsL2YPgvyAEIoFcAvG0Nw8hgroQiHtIRNBLIAhqAlmQfHkIHiQssjg7n131eOjqmeqPnWF24x58oJjp6n7f99dVXdVPI4mpzTmctbg0LTRZi5ybHl9snykTkiicdM5hrcVai8sTW4vt95H0tqTvJP0pyaqotRFMHdC4/9MgpgJQq3+2tvY8F+impCNuMCiOjHPIWtxwiKSPSjFjAH+3k+WcHqyu6vujR/XD8eP65exZ7dy7V77qhaT3bLeLGw5xwyFpu42kz2vyjQGstSMAQFDkeXzxom6AvgZt+PYN6Ib//+zKlTrk3yX9Fnb8uLg4rp/VxOQAcRwLIEkSBHQ7HQDunzvH1sYGCdAAIjIJcIAFBr7vjdVV3lpfh2aTUP3nz/lpeZlnnQ6fSFm8tcbEcRVgYWEBgJ2dHQC+NIbXgLmgeKgQJPUw88DrS0tEzSbbDx/SAWKgA3ycA6SpMXFMo5zQGIOC44YPrisOYILzDeCIB9p+9Ah8/yu+r18T36hLKueYVca3vGjdSNWpAuCcI4rGKQyza5aYCmwURThphhQHUwVAh1i8FuCwVQFw+3gA/18jEEXRvpbhSwM47Cmo7AOSMME+oOC3vD7CzeelAdTJMd7vre+LyLbgfBueBKPS71SAcBpSoEu2xy+vrXHszBnM3Bx/373LH1evst3p0GT8ssphwuLyedLC8BkwpgoQRRE2AOgCb66s8M6dO4Xrjp0+zdKlS7h2m1/Pn+fJrVtEHiQOIJwv3ANOXrtWBIBsztM0HTmQVqulpNUaGYefT50KPcV9SR/4tl52IE8uX9ZN0HXQV75dB22Cdp8+DS9dKTiiCkCS1DmcD22/P3bFwyG220XSu96KjWR7Pb24fVt/bW7K7u6W85xwacqsACdHQWHzVt0OBkh6VdK3E1zlF2m7TVi8FiBJEs3Pz4eBF/a024Hldt792l4vn9r3/VQt2l5vz++IiiVrtVoYY2i32/mKMKE/mKjsjrLllr9VjcHkD5ypLtR6RxS8kmd6PfuldSBDkn8RHZYqI5Cm6dQgEwzlfmHzHI1ywjKAKc3btON9jcCkOzpogWmKphX5rwH+BWxcUbVSlumsAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE2LTExLTAyVDEzOjMzOjAwKzAxOjAw1wGs7QAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNi0xMS0wMlQxMzozMzowMCswMTowMKZcFFEAAAAASUVORK5CYII=") no-repeat left center;
+	background-size: 16px;
+	padding-left: 18px;
+	color: #00f;
+	cursor: auto;
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/anchorform.css b/web/libraries/ckeditor5-anchor-drupal/theme/anchorform.css
new file mode 100644
index 0000000000000000000000000000000000000000..c1ffc370df0b75aef3d01ce99a58cd9222ab8180
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/anchorform.css
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css";
+
+.ck.ck-anchor-form {
+	display: flex;
+
+	& .ck-label {
+		display: none;
+	}
+
+	@mixin ck-media-phone {
+		flex-wrap: wrap;
+
+		& .ck-labeled-field-view {
+			flex-basis: 100%;
+		}
+
+		& .ck-button {
+			flex-basis: 50%;
+		}
+	}
+}
+
+/*
+ * Style anchor form differently when manual decorators are available.
+ * See: https://github.com/ckeditor/ckeditor5-anchor/issues/186.
+ */
+.ck.ck-anchor-form_layout-vertical {
+	display: block;
+
+	/*
+	 * Whether the form is in the responsive mode or not, if there are decorator buttons
+	 * keep the top margin of action buttons medium.
+	 */
+	& .ck-button {
+		&.ck-button-save,
+		&.ck-button-cancel {
+			margin-top: var(--ck-spacing-medium);
+		}
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/anchorimage.css b/web/libraries/ckeditor5-anchor-drupal/theme/anchorimage.css
new file mode 100644
index 0000000000000000000000000000000000000000..10c28f29bb47b3e3e6f25bf354e64d6e15f0ec89
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/anchorimage.css
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-anchor-image_icon {
+	position: absolute;
+	top: var(--ck-spacing-medium);
+	right: var(--ck-spacing-medium);
+	width: 28px;
+	height: 28px;
+	padding: 4px;
+	box-sizing: border-box;
+	border-radius: var(--ck-border-radius);
+
+	& svg {
+		fill: currentColor;
+	}
+}
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/icons/anchor.svg b/web/libraries/ckeditor5-anchor-drupal/theme/icons/anchor.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3b3c232fd85743394665ba2a5feeedaa4b70be9a
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/icons/anchor.svg
@@ -0,0 +1,9 @@
+<svg height="512" viewBox="0 0 58 58" width="512"
+    xmlns="http://www.w3.org/2000/svg">
+    <g id="Page-1" fill="none" fill-rule="evenodd">
+        <g id="037---Waypoint-Flag" fill="rgb(0,0,0)" fill-rule="nonzero" transform="translate(0 -1)">
+            <path id="Shape" d="m14.678 58.9507 1.0678-.2984c1.0270794-.287091 1.6269982-1.3523947 1.34-2.3795l-12.2083-43.6888c-.17227193-.6165569-.58242107-1.139423-1.14021438-1.4535673-.5577933-.3141444-1.21753647-.3938324-1.83408562-.2215327l-.1379.0385c-1.28397381.3587434-2.0340279 1.6904218-1.6753 2.9744l12.2086 43.6888c.2870014 1.0271063 1.3522895 1.6270863 2.3794 1.3401z"/>
+            <path id="Shape" d="m57.67 28.42c-3.8715209-1.930437-7.4530885-4.3944478-10.64-7.32-.2678864-.245221-.3726619-.6216366-.27-.97 1.579074-5.9738125 2.7517572-12.04771023 3.51-18.18.12-1.02-.43-1.32-1.01-.62-11.38 13.61-31.07-2.49-42.79 9.88.14070884.2634479.25140182.5418575.33.83l7.92 28.36c11.74-12.22 31.36 3.78 42.72-9.8.58-.7.69-1.98.23-2.18z"/>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/web/libraries/ckeditor5-anchor-drupal/theme/icons/unanchor.svg b/web/libraries/ckeditor5-anchor-drupal/theme/icons/unanchor.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6a4ad36618158168d78cbe00c932a877facf5066
--- /dev/null
+++ b/web/libraries/ckeditor5-anchor-drupal/theme/icons/unanchor.svg
@@ -0,0 +1,28 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0"
+    xmlns="http://www.w3.org/2000/svg" width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000" preserveAspectRatio="xMidYMid meet">
+    <metadata>
+Created by potrace 1.16, written by Peter Selinger 2001-2019
+    </metadata>
+    <g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
+        <path d="M4255 5006 c-204 -208 -399 -316 -686 -380 -261 -58 -568 -60 -1104
+-7 -170 17 -404 36 -519 43 -593 32 -1000 -83 -1307 -370 l-72 -68 21 -64 c12
+-36 175 -616 363 -1290 188 -674 343 -1227 344 -1228 2 -2 44 31 94 75 201
+175 418 276 711 330 79 15 149 18 410 17 267 0 354 -4 575 -27 143 -15 318
+-32 390 -37 192 -16 529 -14 666 4 349 47 616 174 843 400 95 95 124 139 132
+198 11 83 7 88 -133 163 -250 133 -497 302 -731 501 -62 53 -115 106 -118 119
+-3 12 17 114 45 226 97 396 173 780 227 1157 19 134 34 268 32 296 -3 51 -3
+51 -38 53 -32 1 -44 -7 -145 -111z"/>
+        <path d="M152 4245 c-97 -30 -151 -104 -152 -204 0 -42 126 -506 541 -1990
+297 -1065 548 -1951 556 -1969 26 -54 77 -82 149 -82 159 0 274 80 274 191 0
+35 -1083 3917 -1105 3959 -21 41 -81 87 -131 99 -54 14 -78 13 -132 -4z"/>
+        <path d="M3845 1352 c-16 -11 -47 -38 -67 -60 -33 -36 -38 -49 -38 -89 l0 -47
+212 -213 212 -213 -212 -213 -212 -213 0 -47 c0 -42 5 -53 42 -93 51 -55 85
+-74 134 -74 36 0 50 12 251 212 l213 212 213 -212 c201 -200 215 -212 251
+-212 49 0 83 19 134 74 37 40 42 51 42 93 l0 47 -212 213 -212 213 212 213
+212 213 0 47 c0 42 -5 53 -42 93 -51 55 -85 74 -134 74 -36 0 -50 -12 -251
+-212 l-213 -212 -213 212 c-205 204 -214 212 -253 212 -21 0 -52 -8 -69 -18z"/>
+    </g>
+</svg>
\ No newline at end of file