П'ять мультисайтів Wagtail і криза тем: як народився wagtail-visual-themes
Після випуску мого п'ятого мультисайтового застосунку на Wagtail і чергового копіювання заплутаного клубка перевизначень брендових кольорів я нарешті виніс шар теми в окремий пакет. Це історія про те, чому кожен проєкт на Wagtail зрештою заново винаходить систему тем — і про невелику, з чіткою думкою бібліотеку, яка зупинила мене від того, щоб робити це вшосте.
П'ять сайтів, п'ять різних хаків для тем
Протягом останніх кількох років я випустив стабільний потік мультисайтових застосунків на Wagtail - платформу для коучингу з ко-брендованими портталами курсів, платформу для збору коштів, що обслуговує десятки університетів, агентську CMS, що хостить сузір'я мікросайтів, внутрішню базу знань із розділами, оформленими за темами відділів, і кілька маркетингових сайтів, зшитих разом під одним деревом Wagtail.
Кожен з них випускався з власним самопальним шаром тематизації. Кожен з них був трохи ганебним.
- Проєкт перший: жорстко закодовані SCSS-партіали на кожен сайт, перекомпільовувані під час кожного налаштування бренду. "Just rebuild the CSS" стало мемом у Slack.
- Проєкт другий: модель
SiteSettingsіз сімомаColorFieldі шаблонним тегом, що розбризкував інлайнові атрибутиstyleпо кожному компоненту. - Проєкт третій: конфіг Tailwind, що генерувався під час збірки для кожного тенанта. Працював чудово, доки редактор контенту не захотів переглянути зміну кольору без повторного деплою.
- Проєкт четвертий: JSON-блоб на моделі Wagtail Site, що розбирався контекст-процесором. JSON відростив щупальця.
- Проєкт п'ятий: я почав вп'яте набирати
class SiteTheme(models.Model)і зупинився.
Патерн, який я повторював знову і знову
Якщо відкинути косметичні відмінності, кожна з тих реалізацій розв'язувала одні й ті самі три проблеми:
- Де живуть токени бренду? Жорстко зашиті в CSS, у моделі налаштувань, у конфігу Tailwind чи в рядку бази даних окремого тенанта?
- Як вони потрапляють на сторінку? Перекомпільований CSS, інлайн-стилі, контекстні процесори, кастомні шаблонні теги?
- Як перевизначати на потрібному рівні? На рівні всього сайту - легко. На рівні розділу - складніше. Перевизначення на рівні окремої сторінки - це те, де кожна побудована мною система тихо розсипалася.
І кожна реалізація була тісно пов'язана з моделлю тенантів того проєкту, його конвеєром збірки та будь-якою бібліотекою компонентів, що була модною того кварталу. Перенесення будь-чого з цього в наступний проєкт було операцією з пересадки, а не інструкцією імпорту.
Коли бренд-команда попросила темну тему
Переломний момент настав на платформі для збору коштів. У нас було понад тридцять університетських орендарів, кожен зі своєю фірмовою палітрою кольорів, збереженою у сніпеті Branding, кожен генерував свій власний скомпільований пакет CSS.
Потім одна бренд-команда попросила темний режим. Не для одного сайту - для свого. Опціонально. З перемикачем. Зі збереженням між завантаженнями сторінок. Без перебудови нашого CSS-конвеєра.
Я просидів над цим запитом день, накидав, що знадобилося б, аби вбудувати це в наявну систему, і тихо визнав, що архітектура, яку я тягав від проєкту до проєкту, структурно нездатна це підтримати. Кольори запікалися у стилі під час збірки. Не було жодного поняття про "режим" - лише єдина палітра на орендаря.
Чесним рішенням було не латання системи. Це було викинути її й побудувати ту, яку я мав побудувати ще чотири проєкти тому.
Виокремлення сніпета теми
Першим і найважливішим проєктним рішенням було: теми - це сніпети Wagtail, а не налаштування сайту, не поля орендаря, ані щось прив'язане до конкретної моделі даних. Тема - це самодостатній об'єкт, який можна створити, редагувати, попередньо переглянути та призначити, нічого не знаючи про сайт, на якому вона живе.
Саме це розчеплення зробило пакет переносним. Той самий сніпет Theme працює на односайтовому блозі Wagtail, у SaaS на тридцять орендарів або в кластері мікросайтів - тому що ніщо в ньому не припускає модель оренди.
@register_snippet
class Theme(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(unique=True)
# Brand colours with auto-computed contrast pairs
brand_colors = StreamField(
[("color", BrandColorBlock())], blank=True
)
# Light + dark surface palettes
light_surface = ColorPaletteField()
dark_surface = ColorPaletteField()
typography = TypographyField()
radii = RadiusScaleField()
shadows = ShadowScaleField()
def to_css_variables(self) -> str:
"""Emit a complete set of design tokens as CSS custom properties."""
...
Розв'язання: сторінка, потім предки, потім сайт, потім типове значення
Другим рішенням у дизайні був ланцюжок розв'язання - відповідь на те третє питання, на якому я постійно провалювався: як перевизначати на правильному рівні?
Пакет проходить детермінований ланцюжок для кожного запиту:
- Чи має поточна сторінка явну тему? Використовуй її.
- Чи має якась сторінка-предок тему? Використовуй найближчу.
- Чи має Site налаштовану тему за замовчуванням? Використовуй її.
- Чи є загальносайтовий сніпет теми "default"? Використовуй його.
- Інакше: не видавай жодних змінних теми й дозволь рендеритися базовому CSS.
Це та частина, яку мені слід було побудувати п'ять проєктів тому. Вона віддзеркалює те, як сам Wagtail розв'язує такі речі, як меню та пошукові промоції - спирайся на дерево сторінок, відкочуйся до сайту, відкочуйся до значення за замовчуванням. Редактори отримують рівно одну ментальну модель: встанови тему на найближчому контейнері, якому вона потрібна.
def resolve_theme(page: Page, site: Site) -> Theme | None:
# Page-level override
if (theme := getattr(page.specific, "theme", None)):
return theme
# Walk ancestors looking for the nearest themed page
for ancestor in page.get_ancestors().specific():
if (theme := getattr(ancestor, "theme", None)):
return theme
# Site default, then global default
return SiteThemeSettings.for_site(site).default_theme \
or Theme.objects.filter(slug="default").first()
CSS-змінні, а не компоненти
Третім рішенням було те, чого пакет не робить. Він не постачає кнопки. Він не постачає картки. Він не постачає бібліотеку компонентів. Він навіть не постачає плагіни Tailwind.
Він видає токени дизайну як звичайні CSS-змінні (custom properties). Це весь його вивід. Півсотні змінних для кольорів поверхонь, семантичних станів, модульних типографічних шкал, послідовностей відступів, радіусів, тіней та шарів z-index - і один template tag, щоб вставити їх у ваш <head>.
{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
{% theme_css %} {# видає :root + [data-theme="dark"] #}
<link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>
Чому не постачати компоненти? Тому що кожен проєкт, який я створював, приходив зі своєю власною бібліотекою компонентів — тут Tailwind, там Bootstrap, а десь ще саморобний SCSS, — і система тем постійно заплутувалася з тим, що було зверху. Чисті токени дизайну — це універсальний донор. Tailwind читає їх через theme.extend.colors, рукописний CSS читає їх через var(--color-brand-500), а кодова база на Bootstrap може перевизначити свої SCSS-змінні тими самими токенами. Кожен отримує те, що хоче, і ніхто не бореться з пакетом.
Трюк темної теми без мерехтіння
Функція темного режиму - та сама, з якої почалося все це видобування - виявилася найменшим фрагментом коду в усьому пакеті. Два інгредієнти:
- Тема видає і світлі змінні
:root, і темні змінні[data-theme="dark"]в одній таблиці стилів. - Крихітний вбудований скрипт у head документа зчитує збережені налаштування користувача з
localStorageта встановлює атрибутdata-themeдо першого відмалювання.
Другий крок - це вся суть. Якщо ви відкладете присвоєння data-theme до завершення завантаження сторінки, кожен відвідувач у темному режимі побачить спалах світлої теми. Якщо ви вбудуєте його блокуюче, у head, ви отримаєте чисті відмалювання.
// Inlined in <head> by {% theme_css %} - runs before first paint
(function () {
const stored = localStorage.getItem('theme-mode');
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
const mode = stored || (prefersDark ? 'dark' : 'light');
if (mode === 'dark') {
document.documentElement.dataset.theme = 'dark';
}
})();
Що насправді було потрібно для мультисайту
Озираючись назад, болючою частиною багатосайтового тематичного оформлення Wagtail ніколи не були кольори. Це були припущення. Кожен проєкт закодовував свою модель тенантів у шар тематичного оформлення, і кожен проєкт платив за це в ту саму мить, коли вимоги дрейфували - темна тема, перевизначення для окремих секцій, попередній перегляд для редакторів контенту, white-label мікросайти під батьківським брендом.
Виправлення полягало не в розумнішій моделі SiteSettings. Воно полягало в тому, щоб винести теми геть з абстракції тенантів повністю і дозволити дереву сторінок та системі сніпетів - інструментам, які Wagtail уже вам дає - виконувати цю роботу.
Чому я зробив це з відкритим кодом
Чесно? Щоб я більше ніколи не писав цей код. Пакет живе за адресою github.com/ujeenet/wagtail-visual-themes. Він невеликий, категоричний і навмисно обмежений за обсягом - він робить тематизацію, робить темний режим і не робить більше нічого. Наступного разу, коли хтось передасть мені бриф на мультисайт на Wagtail, розділ про тематизацію - це pip install і тег шаблону.
Якщо ви на другому чи третьому проєкті мультисайту на Wagtail і відчуваєте, як той самий хак тематизації знову кристалізується, зекономте собі цей цикл. Вкрадіть модель сніпета, вкрадіть ланцюжок резолюції або просто встановіть пакет. Сенс у тому, щоб перестати вирішувати цю проблему.
Темізація — це не функція, яку ви будуєте для одного застосунку. Це межа, яку ви проводите один раз між брендовими рішеннями та кодом компонентів. Кожен мультисайтовий проєкт, який я випустив без цієї межі, зрештою за це поплатився.