Разработка кастомной темы Grav (Twig)
Тема Grav — это директория в user/themes/, содержащая Twig-шаблоны, CSS, JS и конфигурационные файлы. Каждый .md-файл контента использует шаблон с тем же именем: services.md → templates/services.html.twig. Если шаблон не найден — используется default.html.twig.
Структура темы
user/themes/my-theme/
templates/
partials/
base.html.twig # базовый layout
navigation.html.twig # навигация
sidebar.html.twig
modular/
hero.html.twig # модульные секции
features.html.twig
testimonials.html.twig
default.html.twig # fallback
home.html.twig
blog.html.twig
post.html.twig
service-detail.html.twig
error.html.twig
css/
theme.css
js/
theme.js
images/
blueprints/ # конфигурация полей темы для админки
pages/
service-detail.yaml
blueprints.yaml # метаданные темы
my-theme.php # PHP-класс темы (опционально)
my-theme.yaml # настройки темы по умолчанию
thumbnail.jpg
Базовый layout: base.html.twig
{# templates/partials/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ grav.language.getLanguage() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}
</title>
{% if page.header.metadata.description is defined %}
<meta name="description" content="{{ page.header.metadata.description }}">
{% elseif page.summary %}
<meta name="description" content="{{ page.summary|striptags|trim }}">
{% endif %}
{% block stylesheets %}
{% do assets.addCss('theme://css/theme.css', 100) %}
{% endblock %}
{{ assets.css()|raw }}
</head>
<body class="{{ page.template }}{% if page.header.body_class %} {{ page.header.body_class }}{% endif %}">
{% include 'partials/navigation.html.twig' %}
{% block content %}{% endblock %}
{% include 'partials/footer.html.twig' %}
{% block javascripts %}
{% do assets.addJs('theme://js/theme.js', 100) %}
{% endblock %}
{{ assets.js()|raw }}
</body>
</html>
Шаблон страницы
{# templates/service-detail.html.twig #}
{% extends 'partials/base.html.twig' %}
{% block content %}
<section class="hero
{%- if page.header.hero_image %} hero--image{% endif %}">
{% if page.media[page.header.hero_image] is defined %}
{% set hero = page.media[page.header.hero_image] %}
<img src="{{ hero.url }}"
srcset="{{ hero.resize(800,400).url }} 800w,
{{ hero.resize(1600,800).url }} 1600w"
alt="{{ page.title }}">
{% endif %}
<div class="hero__inner">
<h1>{{ page.title }}</h1>
{% if page.header.intro %}<p>{{ page.header.intro }}</p>{% endif %}
</div>
</section>
<div class="container layout-main">
<article class="content">
{{ page.content|raw }}
{% if page.header.features is defined %}
<ul class="features-list">
{% for feature in page.header.features %}
<li>
<i class="icon {{ feature.icon }}"></i>
{{ feature.text }}
</li>
{% endfor %}
</ul>
{% endif %}
</article>
{% if page.header.show_sidebar %}
{% include 'partials/sidebar.html.twig' %}
{% endif %}
</div>
{% endblock %}
Навигация с активным состоянием
{# templates/partials/navigation.html.twig #}
{% set tree = pages.find('/').children.visible %}
<nav class="main-nav">
<ul>
{% for item in tree %}
{% set is_active = (item.active or item.activeChild) %}
<li class="{{ is_active ? 'active' : '' }}
{{- item.children.visible|length ? ' has-children' : '' }}">
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.children.visible|length %}
<ul class="submenu">
{% for child in item.children.visible %}
<li class="{{ child.active ? 'active' : '' }}">
<a href="{{ child.url }}">{{ child.title }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
Модульные страницы (Modular)
Модульная страница собирается из нескольких .md-файлов в одной директории:
pages/01.home/
home.md # тип: modular
_hero/
hero.md # шаблон: modular/hero
_features/
features.md # шаблон: modular/features
_cta/
cta.md
{# templates/home.html.twig #}
{% extends 'partials/base.html.twig' %}
{% block content %}
{% for module in page.collection %}
{% include ['modular/' ~ module.template ~ '.html.twig',
'modular/default.html.twig'] ignore missing with {page: module} %}
{% endfor %}
{% endblock %}
{# templates/modular/hero.html.twig #}
<section class="section-hero">
<h1>{{ page.header.title_large ?? page.title }}</h1>
<p>{{ page.header.subtitle }}</p>
{% if page.header.cta_url %}
<a href="{{ page.header.cta_url }}" class="btn btn-primary">
{{ page.header.cta_text ?? 'Подробнее' }}
</a>
{% endif %}
</section>
Работа с медиафайлами
{# Изображения из директории страницы #}
{% set images = page.media.images %}
{% for image in images %}
{# Ресайз с сохранением пропорций #}
<img src="{{ image.cropResize(400, 300).url }}"
alt="{{ image.attribute('alt') }}"
loading="lazy">
{% endfor %}
{# Конкретное изображение #}
{% set img = page.media['hero.jpg'] %}
{% if img %}
<picture>
<source srcset="{{ img.format('webp').resize(1200, 0).url }}" type="image/webp">
<img src="{{ img.resize(1200, 0).url }}" alt="{{ page.title }}">
</picture>
{% endif %}
my-theme.yaml — конфигурация темы
# user/themes/my-theme/my-theme.yaml
enabled: true
favicon: images/favicon.png
google_analytics_id: ''
show_breadcrumbs: true
sidebar_position: right # left, right, none
posts_per_page: 10
social:
twitter: ''
telegram: ''
В шаблоне: {{ theme_config.google_analytics_id }}, {{ theme_config.posts_per_page }}.
Twig-расширения Grav
Grav добавляет глобальные объекты и фильтры:
{# Глобальные объекты #}
{{ grav.language.getLanguage() }} {# текущий язык #}
{{ grav.user.username }} {# текущий пользователь #}
{{ site.title }} {# название сайта #}
{{ page.url }} {# URL страницы #}
{{ base_url_absolute }} {# абсолютный базовый URL #}
{{ theme_url }} {# URL темы #}
{# Фильтры #}
{{ 'hello world'|t }} {# перевод #}
{{ somevar|defined('default') }} {# значение по умолчанию #}
{{ page.date|date('d.m.Y') }}
{# Функции #}
{{ url('theme://images/logo.png') }}
{{ random(['a', 'b', 'c']) }}
Сроки разработки
| Тема | Состав | Срок |
|---|---|---|
| Базовая тема из макета | 5–8 шаблонов, без модульных | 1–2 недели |
| Полная тема с модульными | 8–15 шаблонов, blueprints | 3–5 недель |
| Многоязычная тема | + переводы, языковые меню | +3–5 дней |







