Среди библиотек, предназначенных для подсветки синтаксиса языка, существует два популярных решения: highlight.js и prism.js.
ffdfd
Плюсы Highlight.js перед Prism.js
- Автоматическое определение языка
— Не нужно указывать язык явно: библиотека сама попытается распознать его.
— Удобно для пользовательского контента (например, комментариев или полей ввода, где автор может не знать, как указать язык). - Больше встроенных языков «из коробки»
— Поддерживает ~190 языков, включая довольно экзотические (например, MUMPS, NSIS, Mercury).
— Prism.js по умолчанию включает ~150, но многие требуют отдельного подключения. - Проще в базовой интеграции
— Достаточно подключить один JS и один CSS — и всё работает.
— Нет необходимости думать о плагинах, грамматиках или структуре классов. - Широкая совместимость со старыми системами
— Многие CMS (включая старые версии Drupal, WordPress) изначально интегрированы с Highlight.js.
— Модуль Highlight.js для Drupal существует давно и стабилен. - Меньше HTML-разметки в выводе
— Использует более простые<span>без избыточных классов — иногда это лучше для SEO или копирования «чистого» текста.
Плюсы Prism.js перед Highlight.js
- Лексический (грамматический) разбор, а не регулярные выражения
— Prism.js использует иерархическую токенизацию, что позволяет точно различать:.classи#idв CSS,- строки, шаблоны, регулярки в JavaScript,
- атрибуты и теги в HTML/XML.
— Highlight.js в основном полагается на регулярные выражения, что иногда приводит к «проскокам» (например, подсветка строки как кода внутри комментария).
- Модульность и расширяемость
— Номера строк, подсветка конкретных строк, копирование, отображение имени языка — всё это отдельные плагины, которые легко включать/отключать.
— У Highlight.js такие функции либо отсутствуют, либо требуют сторонних решений или костылей. - Полный контроль над HTML-выводом
— Каждый токен имеет семантический класс:token keyword,token function,token punctuation.
— Это позволяет делать точные стилизации, например:
.token.punctuation { opacity: 0.7; }
.token.attr-name { color: #9b59b6; }- Лучшая поддержка вложенных языков
— Например, в блокеlanguage-jsxPrism корректно выделяет HTML-теги внутри JavaScript.
— Highlight.js часто «ломается» в таких сценариях. - Безопасность и предсказуемость
— Поскольку язык указывается явно, нет риска неправильной автоидентификации (например, Bash воспринимается как Perl).
— Это критично для документации или технических сайтов, где точность важна. - Активная экосистема плагинов и тем
— Официальные плагины:line-numbersline-highlightcopy-to-clipboardshow-languageautoloader
Готовые модули в 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