Budowanie produkcyjnego motywu notACMS — od szablonu do wydania
symfony,statyczna-strona,architektura,rwdCzęść 7 z 7
- 368 testów dla statycznego generatora stron — po co?
- 79 błędów w kodzie Symfony który przeszedł PHPStan
- XSS, open redirecty i path traversal na 'statycznej' stronie
- Wzorzec lokalnych nadpisań — szablony Symfony bez forkowania
- Open-source notACMS — pełna lista kontrolna
- Jak notACMS buduje statyczną stronę — pełny pipeline
- Budowanie produkcyjnego motywu notACMS — od szablonu do wydania
To część 7 serii o przygotowaniu notACMS do wydania jako open-source. Część 6 opisała pipeline budowania. Oryginalna seria o migracji z WordPressa opisuje samą migrację.
Część 4 wyjaśniła jak wzorzec nadpisywania local/ działa w teorii — priorytet przestrzeni nazw Twig, kolejność kaskady SCSS i model łączenia w czasie budowy. Ten post buduje z nim prawdziwy motyw, od początku do końca. Nie zabawkowy przykład. Produkcyjny motyw z własnymi komponentami, responsywnymi obrazami, paskiem bocznym i wyszukiwarką.
Punkty startowe — szablony
Katalog docs/customization/ zawiera sześć zestawów szablonów, każdy demonstrujący jeden wzorzec nadpisywania:
| Katalog | Wzorzec | Co pokazuje |
|---|---|---|
starter-extend/ |
Minimalne nadpisanie bazy | Rozszerza @base, dodaje punkt wejścia app-local |
block-override/ |
Dostosowywanie na poziomie bloków | Nadpisuje pojedyncze bloki Twig |
full-override/ |
Pełna zamiana bazy | Pełna kopia base.html.twig — maksymalna kontrola |
material-cards/ |
Układ bloga oparty na kartach | Poziome karty wpisów z obrazem po lewej |
translation-override/ |
Własne tłumaczenia | Dodawanie lub zastępowanie kluczy w local/translations/ |
custom-post-card/ |
Nadpisanie pojedynczego komponentu | Zastąpienie jednego komponentu karty + jego SCSS |
custom-footer/ |
Pełna baza dla stopki | Zastąpienie bazy bo stopka nie jest blokiem w rdzeniu |
self-hosted-fonts/ |
Własna typografia | Deklaracje @font-face + podpowiedzi preload |
twig-filter/ |
Własny filtr Twig | Atrybut #[AsTwigFilter], zero konfiguracji YAML |
php-service-decorator/ |
Dekoracja serwisów | #[AsDecorator] do zastępowania zachowania rdzenia |
Zaczniemy od material-cards i rozbudujemy w pełny motyw.
Krok 1: Przygotowanie bazy
Kopiujemy szablon do local/:
cp -r docs/customization/starter-extend/. local/
Szablon zawiera dwa pliki. Pierwszy, local/templates/base.html.twig — minimalne nadpisanie bazy:
{% extends '@base/base.html.twig' %}
{% block stylesheets %}
{{ importmap('app') }}
{{ importmap('app-local') }}
{% endblock %}
Prefiks @base/ jawnie przypina do rdzennych szablonów. Bez niego extends 'base.html.twig' szukałoby najpierw w local/ — tworząc cykliczną referencję gdzie local rozszerza samego siebie. Blok stylesheets dodaje punkt wejścia app-local obok rdzennego app.
Drugi, local/assets/app.js — lokalny punkt wejścia JS:
import './styles/app_local.scss';
Ten plik sprawia że app-local pojawia się w importmap.php. Jego jedynym zadaniem jest importowanie lokalnego SCSS. Wszelkie dodatkowe importy JS też trafiają tutaj.
Krok 2: Nadpisanie tokenów SCSS
Tożsamość wizualna motywu żyje w dwóch plikach: local/assets/styles/_tokens.scss (własności CSS) i local/assets/styles/_variables.scss (stałe SCSS).
Motyw demo używa ciemnej palety bursztynowo-fosforowej. Oto co autor motywu zmienia w _tokens.scss:
:root {
--bg: #171717; /* prawie czarne tło strony */
--bg-sidebar: #0f0f0f; /* jeszcze ciemniejszy sidebar */
--text: #e8e8e8; /* jasnoszary tekst */
--text-muted: #8c8c8c; /* przygaszony tekst drugorzędny */
--accent: #f59e0b; /* bursztynowy kolor wyróżnienia */
--border: #404040; /* widoczny ale nie krzykliwy */
--code-bg: #1c1c1c; /* tło bloków kodu */
}
A w _variables.scss:
$font: 'Inter', ui-sans-serif, system-ui, sans-serif;
$font-mono: 'JetBrains Mono', ui-monospace, monospace;
$radius-sm: 4px;
$radius: 6px;
$radius-md: 8px;
Wszystko inne w kaskadzie — _base.scss, _components.scss, _prose.scss — używa tych zmiennych. Zmień --accent a każdy przycisk, link, odznaka i pierścień focus się aktualizuje. Żaden SCSS komponentu nietknięty.
Krok 3: Nadpisanie bloków Twig
base.html.twig definiuje około 20 regionów {% block %}. Te które faktycznie będziesz nadpisywać:
stylesheets — zrobione w kroku 1. Dodaje importmapę app-local.
favicons — własne ścieżki ikon:
{% block favicons %}
<link rel="icon" href="{{ asset('local/favicon.svg') }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ asset('local/apple-touch-icon.png') }}">
{% endblock %}
Lokalne assety używają prefiksu ścieżki local/ bo asset_mapper.yaml mapuje local/assets/: local.
navigation — zastępuje cały include nawigacji:
{% block navigation %}
{{ include('components/navigation.html.twig') }}
{% endblock %}
Deleguje do lokalnego nadpisania komponentu — to samo wywołanie include ale rozwiązuje się najpierw do local/templates/components/navigation.html.twig.
sidebar_top i sidebar_bottom — własna zawartość widgetów:
{% block sidebar_top %}
<div class="widget widget-cta">
<h3>Współpraca</h3>
<p>Buduję niestandardowe aplikacje Symfony i statyczne strony.</p>
<a href="{{ path('contact_' ~ locale) }}" class="btn btn-primary">Skontaktuj się</a>
</div>
{% endblock %}
footer — nie jest blokiem w rdzeniu. Stopka to zakodowany HTML w base.html.twig. Żeby ją dostosować, musisz zastąpić cały base.html.twig pełną kopią. To jedyna część systemu która wymaga pełnego nadpisania szablonu. Alternatywą jest dodanie własnego bloku Twig w lokalnym nadpisaniu bazy i aktualizacja wszystkich szablonów stron by go używały, ale pełna zamiana jest prostsza w większości przypadków.
Krok 4: Nadpisanie szablonów komponentów
Serce tworzenia motywu. Kopiuj komponent z templates/components/ do local/templates/components/ i edytuj:
cp templates/components/post_card.html.twig local/templates/components/post_card.html.twig
Wywołanie include się nie zmienia:
{{ include('components/post_card.html.twig') }}
Rozwiązywanie szablonów szuka najpierw w local/templates/ (główna przestrzeń nazw ''), potem przepada do templates/ (rdzeń). Wywołujący nie wie i nie dba który plik jest użyty.
Sześć komponentów które będziesz najczęściej nadpisywać:
post_card.html.twig — główna karta wpisu. Otrzymywane zmienne: post (ContentItem), featured (bool), locale (string), new_post_days (int). Lokalne nadpisanie może dodać układ poziomy, przesunąć obraz na prawo lub zmienić kolejność meta-linii.
post_card_mini.html.twig — kompaktowa karta dla ostatnich wpisów w sidebarze i powiązanych wpisów. Te same zmienne, mniejszy markup, bez obrazu.
navigation.html.twig — główna nawigacja z linkami i przełącznikiem języków. Zmienne: locale, dostęp do funkcji content_item() i path() Twig. Lokalne nadpisanie dodaje, usuwa lub zmienia kolejność elementów nawigacji.
sidebar_top.html.twig — widget formularza wyszukiwania. Zmienne: sidebar (obiekt wartości SidebarData). Lokalne nadpisanie zastępuje formularz wyszukiwania widgetem CTA lub własną treścią.
sidebar_bottom.html.twig — widgety ostatnich wpisów, kategorii, tagów. Zmienne: sidebar, locale, filter_type, filter_value. Lokalne nadpisanie zmienia prezentację filtrów lub ogranicza liczbę ostatnich wpisów.
breadcrumb.html.twig — ścieżka nawigacyjna. Zmienne: breadcrumbs (tablica z funkcji Twig breadcrumbs()). Lokalne nadpisanie zmienia separator z / na → lub restrukturyzuje markup.
pagination.html.twig — linki poprzedniej/następnej strony. Zmienne: current_page, total_pages, base_url, paginated_route. Lokalne nadpisanie stylizuje kontener paginacji.
Krok 5: Tworzenie własnych komponentów
Wyodrębnij powtarzające się wzorce UI do nowych plików w local/templates/components/:
local/templates/components/hero_banner.html.twig:
<section class="hero-banner">
<h1>{{ title }}</h1>
{% if subtitle is defined %}
<p class="hero-banner__subtitle">{{ subtitle }}</p>
{% endif %}
</section>
Załączony w szablonie strony głównej:
{% block body %}
{{ include('components/hero_banner.html.twig', {
title: site_name,
subtitle: site_description
}) }}
{% endblock %}
Komponenty otrzymują zmienne z kontekstu szablonu wywołującego — bez specjalnej rejestracji, bez manifestu, bez konfiguracji autoloadingu. Po prostu plik Twig w odpowiednim katalogu.
Krok 6: Dodanie lokalnego JavaScript
Punkt wejścia app-local importuje dodatkowe zachowania:
// local/assets/app.js
import './styles/app_local.scss';
import './js/theme-toggle.js';
import './js/scroll-animations.js';
Pliki JS żyją w local/assets/js/ i są kompilowane do public/assets/local/ przez asset-map:compile. Są dostępne w szablonach przez:
<script src="{{ asset('local/js/theme-toggle.js') }}" defer></script>
Kontrolery Stimulus auto-wykrywane z local/assets/controllers/ działają zgodnie z konwencją Symfony — po prostu umieść tam plik *_controller.js i odwołuj się atrybutami data-controller w markupie.
Krok 7: Dodanie własnych funkcji i filtrów Twig
Umieść pliki PHP w local/src/ pod przestrzenią nazw NotACms\Local\:
<?php
declare(strict_types=1);
namespace NotACms\Local\Twig;
use Symfony\Bridge\Twig\Attribute\AsTwigFilter;
use Twig\Extension\AbstractExtension;
final class ReadingTimeExtension extends AbstractExtension
{
#[AsTwigFilter('reading_time')]
public function readingTime(string $html, int $wordsPerMinute = 200): string
{
$wordCount = str_word_count(strip_tags($html));
return (string) max(1, (int) ceil($wordCount / $wordsPerMinute));
}
}
Zero ręcznej rejestracji serwisów. Plik services.yaml już skanuje local/src/:
services:
NotACms\Local\:
resource: '../local/src/'
Symfony auto-wykrywa klasy z atrybutami #[AsTwigFilter], #[AsTwigFunction] lub #[AsTwigExtension]. Użyj atrybutu, umieść plik w local/src/, a funkcja jest dostępna we wszystkich szablonach.
Krok 8: Dekoracja serwisu
Zmień zachowanie rdzenia bez modyfikowania rdzennego kodu. Umieść klasę dekoratora w local/src/Service/:
<?php
declare(strict_types=1);
namespace NotACms\Local\Service;
use NotACms\Service\Turnstile\TurnstileValidatorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator(decorates: TurnstileValidatorInterface::class)]
final readonly class TurnstileSkipDecorator implements TurnstileValidatorInterface
{
public function verify(string $token, ?string $remoteIp = null): bool
{
return true;
}
}
Atrybut #[AsDecorator] mówi Symfony by opakowało prawdziwy TurnstileValidator tym dekoratorem. Cały kod type-hintujący TurnstileValidatorInterface otrzymuje teraz dekorator — który całkowicie pomija weryfikację. Przydatne w lokalnym dev gdzie Cloudflare Turnstile nie może się połączyć z localhost. Usuń plik na produkcji, a prawdziwy walidator przejmuje kontrolę.
Krok 9: Testowanie motywu
Uruchom ddev build i sprawdź każdy typ strony:
- Strona główna — hero banner, karty wpisów, sekcja CTA
- Lista wpisów — paginacja, linki filtrów, układ kart
- Pojedynczy wpis — obraz wyróżniający, pasek postępu czytania, tagi, nawigacja serii, powiązane wpisy
- Strona kategorii — filtrowana lista z nagłówkiem kategorii
- Strona tagu — filtrowana lista z nagłówkiem tagu
- Miesiąc/rok archiwum — lista archiwalna z nagłówkiem daty
- Strony statyczne — o mnie, kontakt, realizacje, polityka prywatności
- Strona wyszukiwarki — wyniki Pagefind z własnym stylowaniem
- Strony błędów — 404 i 500 ze stylowaniem motywu
- Styleguide —
/styleguide/pokazujący wszystkie komponenty w izolacji
Strona styleguide to najcenniejsze narzędzie debugowania. Zaktualizuj local/templates/page/styleguide.html.twig by zawierał nowe własne komponenty. Uruchom build, otwórz styleguide, a każdy komponent — rdzenny i własny — jest widoczny na jednej stronie z przykładowymi danymi.
Sprawdź breakpointy responsywne na mobile (375px), tablecie (768px) i desktopie (1280px). Zweryfikuj że fingerprinting assetów wygenerował nowe hashe w public/assets/ — jeśli hashe zgadzają się z poprzednim buildem, SCSS motywu się nie zmienił.
Mechanizm rozwiązywania szablonów
Zrozumienie jak dokładnie Twig rozwiązuje szablony zapobiega cichym błędom:
include('components/post_card.html.twig')
1. local/templates/components/post_card.html.twig ← główna przestrzeń '' (pierwsza)
2. templates/components/post_card.html.twig ← rdzeń (fallback)
extends '@base/base.html.twig' ← jawnie przypięte do rdzenia, nigdy local
extends 'base.html.twig' ← szuka najpierw local, potem rdzeń
Konfiguracja która to umożliwia, z config/packages/twig.yaml:
twig:
paths:
'%kernel.project_dir%/local/templates': '' # główna przestrzeń — najwyższy priorytet
'%kernel.project_dir%/templates': 'base' # przestrzeń @base — cel jawnego przypięcia
Główna przestrzeń nazw ('') zawsze ma priorytet. Rdzenne szablony są osiągalne przez @base/ gdy potrzebne jest jawne przypięcie. Lokalne szablony które chcą rozszerzać z rdzenia używają {% extends '@base/base.html.twig' %} — to nigdy się nie zmienia, nawet jeśli lokalny base.html.twig też istnieje.
Strategia kaskady SCSS
Dwa punkty wejścia importmap tworzą kaskadę bez !important:
Przeglądarka ładuje:
1. public/assets/app-{h1}.css ← kaskada rdzenia (tokens → variables → components → ...)
2. public/assets/local/app-{h2}.css ← kaskada lokalna (tokens → variables → własne komponenty)
Wynik:
Lokalne reguły ładują się po rdzennych.
Przy równej specyficzności, lokalne wygrywają.
Nigdzie !important.
To ten sam mechanizm który wzorzec local/ opisał w części 4, teraz zastosowany do prawdziwej budowy motywu. Rozdzielenie odpowiedzialności jest czyste: rdzeń dostarcza bazowe style przez zmienne, local nadpisuje te zmienne i dodaje własne style komponentów. Kaskada robi resztę.
Porównanie do innych podejść
| Podejście | Mechanizm motywu | Model nadpisywania | Ograniczenia |
|---|---|---|---|
| Motywy potomne WordPress | Osobny katalog, @import CSS rodzica |
Tylko pełna zamiana plików | Kruche — aktualizacje rodzica psują child jeśli zmienią się selektory |
| Motywy Hugo / Jekyll | Szablony Go/Liquid, config.toml |
Napędzane konfiguracją, limitowane do wyeksponowanych parametrów | Nie można nadpisać dowolnych szablonów |
| Motywy SSG oparte na npm | npm install nazwa-motywu, plik konfiguracyjny |
Oparte na wtyczkach lub konfiguracji | Ciężki toolchain, motyw jest zależnością |
notACMS local/ |
Jeden katalog, scalanie w czasie builda | Nadpisywanie na poziomie plików + rozszerzanie na poziomie bloków | Stopka wymaga zastąpienia bazy |
Motywy notACMS to po prostu Twig i SCSS. Bez specjalnego formatu, bez manifestu motywu, bez rejestracji. Cały "system motywów" to dwie linijki w twig.yaml i warunkowy wpis w importmap.php. Kopiuj plik, edytuj, buduj.
Część 8 zagłębia się w warstwę wyszukiwania: indeksowanie WASM Pagefind, dwa interfejsy wyszukiwania (samodzielna strona i nakładka Cmd+K), wdrożenie produkcyjne na ARM64 i czego Pagefind nie potrafi.