Разработка кастомной темы Drupal
Темы Drupal — это набор Twig-шаблонов, CSS, JS и конфигурационных YAML-файлов. Разработка с нуля или на основе базовой темы. Contrib-темы типа Bootstrap или Gin используются как стартовые точки или для админки.
Структура темы
web/themes/custom/my_theme/
├── my_theme.info.yml # описание темы
├── my_theme.libraries.yml # CSS/JS библиотеки
├── my_theme.theme # PHP хуки темы
├── config/
│ └── install/ # конфиг, устанавливаемый с темой
├── css/
│ ├── base.css
│ ├── layout.css
│ └── components/
├── js/
│ └── main.js
├── images/
├── fonts/
├── templates/
│ ├── layout/
│ │ ├── html.html.twig
│ │ └── page.html.twig
│ ├── content/
│ │ ├── node.html.twig
│ │ ├── node--article.html.twig
│ │ └── node--article--teaser.html.twig
│ ├── block/
│ │ └── block.html.twig
│ └── field/
│ └── field--body.html.twig
└── screenshot.png
my_theme.info.yml
name: My Theme
type: theme
description: 'Кастомная тема проекта'
core_version_requirement: ^10
base theme: stable9 # или false если с нуля
libraries:
- my_theme/global
regions:
header: Header
primary_menu: 'Primary menu'
breadcrumb: Breadcrumb
highlighted: Highlighted
content: Content
sidebar_first: 'Sidebar first'
footer: Footer
Библиотеки CSS/JS
# my_theme.libraries.yml
global:
version: VERSION
css:
base:
css/base.css: {}
layout:
css/layout.css: {}
css/components/header.css: {}
js:
js/main.js: { defer: true }
dependencies:
- core/drupal
- core/jquery
# Отдельная библиотека для слайдера — загружается только при необходимости
slider:
version: VERSION
css:
component:
css/components/slider.css: {}
js:
js/slider.js: {}
dependencies:
- core/once
Библиотеки подключаем в шаблоне:
{# В шаблоне node--landing.html.twig #}
{{ attach_library('my_theme/slider') }}
Twig шаблоны
Drupal использует иерархию шаблонов — чем специфичнее имя, тем выше приоритет:
node.html.twig # все ноды
node--article.html.twig # тип article
node--article--full.html.twig # тип article, вид full
node--123.html.twig # конкретная нода по id
Пример переопределения шаблона статьи:
{# templates/content/node--article--full.html.twig #}
<article{{ attributes.addClass('article', 'article--full') }}>
{% if label %}
<h1{{ title_attributes.addClass('article__title') }}>
{{ label }}
</h1>
{% endif %}
<div class="article__meta">
{% if display_submitted %}
<span class="article__author">{{ author_name }}</span>
<time class="article__date" datetime="{{ date.attributes.datetime }}">
{{ date }}
</time>
{% endif %}
{% if content.field_tags %}
<div class="article__tags">
{{ content.field_tags }}
</div>
{% endif %}
</div>
{% if content.field_image %}
<div class="article__cover">
{{ content.field_image }}
</div>
{% endif %}
<div class="article__body prose">
{{ content.body }}
</div>
{# Рендерим остальные поля кроме тех, что уже вывели #}
{{ content|without('body', 'field_tags', 'field_image', 'links') }}
</article>
PHP хуки в .theme файле
// my_theme.theme
/**
* Добавляем переменные в шаблон страницы.
*/
function my_theme_preprocess_page(array &$variables): void {
$variables['site_name'] = \Drupal::config('system.site')->get('name');
$variables['is_front'] = \Drupal::service('path.matcher')->isFrontPage();
// Хлебные крошки с кастомной логикой
$route = \Drupal::routeMatch();
if ($node = $route->getParameter('node')) {
$variables['node_type'] = $node->bundle();
}
}
/**
* Переменные для шаблона ноды.
*/
function my_theme_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() === 'article') {
$variables['reading_time'] = my_theme_calculate_reading_time($node->get('body')->value);
}
}
function my_theme_calculate_reading_time(string $html): int {
$text = strip_tags($html);
$words = str_word_count($text);
return (int) ceil($words / 200); // 200 слов в минуту
}
/**
* Переопределяем предложения шаблонов — для дебага.
*/
function my_theme_theme_suggestions_node_alter(array &$suggestions, array $variables): void {
$node = $variables['elements']['#node'];
$view_mode = $variables['elements']['#view_mode'];
// Добавляем suggestion по полю типа материала
if ($node->hasField('field_material_type') && !$node->get('field_material_type')->isEmpty()) {
$type = $node->get('field_material_type')->value;
$suggestions[] = 'node__' . $node->bundle() . '__' . $type;
}
}
/**
* Alter для form — добавляем классы.
*/
function my_theme_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
if ($form_id === 'contact_message_feedback_form') {
$form['#attributes']['class'][] = 'contact-form';
$form['actions']['submit']['#attributes']['class'][] = 'btn btn--primary';
}
}
Responsive images и image styles
# config/install/image.style.article_cover.yml
langcode: en
status: true
id: article_cover
label: 'Article cover'
effects:
uuid1:
id: image_scale_and_crop
data:
anchor: center-center
width: 1200
height: 630
В шаблоне используем responsive_image вместо обычного image:
{% if content.field_image %}
{{- content.field_image -}}
{% endif %}
Responsive image group настраивается в /admin/config/media/responsive-image-style через UI или YAML.
Сборка фронтенда (опционально)
Если тема использует npm-сборку:
// package.json
{
"scripts": {
"build": "postcss css/src -o css --map",
"watch": "postcss css/src -o css --watch"
}
}
Или Vite/Webpack если нужны ES-модули, TypeScript:
// vite.config.js
export default {
build: {
outDir: 'dist',
rollupOptions: {
input: { main: 'js/src/main.ts' },
output: { entryFileNames: 'js/[name].js' }
}
},
css: { postcss: './postcss.config.js' }
}
Дебаг шаблонов
В settings.local.php включаем Twig дебаг:
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
$settings['cache']['bins']['render'] = 'cache.backend.null';
// Показывает имена шаблонов в HTML комментариях
$config['twig.settings']['debug'] = TRUE;
После включения в source code страницы будут видны все использованные шаблоны и доступные suggestions. Это основной инструмент при разработке.
Сроки
Базовая тема с шаблонами для основных типов контента, адаптивная, с библиотеками: 5–8 дней. С анимациями, сложным JS, responsive images, кастомными блоками: 10–15 дней.







