diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt
index c907b1468ae1d46afd310774c44eeba308f15bc6..7c53b0d6dab38ae2b0f4b7619dd9fe488dd95ded 100644
--- a/.cspell-project-words.txt
+++ b/.cspell-project-words.txt
@@ -35,7 +35,14 @@ imperdiet
 Adipiscing
 elit
 Amet
+bazz
+bizz
+bozz
 consectetur
+webtest
+
+# ARIA tags.
+brailleroledescription
 
 # Words from ElasticSearch.
 basicauth
@@ -44,6 +51,7 @@ asciifolding
 ~datasource
 ~datasources
 aggs
+~fragmenter
 ~fuzzyness
 ~fuzziness
 testbuild
diff --git a/config/schema/elasticsearch_connector.processor.schema.yml b/config/schema/elasticsearch_connector.processor.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..417c3d1a8bf880b07ae2ce4191ce5a4d6364ff1f
--- /dev/null
+++ b/config/schema/elasticsearch_connector.processor.schema.yml
@@ -0,0 +1,98 @@
+plugin.plugin_configuration.search_api_processor.elasticsearch_connector_es_highlight:
+  type: search_api.default_processor_configuration
+  label: 'Elasticsearch Highlight processor configuration'
+  mapping:
+    boundary_scanner:
+      type: string
+      label: 'Boundary scanner'
+      constraints:
+        Choice:
+          - chars
+          - sentence
+          - word
+    boundary_scanner_locale:
+      type: langcode
+      label: 'Boundary scanner locale'
+    encoder:
+      type: string
+      label: 'Snippet encoder'
+      constraints:
+        Choice:
+          - default
+          - html
+    fields:
+      requiredKey: false
+      type: sequence
+      label: 'Fields to highlight'
+      orderby: value
+      sequence:
+        type: machine_name
+        label: 'Field name'
+      constraints:
+        NotBlank:
+          message: "Enable at least one field, otherwise disable the ElasticSearch Highlight processor."
+    fragment_size:
+      type: integer
+      label: 'Snippet size'
+      constraints:
+        NotBlank: { }
+        Range:
+          min: 0
+          # See PHP_INT_MAX.
+          max: 2147483647
+    fragmenter:
+      type: string
+      label: 'Fragmenter'
+      constraints:
+        Choice:
+          - simple
+          - span
+    no_match_size:
+      type: integer
+      label: 'Snippet size when there is no match'
+      constraints:
+        NotBlank: { }
+        Range:
+          min: 0
+          # See PHP_INT_MAX.
+          max: 2147483647
+    number_of_fragments:
+      type: integer
+      label: 'Maximum number of snippets per field'
+      constraints:
+        NotBlank: { }
+        Range:
+          min: 0
+          # See PHP_INT_MAX.
+          max: 2147483647
+    order:
+      type: string
+      label: 'Snippet order'
+      constraints:
+        Choice:
+          - none
+          - score
+    pre_tag:
+      type: string
+      label: 'Highlight opening tag'
+      constraints:
+        Regex:
+          # Forbid any kind of control character.
+          # @see https://stackoverflow.com/a/66587087
+          pattern: '/([^\PC])/u'
+          match: false
+          message: 'Highlight tags are not allowed to span multiple lines or contain control characters.'
+    require_field_match:
+      type: boolean
+      label: 'Only show snippets from fields that match the query'
+    snippet_joiner:
+      type: label
+      label: 'Snippet-joiner'
+    type:
+      type: string
+      label: 'Highlighter type'
+      constraints:
+        Choice:
+          - unified
+          - plain
+          - fvh
diff --git a/src/Plugin/search_api/processor/ElasticsearchHighlighter.php b/src/Plugin/search_api/processor/ElasticsearchHighlighter.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb6aeb7a8be66c437f502d30560388dcc6454819
--- /dev/null
+++ b/src/Plugin/search_api/processor/ElasticsearchHighlighter.php
@@ -0,0 +1,330 @@
+<?php
+
+namespace Drupal\elasticsearch_connector\Plugin\search_api\processor;
+
+use Drupal\Component\Utility\Xss;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api\LoggerTrait;
+use Drupal\search_api\Plugin\PluginFormTrait;
+use Drupal\search_api\Processor\ProcessorPluginBase;
+use Drupal\search_api\Query\QueryInterface;
+use Drupal\search_api\Query\ResultSetInterface;
+
+/**
+ * Adds a highlighted excerpt to results using ElasticSearch's highlighter.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html#highlighting-settings
+ * @see \search_excerpt()
+ *
+ * @SearchApiProcessor(
+ *   id = "elasticsearch_connector_es_highlight",
+ *   label = @Translation("Elasticsearch Highlighter"),
+ *   description = @Translation("Uses ElasticSearch's highlighter to generate an excerpt."),
+ *   stages = {
+ *     "preprocess_query" = 0,
+ *     "postprocess_query" = 0,
+ *   },
+ * )
+ */
+class ElasticsearchHighlighter extends ProcessorPluginBase implements PluginFormInterface {
+  use HighlightQueryBuilderTrait;
+  use LoggerTrait;
+  use PluginFormTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $config = $this->getConfiguration();
+
+    $form['fields'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Fields to highlight'),
+      '#description' => $this->t('The fields to search for highlights in, and to display highlights for.'),
+      '#default_value' => $config['fields'],
+      '#required' => TRUE,
+      '#multiple' => TRUE,
+      '#options' => $this->getFieldOptions($this->index),
+    ];
+
+    $form['help_terms'] = [
+      '#type' => 'markup',
+      '#markup' => $this->t("The ElasticSearch server's highlighter returns one or more snippets of text for each field in each matching search result (Drupal core calls these snippets, and ElasticSearch documentation calls them fragments). The ElasticSearch Connector module combines the snippets from all fields into a Search API excerpt, which can be displayed to the end-user."),
+    ];
+
+    $form['type'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Highlighter type'),
+      '#default_value' => $config['type'],
+      '#required' => TRUE,
+      '#options' => [
+        'unified' => $this->t('Unified'),
+        'plain' => $this->t('Plain'),
+      ],
+    ];
+    $form['type']['unified']['#description'] = $this->t('Better when highlighting fields that contain HTML, or a mixture of plain-text fields and HTML fields.');
+    $form['type']['plain']['#description'] = $this->t('Faster when highlighting fields that do not contain HTML.');
+
+    $form['boundary_scanner'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Boundary scanner'),
+      '#description' => $this->t('Only valid for the %unified_type highlighter type.', [
+        '%unified_type' => $this->t('Unified'),
+      ]),
+      '#default_value' => $config['boundary_scanner'],
+      '#options' => [
+        'sentence' => $this->t('Sentence'),
+        'word' => $this->t('Word'),
+      ],
+      '#states' => [
+        'visible' => [
+          ':input[name="processors[elasticsearch_connector_es_highlight][settings][type]"]' => [
+            ['value' => 'unified'],
+          ],
+        ],
+      ],
+    ];
+    $form['boundary_scanner']['sentence']['#description'] = $this->t('Snippets should break on sentence- or phrase-boundaries if possible.');
+    $form['boundary_scanner']['word']['#description'] = $this->t('Snippets should break on word-boundaries if possible.');
+
+    $form['boundary_scanner_locale'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Boundary scanner locale'),
+      '#description' => $this->t('In order for the boundary scanner to determine sentence boundaries, it needs to know which human language that the data is stored in. ElasticSearch does not currently support getting this from another field.'),
+      '#default_value' => $config['boundary_scanner_locale'],
+      '#options' => $this->getBoundaryScannerLocaleOptions(),
+      '#states' => [
+        'visible' => [
+          ':input[name="processors[elasticsearch_connector_es_highlight][settings][type]"]' => [
+            ['value' => 'unified'],
+          ],
+          ':input[name="processors[elasticsearch_connector_es_highlight][settings][boundary_scanner]"]' => [
+            ['value' => 'sentence'],
+          ],
+        ],
+      ],
+    ];
+
+    $form['fragmenter'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Fragmenter'),
+      '#default_value' => $config['fragmenter'],
+      '#options' => [
+        'simple' => $this->t('Simple'),
+        'span' => $this->t('Span'),
+      ],
+      '#description' => $this->t('Only valid for the %plain_type highlighter type.', [
+        '%plain_type' => $this->t('Plain'),
+      ]),
+      '#states' => [
+        'visible' => [
+          ':input[name="processors[elasticsearch_connector_es_highlight][settings][type]"]' => [
+            ['value' => 'plain'],
+          ],
+        ],
+      ],
+    ];
+    $form['fragmenter']['simple']['#description'] = $this->t('Puts each match into its own snippet, even if they are nearby/adjacent. If matching words are nearby in the text, the excerpt may repeat itself, or may contain unnecessary Snippet-joiners.');
+    $form['fragmenter']['span']['#description'] = $this->t('Puts nearby/adjacent matches into a single snippet with multiple highlights in that snippet.');
+
+    $form['pre_tag'] = [
+      '#type' => 'textfield',
+      '#default_value' => $config['pre_tag'],
+      '#title' => $this->t('Highlight opening tag'),
+      '#description' => $this->t("The HTML tag to surround the highlighted text with. ElasticSearch uses @emphasis_html_tag by default, but Drupal's core Search and Search API's default highlighter use @strong_html_tag by default. Some sites use @mark_html_tag. Adding one or more HTML classes can help with custom theming. Multiple tags are not supported.", [
+        '@emphasis_html_tag' => '<em>',
+        '@strong_html_tag' => '<strong>',
+        '@mark_html_tag' => '<mark>',
+      ]),
+      '#required' => TRUE,
+    ];
+
+    $form['encoder'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Snippet encoder'),
+      '#default_value' => $config['encoder'],
+      '#required' => TRUE,
+      '#options' => [
+        'default' => $this->t('No encoding'),
+        'html' => $this->t('HTML'),
+      ],
+      '#description' => $this->t("ElasticSearch doesn't natively provide a way to remove HTML tags, only escape them. The ElasticSearch Connector module's post-processor strips out any HTML tags that are not the highlighting tag to avoid invalid HTML in snippets, so %default_option is safe to use and usually produces the results you would expect.", [
+        '%default_option' => $this->t('No encoding'),
+      ]),
+    ];
+    $form['encoder']['default']['#description'] = $this->t('Simply insert the highlight tags without making any changes to the snippet.');
+    $form['encoder']['html']['#description'] = $this->t('Escape HTML in the snippet (i.e.: making the HTML tags in the field visible), before inserting the highlight tags.');
+
+    $form['number_of_fragments'] = [
+      '#type' => 'number',
+      '#default_value' => $config['number_of_fragments'],
+      '#title' => $this->t('Maximum number of snippets per field'),
+      '#description' => $this->t("The maximum number of fragments to return for each field. If set to 0, no fragments are returned. ElasticSearch's default is 5."),
+      '#min' => 0,
+    ];
+
+    $form['fragment_size'] = [
+      '#type' => 'number',
+      '#default_value' => $config['fragment_size'],
+      '#title' => $this->t('Snippet size'),
+      '#field_suffix' => $this->t('characters'),
+      '#description' => $this->t("The approximate number of characters that should be in a snippet. When the Boundary scanner is set to %boundary_scanner_sentence, this value is treated as a maximum, and you can set it to 0 to never split a sentence. Drupal core's default is 60 characters; ElasticSearch's default is 100 characters.", [
+        '%boundary_scanner_sentence' => $this->t('Sentence'),
+      ]),
+      '#min' => 0,
+    ];
+
+    $form['no_match_size'] = [
+      '#type' => 'number',
+      '#default_value' => $config['no_match_size'],
+      '#title' => $this->t('Snippet size when there is no match'),
+      '#description' => $this->t('If ElasticSearch finds a match in a field that is not selected for highlighting, it can return a snippet from the beginning of the field(s) that are selected for highlighting instead, so that there is an excerpt to display. It defaults to 0, meaning "do not return a snippet from a field that does not match". If you set it to a number greater than 0, this might produce unexpected results if you have also selected more than one field for highlighting!'),
+      '#field_suffix' => $this->t('characters'),
+      '#min' => 0,
+    ];
+
+    $form['order'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Snippet order'),
+      '#default_value' => $config['order'],
+      '#required' => TRUE,
+      '#options' => [
+        'none' => $this->t('Order snippets by the order they appear in the field'),
+        'score' => $this->t('Order snippets so the most relevant snippets are first'),
+      ],
+    ];
+
+    $form['require_field_match'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Only show snippets from fields that match the query'),
+      '#default_value' => $config['require_field_match'],
+      '#options' => [
+        0 => $this->t('Show snippets from all fields, even if they do not match the query'),
+        1 => $this->t('Only show snippets from fields that match the query'),
+      ],
+    ];
+
+    $form['snippet_joiner'] = [
+      '#type' => 'textfield',
+      '#default_value' => $config['snippet_joiner'],
+      '#title' => $this->t('Snippet-joiner'),
+      '#description' => $this->t("When joining snippets together into an excerpt, use this string between two snippets. Drupal's core Search and Search API's default highlighter use an ellipsis (…)."),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'boundary_scanner' => 'sentence',
+      'boundary_scanner_locale' => LanguageInterface::LANGCODE_SYSTEM,
+      'encoder' => 'default',
+      'fields' => [],
+      'fragment_size' => 60,
+      'fragmenter' => 'span',
+      'no_match_size' => 0,
+      'number_of_fragments' => 5,
+      'order' => 'none',
+      'pre_tag' => '<em class="placeholder">',
+      'require_field_match' => 1,
+      'snippet_joiner' => ' … ',
+      'type' => 'unified',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postprocessSearchResults(ResultSetInterface $results) {
+    $highlightingFields = \array_keys($this->getConfiguration()['fields']);
+
+    // Loop through each result item. If we have highlighting data about it,
+    // then proceed with generating an excerpt for that result item.
+    foreach ($results->getResultItems() as $resultItem) {
+      if ($resultItem->hasExtraData('highlight')) {
+        $highlightData = $resultItem->getExtraData('highlight');
+
+        // Collect all the snippets into an array for combining at the end.
+        $excerptArray = [];
+
+        // Loop through each field. Only use the snippets from that field if we
+        // were supposed to highlight from it.
+        foreach ($highlightData as $fieldName => $fieldHighlights) {
+          if (\in_array($fieldName, $highlightingFields)) {
+            foreach ($fieldHighlights as $highlight) {
+              $excerptArray[] = $this->createExcerpt($highlight);
+            }
+          }
+        }
+
+        // Combine the snippets into a single excerpt.
+        $resultItem->setExcerpt(implode($this->getConfiguration()['snippet_joiner'], $excerptArray));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preprocessSearchQuery(QueryInterface $query) {
+    parent::preprocessSearchQuery($query);
+    $query->setOption('highlight', $this->buildHighlightQueryFragment());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function requiresReindexing(array $old_settings = NULL, array $new_settings = NULL) {
+    return FALSE;
+  }
+
+  /**
+   * Create an excerpt from ElasticSearch output.
+   *
+   * Note that ElasticSearch generates the excerpt, and inserts an HTML tag
+   * highlighting the search term match(es) for us.
+   *
+   * @param string $input
+   *   The raw highlighted string from ElasticSearch.
+   *
+   * @return string
+   *   An escaped excerpt for Search API.
+   *
+   * @see search_excerpt()
+   * @see \Drupal\search_api\Plugin\search_api\processor\Highlight::createExcerpt()
+   */
+  protected function createExcerpt(string $input): string {
+    $preTagName = $this->convertHtmlTagToTagName($this->getConfiguration()['pre_tag']);
+    return Xss::filter($input, [$preTagName]);
+  }
+
+  /**
+   * Get an options list of configured Search API Index fields.
+   *
+   * @param \Drupal\search_api\IndexInterface $index
+   *   The index to get a list of configured fields from.
+   *
+   * @return array
+   *   An associative array of Search API Index field options, suitable for use
+   *   in a Form API select/radios #options array; where the keys are field
+   *   machine names, and the values are field labels.
+   */
+  protected function getFieldOptions(IndexInterface $index): array {
+    $answer = [];
+
+    $indexFields = $index->getFields(TRUE);
+
+    foreach ($indexFields as $fieldId => $field) {
+      $answer[$fieldId] = $field->getLabel();
+    }
+
+    return $answer;
+  }
+
+}
diff --git a/src/Plugin/search_api/processor/HighlightQueryBuilderTrait.php b/src/Plugin/search_api/processor/HighlightQueryBuilderTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..9af2b84d08b5c9d99987169df7d9a55394490d4b
--- /dev/null
+++ b/src/Plugin/search_api/processor/HighlightQueryBuilderTrait.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\elasticsearch_connector\Plugin\search_api\processor;
+
+use Drupal\Core\Language\LanguageInterface;
+
+/**
+ * Build highlight queries from the ElasticsearchHighlighter processor config.
+ */
+trait HighlightQueryBuilderTrait {
+
+  /**
+   * Format a field option for ElasticSearch highlighting JSON.
+   *
+   * @param string[] $fieldList
+   *   An array of field ID strings.
+   *
+   * @return array<string,object>
+   *   An array where the keys are field ID strings, and the values are empty
+   *   objects.
+   */
+  protected function buildHighlightQueryFieldOption(array $fieldList): array {
+    $answer = [];
+
+    foreach ($fieldList as $fieldMachineName) {
+      if (\is_int($fieldMachineName) || \is_string($fieldMachineName)) {
+        $answer[$fieldMachineName] = new \stdClass();
+      }
+    }
+
+    return $answer;
+  }
+
+  /**
+   * Build an ElasticSearch 'highlight' query fragment.
+   *
+   * Note, this function does not currently support the 'fvh' highlighter,
+   * because it requires 'term_vector' fields, and it is not currently possible
+   * to map a field to the 'term_vector' type.
+   *
+   * Because we don't support the 'fvh' highlighter, this means that we also
+   * don't support sending the 'chars' boundary_scanner, and therefore, we don't
+   * support the 'boundary_chars', 'boundary_max_scan', 'fragment_offset', or
+   * 'phrase_limit' options either.
+   *
+   * This function also does not support:
+   * - the 'tags_schema' option, as it limits our pre- and post-tag options;
+   * - the 'highlight_query' option, because there's no good universal default
+   *   value, so it would require a second search form field;
+   * - the 'max_analyzed_offset' option, because we don't anticipate it needing
+   *   to be changed, it interacts in a non-trivial way with a similar setting
+   *   in the index configuration (which we don't currently expose), and we can
+   *   only control it on a per-index basis anyway, so there's no point in
+   *   exposing it here;
+   *
+   * @return array
+   *   An array intended to be JSON-encoded and included in an ElasticSearch
+   *   query.
+   */
+  protected function buildHighlightQueryFragment(): array {
+    $output = [];
+    $config = $this->getConfiguration();
+
+    $output['type'] = $config['type'];
+    $output['encoder'] = $config['encoder'];
+    $output['fragment_size'] = $config['fragment_size'];
+    $output['no_match_size'] = $config['no_match_size'];
+    $output['number_of_fragments'] = $config['number_of_fragments'];
+    $output['order'] = $config['order'];
+    $output['require_field_match'] = (bool) $config['require_field_match'];
+
+    // The fields to search for highlights in. Note that we do NOT set the
+    // 'matched_fields' option, because 'matched_fields' only affects 'unified'
+    // and 'fvh' highlighters; we don't support 'fvh' at this time; and we would
+    // have to explain the complicated rules for the 'unified' highlighter. We
+    // also don't support wildcards in the field names.
+    $output['fields'] = $this->buildHighlightQueryFieldOption($config['fields']);
+
+    // Pre- and post-tags surround matches in the highlighted snippets.
+    // ElasticSearch 8.15 accepts an array of strings, but by observation, only
+    // uses the first string, so we only accept one value as well. This allows
+    // us to derive the post-tag from the pre-tag.
+    $preTag = $config['pre_tag'];
+    $postTag = $this->getClosingTagFromOpeningTag($preTag);
+    $output['pre_tags'] = [$preTag];
+    $output['post_tags'] = [$postTag];
+
+    // The boundary_scanner option is only valid for the 'unified' or 'fvh'
+    // types (but note that we don't support 'fvh' yet).
+    if ($config['type'] === 'unified') {
+      $output['boundary_scanner'] = $config['boundary_scanner'];
+
+      // The boundary_scanner_locale option is only valid for the 'sentence'
+      // boundary_scanner.
+      if ($config['boundary_scanner'] === 'sentence') {
+        $output['boundary_scanner_locale'] = $this->buildHighlightQueryBoundaryScannerLocale($config['boundary_scanner_locale']);
+      }
+    }
+
+    // The 'fragmenter' option is only valid for the 'plain' highlighter.
+    if ($config['type'] === 'plain') {
+      $output['fragmenter'] = $config['fragmenter'];
+    }
+
+    return $output;
+  }
+
+  /**
+   * Interpret the boundary scanner locale config in a way we can send in query.
+   *
+   * @param string $configuredLocale
+   *   The locale stored in configuration.
+   *
+   * @return string
+   *   The locale to send in the highlight query. Usually this is the value
+   *   stored in $configuredLocale, unless $configuredLocale is set to the
+   *   the value of \Drupal\Core\Language\LanguageInterface::LANGCODE_SYSTEM
+   *   (i.e.: the string 'system'): then it gets the current locale from
+   *   Drupal's language manager.
+   *
+   * @see self::getBoundaryScannerLocaleOptions()
+   */
+  protected function buildHighlightQueryBoundaryScannerLocale(string $configuredLocale): string {
+    if ($configuredLocale === LanguageInterface::LANGCODE_SYSTEM) {
+      return \Drupal::languageManager()->getCurrentLanguage()->getId();
+    }
+
+    return $configuredLocale;
+  }
+
+  /**
+   * Convert string with at least one HTML opening tag to the name of that tag.
+   *
+   * @param string $htmlString
+   *   The string to interpret, containing at least one HTML opening tag.
+   *
+   * @return string
+   *   The name of the first HTML tag found in $htmlString. For example, if
+   *   $htmlString is '<strong class="placeholder">' then this will return
+   *   'strong'.
+   */
+  protected function convertHtmlTagToTagName(string $htmlString): string {
+    // This regular expression is from
+    // \Drupal\filter\Plugin\Filter\FilterHtml::tips().
+    preg_match('/<([a-z0-9]+)[^a-z0-9]/i', $htmlString, $out);
+    return $out[1];
+  }
+
+  /**
+   * Get a list of locale options for the boundary scanner.
+   *
+   * @return array
+   *   An associative array of locale options, suitable for use in a Form API
+   *   select/radios #options array; where the keys are language codes and the
+   *   values are labels for that language. This function also adds an option
+   *   for \Drupal\Core\Language\LanguageInterface::LANGCODE_SYSTEM with the
+   *   title '- Interface language -'.
+   *
+   * @see self::buildHighlightQueryBoundaryScannerLocale()
+   */
+  protected function getBoundaryScannerLocaleOptions(): array {
+    $answer = [];
+
+    $answer[LanguageInterface::LANGCODE_SYSTEM] = $this->t('- Interface language -');
+
+    $language_list = \Drupal::languageManager()->getLanguages();
+    foreach ($language_list as $langcode => $language) {
+      // Make locked languages appear special in the list.
+      $answer[$langcode] = $language->isLocked() ? $this->t('- @name -', ['@name' => $language->getName()]) : $language->getName();
+    }
+
+    return $answer;
+  }
+
+  /**
+   * Construct an HTML closing tag string, given an HTML opening tag.
+   *
+   * @param string $htmlString
+   *   The string to interpret, containing at least one HTML opening tag.
+   *
+   * @return string
+   *   A closing tag for the first HTML tag found in $htmlString. For example,
+   *   if $htmlString is '<em class="placeholder">', then this will return
+   *   '</em>'.
+   */
+  protected function getClosingTagFromOpeningTag(string $htmlString): string {
+    $tagName = $this->convertHtmlTagToTagName($htmlString);
+    return \sprintf('</%s>', $tagName);
+  }
+
+}
diff --git a/src/SearchAPI/Query/QueryParamBuilder.php b/src/SearchAPI/Query/QueryParamBuilder.php
index 21727a04ca1cff055f7fc12350466ae5a3af0ade..4dcd2d7b0af5bc3ebbafc6b018ad8a3f568bd9f1 100644
--- a/src/SearchAPI/Query/QueryParamBuilder.php
+++ b/src/SearchAPI/Query/QueryParamBuilder.php
@@ -165,6 +165,11 @@ class QueryParamBuilder {
       }
     }
 
+    // Add highlighting configuration, if configured.
+    if (!empty($query->getOption('highlight'))) {
+      $body['highlight'] = $query->getOption('highlight');
+    }
+
     $params['body'] = $body;
     // Preserve the options for further manipulation if necessary.
     $query->setOption('ElasticSearchParams', $params);
diff --git a/src/SearchAPI/Query/QueryResultParser.php b/src/SearchAPI/Query/QueryResultParser.php
index 66b80e76584e23777d68e84cd2d7af0a9dba78bd..246c8fdbce01e4921a51488f5a48bc2f0d101afc 100644
--- a/src/SearchAPI/Query/QueryResultParser.php
+++ b/src/SearchAPI/Query/QueryResultParser.php
@@ -31,8 +31,6 @@ class QueryResultParser {
   /**
    * Parse a ElasticSearch response into a ResultSetInterface.
    *
-   * @todo Add excerpt handling.
-   *
    * @param \Drupal\search_api\Query\QueryInterface $query
    *   Search API query.
    * @param array $response
@@ -65,6 +63,13 @@ class QueryResultParser {
           $result_item->setField($id, $field);
         }
 
+        // If we see that ElasticSearch highlighted matching excerpts in the
+        // search results, then store those matches in the result set so that
+        // ElasticsearchHighlighter can process them.
+        if (isset($result['highlight'])) {
+          $result_item->setExtraData('highlight', $result['highlight']);
+        }
+
         $results->addResultItem($result_item);
       }
     }
diff --git a/tests/src/Functional/ElasticsearchHighlighterConfigFormTest.php b/tests/src/Functional/ElasticsearchHighlighterConfigFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6a8d751e7433fb21b788c9a56c8d4e8d7823bfba
--- /dev/null
+++ b/tests/src/Functional/ElasticsearchHighlighterConfigFormTest.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Drupal\Tests\elasticsearch_connector\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\search_api\Functional\SearchApiBrowserTestBase;
+use Drupal\search_api\Entity\Index;
+use Drupal\search_api\Entity\Server;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api\ServerInterface;
+
+/**
+ * Test the ElasticSearch highlighter processor plugin's configuration form.
+ *
+ * @coversDefaultClass \Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter
+ *
+ * @group elasticsearch_connector
+ */
+class ElasticsearchHighlighterConfigFormTest extends SearchApiBrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'search_api',
+    'search_api_test',
+    'elasticsearch_connector',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTestIndex(): IndexInterface {
+    $this->indexId = 'webtest_index';
+    $index = Index::load($this->indexId);
+    if (!$index) {
+      $index = Index::create([
+        'id' => $this->indexId,
+        'name' => 'WebTest index',
+        'description' => 'WebTest index description',
+        'server' => 'webtest_server',
+        'field_settings' => [
+          'body' => [
+            'label' => 'Body',
+            'datasource_id' => 'entity:node',
+            'property_path' => 'body',
+            'type' => 'text',
+            'dependencies' => [
+              'config' => ['field.storage.node.body'],
+            ],
+          ],
+        ],
+        'datasource_settings' => [
+          'entity:node' => [],
+        ],
+      ]);
+      $index->save();
+    }
+
+    return $index;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTestServer(): ServerInterface {
+    $server = Server::load('webtest_server');
+    if (!$server) {
+      $server = Server::create([
+        'id' => 'webtest_server',
+        'name' => 'WebTest server',
+        'description' => 'WebTest server description',
+        'backend' => 'elasticsearch',
+        'backend_config' => [
+          'connector' => 'standard',
+          'connector_config' => [
+            'url' => 'http://elasticsearch:9200',
+            'enable_debug_logging' => FALSE,
+          ],
+          'advanced' => [
+            'fuzziness' => 'auto',
+            'prefix' => '',
+            'suffix' => '',
+            'synonyms' => [],
+          ],
+        ],
+      ]);
+      $server->save();
+    }
+
+    return $server;
+  }
+
+  /**
+   * Test that we can use the highlighter processor configuration form.
+   *
+   * @covers ::buildConfigurationForm
+   */
+  public function testHighlighterProcessorForm(): void {
+    // Setup: Create an index and server.
+    $this->getTestServer();
+    $this->getTestIndex();
+
+    // Setup: Log in as a user account that can configure the index processors.
+    $this->drupalLogin($this->adminUser);
+
+    // Run system under test: Visit the processor page.
+    $this->drupalGet(Url::fromRoute('entity.search_api_index.processors', [
+      'search_api_index' => $this->indexId,
+    ]));
+
+    // Assertions: Test that the fields exist.
+    $this->assertSession()->fieldExists('status[elasticsearch_connector_es_highlight]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][fields][]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][type]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][fragmenter]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][boundary_scanner]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][boundary_scanner_locale]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][pre_tag]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][encoder]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][number_of_fragments]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][fragment_size]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][no_match_size]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][order]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][require_field_match]');
+    $this->assertSession()->fieldExists('processors[elasticsearch_connector_es_highlight][settings][snippet_joiner]');
+
+    // Run system under test: Change as many fields from the default as we can.
+    $this->submitForm([
+      'status[elasticsearch_connector_es_highlight]' => TRUE,
+      'processors[elasticsearch_connector_es_highlight][settings][fields][]' => ['body'],
+      'processors[elasticsearch_connector_es_highlight][settings][type]' => 'plain',
+      'processors[elasticsearch_connector_es_highlight][settings][fragmenter]' => 'span',
+      'processors[elasticsearch_connector_es_highlight][settings][boundary_scanner]' => 'word',
+      'processors[elasticsearch_connector_es_highlight][settings][boundary_scanner_locale]' => 'en',
+      'processors[elasticsearch_connector_es_highlight][settings][pre_tag]' => '<mark>',
+      'processors[elasticsearch_connector_es_highlight][settings][encoder]' => 'html',
+      'processors[elasticsearch_connector_es_highlight][settings][number_of_fragments]' => '1',
+      'processors[elasticsearch_connector_es_highlight][settings][fragment_size]' => '1',
+      'processors[elasticsearch_connector_es_highlight][settings][no_match_size]' => '1',
+      'processors[elasticsearch_connector_es_highlight][settings][order]' => 'score',
+      'processors[elasticsearch_connector_es_highlight][settings][require_field_match]' => 0,
+      'processors[elasticsearch_connector_es_highlight][settings][snippet_joiner]' => 'also',
+    ], 'Save');
+
+    // Assertions: Ensure the page can be saved successfully.
+    $this->assertSession()->statusMessageContains('The indexing workflow was successfully edited.');
+  }
+
+}
diff --git a/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterRequestBuilderTest.php b/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterRequestBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..add079ea32e39d47fb13cc64f23ad62f547bceb8
--- /dev/null
+++ b/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterRequestBuilderTest.php
@@ -0,0 +1,349 @@
+<?php
+
+namespace Drupal\Tests\elasticsearch_connector\Kernel\Plugin\processor;
+
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api\Query\Query;
+
+/**
+ * Tests how the Elasticsearch Highlight processor builds highlight queries.
+ *
+ * @coversDefaultClass \Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter
+ *
+ * @group elasticsearch_connector
+ */
+class ElasticsearchHighlighterRequestBuilderTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'language',
+    'locale',
+    'search_api',
+    'elasticsearch_connector',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installSchema('locale', ['locales_source', 'locales_target', 'locales_location']);
+
+    // Install the Lolspeak language, and make it the default language. The
+    // author chose this particular language because it is a valid language from
+    // \Drupal\Core\Language\LanguageManager::getStandardLanguageList(); it has
+    // an easy-to-notice, extra-long langcode, and it is very unlikely that a
+    // real site would have it installed because it is a joke language.
+    $defaultLanguage = ConfigurableLanguage::createFromLangcode('xx-lolspeak');
+    $defaultLanguage->save();
+    $this->container->get('language.default')->set($defaultLanguage);
+
+    // Install the French language.
+    ConfigurableLanguage::createFromLangcode('fr')->save();
+
+    locale_system_set_config_langcodes();
+  }
+
+  /**
+   * Data provider for testPreprocessSearchQuery().
+   *
+   * @return array
+   *   An associative array where the keys are data set names, and the values
+   *   are arrays of arguments to pass to testPreprocessSearchQuery().
+   */
+  public static function preprocessSearchResultsDataProvider(): array {
+    $testCases = [];
+
+    $testCases['Default configuration values'] = [
+      [],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Plain highlighter, unspecified fragmenter'] = [
+      ['type' => 'plain'],
+      [
+        'type' => 'plain',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'fragmenter' => 'span',
+      ],
+    ];
+
+    $testCases['Plain highlighter, simple fragmenter'] = [
+      [
+        'type' => 'plain',
+        'fragmenter' => 'simple',
+      ],
+      [
+        'type' => 'plain',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'fragmenter' => 'simple',
+      ],
+    ];
+
+    $testCases['Plain highlighter, span fragmenter'] = [
+      [
+        'type' => 'plain',
+        'fragmenter' => 'span',
+      ],
+      [
+        'type' => 'plain',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'fragmenter' => 'span',
+      ],
+    ];
+
+    $testCases['Unified highlighter, word boundary scanner'] = [
+      [
+        'type' => 'unified',
+        'boundary_scanner' => 'word',
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'word',
+      ],
+    ];
+
+    $testCases['Unified highlighter, sentence boundary scanner, unspecified locale'] = [
+      [
+        'type' => 'unified',
+        'boundary_scanner' => 'sentence',
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Unified highlighter, sentence boundary scanner, LANGCODE_SYSTEM locale'] = [
+      [
+        'type' => 'unified',
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => LanguageInterface::LANGCODE_SYSTEM,
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Unified highlighter, sentence boundary scanner, French-language locale'] = [
+      [
+        'type' => 'unified',
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'fr',
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'fr',
+      ],
+    ];
+
+    $testCases['Fields test'] = [
+      [
+        'fields' => [
+          'body',
+          'title',
+          'field_testing',
+        ],
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [
+          'body' => new \stdClass(),
+          'title' => new \stdClass(),
+          'field_testing' => new \stdClass(),
+        ],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Simple custom pre-tag'] = [
+      [
+        'pre_tag' => '<mark>',
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<mark>'],
+        'post_tags' => ['</mark>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Complex custom pre-tag'] = [
+      [
+        'pre_tag' => '<span class="foo bar" role="mark" aria-brailleroledescription="Your search term">',
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'default',
+        'fragment_size' => 60,
+        'no_match_size' => 0,
+        'number_of_fragments' => 5,
+        'order' => 'none',
+        'require_field_match' => TRUE,
+        'fields' => [],
+        'pre_tags' => ['<span class="foo bar" role="mark" aria-brailleroledescription="Your search term">'],
+        'post_tags' => ['</span>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    $testCases['Other fields at non-default values'] = [
+      [
+        'encoder' => 'html',
+        'fragment_size' => 432,
+        'no_match_size' => 234,
+        'number_of_fragments' => 11,
+        'order' => 'none',
+        'require_field_match' => FALSE,
+
+      ],
+      [
+        'type' => 'unified',
+        'encoder' => 'html',
+        'fragment_size' => 432,
+        'no_match_size' => 234,
+        'number_of_fragments' => 11,
+        'order' => 'none',
+        'require_field_match' => FALSE,
+        'fields' => [],
+        'pre_tags' => ['<em class="placeholder">'],
+        'post_tags' => ['</em>'],
+        'boundary_scanner' => 'sentence',
+        'boundary_scanner_locale' => 'xx-lolspeak',
+      ],
+    ];
+
+    return $testCases;
+  }
+
+  /**
+   * Test that we can build a highlight query fragment from processor config.
+   *
+   * @covers ::preprocessSearchQuery
+   *
+   * @dataProvider preprocessSearchResultsDataProvider
+   */
+  public function testPreprocessSearchQuery(array $processorConfig, array $expectedHighlightFragment): void {
+    // Setup: Instantiate an ElasticsearchHighlighter plugin with the
+    // configuration we are being passed for this test case.
+    $processor = new ElasticsearchHighlighter($processorConfig, 'elasticsearch_connector_es_highlight', []);
+
+    // Setup: Create a mock index.
+    $index = $this->prophesize(IndexInterface::class);
+    $index->status()->willReturn(TRUE);
+
+    // Setup: Create a query.
+    $query = Query::create($index->reveal(), []);
+
+    // System Under Test: Preprocess the query.
+    $processor->preprocessSearchQuery($query);
+
+    // Assertions: Ensure that the highlight query fragment matches what we
+    // expect.
+    $actualHighlightFragment = $query->getOption('highlight');
+    $this->assertEquals($expectedHighlightFragment, $actualHighlightFragment);
+  }
+
+}
diff --git a/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterResponseParserTest.php b/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterResponseParserTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a6d707c067326ae5527ec2781ead76d22fdb5a62
--- /dev/null
+++ b/tests/src/Kernel/Plugin/processor/ElasticsearchHighlighterResponseParserTest.php
@@ -0,0 +1,301 @@
+<?php
+
+namespace Drupal\Tests\elasticsearch_connector\Kernel\Plugin\processor;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\TestTools\Random;
+use Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api\Item\ItemInterface;
+use Drupal\search_api\Query\Query;
+use Prophecy\PhpUnit\ProphecyTrait;
+
+/**
+ * Tests how the Elasticsearch Highlight processor handles search responses.
+ *
+ * @coversDefaultClass \Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter
+ *
+ * @group elasticsearch_connector
+ */
+class ElasticsearchHighlighterResponseParserTest extends KernelTestBase {
+  use ProphecyTrait;
+
+  /**
+   * The name of the fictional index that we will use during this test.
+   *
+   * @var string
+   */
+  public const INDEX_NAME = 'content_index';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'search_api',
+    'elasticsearch_connector',
+  ];
+
+  /**
+   * Data provider for testPostprocessSearchResults().
+   *
+   * @return array
+   *   An associative array where the keys are data set names, and the values
+   *   are arrays of arguments to pass to testPostprocessSearchResults().
+   */
+  public static function postprocessSearchResultsDataProvider(): array {
+    $testCases = [];
+
+    // Test the case where there is no highlight data in the result, resulting
+    // in empty excerpts.
+    $testCases['No Highlight Data'] = [
+      [
+        self::responseHitJson('en', []),
+        self::responseHitJson('en', []),
+      ],
+      [
+        '',
+        '',
+      ],
+    ];
+
+    // Test the case where there is field and one snippet in it. Add some HTML:
+    // we expect only the emphasis tag (em) to successfully pass through the
+    // Xss::filter() command, because it's the tag configured as the highlight.
+    $testCases['One field one snippet'] = [
+      [
+        self::responseHitJson('en', [
+          'body' => ['lorem <em>ipsum</em> dolor'],
+        ]),
+        self::responseHitJson('en', [
+          'body' => ['<strong>dolor</strong> ipsum sit'],
+        ]),
+      ],
+      [
+        'lorem <em>ipsum</em> dolor',
+        'dolor ipsum sit',
+      ],
+    ];
+
+    // Test the case where there is one field and two snippets in it. Add some
+    // HTML: we expect only the emphasis tag (em) to successfully pass through
+    // the Xss::filter() command, because it's the tag configured as the
+    // highlight.
+    $testCases['Highlight one field with two snippets in it'] = [
+      [
+        self::responseHitJson('en', [
+          'body' => [
+            'fizz <em>bizz</em> buzz',
+            'bazz bizz <mark>bozz</mark>',
+          ],
+        ]),
+        self::responseHitJson('en', [
+          'body' => [
+            '<strong>aliquam</strong> bizz lacinia',
+            'nulla <em>bizz</em> mi',
+          ],
+        ]),
+      ],
+      [
+        'fizz <em>bizz</em> buzz … bazz bizz bozz',
+        'aliquam bizz lacinia … nulla <em>bizz</em> mi',
+      ],
+    ];
+
+    // Test the case where there is two fields and one snippet in each. Let us
+    // try some nested HTML, to make sure that Xss::filter() continues to only
+    // let through the highlighting tag.
+    $testCases['Highlight two fields with one snippet in each'] = [
+      [
+        self::responseHitJson('en', [
+          'body' => ['gravida <span>vel</spam> <em>nisl</em> <del>aliquam</del> sed'],
+          'title' => ['imperdiet <code>aliquam</code> <div>rhoncus</div> nisi'],
+        ]),
+        self::responseHitJson('en', [
+          'body' => ['<ul><li>aliquam</li> <li><em>bizz</em></li> <li>lacinia</li></ul>'],
+          'title' => ['<div><ol><li><em>praesent</em></li> <li><strong>aliquam</strong></li> <li>ipsum</li></ol></div>'],
+        ]),
+      ],
+      [
+        'gravida vel <em>nisl</em> aliquam sed … imperdiet aliquam rhoncus nisi',
+        'aliquam <em>bizz</em> lacinia … <em>praesent</em> aliquam ipsum',
+      ],
+    ];
+
+    // Test the case where there is are two fields, each with two snippets. Try
+    // with some invalid HTML to make sure we are properly filtering.
+    $testCases['Highlight two fields with two snippets in each'] = [
+      [
+        self::responseHitJson('en', [
+          'body' => [
+            '</ol><h1>quis sagittis <em>lorem</em> tincidunt',
+            'interdum lorem malesuada fames',
+          ],
+          'title' => [
+            'ante lorem primis faucibus',
+            'quisque sit lorem enim',
+          ],
+        ]),
+        self::responseHitJson('en', [
+          'body' => [
+            'aenean lorem arcu bibendum',
+            'vestibulum venenatis lorem hendrerit',
+          ],
+          'title' => [
+            'cras faucibus lorem sed',
+            'quisque lorem libero',
+          ],
+        ]),
+      ],
+      [
+        'quis sagittis <em>lorem</em> tincidunt … interdum lorem malesuada fames … ante lorem primis faucibus … quisque sit lorem enim',
+        'aenean lorem arcu bibendum … vestibulum venenatis lorem hendrerit … cras faucibus lorem sed … quisque lorem libero',
+      ],
+    ];
+
+    return $testCases;
+  }
+
+  /**
+   * Return an array that looks like a decoded JSON response for a search hit.
+   *
+   * Each time this function is called, we get a new Search API ID in the return
+   * value, so that we don't get search result item ID collisions.
+   *
+   * This function randomly generates a title and body field, because
+   * \Drupal\elasticsearch_connector\Plugin\search_api\processor\ElasticsearchHighlighter::postprocessSearchResults()
+   * only cares about the 'highlights' part of the response.
+   *
+   * For simplicity, this function:
+   * - Assigns a score of '5.15' to each result.
+   * - Sets every Search Api DataSource to 'entity:node' (which is also used in
+   *   the Search API ID).
+   * - Sets the index name to the value of the constant self::INDEX_NAME.
+   *
+   * @param string $langcode
+   *   The language code that we should expect in the response.
+   * @param array $highlights
+   *   An array of highlight data for the given search hit.
+   *
+   * @return array
+   *   An array that looks like a decoded JSON response for a search query hit.
+   */
+  protected static function responseHitJson(string $langcode, array $highlights = []): array {
+    $nodeId = self::yieldNewNodeId();
+    $searchApiDataSource = 'entity:node';
+    $searchApiId = "{$searchApiDataSource}/{$nodeId}:{$langcode}";
+    $title = Random::string(5);
+    $body = Random::string(10);
+
+    return [
+      '_index' => self::INDEX_NAME,
+      '_id' => $searchApiId,
+      '_score' => 5.15,
+      '_ignored' => [],
+      '_source' => [
+        'body' => [$body],
+        'title' => [$title],
+        'search_api_id' => [$searchApiId],
+        'search_api_datasource' => [$searchApiDataSource],
+        'search_api_language' => [$langcode],
+      ],
+      'highlight' => $highlights,
+    ];
+  }
+
+  /**
+   * Return an array that looks like a decoded JSON response from a search.
+   *
+   * For simplicity, this function assumes:
+   * - the search took 5ms;
+   * - the search did not time out;
+   * - the search was performed successfully on 1 of 1 shards, and there were no
+   *   skipped or failed shards;
+   * - there were exactly \count($hits) search result hits; and;
+   * - the maximum score of all the hits was '5.15'.
+   *
+   * @param array $hits
+   *   An array of arrays that look like decoded JSON responses for search hits.
+   *
+   * @return array
+   *   An array that looks like a decoded JSON response for a search query.
+   */
+  protected static function responseStructure(array $hits = []): array {
+    return [
+      'took' => 5,
+      'timed_out' => FALSE,
+      '_shards' => [
+        'total' => 1,
+        'successful' => 1,
+        'skipped' => 0,
+        'failed' => 0,
+      ],
+      'hits' => [
+        'total' => [
+          'value' => \count($hits),
+          'relation' => 'eq',
+        ],
+        'max_score' => 5.15,
+        'hits' => $hits,
+      ],
+    ];
+  }
+
+  /**
+   * Get a new node ID every time this function is called.
+   *
+   * Note this uses a static variable.
+   *
+   * @return int
+   *   A monotonically increasing integer beginning with 1. Presumably, if you
+   *   call this function more than PHP_INT_MAX times in one test, you will get
+   *   undefined behavior.
+   */
+  protected static function yieldNewNodeId(): int {
+    static $nid = 0;
+    return ++$nid;
+  }
+
+  /**
+   * Test that we can interpret the Highlight data returned by ElasticSearch.
+   *
+   * @covers ::postprocessSearchResults
+   *
+   * @dataProvider postprocessSearchResultsDataProvider
+   */
+  public function testPostprocessSearchResults(array $responseHitStructures, array $expectedExcerpts): void {
+    // Setup: Create a mock index.
+    $index = $this->prophesize(IndexInterface::class);
+    $index->status()->willReturn(TRUE);
+    $index->id()->willReturn(self::INDEX_NAME);
+
+    // Setup: Create a query.
+    $query = Query::create($index->reveal(), []);
+
+    // Setup: Get a ResultSet by parsing a response containing the given hits.
+    /** @var \Drupal\elasticsearch_connector\SearchAPI\Query\QueryResultParser $resultParser */
+    $resultParser = $this->container->get('elasticsearch_connector.query_result_parser');
+    $resultSet = $resultParser->parseResult($query, self::responseStructure($responseHitStructures));
+
+    // Setup: Instantiate an ElasticsearchHighlighter plugin.
+    $processor = new ElasticsearchHighlighter([
+      'fields' => ['title' => 'title', 'body' => 'body'],
+      'pre_tag' => '<em>',
+      'snippet_joiner' => ' … ',
+    ], 'elasticsearch_connector_es_highlight', []);
+
+    // System Under Test: Postprocess the search results.
+    $processor->postprocessSearchResults($resultSet);
+
+    // Assertions: loop through each result and make sure that the excerpt
+    // corresponds with the expected result.
+    $iterator = new \MultipleIterator(\MultipleIterator::MIT_NEED_ANY | \MultipleIterator::MIT_KEYS_ASSOC);
+    $iterator->attachIterator(new \ArrayIterator($resultSet->getResultItems()), 'searchResult');
+    $iterator->attachIterator(new \ArrayIterator($expectedExcerpts), 'expectedExcerpt');
+    foreach ($iterator as $cursor) {
+      if ($cursor['searchResult'] instanceof ItemInterface) {
+        $this->assertEquals($cursor['expectedExcerpt'], $cursor['searchResult']->getExcerpt());
+      }
+    }
+  }
+
+}
diff --git a/tests/src/Unit/SearchAPI/Query/QueryParamBuilderTest.php b/tests/src/Unit/SearchAPI/Query/QueryParamBuilderTest.php
index fb421ca8ff5d163bf803202c494c5661f01d16f1..a056929465dcb13cc95fa24921bd5ecfa87b23fa 100644
--- a/tests/src/Unit/SearchAPI/Query/QueryParamBuilderTest.php
+++ b/tests/src/Unit/SearchAPI/Query/QueryParamBuilderTest.php
@@ -84,6 +84,8 @@ class QueryParamBuilderTest extends UnitTestCase {
       ->willReturn(NULL);
     $query->getOption('search_api_spellcheck')
       ->willReturn(NULL);
+    $query->getOption('highlight')
+      ->willReturn(NULL);
     $query->getLanguages()
       ->willReturn(NULL);
     $query->getIndex()