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 %}

footernie 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:

  1. Strona główna — hero banner, karty wpisów, sekcja CTA
  2. Lista wpisów — paginacja, linki filtrów, układ kart
  3. Pojedynczy wpis — obraz wyróżniający, pasek postępu czytania, tagi, nawigacja serii, powiązane wpisy
  4. Strona kategorii — filtrowana lista z nagłówkiem kategorii
  5. Strona tagu — filtrowana lista z nagłówkiem tagu
  6. Miesiąc/rok archiwum — lista archiwalna z nagłówkiem daty
  7. Strony statyczne — o mnie, kontakt, realizacje, polityka prywatności
  8. Strona wyszukiwarki — wyniki Pagefind z własnym stylowaniem
  9. Strony błędów — 404 i 500 ze stylowaniem motywu
  10. 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.