Developer Journal

Advanced 12 min read

Debugging Drupal Syntax Highlighting Across CKEditor 5, Text Filters, and Highlight.js

A production debugging case study tracing code markup from CKEditor storage through Drupal text filters and asset libraries to Highlight.js in the browser.

Last updated August 8, 2026

Syntax highlighting looks like a frontend feature, but in Drupal the final colored code block can depend on content stored in the database, CKEditor configuration, text filters, render attachments, Drupal libraries, JavaScript modules, CDN assets, CSS, and the browser DOM. When one of those layers disagrees with another, the visible symptom can point at completely the wrong part of the system.

The symptom looked like a JavaScript failure

I hit this while updating the journal on this site. Code blocks highlighted correctly in the local environment, but a production article displayed plain code. Browser DevTools had also shown a failed request involving the Highlight.js copy plugin, which made the asset pipeline an obvious first suspect.

That was plausible, but it was not enough evidence to change code. The production site had recently moved syntax highlighting out of custom controller and theme attachments and into the Drupal text-filter pipeline. The Highlight.js contrib module had also been updated. Several independent changes were close enough together that assuming which one caused the failure would have been risky.

The useful approach was to treat syntax highlighting as a pipeline and prove each stage independently.

Map the complete pipeline

For this site, a highlighted block passes through these layers:

stored node body
  -> CKEditor 5 code block markup
  -> Drupal text format
  -> filter_highlightjs
  -> FilterProcessResult attachments
  -> Drupal library discovery
  -> rendered HTML and drupalSettings
  -> Highlight.js ES modules
  -> language registration
  -> hljs.highlightAll()
  -> highlighted DOM
  -> copy-button plugin and theme CSS

The important debugging rule is to move through that sequence in order. Do not start changing JavaScript because the final DOM is wrong. First establish whether the server gave the browser the right instructions.

Prove the server-side state first

The first checks established the exact deployed commit, Drupal bootstrap state, installed contrib-module version, active text-filter configuration, and Highlight.js settings:

git log -1 --oneline

composer show drupal/highlightjs_input_filter

drush status --fields=drupal-version,bootstrap,db-status

drush config:get filter.format.full_html \
  filters.filter_highlightjs

drush config:get highlightjs_input_filter.settings

Those checks answered several important questions before the browser was involved. Production was running the expected commit. Drupal 11.4.5 bootstrapped successfully. The Highlight.js Input Filter module was version 1.2.0. The filter_highlightjs filter was enabled for Full HTML, and the module was configured to use the external Highlight.js CDN.

Next I inspected Drupal's parsed library definition instead of assuming the YAML file was what Drupal believed:

print_r(
  \Drupal::service('library.discovery')
    ->getLibraryByName(
      'highlightjs_input_filter',
      'highlightjs-copy'
    )
);

Drupal reported the copy plugin as an external library pointing at the expected unpkg URL. Rebuilding caches produced the same result. This ruled out a stale library definition and an incorrect installed contrib file.

Inspect what Drupal actually sends

The next boundary was the final HTML response. A server can have correct configuration and still fail to attach a library to a particular render tree, so I inspected the public journal response directly:

curl -sS \
  https://www.example.com/journal/example-article \
  | grep -iE \
  'highlightjs|highlightJsLanguages|highlightJsBaseUrl|enableCopyButton'

The rendered response contained all of the expected pieces: the Highlight.js theme CSS, the module JavaScript, the external copy-plugin script, enableCopyButton: true, the Highlight.js base URL, and the language map for the article.

At that point the server-side pipeline was not merely probably correct. It had been observed all the way to the bytes sent to the browser.

Inspect the browser state, not just the Network tab

The Network panel confirmed that the current external assets returned HTTP 200. The remaining question was whether the JavaScript actually transformed the code element.

A small console inspection made that visible:

({
  readyState: document.readyState,

  settings: {
    baseUrl: drupalSettings.highlightJsBaseUrl,
    languages: drupalSettings.highlightJsLanguages,
    copy: drupalSettings.enableCopyButton
  },

  blocks: [...document.querySelectorAll('pre > code')].map((el) => ({
    className: el.className,
    highlighted: el.classList.contains('hljs'),
    hasSpans: el.querySelectorAll('span[class^="hljs-"]').length,
    parentClass: el.parentElement.className
  })),

  copyPlugin: typeof CopyButtonPlugin,
  behavior: typeof Drupal?.behaviors?.highlightInit?.attach
})

The result was the turning point. The Highlight.js settings existed, the copy plugin existed, the Drupal behavior existed, but the code element had not been highlighted.

Its class attribute was:

<code class="language-plaintext language-bash">

That single detail explained the failure better than every earlier cache or asset theory.

CKEditor was preserving an unknown language next to plaintext

CKEditor 5's Code Block feature has a configurable list of languages. The first configured language is its default, which is normally Plain text. CKEditor also uses the first matching configured class when determining the language of loaded code-block data.

The editor on this site originally knew PHP, JavaScript, CSS, Python, and several other defaults, but it did not know Bash, YAML, JSON, Twig, or Apache. When content containing language-bash was edited, CKEditor could preserve that class while also assigning its default plaintext language.

The stored production content therefore became:

<pre><code class="language-plaintext language-bash">
composer audit
</code></pre>

The Drupal Highlight.js filter and Highlight.js itself were then looking at slightly different interpretations of the same markup. The filter detected Bash and loaded the Bash language module, while the browser saw plaintext first on the code element. Highlight.js did not apply Bash highlighting.

A temporary browser test proved the diagnosis immediately:

const code = document.querySelector('pre > code');

code.classList.remove('language-plaintext');

Drupal.behaviors.highlightInit.attach(document);

The block highlighted as soon as the conflicting plaintext class was removed.

Fix the editor before repairing content

Cleaning existing nodes without fixing CKEditor would only allow the problem to return the next time an article was edited. The first permanent fix was therefore to add every code language actually used by the journal to the CKEditor Code Block configuration.

The relevant configuration now includes:

ckeditor5_codeBlock:
  languages:
    - label: Plain text
      language: plaintext
    - label: Apache
      language: apache
    - label: Bash
      language: bash
    - label: JSON
      language: json
    - label: Twig
      language: twig
    - label: YAML
      language: yaml

I then performed a round-trip test locally: open a Bash block in CKEditor, save the node, reload the raw stored body, and confirm it still contained only language-bash. That test mattered more than simply seeing the new choices in the editor toolbar.

Repair existing content as a separate operation

The configuration fix prevents new corruption; it does not modify content already stored in the production database. A production scan identified the exact affected nodes and language combinations.

The repair was intentionally narrow. A genuine plaintext block stayed plaintext. Only combinations in which plaintext appeared next to a real language were changed:

language-plaintext language-bash   -> language-bash
language-plaintext language-json   -> language-json
language-plaintext language-apache -> language-apache

The nodes were saved through Drupal's Entity API with new revisions instead of being edited through SQL. Afterward, another complete journal scan verified that no code element contained multiple language-* classes.

The scrollbar was a second, unrelated bug

Once highlighting worked, another visual defect became obvious. A horizontal scrollbar appeared underneath highlighted code while the copy button was hidden. Hovering the code block caused the copy button to appear and the scrollbar to disappear.

Browser geometry made the cause measurable. The <pre> element had a client width of 759 pixels and a scroll width of 822 pixels, a difference of about 63 pixels. The hidden copy-button container was translated horizontally by about 63 pixels.

The theme contained:

.journal-prose pre {
  overflow: auto;
}

The copy plugin expected its wrapper to clip the translated button. The more specific theme selector won the cascade and turned the off-screen button into horizontal overflow.

The final CSS fix was deliberately scoped to enhanced code blocks:

.journal-prose pre.hljs-copy-wrapper {
  overflow: hidden;
}

Normal <pre> elements remain scrollable, while the Highlight.js copy wrapper can hide its temporarily translated control.

What made the debugging effective

The most important lesson was not the final class-name fix. It was the debugging sequence. Several explanations were plausible during the investigation: a bad contrib-module update, stale production files, incorrect Drupal library discovery, cache state, a browser-cached module, missing render attachments, a copy-plugin loading race, and custom theme code.

Each of those became less important once the corresponding layer was inspected directly. Package metadata proved the installed version. Library discovery proved Drupal's parsed asset definition. Curl proved the final HTML. DevTools proved network delivery. A DOM inspection proved that highlighting never occurred. The class attribute finally explained why.

That approach is slower than guessing for the first few minutes and much faster over the whole incident.

Key Takeaways

  • Debug syntax highlighting as a pipeline from stored content to the final DOM.
  • Inspect Drupal library discovery and rendered HTML before changing frontend code.
  • Keep CKEditor Code Block languages synchronized with the languages the frontend highlighter supports.
  • Fix editor configuration before repairing already-corrupted content.
  • Treat a new visual symptom after the main fix as potentially independent instead of forcing it into the original theory.
  • Use browser-computed styles and element geometry to prove CSS overflow problems.

Further Reading