Перейти к основному содержанию

Подсветка синтаксиса кода в CKEditor 5 на Drupal 11. Highlight.js, prism.js.

Среди библиотек, предназначенных для подсветки синтаксиса языка, существует два популярных решения: highlight.js и prism.js.
 ffdfd

Плюсы Highlight.js перед Prism.js

  1. Автоматическое определение языка
    — Не нужно указывать язык явно: библиотека сама попытается распознать его.
    — Удобно для пользовательского контента (например, комментариев или полей ввода, где автор может не знать, как указать язык).
  2. Больше встроенных языков «из коробки»
    — Поддерживает ~190 языков, включая довольно экзотические (например, MUMPS, NSIS, Mercury).
    — Prism.js по умолчанию включает ~150, но многие требуют отдельного подключения.
  3. Проще в базовой интеграции
    — Достаточно подключить один JS и один CSS — и всё работает.
    — Нет необходимости думать о плагинах, грамматиках или структуре классов.
  4. Широкая совместимость со старыми системами
    — Многие CMS (включая старые версии Drupal, WordPress) изначально интегрированы с Highlight.js.
    — Модуль Highlight.js для Drupal существует давно и стабилен.
  5. Меньше HTML-разметки в выводе
    — Использует более простые <span> без избыточных классов — иногда это лучше для SEO или копирования «чистого» текста.

Плюсы Prism.js перед Highlight.js

  1. Лексический (грамматический) разбор, а не регулярные выражения
    — Prism.js использует иерархическую токенизацию, что позволяет точно различать:
    1. .class и #id в CSS,
    2. строки, шаблоны, регулярки в JavaScript,
    3. атрибуты и теги в HTML/XML.
      — Highlight.js в основном полагается на регулярные выражения, что иногда приводит к «проскокам» (например, подсветка строки как кода внутри комментария).
  2. Модульность и расширяемость
    — Номера строк, подсветка конкретных строк, копирование, отображение имени языка — всё это отдельные плагины, которые легко включать/отключать.
    — У Highlight.js такие функции либо отсутствуют, либо требуют сторонних решений или костылей.
  3. Полный контроль над HTML-выводом
    — Каждый токен имеет семантический класс: token keyword, token function, token punctuation.
    — Это позволяет делать точные стилизации, например: 

.token.punctuation { opacity: 0.7; }
.token.attr-name { color: #9b59b6; }

  1. Лучшая поддержка вложенных языков
    — Например, в блоке language-jsx Prism корректно выделяет HTML-теги внутри JavaScript.
    — Highlight.js часто «ломается» в таких сценариях.
  2. Безопасность и предсказуемость
    — Поскольку язык указывается явно, нет риска неправильной автоидентификации (например, Bash воспринимается как Perl).
    — Это критично для документации или технических сайтов, где точность важна.
  3. Активная экосистема плагинов и тем
    — Официальные плагины:
    1. line-numbers
    2. line-highlight
    3. copy-to-clipboard
    4. show-language
    5. autoloader

 

Готовые модули в Drupal 11

  • Prism.js — через модуль prism предоставляет:
    • выбор поддерживаемых языков,
    • 7 встроенных цветовых тем
  • Highlight.js — через модуль highlight_js предоставляет:
    • выбор поддерживаемых языков,
    • огромную коллекцию из 244 тем оформления,
    • встроенную кнопку копирования.
      Однако на практике встроенная кнопка копирования оказывается серьёзным UX-недостатком: она срабатывает автоматически при любом клике по блоку кода, мгновенно копируя весь фрагмент и сбрасывая пользовательское выделение. Это делает невозможным копирование отдельных частей кода через стандартное Ctrl+C.

 

Кастомизация модуля highlight_js

На данном сайте используется модуль highlight_js с темой Atlas, выключенной кнопкой копирования и дополнительной кастомизацией, которая включает:

  • шапку с названием языка
  • кнопку копирования, срабатывающую при клике
  • нумерацию строк

highlightjs-code-block-enhancer.js

(function (Drupal) {
  /**
   * Добавляет нумерацию строк: создаёт <div> с номерами слева от <code>.
   * Цифры не копируются, так как находятся вне <code>.
   */
  function addLineNumbers(codeElement) {
    if (codeElement.hasAttribute('data-line-numbers-done')) {
      return;
    }
    codeElement.setAttribute('data-line-numbers-done', 'true');

    let text = codeElement.textContent;
    if (text.endsWith('\n')) {
      text = text.slice(0, -1);
    }

    const lines = text.split('\n');
    let spansHtml = '';

    for (let i = 0; i < lines.length; i++) {
      spansHtml += `<span>${i + 1}</span>`;
    }

    const numbersWrapper = document.createElement('div');
    numbersWrapper.className = 'hljs-line-numbers';
    numbersWrapper.innerHTML = spansHtml;
    numbersWrapper.style.display = 'block';

    const container = codeElement.parentNode;
    container.style.position = 'relative';
    container.insertBefore(numbersWrapper, codeElement);
  }

  /**
   * Универсальное безопасное копирование (HTTPS + HTTP)
   */
  function safeCopyToClipboard(text) {
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(text).catch(() => {});
      return true;
    }

    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.setAttribute('readonly', '');
    textarea.style.position = 'absolute';
    textarea.style.left = '-9999px';
    document.body.appendChild(textarea);
    textarea.select();
    try {
      const success = document.execCommand('copy');
      document.body.removeChild(textarea);
      return success;
    } catch (err) {
      document.body.removeChild(textarea);
      return false;
    }
  }

  /**
   * Копирование инлайнового кода по клику (с защитой от выделения текста)
   */
  function initInlineCodeCopying(context) {
    context.addEventListener('click', function (e) {
      // Игнорируем, если есть выделение
      if (window.getSelection().toString()) return;

      const inlineCode = e.target.closest('.code-container--inline');
      if (!inlineCode) return;

      const codeElement = inlineCode.querySelector('code');
      if (!codeElement) return;

      const text = codeElement.innerText.trim();
      if (!text) return;

      const success = safeCopyToClipboard(text);
      if (success) {
        const originalBg = inlineCode.style.backgroundColor;
        inlineCode.style.backgroundColor = '#d1e7dd';
        setTimeout(() => {
          inlineCode.style.backgroundColor = originalBg || '';
        }, 200);
      }
    });
  }

  Drupal.behaviors.highlightJsHeader = {
    attach: function (context, settings) {
      // Инициализация копирования для инлайнов (делегировано на context)
      initInlineCodeCopying(context);

      // Обработка блочных <pre>
      const preBlocks = context.querySelectorAll(
        ':not(.highlightjs-wrapper) > pre[data-src="highlight.js"]'
      );

      preBlocks.forEach(function (pre) {
        if (pre.parentElement?.closest('.highlightjs-wrapper')) {
          return;
        }

        let language = 'TEXT';
        const langMatch = pre.className.match(/language-(\w+)/);
        if (langMatch) {
          language = langMatch[1].toUpperCase();
        } else {
          const code = pre.querySelector('code');
          if (code) {
            const codeLangMatch = code.className.match(/language-(\w+)/);
            if (codeLangMatch) language = codeLangMatch[1].toUpperCase();
          }
        }

        const header = document.createElement('div');
        header.className = 'code-header';
        header.innerHTML = `
          <span class="code-language">${language}</span>
          <span class="copy-icon" title="Copy to clipboard">
            <i class="fas fa-copy"></i>
          </span>
        `;

        const wrapper = document.createElement('div');
        wrapper.className = 'highlightjs-wrapper';
        wrapper.appendChild(header);
        pre.insertAdjacentElement('beforebegin', wrapper);
        wrapper.appendChild(pre);

        setTimeout(function () {
          const code = pre.querySelector('code.hljs');
          if (code) {
            addLineNumbers(code);

            const copyIcon = header.querySelector('.copy-icon');
            copyIcon.addEventListener('click', function () {
              const textToCopy = code.textContent;
              const success = safeCopyToClipboard(textToCopy);

              const iconEl = copyIcon.querySelector('i');
              const originalTitle = copyIcon.title;

              if (success) {
                iconEl.className = 'fas fa-check';
                copyIcon.title = 'Copied!';
              } else {
                iconEl.className = 'fas fa-times';
                copyIcon.title = 'Copy failed';
              }

              setTimeout(() => {
                iconEl.className = 'fas fa-copy';
                copyIcon.title = originalTitle;
              }, 1500);
            });
          }
        }, 100);
      });
    }
  };
})(Drupal);

highlightjs-code-block-enhancer.css

/* === Обёртка и шапка над кодом === */
.highlightjs-wrapper {
  margin: 1.2em 0;
  position: relative;
}

.code-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.4em 0.8em;
  background: #1c3c49;
  color: #eee;
  font-size: 0.85em;
  border-radius: 4px 4px 0 0;
  font-family: system-ui, -apple-system, sans-serif;
}

.code-language::before {
  content: "Language: ";
  opacity: 0.7;
  font-weight: normal;
}

.copy-icon {
  cursor: pointer;
  padding: 0.25em;
  border-radius: 3px;
  transition: background-color 0.2s;
}

.copy-icon:hover {
  background: rgba(255, 255, 255, 0.1);
}

/* === Убираем верхние скругления у кода === */
.code-container code {
  border-radius: 0 0 4px 0 !important;
  line-height: 1.5;
}

/* === Блочные блоки: место под номера строк === */
.code-container:not(.code-container--inline) {
  position: relative;
  padding-left: 40px; /* только для блочных */
}

/* === Инлайновый код — отдельный стиль === */
.code-container--inline {
  display: inline-block;
  border-radius: 4px;
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  font-size: 85%;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}

.code-container--inline:hover {
  background-color: #eaeef2;
  color: #24292f;
}

.code-container--inline code {
  background-color: #8aaebc;
  color: #fff;
  border-radius: 4px !important;
}

/* === Нумерация строк (только для блочных) === */
.code-container:not(.code-container--inline) .hljs-line-numbers {
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  border-right: 1px solid #003647;
  padding: 1em 8px 1em 0;
  text-align: right;
  user-select: none;
  color: #8aaebc;
  background: #002635;
  line-height: 1.5;
  pointer-events: none;
  display: block;
  border-bottom-left-radius: 4px;
}

/* Убедимся, что span отображаются */
.hljs-line-numbers span {
  display: block;
  color: inherit;
}

/* Убираем курсор pointer у обычных блоков */
.code-container:not(.code-container--inline) {
  cursor: auto;
}


Подключение в теме сайта:

<your_theme>.libraries.yml

highlightjs-enhanced:
  css:
    theme:
      assets/css/highlightjs-code-block-enhancer.css: {}
  js:
    assets/js/highlightjs-code-block-enhancer.js: {}
  dependencies:
    - core/drupal

<your_theme>.info.yml

libraries:
  - <your_theme>/highlightjs-enhanced