Pięć witryn multisite Wagtail i kryzys motywów: jak narodził się wagtail-visual-themes
Po wdrożeniu piątej aplikacji multisite w Wagtailu i skopiowaniu kolejnego plątaniny nadpisań kolorów marki w końcu wyodrębniłem warstwę motywów do osobnego pakietu. To historia o tym, dlaczego każdy projekt w Wagtailu kończy się ponownym wynajdywaniem motywów — oraz o małej, opiniotwórczej bibliotece, która powstrzymała mnie od zrobienia tego po raz szósty.
Pięć witryn, pięć różnych hacków na motywy
W ciągu ostatnich kilku lat dostarczyłem stały strumień wielowitrynowych aplikacji Wagtail - platformę coachingową ze współbrandowanymi portalami kursów, platformę fundraisingową obsługującą dziesiątki uniwersytetów, agencyjny CMS hostujący konstelację mikrowitryn, wewnętrzną bazę wiedzy z sekcjami stematyzowanymi według działów oraz garść witryn marketingowych zszytych razem pod jednym drzewem Wagtail.
Każda z nich została dostarczona z własną, uszytą na miarę warstwą tematyzacji. Każda z nich była trochę żenująca.
- Projekt pierwszy: zakodowane na sztywno partiale SCSS dla każdej witryny, rekompilowane przy każdej zmianie marki. „Po prostu przebuduj CSS” stało się memem na Slacku.
- Projekt drugi: model
SiteSettingsz siedmioma polamiColorFieldi tag szablonowy, który rozpylał wbudowane atrybutystylepo każdym komponencie. - Projekt trzeci: konfiguracja Tailwind generowana na etapie budowania dla każdego tenanta. Działało pięknie, dopóki redaktor treści nie chciał podejrzeć zmiany koloru bez ponownego wdrożenia.
- Projekt czwarty: blob JSON na modelu Wagtail Site parsowany przez procesor kontekstu. JSON wypuścił macki.
- Projekt piąty: zacząłem po raz piąty pisać
class SiteTheme(models.Model)i przestałem.
Wzorzec, który powtarzałem raz za razem
Pozbawiona różnic kosmetycznych, każda z tych implementacji rozwiązywała te same trzy problemy:
- Gdzie żyją tokeny marki? Zakodowane na sztywno w CSS, w modelu ustawień, w konfiguracji Tailwind, czy w wierszu bazy danych dla każdego dzierżawcy?
- Jak docierają do strony? Rekompilowany CSS, style inline, procesory kontekstu, niestandardowe tagi szablonów?
- Jak nadpisujesz je we właściwym zakresie? Dla całej witryny to łatwe. Dla całej sekcji trudniejsze. Nadpisania per strona to miejsce, w którym każdy zbudowany przeze mnie system po cichu się rozpadał.
A każda implementacja była ściśle sprzężona z modelem dzierżawcy danego projektu, jego potokiem buildowania i którąkolwiek biblioteką komponentów modną w danym kwartale. Przeniesienie czegokolwiek z tego do kolejnego projektu było operacją przeszczepu, a nie instrukcją importu.
Gdy zespół od marki poprosił o tryb ciemny
Punkt przełomowy nadszedł na platformie do zbiórek pieniędzy. Mieliśmy trzydzieści kilka dzierżaw uniwersyteckich, każda z własną paletą marki przechowywaną w snippecie Branding, każda generująca własny skompilowany pakiet CSS.
Wtedy zespół ds. marki poprosił o tryb ciemny. Nie dla jednej witryny - dla ich własnej. Opcjonalnie. Z przełącznikiem. Z zachowaniem stanu między załadowaniami stron. Bez przebudowywania naszego potoku CSS.
Siedziałem z tą prośbą przez dzień, naszkicowałem, czego wymagałoby dostosowanie jej do istniejącego systemu, i po cichu przyznałem, że architektura, którą przenosiłem z projektu na projekt, była strukturalnie niezdolna do jej obsługi. Kolory były wypalane w arkuszach stylów na etapie budowania. Nie istniało pojęcie "trybu" - jedynie pojedyncza paleta na dzierżawę.
Uczciwym rozwiązaniem nie było łatanie systemu. Było nim wyrzucenie go i zbudowanie tego, który powinienem był zbudować cztery projekty wcześniej.
Wyodrębnianie snippetu motywu
Pierwsza decyzja projektowa była najważniejsza: motywy są snippetami Wagtail, a nie ustawieniami witryny, nie polami tenanta, ani niczym powiązanym z konkretnym modelem danych. Motyw to samodzielny obiekt, który można tworzyć, edytować, wyświetlać w podglądzie i przypisywać, nie wiedząc niczego o witrynie, w której się znajduje.
To odsprzężenie sprawiło, że pakiet stał się przenośny. Ten sam snippet Theme działa na jednowitrynowym blogu Wagtail, w SaaS z trzydziestoma tenantami albo w klastrze mikrostron - ponieważ nic w nim nie zakłada modelu wielotenantowości.
@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."""
...
Rozwiązywanie: najpierw strona, potem przodkowie, potem witryna, na końcu wartość domyślna
Drugą decyzją projektową był łańcuch rozstrzygania (resolution chain) - odpowiedź na to trzecie pytanie, przy którym wciąż zawodziłem: jak nadpisywać na właściwym poziomie zakresu?
Pakiet przechodzi deterministyczny łańcuch dla każdego żądania:
- Czy bieżąca strona ma jawnie przypisany motyw? Użyj go.
- Czy jakakolwiek strona nadrzędna ma motyw? Użyj najbliższego.
- Czy Site ma skonfigurowany domyślny motyw? Użyj go.
- Czy istnieje ogólnowitrynowy snippet motywu "domyślnego"? Użyj go.
- W przeciwnym razie: nie emituj żadnych zmiennych motywu i pozwól, by renderował się bazowy CSS.
To jest część, którą powinienem był zbudować pięć projektów temu. Odzwierciedla ona sposób, w jaki sam Wagtail rozstrzyga rzeczy takie jak menu i promocje wyszukiwania - oprzyj się na drzewie stron, cofnij się do witryny, cofnij się do wartości domyślnej. Redaktorzy dostają dokładnie jeden model mentalny: ustaw motyw na najbliższym kontenerze, który go potrzebuje.
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()
Zmienne CSS, nie komponenty
Trzecią decyzją było to, czego pakiet nie robi. Nie dostarcza przycisków. Nie dostarcza kart. Nie dostarcza biblioteki komponentów. Nie dostarcza nawet wtyczek do Tailwind.
Emituje tokeny projektowe jako zwykłe niestandardowe właściwości CSS. To cały jego rezultat. Kilkadziesiąt zmiennych dla kolorów powierzchni, stanów semantycznych, modularnych skal typograficznych, sekwencji odstępów, zaokrągleń, cieni i warstw z-index - oraz jeden znacznik szablonu, który wstawia je do twojej sekcji <head>.
{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
{% theme_css %} {# emituje :root + [data-theme="dark"] #}
<link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>
Dlaczego nie dostarczać komponentów? Ponieważ każdy projekt, który zbudowałem, miał własną bibliotekę komponentów - tu Tailwind, tam Bootstrap, gdzie indziej ręcznie napisany SCSS - a system motywów wciąż plątał się z tym, co było na wierzchu. Czyste tokeny projektowe są uniwersalnym dawcą. Tailwind odczytuje je przez theme.extend.colors, ręcznie pisany CSS odczytuje je przez var(--color-brand-500), a baza kodu w Bootstrapie może nadpisać swoje zmienne SCSS tymi samymi tokenami. Każdy dostaje to, czego chce, i nikt nie walczy z pakietem.
Sztuczka na tryb ciemny bez mignięcia
Funkcja trybu ciemnego - ta, która zapoczątkowała całą tę ekstrakcję - okazała się najmniejszym fragmentem kodu w całym pakiecie. Dwa składniki:
- Motyw emituje zarówno zmienne jasne
:root, jak i zmienne ciemne[data-theme="dark"]w tym samym arkuszu stylów. - Niewielki skrypt inline w nagłówku dokumentu odczytuje zapisaną preferencję użytkownika z
localStoragei ustawia atrybutdata-themeprzed pierwszym wyrenderowaniem.
Ten drugi krok to cała gra. Jeśli odłożysz przypisanie data-theme do momentu po załadowaniu strony, każdy odwiedzający w trybie ciemnym zobaczy błysk jasnego motywu. Jeśli umieścisz to inline, blokująco, w nagłówku, uzyskasz czyste renderowanie.
// Wstawiane inline w <head> przez {% theme_css %} - działa przed pierwszym rysowaniem
(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';
}
})();
Czego naprawdę potrzebował multisite
Patrząc wstecz, bolesną częścią wielowitrynowego stylowania w Wagtailu nigdy nie były kolory. Były nią założenia. Każdy projekt kodował swój model najemcy w warstwie stylowania i każdy projekt płacił za to w momencie, gdy wymagania zaczynały dryfować - tryb ciemny, nadpisania per sekcja, podglądy dla edytorów treści, mikrostrony white-label pod marką nadrzędną.
Rozwiązaniem nie był sprytniejszy model SiteSettings. Było nim przeniesienie motywów na zewnątrz abstrakcji najemcy całkowicie i pozwolenie, aby drzewo stron i system snippetów - narzędzia, które Wagtail już ci daje - wykonały robotę.
Dlaczego udostępniłem to jako open source
Szczerze? Żebym już nigdy nie musiał pisać tego kodu ponownie. Pakiet znajduje się pod adresem github.com/ujeenet/wagtail-visual-themes. Jest niewielki, ma zdania i celowo ograniczony zakres - robi motywy, robi tryb ciemny i nic więcej. Następnym razem, gdy ktoś wręczy mi zlecenie na multisite w Wagtailu, rozdział o motywach to pip install i tag szablonu.
Jeśli jesteś przy drugim lub trzecim projekcie multisite w Wagtailu i czujesz, że ten sam hack z motywami zaczyna się krystalizować na nowo, oszczędź sobie cyklu. Podbierz model snippetu, podbierz łańcuch rozwiązywania albo po prostu zainstaluj pakiet. Chodzi o to, by przestać rozwiązywać ten problem.
Motywy (theming) to nie funkcja, którą buduje się dla jednej aplikacji. To granica, którą wytyczasz raz, pomiędzy decyzjami dotyczącymi marki a kodem komponentów. Każdy projekt multisite, który wdrożyłem bez tej granicy, ostatecznie za to zapłacił.