diff --git a/docs/PLAYBOOK.md b/docs/PLAYBOOK.md new file mode 100644 index 0000000..dfe9e5d --- /dev/null +++ b/docs/PLAYBOOK.md @@ -0,0 +1,239 @@ +# Playbook wdrożenia — ipal-kit + +Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ +KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z +faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją. + +Powiązane: [publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md) +(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI). + +--- + +## ZŁOTE ZASADY + +1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod. +2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co + robi już plugin — zatrzymaj się, użyj pluginu. +3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń. +4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź. +5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki. +6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.** + +--- + +## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze) + +Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie +zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez +PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie. + +``` +źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart +``` + +### Sztywna procedura zmiany w pluginie + +```bash +cd ~/payload-cms/ipal-kit + +# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest +grep -c "" src/<ścieżka> # MUSI być >0 + +# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę +pnpm build +grep -c "" dist/<ścieżka> # MUSI być >0 + +# 3. COMMIT (PRZED version — inaczej "working directory not clean") +git add -A && git commit -m "opis" + +# 4. VERSION + PUBLISH +npm version patch # czyste repo wymagane +npm publish + +# 5. PUSH +git push && git push --tags + +# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę +cd ~/ +pnpm add @intecion/ipal-kit@ +grep -c "" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0 + +# 7. RESTART dev (Payload buduje adaptery/config przy starcie!) +pnpm dev +``` + +### TRZY punkty kontrolne grep (nie pomijaj żadnego) + +| Etap | Grep | Jeśli 0 | +|---|---|---| +| po edycji | `src/...` | zmiana nie zapisana / zły plik | +| po build | `dist/...` | build nie złapał / błąd typów | +| po pnpm add | `node_modules/...` | projekt ma starą wersję | + +**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`. +Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts` +→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js` +pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa. + +### Pułapki kolejności (realne błędy sesji) + +- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE + commit przed version. +- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed + publish, grep dist po buildzie. +- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci. + Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu. + +--- + +## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze) + +Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze. + +### Reguła: grepuj TOKENY, nie całe frazy z kolejnością + +**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK — +bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`). +Obiekt JS ignoruje kolejność, ale grep nie. + +```bash +# ŹLE — zależny od kolejności/formatowania: +grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu + +# DOBRZE — pojedynczy token, odporny: +grep -c "senderName" plik.ts # 3 ✓ +``` + +Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją. + +--- + +## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE" + +Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę: + +1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie. + Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.) +2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie. + Kliknij Save. +3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie + instancje = hooki bez kontekstu. Sprawdź: + `ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui` + Jest? → peerDependency problem (patrz Część D). +4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy + mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na + inny adres. +5. **Import map nieaktualny?** Custom komponenty Payload: + `npx payload generate:importmap`. + +**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'". +Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) → +`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D. + +--- + +## CZĘŚĆ D — peerDependencies (dublety zależności) + +**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie, +NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet +→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem). + +Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`, +`@payloadcms/plugin-*`, `react`, `react-dom`, `next`. + +**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) → +pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner +wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies, +opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`. + +Weryfikacja braku dubletu: +```bash +ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \ + && echo "DUBLET ✗" || echo "OK ✓" +``` + +--- + +## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność) + +Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności. + +1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22 +2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion` +3. **`pnpm add @intecion/ipal-kit`** + zależności peer +4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje) +5. **i18n.config.ts** — jedno źródło locale +6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()` +7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno) +8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload +9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota +10. **Bloki** — dane przez enhanceProps, nie import lib (cykl) +11. **buildSlugField** zamiast ręcznego slug +12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek +13. **buildSecurityHeaders** w next.config +14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa + +--- + +## CZĘŚĆ F — EMAIL (SMTP vs Graph) + +Pełne szczegóły: [email.md](./email.md). Decyzja transportu: + +- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony) +- **Klient z własnym SMTP / Gmail** → SMTP +- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport +- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce + +### Graph — checklist wdrożenia (Wasza strona, jednorazowo) + +1. Azure: App registration → tenantId, clientId, clientSecret +2. Azure: Mail.Send APPLICATION permission + **Grant admin consent** +3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER +4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to) + +### Realne pułapki Graph (wszystkie zdarzyły się w sesji) + +| Błąd | Przyczyna | Naprawa | +|---|---|---| +| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo | +| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki | +| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure | +| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send | + +**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka, +zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta +→ replyTo (odpowiedzi trafiają do klienta). + +--- + +## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU + +Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu +→ projekt. Jeśli identyczne wszędzie → plugin.** + +| Rzecz | Gdzie | Dlaczego | +|---|---|---| +| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne | +| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu | +| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side | +| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie | +| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env | +| **CSP** | **projekt** | zależy od domen projektu | +| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy | +| **Breadcrumbs** | **projekt** | render z danych routingu projektu | +| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy | + +--- + +## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE" + +Nie mów „działa", dopóki: + +- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev) +- [ ] root `/` przekierowuje na locale (bez middleware.ts) +- [ ] formularz wysyła (test przez panel: Send test) +- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno) +- [ ] brak dubletu @payloadcms/ui (Część D) +- [ ] grep potwierdza wersję pluginu w node_modules +- [ ] sekrety w .env (nie w repo), maskowane w panelu +- [ ] brak plików middleware.ts, brak zaszytej mapy slugów \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index a7c4909..58cf2f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # IPAL — Dokumentacja modułów +> **Zaczynasz wdrożenie?** Przeczytaj najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) — sztywna procedura, kolejność, realne przykłady błędów. + **Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny [README](../README.md). **Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md). @@ -118,7 +120,7 @@ export default buildConfig({ | content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) | Nowy projekt krok po kroku: [getting-started.md](./getting-started.md) -Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md) +Referencja wdrożenia frontu: [getting-started.md](./getting-started.md) Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md) Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md) Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md) diff --git a/docs/email.md b/docs/email.md index 521c0d7..83bee37 100644 --- a/docs/email.md +++ b/docs/email.md @@ -149,4 +149,31 @@ Jeśli `from` w panelu = cudza domena (np. `noreply@klient.pl`), a sender = `forms@intecion.pl` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze -domeny nie jest potrzebny. \ No newline at end of file +domeny nie jest potrzebny. + +## Przełącznik transportu — mailAdapter + +`mailAdapter()` to dyspozytor: jeden adapter wpięty w config, wybiera transport +(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu +przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie +da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz). + +```ts +// payload.config.ts — JEDEN adapter, wybór wewnątrz +import { mailAdapter } from '@intecion/ipal-kit' +email: mailAdapter() +``` + +Panel → Site Integrations → SMTP → **Email Transport** (SMTP / Microsoft Graph). +Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak +sekretów w .env → log + fallback na SMTP (nie cicha awaria). + +## Test wysyłki — przycisk w panelu + +W tabie SMTP jest przycisk **Send test**: podaj adres, kliknij, wyślij testowy +mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint +`POST /api/ipal/test-email` (admin-only). Zapisz zmiany przed testem — endpoint +czyta z bazy, nie z pola na ekranie. + +> Bezcenne przy diagnozie Graph — od razu widzisz `ErrorSendAsDenied`, +> `Insufficient privileges` itp. zamiast zgadywać. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md index 3876d4c..aa7b553 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,14 +1,18 @@ -# Nowy projekt — krok po kroku +# Setup projektu — od zera do wdrożenia -> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym +Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z +blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność +kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata). + +> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym > [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest -> już zainstalowany, i przeprowadza przez **konfigurację** projektu. +> zainstalowany, i przeprowadza przez konfigurację. +> +> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) +> — zasady, procedura, pułapki. -Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i -formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat -bazy, importMap, kolejność wpięcia). - -Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter). +Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy, +importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16. --- @@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter). ```bash npx create-payload-app@latest moj-projekt -# → Blank, SQLite +# → Blank, SQLite (dev) / Postgres (prod) cd moj-projekt ``` ## 2. Plugin i zależności -Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea -albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności -współdzielone z Payloadem, których plugin nie zaciąga sam: +Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj +zależności współdzielone z Payloadem, których plugin nie zaciąga sam: ```bash -pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \ +pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \ nodemailer lucide-react slugify server-only ``` +### Spójność wersji @payloadcms/* (KRYTYCZNE) + Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo projekt się wywala. Wymuś w `package.json`: @@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`: ```json "pnpm": { "overrides": { - "payload": "3.84.1", - "@payloadcms/ui": "3.84.1", - "@payloadcms/next": "3.84.1", - "@payloadcms/db-sqlite": "3.84.1", - "@payloadcms/richtext-lexical": "3.84.1", - "@payloadcms/plugin-seo": "3.84.1", - "@payloadcms/plugin-form-builder": "3.84.1" + "payload": "3.88.0", + "@payloadcms/ui": "3.88.0", + "@payloadcms/next": "3.88.0", + "@payloadcms/db-postgres": "3.88.0", + "@payloadcms/richtext-lexical": "3.88.0", + "@payloadcms/plugin-seo": "3.88.0", + "@payloadcms/plugin-form-builder": "3.88.0" } } ``` @@ -53,11 +58,17 @@ projekt się wywala. Wymuś w `package.json`: rm -rf node_modules pnpm-lock.yaml && pnpm install ``` +### Build script z --webpack (Next 16) + +Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`: +```json +"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack" +``` + ## 3. Konfiguracja locale — jedno źródło -Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc -nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu -miejscach: +Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie +może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie: ```ts // src/i18n.config.ts @@ -67,25 +78,24 @@ export const i18nConfig = { { code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }, ], -} as const +} as const // as const — inaczej TS nie uzna locales za niepustą tuple ``` -`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę. +Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`. ## 4. payload.config.ts ```ts -import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit' +import { ipalKit, mailAdapter } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' import { Pages } from '@/collections/Pages' export default buildConfig({ - // …reszta z template'u collections: [Users, Media, Pages], - // SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu. + // Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce. // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka). - email: panelSmtpAdapter(), + email: mailAdapter(), plugins: [ ipalKit({ @@ -113,11 +123,11 @@ export const Pages: CollectionConfig = { access: { read: () => true }, fields: [ { name: 'title', type: 'text', required: true, localized: true }, - buildSlugField({ from: 'title' }), + buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma { name: 'layout', type: 'blocks', - blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala + blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje }, ], } @@ -158,15 +168,28 @@ import type { BlockComponentMap } from '@intecion/ipal-kit/rsc' import { ContentBlockComponent } from '@/blocks/Content/Component' export const blockRegistry: BlockComponentMap = { - content: ContentBlockComponent, + content: ContentBlockComponent, // klucz = slug bloku } ``` -Klucz w rejestrze = `slug` bloku. +**Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok. -## 7. Tailwind +### enhanceProps — wstrzykiwanie danych server-side do bloków -Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie. +Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey, +odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu: + +```ts +const enhanceProps = ({ block }) => { + if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo } + return {} +} +``` + +## 7. Tailwind (WYMÓG) + +Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w +czystym Tailwindzie. ```bash pnpm add tailwindcss @tailwindcss/postcss @@ -183,16 +206,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } } @source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js"; ``` -`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez -niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły. -Ścieżka jest relatywna do pliku CSS. +**`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez +niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły). +Ścieżka relatywna do pliku CSS. -## 8. Proxy (dawniej middleware) +### Przestylowanie pod klienta -> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz -> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian: -> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy: -> `npx @next/codemod@canary middleware-to-proxy .` +Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom +properties (fallbacki wbudowane): +```css +:root { + --ipal-primary: #16a34a; + --ipal-radius: 1rem; +} +``` +Pełna lista tokenów + opcja classNames: [consent.md](./consent.md). + +## 8. Proxy (routing locale) — NIGDY middleware.ts + +> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja +> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy +> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .` +> +> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa +> subpath eksportu, NIE nazwa pliku. Nie myl ich. ```ts // src/proxy.ts @@ -205,104 +242,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig }) export function proxy(request: NextRequest) { const result = localeMiddleware(request) - if (result.type === 'next') return NextResponse.next() - const response = NextResponse.redirect(result.location) - // cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined - if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value) + // Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie + // języka), TYLKO gdy jest zgoda na functional. + const response = + result.type === 'next' + ? NextResponse.next() + : NextResponse.redirect(result.location) + + if (result.cookie) { + response.cookies.set(result.cookie.name, result.cookie.value) + } return response } -// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje -// importów. Importowana stała zostanie zignorowana, proxy złapie /admin -// i /_next, i wszystko zwróci 500. +// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów. +// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500. +// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki. export const config = { matcher: ['/((?!api|admin|_next|.*\\..*).*)'], } ``` -> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa -> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się -> `middleware.ts` czy `proxy.ts`. +### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa) -## 9. Warstwa dostępu do danych - -Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()` -sprawia, że nie pytają bazy dwa razy o to samo. +Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów. +Slugi są w bazie (pole `slug` localized): ```ts -// src/lib/payload.ts -import { cache } from 'react' -import { getPayload } from 'payload' -import config from '@/payload.config' +import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit' -export const getCachedPayload = cache(async () => getPayload({ config: await config })) - -export const getSettings = cache(async (locale: string) => - (await getCachedPayload()).findGlobal({ - slug: 'site-settings', - locale: locale as 'pl' | 'en', - depth: 2, - }), -) +const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' }) +const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig }) +switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about' ``` -```ts -// src/lib/locales.ts -import { cache } from 'react' -import config from '@/payload.config' +## 9. Warstwa dostępu do danych — lib/ (jedno źródło) -export const getConfiguredLocales = cache(async (): Promise => { - const payloadConfig = await config - return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : [] +```ts +// src/lib/content.ts — JEDYNE źródło helperów pluginu +import { createContentHelpers } from '@intecion/ipal-kit' +import payloadConfig from '@/payload.config' +import { i18nConfig } from '@/i18n.config' + +export const { + getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots, +} = createContentHelpers({ + config: payloadConfig, // PAYLOAD config (nie i18n!) + content: { collections: [] }, + i18n: i18nConfig, // i18n OSOBNO }) ``` ```ts -// src/lib/pages.ts +// src/lib/payload.ts — funkcje projektu, typowane import { cache } from 'react' -import type { Page, SiteSetting } from '@/payload-types' -import { getCachedPayload, getSettings } from './payload' +import { getSiteSettings } from '@intecion/ipal-kit' +import { getCachedPayload } from './content' // z content, nie osobny getPayload +import type { SiteSetting } from '@/payload-types' -export const resolvePage = cache( - async (locale: string, slugPath: string | null): Promise => { - if (!slugPath) { - // Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu. - const settings = (await getSettings(locale)) as SiteSetting - const homepage = settings.homepage - return homepage && typeof homepage === 'object' ? homepage : null - } - - const payload = await getCachedPayload() - const result = await payload.find({ - collection: 'pages', - where: { slug: { equals: slugPath } }, - locale: locale as 'pl' | 'en', - depth: 2, - limit: 1, - }) - return result.docs[0] ?? null - }, +export const getSettings = cache(async (locale: string) => + getSiteSettings(await getCachedPayload(), { locale: locale as never, depth: 2 }), ) ``` +> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma +> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd. + ## 10. Trasy Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje -layout locale, bo `` musi znać język, a `(frontend)` jest ponad -segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje. +layout locale (bo `` musi znać język). ``` src/app/(frontend)/ styles.css [locale]/ - layout.tsx + layout.tsx # walidacja locale + ConsentProvider + Analytics [[...slug]]/ - page.tsx + page.tsx # render bloków ``` -`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast -tablicy (`slug.join is not a function`) i nie łapią samego `/pl`. +**`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]` +dają string (`slug.join is not a function`) i nie łapią samego `/pl`. ```tsx // src/app/(frontend)/[locale]/layout.tsx @@ -310,8 +332,8 @@ import { notFound } from 'next/navigation' import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit' import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client' import { i18nConfig } from '@/i18n.config' -import { getCachedPayload, getSettings } from '@/lib/payload' -import { getConfiguredLocales } from '@/lib/locales' +import { getCachedPayload, getConfiguredLocales } from '@/lib/content' +import { getSettings } from '@/lib/payload' import '../styles.css' export default async function LocaleLayout({ children, params }) { @@ -325,13 +347,9 @@ export default async function LocaleLayout({ children, params }) { const [texts, analytics] = await Promise.all([ getConsentTexts({ - config: i18nConfig, - locale, - payload, - privacyPolicy: - privacyPage && typeof privacyPage === 'object' - ? { page: privacyPage, label: 'Polityka prywatności' } - : undefined, + config: i18nConfig, locale, payload, + privacyPolicy: privacyPage && typeof privacyPage === 'object' + ? { page: privacyPage, label: 'Polityka prywatności' } : undefined, }), getAnalyticsConfig(payload), ]) @@ -343,7 +361,7 @@ export default async function LocaleLayout({ children, params }) {
{children}
- + {/* WEWNĄTRZ ConsentProvider */} @@ -364,8 +382,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc' import { createPageMetadata } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' import { blockRegistry } from '@/blocks/registry' -import { getCachedPayload } from '@/lib/payload' -import { resolvePage } from '@/lib/pages' +import { getCachedPayload, resolveRoute } from '@/lib/content' const pageMetadata = createPageMetadata({ config: i18nConfig, @@ -377,104 +394,92 @@ export async function generateMetadata({ params }): Promise { return pageMetadata({ payload: await getCachedPayload(), locale, slug }) } -export default async function Page({ params }) { +export default async function Page({ params, searchParams }) { const { locale, slug } = await params - const page = await resolvePage(locale, slug?.length ? slug.join('/') : null) - if (!page) notFound() - - return + const { page } = await searchParams + const route = await resolveRoute(locale, slug ?? [], page) // 3 args + if (!route) notFound() + return } ``` -## 11. Środowisko +- brak slug (`/pl`) → home przez System Pages (nie hardkod slug) +- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale +- `depth: 2` → relacje w blokach (form) się populują -```bash -# .env -DATABASE_URL=file:./moj-projekt.db -PAYLOAD_SECRET= -NEXT_PUBLIC_SERVER_URL=http://localhost:3000 -``` +## 11. Metadata / SEO (szczegóły) + +`createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera +dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której +budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string, +hreflang nie powstanie. Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne. -## 12. Generowanie i start +## 12. Nagłówki bezpieczeństwa + +```ts +// next.config.ts +import { buildSecurityHeaders } from '@intecion/ipal-kit' +const securityHeaders = buildSecurityHeaders({ + hsts: process.env.NODE_ENV === 'production', // off w dev (http) + additional: [ /* CSP projektu — zna swoje domeny */ ], +}) +// async headers() { return [{ source: '/:path*', headers: securityHeaders }] } +``` +Szczegóły: [security.md](./security.md). + +## 13. Środowisko + +```bash +# .env +DATABASE_URI= +PAYLOAD_SECRET= +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +# Email przez Graph (opcjonalnie — sekrety agencyjne): +# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=... +``` + +## 14. Generowanie i start ```bash pnpm generate:types -pnpm payload generate:importmap # pola SEO to komponenty admina +pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...) pnpm dev ``` -`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty -admina. +`generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina. -## 13. Konfiguracja w panelu +## 15. Konfiguracja w panelu `http://localhost:3000/admin` -1. **Utwórz pierwszego użytkownika** (dostanie rolę admin). -2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu. -3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale** - (przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza - 404 na `/en/…`. -4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404. -5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne). +1. **Utwórz pierwszego użytkownika** (rola admin). +2. **Site Settings → General** — nazwa witryny, tytuł. +3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty + slug EN = 404 na `/en/…`). +4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404). +5. **Cookie Settings** — treść bannera per język. +6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback). +7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address. -Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę. +Wejdź na `/` — powinno przekierować na `/pl`. --- -## Rzeczy opcjonalne +## Opcjonalne ### Formularz z Turnstile +Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations → +Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret +`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez +mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`. -Wymaga bloku formularza w projekcie (patrz forms.md) oraz: - -- **Site Integrations → Turnstile** — site key i secret. Klucze testowe - Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret - `1x0000000000000000000000000000000AA`. -- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy. -- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}` - wypisze wszystkie pola tabelką). Maile wysyła form-builder przez - `panelSmtpAdapter` — nie pisze się ich w kodzie. - - -### Blog / archiwum (kolekcja pod stroną-archiwum) - -Pełny opis: content.md. W skrócie: - -1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`, - pola, bloki. Dodaj ją do `collections` w payload.config. - -2. **content.config.ts** obok i18n.config.ts: -```ts -import type { ContentOption } from '@intecion/ipal-kit' -export const contentConfig: ContentOption = { - collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }], -} -``` - -3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`. - -4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute` - w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList). - -5. **Baza + typy** — nowa kolekcja to nowy schemat: -```bash -rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev -``` - -6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok - listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy. - -Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny -typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config. - -### Sitemapa i robots.txt - -`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego -argumentów (patrz seo.md), potem dwa pliki po jednej linii: +### Blog / archiwum +Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts + +przypisanie strony-archiwum w System Pages. +### Sitemapa i robots ```ts // app/sitemap.ts export { sitemap as default } from '@/lib/content' @@ -482,44 +487,22 @@ export { sitemap as default } from '@/lib/content' export { robots as default } from '@/lib/content' ``` -Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex. - -### Analytics - -**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują -się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie -zaakceptuje kategorii Analytics. - -### Przestylowanie pod klienta - -```css -/* styles.css */ -:root { - --ipal-primary: #16a34a; - --ipal-radius: 1rem; -} -``` - -Pełna lista tokenów: consent.md. - --- ## Kiedy coś nie działa | Objaw | Przyczyna | |---|---| -| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` | +| Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` | | `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` | -| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda | -| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline | -| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` | +| `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) | +| Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda | +| `/admin` i `/_next` → 500 | matcher w proxy nie jest inline | +| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` | | `/pl` → 404 | Homepage nieustawiony w System Pages | -| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN | +| `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN | | `Missing and ` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma | -| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` | -| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` | -| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu | -| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 | -| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages | -| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu | -| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku | \ No newline at end of file +| Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) | +| Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph | +| istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` | +| zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy | \ No newline at end of file diff --git a/docs/secuirt.md b/docs/security.md similarity index 100% rename from docs/secuirt.md rename to docs/security.md diff --git a/package.json b/package.json index fa078d1..b8d5ec9 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "docs" ], "scripts": { "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",