init commit for iPAL-kit plugin
This commit is contained in:
+17
-11
@@ -67,7 +67,6 @@ const route = await resolveRoute({
|
||||
segments: ['artykuly', 'moj-post'],
|
||||
page: 1, // z ?page=
|
||||
content: contentConfig,
|
||||
withEntries: false, // true → dociąga wpisy do archiwum (patrz niżej)
|
||||
})
|
||||
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
|
||||
```
|
||||
@@ -96,7 +95,7 @@ import { createContentHelpers } from 'ipal-kit'
|
||||
import config from '@/payload.config'
|
||||
import { contentConfig } from '@/content.config'
|
||||
|
||||
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } =
|
||||
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute, getEntries } =
|
||||
createContentHelpers({ config, content: contentConfig })
|
||||
```
|
||||
|
||||
@@ -105,23 +104,30 @@ fabryce i jest współdzielona — dlatego to fabryka, nie luźne funkcje. Bez t
|
||||
`cache()` nie dedupikowałby między helperami, a Next woła generateMetadata i
|
||||
komponent strony osobno.
|
||||
|
||||
`resolveRoute` z fabryki przyjmuje `(locale, segments, page, withEntries?)`.
|
||||
`resolveRoute` z fabryki przyjmuje `(locale, segments, page)` — rozstrzyga
|
||||
trasę. Pobranie wpisów to osobna funkcja `getEntries` (patrz niżej), bo
|
||||
routing i pobieranie danych to dwie różne odpowiedzialności.
|
||||
|
||||
## withEntries — wpisy tylko gdy trzeba
|
||||
## Routing i pobieranie — osobno
|
||||
|
||||
Archiwum potrzebuje listy wpisów; metadane nie. `withEntries` rozstrzyga:
|
||||
Archiwum potrzebuje listy wpisów; metadane nie. Rozstrzyganie trasy i pobieranie
|
||||
wpisów to dwie funkcje, składane jawnie tam, gdzie trzeba obu:
|
||||
|
||||
```ts
|
||||
// generateMetadata — bez wpisów (nie płaci za zapytanie)
|
||||
// generateMetadata — sama trasa (nie płaci za zapytanie o wpisy)
|
||||
const route = await resolveRoute(locale, slug, page)
|
||||
|
||||
// komponent strony — z wpisami
|
||||
const route = await resolveRoute(locale, slug, page, true)
|
||||
// route.entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
|
||||
// komponent strony — trasa, a potem wpisy, jeśli to archiwum
|
||||
const route = await resolveRoute(locale, slug, page)
|
||||
const entries = route?.type === 'archive'
|
||||
? await getEntries(route.collection, locale, route.page, route.perPage)
|
||||
: null
|
||||
// entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
|
||||
```
|
||||
|
||||
Metadane i strona wołają z różnymi argumentami, więc `cache()` traktuje je jako
|
||||
osobne wywołania — i słusznie, bo metadane wpisów nie potrzebują.
|
||||
`resolveRoute` zwraca `collection` i `perPage` — czyli CO i ILE pobrać — ale
|
||||
pobierania nie robi. Dzięki temu zmiana sortowania czy filtrowania listingu nie
|
||||
dotyka reguł routingu, a metadane nie pobierają wpisów, których i tak nie użyją.
|
||||
|
||||
## Listing
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Publikacja ipal-kit — checklist
|
||||
|
||||
## package.json — wymagane pola
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@intecion/ipal-kit", // scoped pod organizację
|
||||
"version": "1.0.0", // BUMP przy każdej publikacji (semver)
|
||||
"files": ["dist"], // tylko dist trafia do pakietu (NIE src)
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": { ... }, // wszystkie entry points na ./dist/*
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com",
|
||||
"exports": { ... } // mirror z ./dist (masz to już zrobione)
|
||||
},
|
||||
"scripts": {
|
||||
"build": "...",
|
||||
"prepublishOnly": "pnpm clean && pnpm build" // build ZAWSZE przed publish
|
||||
},
|
||||
"peerDependencies": { // NIE dependencies — klient już je ma
|
||||
"payload": "3.84.1",
|
||||
"next": ">=15",
|
||||
"react": ">=19"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Krytyczne przed pierwszą publikacją
|
||||
|
||||
- [ ] **peerDependencies zamiast dependencies** dla payload/next/react —
|
||||
inaczej pakiet zaciąga drugą kopię Payloada i wszystko się sypie.
|
||||
@payloadcms/plugin-seo i plugin-form-builder też jako peer (klient pinuje).
|
||||
- [ ] **`"files": ["dist"]`** — bez tego do pakietu trafia src/ (widzieliśmy to
|
||||
w stack trace). Sam dist.
|
||||
- [ ] **usuń self-reference** — sprawdź, że w dependencies NIE ma
|
||||
"ipal-kit": "file:..." (ta zaraza z pnpm add w złym katalogu).
|
||||
- [ ] **prepublishOnly** buduje przed publikacją — nigdy nie publikuj ręcznie
|
||||
zbudowanego dist (łatwo o nieaktualny).
|
||||
- [ ] **bump wersji** — koniec z 1.0.0 na zawsze. Każda publikacja = nowy numer.
|
||||
To rozwiązuje cały cykl cache/store prune, który gryzł podczas developmentu.
|
||||
|
||||
## Publikacja (GitHub Packages)
|
||||
|
||||
```bash
|
||||
# jednorazowo: token GitHuba z prawami write:packages w ~/.npmrc
|
||||
echo "//npm.pkg.github.com/:_authToken=TWÓJ_TOKEN" >> ~/.npmrc
|
||||
|
||||
# przy każdym wydaniu
|
||||
npm version patch # 1.0.0 → 1.0.1 (albo minor/major)
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Instalacja u pracownika
|
||||
|
||||
```bash
|
||||
# ~/.npmrc w projekcie albo globalnie
|
||||
@intecion:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=ICH_TOKEN
|
||||
|
||||
# potem normalnie
|
||||
pnpm add @intecion/ipal-kit
|
||||
pnpm add @payloadcms/[email protected] @payloadcms/[email protected] \
|
||||
nodemailer lucide-react slugify server-only
|
||||
```
|
||||
|
||||
## README pakietu
|
||||
|
||||
Wskaż na docs/getting-started.md jako pierwszy krok. Pracownik z dostępem do
|
||||
rejestru + getting-started postawi projekt bez pytania Ciebie o nic.
|
||||
+141
-37
@@ -1,8 +1,8 @@
|
||||
# seo
|
||||
|
||||
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę
|
||||
logiki: składanie tytułów, budowanie `Metadata` dla Next.js z canonical i
|
||||
hreflang, auto-fill pustych meta z treści dokumentu.
|
||||
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę logiki:
|
||||
składanie tytułów, budowanie `Metadata` dla Next.js z canonical i hreflang,
|
||||
auto-fill pustych meta z treści dokumentu.
|
||||
|
||||
## Zależność
|
||||
|
||||
@@ -12,6 +12,9 @@ Dodaj do `dependencies` (pin do wersji payload):
|
||||
"dependencies": { "@payloadcms/plugin-seo": "3.84.1" }
|
||||
```
|
||||
|
||||
Po wpięciu uruchom `pnpm payload generate:importmap` — pola SEO to komponenty
|
||||
admina i bez importMap się nie wyrenderują.
|
||||
|
||||
## Config (payload.config.ts)
|
||||
|
||||
```ts
|
||||
@@ -27,60 +30,137 @@ ipalKit({
|
||||
})
|
||||
```
|
||||
|
||||
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji.
|
||||
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu
|
||||
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie.
|
||||
Dodaje tab **SEO** do wskazanych kolekcji: title, description, image,
|
||||
titleOverride. Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola
|
||||
dokumentu (domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał
|
||||
ręcznie.
|
||||
|
||||
## Front — generateMetadata (factory)
|
||||
Tab budowany jest przez `injectSeoTabs`, nie przez `tabbedUI` plugin-seo —
|
||||
tamten scala taby patrząc na pierwsze pole kolekcji i psuje się, gdy są tam już
|
||||
inne pola (slug, role), zostawiając pusty tab SEO.
|
||||
|
||||
Najprościej: factory redukuje boilerplate. Klient podaje resolvery (bo zna
|
||||
swoje kolekcje/routing), plugin składa metadata.
|
||||
## Tytuły — konfiguracja z panelu
|
||||
|
||||
Bez kodu, per witryna (Site Settings → General):
|
||||
|
||||
- **Title Order** — `Page first` (O nas | Acme) albo `Site first` (Acme | O nas)
|
||||
- **Title Separator** — `|` `–` `-` `·` `/`
|
||||
|
||||
Per strona (tab SEO):
|
||||
|
||||
- **Title Override** — wpisany tekst trafia do karty dosłownie, ignorując oba
|
||||
powyższe. Do strony głównej, gdzie składanie dałoby "Acme | Acme".
|
||||
|
||||
Domyślnie: `Tytuł | Nazwa witryny`.
|
||||
|
||||
## Front — createPageMetadata
|
||||
|
||||
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
|
||||
Pages, grupa meta), więc nie trzeba mu tego opisywać:
|
||||
|
||||
```ts
|
||||
// app/(frontend)/[locale]/[[...segments]]/page.tsx
|
||||
import { createMetadataGenerator } from 'ipal-kit'
|
||||
import { getSiteSettings } from 'ipal-kit'
|
||||
// app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||
import { createPageMetadata } from 'ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
|
||||
const gen = createMetadataGenerator({
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
resolveDocument: async ({ payload, params, locale }) => {
|
||||
const slug = /* z params */ ''
|
||||
const res = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all', depth: 1, limit: 1,
|
||||
})
|
||||
return res.docs[0] ?? null // musi mieć .meta i .slug (locale:'all')
|
||||
},
|
||||
resolveSiteName: async ({ payload, locale }) =>
|
||||
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
||||
// collection: 'pages', // domyślne
|
||||
// settingsSlug: 'site-settings', // domyślne
|
||||
// siteNameField: 'siteName', // domyślne
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const payload = await getPayload({ config })
|
||||
const { locale } = await params
|
||||
return gen({ payload, params: await params, locale })
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { locale, slug } = await params
|
||||
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
|
||||
}
|
||||
```
|
||||
|
||||
## Front — niżej: buildMetadata bezpośrednio
|
||||
Wrapper jest konieczny: Next woła `generateMetadata({ params })` bez payloada, a
|
||||
plugin nigdy nie wywołuje `getPayload` sam.
|
||||
|
||||
Jeśli chcesz pełną kontrolę zamiast factory:
|
||||
Ogarnia: tytuł (order/separator/override z panelu), description, canonical,
|
||||
hreflang dla wszystkich locale, OG, home zwinięty do `/pl` (slug home czytany z
|
||||
System Pages).
|
||||
|
||||
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang będą względne (`/pl` zamiast
|
||||
`https://…/pl`).
|
||||
|
||||
## Front — createMetadataGenerator
|
||||
|
||||
Dla tras spoza konwencji: inna kolekcja (blog), własna logika obrazka, inny
|
||||
global. Klient dostarcza resolvery.
|
||||
|
||||
```ts
|
||||
import { createMetadataGenerator } from 'ipal-kit'
|
||||
|
||||
const generate = createMetadataGenerator({
|
||||
config: i18nConfig,
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
homeSlug: 'homepage',
|
||||
|
||||
resolveDocument: async ({ payload, params, locale }) => {
|
||||
const slug = (params.slug as string[])?.join('/')
|
||||
|
||||
// meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa
|
||||
// locale→wartość (hreflang) — to dwa różne odczyty.
|
||||
const found = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
locale,
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
})
|
||||
const doc = found.docs[0]
|
||||
if (!doc) return null
|
||||
|
||||
const allLocales = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: doc.id,
|
||||
locale: 'all',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return { ...doc, slug: allLocales.slug }
|
||||
},
|
||||
|
||||
resolveSiteName: async ({ payload, locale }) =>
|
||||
(await getSiteSettings(payload, { locale })).siteName ?? null,
|
||||
resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null,
|
||||
})
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { locale, slug } = await params
|
||||
return generate({ payload: await getPayload({ config }), params: { slug }, locale })
|
||||
}
|
||||
```
|
||||
|
||||
**Pułapka:** pojedynczy odczyt z `locale: 'all'` wygląda kusząco, ale wtedy
|
||||
**każde** zlokalizowane pole jest mapą — `meta.title` też. Składanie tytułu
|
||||
dostaje obiekt zamiast stringa i leci `pageTitle?.trim is not a function`.
|
||||
|
||||
Next uruchamia `generateMetadata` i komponent strony niezależnie — bez React
|
||||
`cache()` wokół tych odczytów każde żądanie pyta bazę dwa razy.
|
||||
|
||||
## Front — buildMetadata bezpośrednio
|
||||
|
||||
Gdy chcesz pełną kontrolę:
|
||||
|
||||
```ts
|
||||
import { buildMetadata, getLocalizedSlugs } from 'ipal-kit'
|
||||
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
|
||||
|
||||
return buildMetadata({
|
||||
meta: doc.meta, // z plugin-seo
|
||||
siteName: settings.siteName,
|
||||
imageUrl: /* url OG image */,
|
||||
imageUrl: '/og.png',
|
||||
locale: 'pl',
|
||||
slugs: getLocalizedSlugs({ slugField: doc.slug, config }),
|
||||
slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
|
||||
config,
|
||||
baseUrl: 'https://example.com',
|
||||
separator: ' – ', // opcjonalne
|
||||
order: 'site-first', // opcjonalne
|
||||
homeSlug: 'homepage',
|
||||
})
|
||||
// → { title, description, openGraph, alternates: { canonical, languages } }
|
||||
```
|
||||
@@ -90,6 +170,30 @@ return buildMetadata({
|
||||
```ts
|
||||
import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
|
||||
|
||||
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme'
|
||||
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
|
||||
```
|
||||
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' })
|
||||
// 'O nas | Acme'
|
||||
composeTitle({ pageTitle: 'O nas', siteName: 'Acme', separator: ' – ', order: 'site-first' })
|
||||
// 'Acme – O nas'
|
||||
|
||||
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
|
||||
```
|
||||
|
||||
## Pomocnicze niższego poziomu
|
||||
|
||||
`createPageMetadata` składa się z mniejszych, testowalnych kawałków —
|
||||
eksportowanych, gdybyś budował własny generator metadanych:
|
||||
|
||||
```ts
|
||||
import { readSiteMetaConfig, slugsAcrossLocales } from 'ipal-kit'
|
||||
|
||||
// nazwa witryny, separator (dopełniony), kolejność, homeSlug — z SiteSettings
|
||||
const site = await readSiteMetaConfig({ payload, locale })
|
||||
|
||||
// slug dokumentu we wszystkich locale — mapa dla hreflang
|
||||
const slugs = await slugsAcrossLocales({ payload, collection: 'pages', id, config })
|
||||
```
|
||||
|
||||
`slugsAcrossLocales` robi osobne zapytanie z `locale: 'all'` — bo ten tryb
|
||||
zamienia KAŻDE zlokalizowane pole w mapę, co jest dobre dla sluga i złe dla
|
||||
reszty (mapa w title rozłożyłaby składanie tytułu). Dlatego czyta tylko slug,
|
||||
depth 0.
|
||||
Reference in New Issue
Block a user