Wyszukiwanie pełnotekstowe dla statycznych stron — Pagefind w produkcji
statyczna-strona,performance,architektura,jsCzęść 8 z 8
- 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
- Wyszukiwanie pełnotekstowe dla statycznych stron — Pagefind w produkcji
To część 8 serii o przygotowaniu notACMS do wydania jako open-source. Część 7 opisała budowę motywu produkcyjnego. Oryginalna seria o migracji z WordPressa opisuje samą migrację.
Wyszukiwanie na statycznej stronie. Bez Elasticsearch. Bez Algolii. Bez endpointa wyszukiwania w PHP. Po prostu zestaw statycznych plików i biblioteka JavaScript kompilująca się do WebAssembly. Oto jak to działa, ile kosztuje i co psuje się na produkcji.
Czym jest Pagefind
Pagefind to narzędzie CLI napisane w Ruście przez CloudCannon, na licencji MIT. W czasie budowy indeksuje wstępnie wyrenderowane statyczne pliki HTML. W czasie działania klient JavaScript ładuje skompilowaną binarkę WASM i wykonuje zapytania w całości w przeglądarce. Zerowy koszt po stronie serwera — indeks to po prostu statyczne pliki. Bez klucza API, bez limitów użycia, bez infrastruktury.
Pipeline indeksowania
Pagefind uruchamia się po tym jak app:build wyprodukuje cały statyczny HTML w public/static/:
npx pagefind --site public/static --output-path public/pagefind
Proces:
- Skanuje każdy plik HTML w
public/static/w poszukiwaniu regionówdata-pagefind-body - Wyodrębnia zawartość tekstową, strukturę nagłówków i metadane z atrybutów
data-pagefind-meta - Wykrywa język strony z atrybutu HTML
lang(<html lang="en">) - Buduje per-językową binarkę indeksu wyszukiwania WASM — angielskie strony trafiają do
wasm.en.pagefind, polskie dowasm.pl.pagefind - Generuje pliki metadanych per-strona (
pagefind.en_*.pf_meta) — jeden na zaindeksowaną stronę, na język - Zapisuje runtime JavaScript (
pagefind.js), Web Worker (pagefind-worker.js) oraz manifest metadanych (pagefind-entry.json)
Dla strony o ~170 podstronach z 2 językami (angielski + polski), produkuje to około 437 plików:
public/pagefind/
├── pagefind.js ← runtime JS (ładowany przez dynamic import)
├── pagefind-entry.json ← mapowanie język → hash → liczba stron
├── pagefind-highlight.js ← silnik podświetlania tekstu po stronie klienta
├── pagefind-worker.js ← Web Worker dla równoległych zapytań
├── wasm.en.pagefind ← binarka indeksu angielskiego
├── wasm.pl.pagefind ← binarka indeksu polskiego
├── fragment/ ← podzielone fragmenty indeksu
├── index/ ← metadane indeksu
├── pagefind.en_*.pf_meta ← metadane per-strona angielski (~170 plików)
└── pagefind.pl_*.pf_meta ← metadane per-strona polski (~170 plików)
Manifest pagefind-entry.json:
{
"version": "1.5.2",
"languages": {
"en": {"hash": "en_cca8c68272", "wasm": "en", "page_count": 170},
"pl": {"hash": "pl_8f57265768", "wasm": "pl", "page_count": 170}
}
}
Każdy język to całkowicie niezależny indeks. Klient ładuje tylko binarkę WASM dla języka bieżącego locale.
Kontrakt data-pagefind-*
Trzy atrybuty HTML kontrolują co jest indeksowane i co pojawia się w wynikach wyszukiwania. Są umieszczane per szablon treści, nie w bazowym layoucie — każdy typ strony decyduje co jest przeszukiwalne.
data-pagefind-body
Umieszczony na głównym kontenerze treści. Mówi Pagefind: indeksuj wszystko wewnątrz tego elementu.
Szablon wpisu:
<article data-pagefind-body>
<h1>{{ content.title() }}</h1>
<div class="prose">{{ content.body() }}</div>
</article>
Każdy szablon treści ustawia to na swoim własnym wrapperze article/div. Strona wyszukiwarki jawnie rezygnuje:
<div class="search-page" data-pagefind-ignore="all">
Zapobiega to indeksowaniu strony wyników wyszukiwania przez samą siebie — realny problem dla stron gdzie Pagefind indeksuje snippety wyników.
data-pagefind-meta
Wstrzykuje ustrukturyzowane metadane do indeksu wyszukiwania. Dostępne jako result.meta.* w JS po stronie klienta. Szablon wpisu:
<article data-pagefind-body>
<h1 data-pagefind-meta="title">{{ content.title() }}</h1>
<span style="display:none" data-pagefind-meta="date">{{ content.dateString() }}</span>
<span style="display:none" data-pagefind-meta="category">{{ content.category(locale) }}</span>
<span style="display:none" data-pagefind-meta="tags">{{ content.tags()|join(',') }}</span>
</article>
Komponent responsywnego obrazu wstrzykuje metadane image[src] na żądanie:
{# responsive_img.html.twig #}
<img src="{{ src }}" alt="{{ alt }}"
{% if pagefind ?? false %}data-pagefind-meta="image[src]"{% endif %}>
Wywołujący przekazują pagefind: true:
{# featured_image.html.twig #}
{{ include('components/responsive_img.html.twig', {
src: item.image(),
alt: item.imageAlt(),
pagefind: true
}) }}
Pagefind przechowuje URL obrazu, który JS wyszukiwania może odczytać by renderować miniatury w wynikach.
data-pagefind-ignore
Wyklucza treści które nie powinny pojawiać się w tekście wyszukiwania:
<div class="post-tags" data-pagefind-ignore>
{# pigułki tagów — nawigacja użytkownika, nie treść wpisu #}
</div>
<section class="related-posts" data-pagefind-ignore>
{# treści referencyjne nie są treścią bieżącego wpisu #}
</section>
<details class="series-nav" data-pagefind-ignore>
{# widgety nawigacji serii — zaśmiecają tekst wyszukiwania #}
</details>
<nav class="post-navigation" data-pagefind-ignore>
{# linki poprzedni/następny — nie treść wpisu #}
</nav>
Bez tych ignorowań, każde wyszukiwanie Pagefind zwracałoby wyniki wypełnione szablonową nawigacją i tekstem widgetów.
Dwa interfejsy wyszukiwania
notACMS dostarcza dwie implementacje wyszukiwania po stronie klienta. Obie ładują Pagefind leniwie i stosują debouncing zapytań. Służą różnym wzorcom interakcji.
Interfejs A: Samodzielna strona wyszukiwania (/search/?q=...)
Plik: assets/search.js (103 linie). Używany na dedykowanej stronie /search/.
Konfiguracja przez atrybuty data:
{# search/index.html.twig #}
<div id="search"
data-placeholder="{{ 'search.placeholder'|trans }}"
data-zero-results="{{ 'search.no_results'|trans({'%query%': '[SEARCH_TERM]'}) }}"
data-read-more="{{ 'blog.read_more'|trans }}"
data-tag-base="{{ path('blog_tag_' ~ locale, {tag: 'tag-placeholder'}) }}"
data-image-widths="{{ image_variant_widths|join(',') }}">
</div>
Token [SEARCH_TERM] jest zastępowany w czasie renderowania. Token tag-placeholder jest zastępowany slugiem tagu każdego wyniku.
Leniwę ładowanie:
import('/pagefind/pagefind.js').then(function (pf) {
pagefind = pf;
// Jeśli URL ma ?q=, wypełnij input i szukaj
var params = new URLSearchParams(window.location.search);
var q = params.get('q') || '';
if (q) { input.value = q; doSearch(q); }
});
Pagefind jest ładowany tylko gdy ktoś nawiguje do strony wyszukiwania. Każda inna strona ma zerowy narzut Pagefind. Jeśli URL już ma ?q=bezpieczeństwo, wyszukiwanie uruchamia się natychmiast bez czekania na input użytkownika.
Wyszukiwanie z debouncingiem:
var timer;
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
timer = setTimeout(function () { doSearch(q); }, 250);
});
250ms po ostatnim naciśnięciu klawisza. Wystarczająco szybko by czuć się natychmiastowym, wystarczająco wolno by zapobiec 10 równoległym zapytaniom przy wpisywaniu "symfony statyczna strona."
Zapytania i wyniki:
function doSearch(query) {
pagefind.search(query).then(function (search) {
var top = search.results.slice(0, 10);
Promise.all(top.map(function (r) { return r.data(); })).then(function (items) {
render(items, query);
});
});
}
Top 10 wyników. .data() rozwiązuje się asynchronicznie — każdy wynik wymaga załadowania metadanych z pliku .pf_meta. Promise.all zrównolegla pobieranie metadanych.
Renderowanie wyników:
Każda karta wyniku zawiera:
- Tytuł (zlinkowany do URL strony)
- Meta-linia: kategoria + data oddzielone
• - Tekst zajawek — HTML-escape'owany przez
esc() - Tagi jako linki używające szablonu URL
data-tag-base - Przycisk "Czytaj więcej"
- Obraz wyróżniający z responsywnym
srcsetzbudowanym zimage_variant_widths
Kompromis bezpieczeństwa — bez podświetlonych dopasowań:
Każdy kawałek danych użytkownika i treści przechodzi przez esc():
function esc(s) {
return String(s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
To usuwa znaczniki podświetlenia <mark> Pagefind — </mark> staje się </mark>. Świadomy kompromis: zero ryzyka XSS przez spreparowaną treść w wynikach wyszukiwania, kosztem braku wizualnego podświetlania dopasowanych terminów.
To była poprawka bezpieczeństwa. Oryginalny kod używał innerHTML dla zajawek, umożliwiając XSS przez złośliwy frontmatter treści. Poprawka (CHANGELOG.md linia 37) zmieniła na esc() + textContent. Postawa bezpieczeństwa: cała treść wyszukiwania jest escape'owana. Podświetlenia są poświęcone.
Interfejs B: Nakładka wyszukiwania (Cmd+K wszędzie)
Plik: docs/demo/assets/search-overlay.js (148 linii). Osadzona w base.html.twig jako ukryta pełnoekranowa nakładka.
Markup w szablonie bazowym:
{# base.html.twig — nakładka zawsze w DOM, domyślnie ukryta #}
<div class="search-overlay" id="site-search"
aria-hidden="true" role="dialog" aria-modal="true"
aria-label="{{ 'search.title'|trans }}">
<div class="search-overlay__panel">
<div class="search-overlay__header">
<i class="ph ph-magnifying-glass" aria-hidden="true"></i>
<input class="search-overlay__input" id="site-search-input"
type="search" placeholder="{{ 'search.placeholder'|trans }}"
autocomplete="off" spellcheck="false">
<button class="search-overlay__close"
aria-label="{{ 'search.close'|trans }}">
<i class="ph ph-x" aria-hidden="true"></i><kbd>Esc</kbd>
</button>
</div>
<div class="search-overlay__results" id="search-overlay-results"
aria-live="polite"></div>
<div class="search-overlay__footer">
<span><kbd>↑</kbd><kbd>↓</kbd> Nawiguj</span>
<span><kbd>↵</kbd> Otwórz</span>
<span><kbd>Esc</kbd> Zamknij</span>
</div>
</div>
</div>
Nakładka istnieje na każdej stronie. Domyślnie display: none, przełączana do display: flex przez klasę .is-open.
Wyzwalacze:
- Kliknięcie dowolnego przycisku
.search-trigger(ikona lupy w nawigacji) Cmd+KlubCtrl+Kz dowolnego miejsca na stronie — ponowne naciśnięcie zamyka
Mechanizmy zamykania:
- Kliknięcie przycisku zamknięcia
- Kliknięcie tła (samej nakładki)
- Naciśnięcie
Escape
Leniwę ładowanie — przy pierwszym otwarciu, nie przy ładowaniu strony:
function loadPagefind() {
if (pagefind || isLoading) return;
isLoading = true;
import('/pagefind/pagefind.js').then(function (pf) {
pagefind = pf;
isLoading = false;
var q = input.value.trim();
if (q) doSearch(q);
}).catch(function () {
isLoading = false;
});
}
Ktoś może przeglądać 10 stron i nigdy nie otworzyć wyszukiwania. Pagefind ładuje się tylko gdy po raz pierwszy naciśnie Cmd+K. Handler catch zapobiega zawieszeniu isLoading jeśli import się nie powiedzie.
Wyniki — prostsze renderowanie:
Top 8 wyników, tylko tytuł + zajawka. Bez obrazów, bez tagów, bez meta-linii. Zajawka renderowana przez innerHTML:
'<span class="search-overlay__result-excerpt">' + r.excerpt + '</span>'
To zachowuje znaczniki podświetlenia <mark> Pagefind — w przeciwieństwie do samodzielnego wyszukiwania które je escape'uje. Nakładka akceptuje powierzchnię XSS spreparowanej treści w zamian za widoczne podświetlanie terminów. Tytuł i URL nadal są escape'owane przez esc().
Nawigacja klawiaturą:
| Klawisz | Z inputa | Z wyniku |
|---|---|---|
↓ (StrzałkaDół) |
Focus na pierwszy wynik | Przejdź do następnego |
↑ (StrzałkaGóra) |
(ignorowane) | Przejdź do poprzedniego lub do inputa jeśli pierwszy |
Enter |
(bez akcji) | Nawiguj do URL wyniku |
Esc |
Zamknij nakładkę | Zamknij nakładkę |
Zarządzanie focusem: przy otwarciu, focus przechodzi na input po 50ms opóźnieniu (pozwala rozpocząć się przejściu CSS). Przy zamknięciu, focus wraca do elementu który był aktywny przed otwarciem nakładki.
Dostępność:
aria-hidden="true"/"false"— przełączane przy otwarciu/zamknięciurole="dialog",aria-modal="true"— czytnik ekranu ogłasza jako modalaria-live="polite"na wynikach — czytnik ogłasza zmiany liczby wynikówaria-labelna inpucie, przycisku zamknięcia, kontenerze wyników
Wdrożenie produkcyjne
Przypinanie wersji (wojenna opowieść z Raspberry Pi 5)
Skrypt produkcyjnego deployu (scripts/rebuild-content.sh) przypina Pagefind do @1.5.0:
docker compose run --rm php npx --yes pagefind@1.5.0 \
--site public/static \
--output-path public/pagefind
Lokalny skrypt DDEV (.ddev/commands/web/build) nie przypina:
npx --yes pagefind --site public/static --output-path public/pagefind
Dlaczego podział? Późniejsze wersje Pagefind dostarczają binarkę ARM64 linkowaną z jemalloc. Ta binarka crashuje na hostach z jądrem używającym 16K stron — co obejmuje Raspberry Pi 5 z domyślnym jądrem 64-bitowym. Błąd:
<jemalloc>: Unsupported system page size
Przypięta wersja 1.5.0 jest ostatnią bez zależności od jemalloc. DDEV-local działa na x86_64, gdzie wszystkie wersje są poprawne — bez potrzeby przypinania. Ten podział oznacza że środowisko deweloperskie zawsze używa najnowszego Pagefind, podczas gdy produkcja zostaje na sprawdzonej działającej wersji. Śledzenie upstream: Pagefind#1147.
Rozmiar indeksu
~437 plików dla strony o 170 podstronach w 2 językach. Każda nowa strona dodaje około 3 plików metadanych (po jednym na język). Rozmiar rośnie liniowo, nie wykładniczo. 1000-stronicowa strona z 2 językami generuje około 6000 plików indeksu — wciąż zarządzalne dla każdego systemu plików i nginx try_files.
Wydajność WASM
Binarka WASM jest per-językowa (~200–500KB). Zapytania działają w Web Workerze (`pagefind-worker.js``), poza głównym wątkiem. Opóźnienie: poniżej 50ms dla większości zapytań na indeksie 170 stron. Wąskim gardłem jest transfer sieciowy pliku WASM, nie wykonanie zapytania — a plik jest cache'owany przez przeglądarkę po pierwszym załadowaniu. Całkowicie zerowe przetwarzanie zapytań po stronie serwera.
Czas budowy
npx pagefind wykonuje się w 2–5 sekund dla 170 stron. Zdominowane przez kompilację WASM z kodu źródłowego Rusta (dzieje się raz, nie per strona), nie parsowanie HTML. Skaluje się liniowo z liczbą stron. Dla strony o 10 000 podstron, oczekuj około 30–60 sekund — wciąż akceptowalne dla kroku deployu.
Konfiguracja nginx
Bez specjalnej konfiguracji. public/pagefind/ to po prostu statyczne pliki, serwowane przez try_files jak wszystko inne. import('/pagefind/pagefind.js') w JS rozwiązuje się jako normalne żądanie statycznego pliku do /pagefind/pagefind.js. Bez przekierowań origin, bez nagłówków CORS, bez specjalnych bloków lokalizacji.
Opcje dostosowywania
Zmień wygląd wyników — umieść zmodyfikowane kopie search.js lub search-overlay.js w local/assets/. Oryginały są odpowiednio w assets/ i docs/demo/assets/. Lokalna kopia ma priorytet przez przestrzeń nazw local/.
Dodaj więcej metadanych — każdy element z data-pagefind-meta="mojKlucz" staje się result.meta.mojKlucz w JS. Dodaj atrybut data-pagefind-meta="series" do wpisów z serii, odczytaj go w renderowaniu search.js, wyświetlaj odznaki serii.
Stylizuj interfejs wyszukiwania — oba UI używają zwykłych klas CSS (.search-page__input, .search-overlay__result, .search-result). Nadpisuj w local/assets/styles/ jak każdy inny SCSS komponentu.
Zmień wyzwalacz nakładki — nakładka jest osadzona przez nadpisanie szablonu. Kopiuj docs/demo/templates/base.html.twig do local/templates/base.html.twig, dostosuj przycisk wyzwalacza lub skrót klawiszowy.
Wielojęzykowe wyszukiwanie — automatyczne. Pagefind wykrywa atrybut lang na <html> i ładuje pasującą binarkę WASM. Strona wyszukiwania przeszukuje indeks bieżącego locale. Przełączenie na inny język nawiguje do /{locale}/search/, ładując inną binarkę WASM dla tego locale.
Czego Pagefind nie potrafi
Szczere ograniczenia które mają znaczenie dla prawdziwych stron:
Bez wyszukiwania rozmytego. Dokładne dopasowanie podciągów ze stemmingiem per język. Wpisanie "bezpieczensto" nie znajdzie "bezpieczeństwo". Wpisanie "symfony" nie znajdzie "Symfony". To największa luka UX. Google Programmable Search jest rozwiązaniem awaryjnym dla stron które naprawdę potrzebują dopasowania rozmytego.
Bez analityki wyszukiwania. Brak serwera do logowania. Brak panelu "najczęściej wyszukiwane terminy". Jeśli potrzebujesz wiedzieć czego ludzie szukają, potrzebujesz integracji analityki po stronie serwera (Cloudflare Analytics, Plausible, itp.) wyzwalanej z JS wyszukiwania.
Bez indeksowania w czasie rzeczywistym. Indeks jest budowany w czasie deployu. Nowy wpis opublikowany przez zaplanowaną datę pojawia się w wyszukiwarce dopiero po kolejnym deployu. Dla stron które deployują przy każdej zmianie treści (standardowy workflow notACMS), to nie problem. Dla stron które deployują raz w tygodniu, wyszukiwanie ma opóźnienie.
Bez wyszukiwania fasetowego. Filtry jak "tylko wpisy z 2026" lub "tylko porady" to nie fasetki wyszukiwania — to osobne strony (/wpisy/archiwum/2026/, /wpisy/kategoria/porady/). Pagefind przeszukuje całą treść jednolicie. Wyszukiwanie fasetowe wymagałoby zbudowania własnej struktury indeksu na bazie metadanych Pagefind lub przejścia na inny silnik wyszukiwania.
Bez wyszukiwania między locale. Każde locale to osobny indeks. Szukanie "security" po angielsku nie znajdzie polskich wpisów otagowanych bezpieczenstwo. To właściwość modelu wykrywania języka Pagefind — traktuje każdy język jako niezależny korpus. Dla dwujęzycznego bloga oznacza to że użytkownicy czasem tracą treści w drugim języku.
"Wyszukiwanie na statycznej stronie" brzmi jak sprzeczność. Nie jest — indeks WASM budowany w czasie deployu z leniwie ładowanym klientem JS radzi sobie z tym w całości. Bez Elasticsearch, bez Algolii, bez endpointa wyszukiwania w PHP. Dwa UI dla dwóch trybów interakcji. Jedna produkcyjna opowieść wojenna o ARM64 i jemalloc. Koszt to ~2–5 sekund przy deployu i ~200–500KB cache'owanej przeglądarkowo binarki WASM. Dla 99% przypadków użycia statycznych stron, to wszystko czego potrzeba. Alternatywa — Algolia, Elasticsearch, endpoint wyszukiwania w PHP — kosztuje pieniądze, dodaje ruchome części i rozwiązuje problemy których większość statycznych stron nie ma.
To kończy serię open-source-notacms. Część 1 zaczęła od 368 testów. Część 2 opisała audyt kodu przez AI. Część 3 dotyczyła luk bezpieczeństwa. Część 4 zaprojektowała wzorzec nadpisywania. Część 5 wypuściła projekt. Część 6 wyjaśniła pipeline budowania. Część 7 zbudowała motyw produkcyjny. Część 8 opisała architekturę wyszukiwania. Oryginalna seria o migracji z WordPressa opowiada pełną historię migracji.