Added support for single localization

This commit is contained in:
2026-09-08 18:12:58 +02:00
parent e30ac71044
commit 419207ac12
16 changed files with 287 additions and 102 deletions
+24
View File
@@ -10,6 +10,30 @@ registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
## ⚠️ NIE pisz własnego renderera bloków (switch)
**Renderer bloków to `RenderBlocks` z pluginu — NIGDY własny `switch`/`if`.**
Częsty błąd: projekt pisze własny `BlockRenderer` z `switch (block.blockType)`
i 20 case'ami. To łamie A0 — plugin ma silnik, projekt dostarcza tylko MAPĘ
komponentów.
```tsx
// ŹLE — własny switch w projekcie (gadatliwy, bez enhanceProps, rośnie liniowo)
switch (block.blockType) {
case 'hero': return <Hero {...block} />
case 'faq': return <FAQ {...block} />
// ...20 case'ów
}
// DOBRZE — mapa + RenderBlocks (silnik z pluginu)
const registry = { hero: Hero, faq: FAQ, /* ... */ }
<RenderBlocks blocks={page.layout} components={registry} />
```
Dlaczego RenderBlocks, nie switch: enhanceProps (anchory nav, itp.), guardy,
obsługa zagnieżdżeń, spójność między projektami. Switch tego nie ma i rośnie
z każdym blokiem. Mapa jest płaska i deklaratywna.
## Front — RenderBlocks
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
+1 -1
View File
@@ -121,7 +121,7 @@ utrata SEO. **Każdy projekt** tego potrzebuje w next.config:
```ts
const nextConfig: NextConfig = {
htmlLimitedBots:
/Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i,
/Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i,
// ...
}
```
+95 -1
View File
@@ -154,4 +154,98 @@ Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą)
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
## Strona jednojęzyczna (bez prefiksu /pl)
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie
`/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb
jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
się same:
- **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/`
- **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych)
- **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji)
- **canonical** → `https://klient.pl/o-nas` (bez prefiksu)
### Config — jeden locale
```ts
// i18n.config.ts
export const i18nConfig = {
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
defaultLocale: 'pl',
}
```
### Struktura katalogów — BEZ [locale]
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`:
```
# JEDNOJĘZYCZNY (bez [locale])
app/(frontend)/
layout.tsx # locale stałe z config, nie z params
not-found.tsx
[[...slug]]/page.tsx # /o-nas, /kontakt
# WIELOJĘZYCZNY (z [locale]) — dla porównania
app/(frontend)/[locale]/
layout.tsx # locale z params
[[...slug]]/page.tsx # /pl/o-nas, /en/about
```
### Layout jednojęzyczny — locale z config
```tsx
// app/(frontend)/layout.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export default async function Layout({ children }: { children: React.ReactNode }) {
const locale = i18nConfig.defaultLocale // stałe, nie z params
const settings = await getSettings(locale)
// ...reszta jak zwykle, ale locale jest stałe
return <html lang={locale}>...</html>
}
```
### Strony jednojęzyczne — locale z config
```tsx
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export async function generateMetadata({ params }) {
const { slug } = await params // TYLKO slug, nie locale
const locale = i18nConfig.defaultLocale // stałe
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
}
export default async function Page({ params }) {
const { slug } = await params
const locale = i18nConfig.defaultLocale // stałe
const route = await resolveRoute(locale, slug ?? [], pageNum)
// ...
}
```
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config.
### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz
go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez
przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
### Przejście jedno- → wielojęzyczny (later)
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
- dodaj locale do config
- przenieś strukturę do `[locale]/`
- layout/strony czytają locale z params
- wróci prefiks `/pl`, `/en` + hreflang
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
`/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.
+25
View File
@@ -53,6 +53,31 @@ Per strona (tab SEO):
Domyślnie: `Tytuł | Nazwa witryny`.
### Skąd bierze się „Tytuł" (priorytet źródła)
Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:
1. **titleOverride** — jeśli wypełniony, jest całym tytułem (bez składania).
2. **meta.title** — tytuł SEO wpisany w tab SEO.
3. **page.title** — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.
Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:
- **buildAutoFillMetaHook** (przy ZAPISIE) — wypełnia puste `meta.title` z pola
dokumentu (`title`). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
- **pageTitle w buildMetadata** (przy RENDEROWANIU) — jeśli meta.title mimo to
puste (np. auto-fill niewpięty), używa `page.title`. Druga linia obrony.
Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie
pusty tytuł ani sam siteName.
> **Uwaga — „Strona Główna” w tytule:** jeśli strona główna ma nazwę dokumentu
> „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie
> „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz **titleOverride**
> dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na
> coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest
> sama treść nazwy, nie brak tytułu.
## Front — createPageMetadata
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
+21
View File
@@ -133,6 +133,27 @@ pnpm dev
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
```
## Root subdomeny media zwraca 404 (to normalne)
`media.klient.pl/plik.jpg` → R2 zwraca plik. Ale `media.klient.pl/` (sam root,
bez pliku) → **404**, bo R2 nie ma obiektu pod rootem. To NORMALNE zachowanie
R2, nie błąd.
Audyty SEO (Screaming Frog) czasem zgłaszają to 404 — bo crawler widzi URL-e
plików (`media.../logo.svg`) i próbuje roota. Ale:
- **NIE linkuj do samego roota** `media.klient.pl/` — tylko do plików. Kod nie
powinien nigdzie mieć `media.klient.pl/` bez nazwy pliku.
- Root media 404 **nie szkodzi SEO** głównej domeny (Google indeksuje klient.pl,
nie media.klient.pl). Nikt nie trafia na root media.
**Plugin tego nie naprawi** — subdomena media to serwis R2/Cloudflare, nie
aplikacja Next. Żądania do media.klient.pl nie docierają do Twojego kodu.
Jeśli chcesz „czysto" w Search Console (opcjonalne): Cloudflare → Rules →
Redirect Rules → gdy hostname = `media.klient.pl` i path = `/` → 301 na
`klient.pl`. Jednorazowo w panelu CF. Ale to kosmetyka — root media 404 jest
nieszkodliwe.
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**: