@justanarthur/payload-www 0.3.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +317 -307
  2. package/dist/access.d.ts +2 -2
  3. package/dist/access.js +3 -17
  4. package/dist/blocks.d.ts +7 -12
  5. package/dist/blocks.js +24 -43
  6. package/dist/cli.js +201 -0
  7. package/dist/collections.d.ts +33 -225
  8. package/dist/collections.js +185 -639
  9. package/dist/config.d.ts +19 -105
  10. package/dist/config.js +257 -855
  11. package/dist/fields.d.ts +4 -46
  12. package/dist/fields.js +177 -53
  13. package/dist/imagehash.js +0 -15
  14. package/dist/import-map-provider.d.ts +0 -16
  15. package/dist/import-map-provider.js +0 -15
  16. package/dist/metadata.d.ts +24 -89
  17. package/dist/metadata.js +123 -255
  18. package/dist/pages.d.ts +37 -319
  19. package/dist/pages.js +349 -964
  20. package/dist/render-pages.d.ts +70 -565
  21. package/dist/render-pages.js +464 -1477
  22. package/dist/sitemap.d.ts +10 -0
  23. package/dist/sitemap.js +337 -0
  24. package/dist/translator.js +0 -15
  25. package/dist/utils.d.ts +10 -12
  26. package/dist/utils.js +18 -41
  27. package/package.json +81 -178
  28. package/dist/components.d.ts +0 -6
  29. package/dist/components.js +0 -39
  30. package/dist/core-access.d.ts +0 -11
  31. package/dist/core-access.js +0 -34
  32. package/dist/core-blocks.d.ts +0 -24
  33. package/dist/core-blocks.js +0 -79
  34. package/dist/core-fields.d.ts +0 -73
  35. package/dist/core-fields.js +0 -167
  36. package/dist/core-utils.d.ts +0 -16
  37. package/dist/core-utils.js +0 -63
  38. package/dist/data-collections.d.ts +0 -229
  39. package/dist/data-collections.js +0 -676
  40. package/dist/data-seed.d.ts +0 -76
  41. package/dist/data-seed.js +0 -212
  42. package/dist/data-test.d.ts +0 -30
  43. package/dist/data-test.js +0 -1110
  44. package/dist/globals.d.ts +0 -69
  45. package/dist/globals.js +0 -251
  46. package/dist/hooks.d.ts +0 -108
  47. package/dist/hooks.js +0 -228
  48. package/dist/index.d.ts +0 -6
  49. package/dist/index.js +0 -39
  50. package/dist/render-components.d.ts +0 -42
  51. package/dist/render-components.js +0 -154
  52. package/dist/render-metadata.d.ts +0 -122
  53. package/dist/render-metadata.js +0 -390
  54. package/dist/render-utils.d.ts +0 -166
  55. package/dist/render-utils.js +0 -398
  56. package/dist/seed.d.ts +0 -76
  57. package/dist/seed.js +0 -212
  58. package/dist/server.d.ts +0 -980
  59. package/dist/server.js +0 -2231
  60. package/dist/test.d.ts +0 -30
  61. package/dist/test.js +0 -1110
  62. package/dist/with-www-config.d.ts +0 -108
  63. package/dist/with-www-config.js +0 -1006
package/README.md CHANGED
@@ -1,382 +1,392 @@
1
1
  # @justanarthur/payload-www
2
2
 
3
- Reusable Payload CMS website template. Wires collections, globals,
4
- blocks, fields, access, hooks, metadata (JSON-LD, hreflang), and
5
- Next.js page renderers behind a single `createWWWConfig({ locales, blocks })`
6
- composer. The lib consumes your [next-intl](https://next-intl.dev) routing
7
- config so locale validation, URL shape, hreflang alternates, and the
8
- language switcher share a single source of truth with the rest of your app.
3
+ A reusable Payload CMS website template: collections, globals, blocks, fields, access, JSON-LD / hreflang metadata, Next.js page renderers, sitemap, and the default plugin set (SEO + imagehash + translator + MCP).
4
+
5
+ The lib reads your [next-intl](https://next-intl.dev) routing config so locale validation, URL shape,
6
+ hreflang alternates, and the language switcher share a single source of truth with the rest of the
7
+ app.
8
+
9
+ The composition root is `createWWWConfig()` — see [Quick start](#quick-start) below.
9
10
 
10
11
  ## What's inside
11
12
 
12
- - **Composer** `createWWWConfig({ locales, blocks, defaultPlugins? })` returns `{ withWWWConfig }`. One composer call is enough for the most common cases.
13
- - **Collections** — `Pages` (title, blocks tab, slug, drafts, revalidation hook), `Posts` (title, excerpt, richText, drafts, revalidation hook), `StaticPages` (system pages — 404, 500, search-empty, … — addressed by a `kind` discriminator, not a slug)
14
- - **Globals** `Header` and `Footer` (both `nav` blocks with `navColumn` / `navItem`). Extend the nav link schema (e.g. a `description` or a `navHover` mega-menu group) via `createHeaderGlobal({ navColumnLinkFields, navItemLinkFields })` / `createFooterGlobal({ … })`, or `link({ extraFields })` directly.
15
- - **Default render components** `PagesPage`, `HeaderPage`, `FooterPage` Server Components. Override any of them by setting a different `custom.path` on the collection / global.
16
- - **LivePreviewListener** built in. The lib's `createCollectionPageExports` default page renders it (via `React.lazy` so the server dist stays free of `'use client'` imports) whenever Next.js draft mode is on. Hosts get live preview automatically — no opt-in required. The component itself is also exported from `/render-components` for hosts that want to mount it elsewhere.
17
- - **Hooks** `createRevalidateCollectionHook(opts)` (canonical factory for **all** collections: Pages, Posts, host-defined; per-locale `revalidatePath` fan-out + `revalidateTag('collection_<slug>_<id>', 'max')` + sitemap tag, with a `pathMode: 'tag-only'` mode for collections without a URL like `staticPages`), `createRevalidatePageHooks()` (deprecated alias for Pages preset), `createRevalidateGlobalHook(slug)` (per-locale tag for globals)
18
- - **Access** `anyone`, `authenticated`, `authenticatedOrPublished`
19
- - **Fields** `link`, `linkGroup` (with `disableLabel` / `appearances` / `localized` / `relationTo` / `overrides` options)
20
- - **Metadata** `buildArticleLd`, `buildBreadcrumbsLd`, `buildOrganizationLd`, `buildHreflangAlternates`, slug transforms, `queryDocBySlug` / `queryAllDocs` / `queryAllLocaleSlugs`
21
- - **Pages** `createCollectionPageExports` (Next.js App Router render factory), `addCollectionsToSitemap`. Supports a `showcase` sidebar and a `homeExtras` callback for the home route.
22
- - **Components** `LivePreviewListener`, `RenderBlocks`, `PageShowcase` (sidebar layout for demos / previews), `LocaleSwitcher` (server-renderable nav built from the page's hreflang alternates)
23
- - **Route handlers** `createPreviewHandler` (from the `/render-utils` subpath). The Next.js sitemap convention ships as `createSitemapFile` (same subpath) — it's a `MetadataRoute.Sitemap` factory for `app/(frontend)/sitemap.ts`, not a route handler, and it's `localePrefix`-aware.
24
- - **Utils** `getFromImportMap`, `generateImportName`, `renderCollectionModule`
25
- - **Seed / Test** `createBaseSeed` (publishes by default pass `status: 'draft'` to keep a doc as a draft), `createTestPayload`
13
+ | piece | exported from | purpose |
14
+ |---|---|---|
15
+ | Composer | [`/config`](#quick-start) | `createWWWConfig()` returns `{ withWWWConfig }`. One call wires Pages + Posts + Header + Footer + the default plugin set. |
16
+ | Pages + Posts collections | `createPagesCollection`, `createPostsCollection` (internal composed by the composer) | Pages (title, blocks tab, slug, drafts, revalidation); Posts (title, excerpt, richText, drafts, revalidation). |
17
+ | Header + Footer globals | `createHeaderGlobal`, `createFooterGlobal` (internal) | Both nav blocks with `navColumn` / `navItem`. Extend via `link({ extraFields })`. |
18
+ | Static-page collection | `createWWWCollectionGlobal` ([`/collections`](#collections)) | Generic factory for system pages (404, 500, search-empty) keyed by a discriminator, no slug. |
19
+ | Default plugins | composed by `createWWWConfig()` | `seoPlugin`, `imageHashPlugin`, `translator`, `mcpPlugin`. Tune via `defaultPluginsConfigs`. |
20
+ | Fields | [`/fields`](#fields) | `link`, `linkGroup` (with `disableLabel` / `appearances` / `localized` / `relationTo` / `extraFields`), `slugField`, `appearanceOptions`. |
21
+ | Access | [`/access`](#access) | `anyone`, `authenticated`, `authenticatedOrPublished`. |
22
+ | Revalidation hooks | [`/collections`](#collections) (`createRevalidateCollectionGlobalHook`, `createCollectionCacheKey`, `populatePublishedAt`) | Tag-based cache invalidation + published-at population. |
23
+ | Metadata | [`/metadata`](#metadata) | `buildArticleLd`, `buildBreadcrumbsLd`, `buildOrganizationLd`, `buildWebSiteLd`, `buildProductLd`, `buildRootJsonLd`; slug transforms; `queryDocBySlug`, `queryAllDocs`, `queryAllLocaleSlugs`. |
24
+ | Next.js page renderers | [`/render-pages`](#render-pages) | `createCollectionPageExports`, `createRootLayoutExports`; default render components `PagesPage`, `PostsPage`, `HeaderPage`, `FooterPage`, `RootJsonLd`. |
25
+ | Sitemap | [`/sitemap`](#sitemap) | `createSitemapFromCollections` (Next.js file-convention helper). |
26
+ | Plugin re-exports | [`/imagehash`](#plugin-re-exports), [`/translator`](#plugin-re-exports) | Drop-in for hosts that don't want to import the sibling packages directly. |
26
27
 
27
28
  ## Quick start
28
29
 
29
- ### 1. Wire the composer
30
-
31
30
  ```ts
32
31
  // payload.config.ts
33
32
  import { buildConfig } from 'payload'
34
- import { createWWWConfig } from '@justanarthur/payload-www/with-www-config'
35
- import { MyCtaBlock, MyHeroBlock, MyRichTextBlock } from './blocks'
33
+ import { createWWWConfig } from '@justanarthur/payload-www/config'
34
+ import { blocks } from '@/components/blocks'
35
+ import { plugins } from '@/plugins'
36
+
37
+ const { withWWWConfig } = createWWWConfig()
38
+
39
+ export default buildConfig(withWWWConfig({
40
+ blocks, // Page block set
41
+ plugins, // host plugins appended after the lib's defaults
42
+ collections: (defaults) => [
43
+ ...defaults,
44
+ Media,
45
+ Users
46
+ ],
47
+ globals: (defaults) => [
48
+ ...defaults.map(g => g.slug === 'header'
49
+ ? { ...g, custom: { [payloadWwwName]: { path: '@/components/Header/Component#Header' } }, fields: headerFields }
50
+ : g.slug === 'footer'
51
+ ? { ...g, custom: { [payloadWwwName]: { path: '@/components/Footer/Component#Footer' } }, fields: footerFields }
52
+ : g),
53
+ createStaticGlobal(),
54
+ createMessagesGlobal()
55
+ ],
56
+ defaultPluginsConfigs: { // tune the lib's default plugin set
57
+ seo: (d) => ({ ...d, collections: ['pages', 'posts'], openaiApiKey: process.env.OPENAI_API_KEY }),
58
+ imageHash: (d) => ({ ...d, algorithm: 'lqip-modern' }),
59
+ translator: (d) => ({ ...d, autoTranslate: true, collections: ['pages', 'posts'], globals: ['header', 'footer', 'messages'] })
60
+ },
61
+ localization: { /* ... */ },
62
+ db: postgresAdapter({ /* ... */ }),
63
+ // ...rest of Payload config
64
+ }))
65
+ ```
36
66
 
37
- const { withWWWConfig } = createWWWConfig({
38
- locales: ['en', 'sk', 'de'],
39
- blocks: [MyCtaBlock, MyHeroBlock, MyRichTextBlock]
40
- })
67
+ `createWWWConfig()` takes **no arguments**. You tune everything via the `WWWInputConfig` passed to
68
+ `withWWWConfig`. The composer reads its own package name from `package.json` and uses it as the
69
+ `custom.<packageName>` key that's how the block / collection / global `custom.path` entries get
70
+ threaded into Payload's import map.
41
71
 
42
- export default buildConfig(
43
- withWWWConfig({
44
- collections: [], // optional extra collections
45
- globals: [], // optional extra globals
46
- // ...rest of your config
47
- })
48
- )
49
- ```
72
+ The `defaultPluginsConfigs` map lets you keep the defaults (seoPlugin, imageHashPlugin, translator,
73
+ mcpPlugin) and tweak their constructor args without re-importing them. Pass `(defaults) => …` to
74
+ merge or `…` to replace.
50
75
 
51
- `withWWWConfig` injects `Pages`, `Header`, and `Footer` plus the lib's
52
- default plugin set (seoPlugin, imageHashPlugin, translator). Use the
53
- `defaultPlugins` callback to drop or extend the list:
76
+ ## Page exports (Next.js App Router)
54
77
 
55
78
  ```ts
56
- createWWWConfig({
57
- locales: ['en', 'sk', 'de'],
58
- blocks: [MyCtaBlock],
59
- defaultPlugins: (defaults) => defaults.filter((p) => p !== translator)
60
- })
61
- ```
62
-
63
- ### 2. Define your next-intl routing
79
+ // app/(frontend)/[locale]/layout.tsx
80
+ import { createRootLayoutExports } from '@justanarthur/payload-www/render-pages'
81
+ import { importMap } from '@/app/(payload)/admin/importMap'
82
+ import { routing } from '@/i18n/routing'
83
+ import config from '@payload-config'
84
+ import { getServerSideURL } from '@/lib/utils/getURL'
64
85
 
65
- The lib reads the same routing config next-intl uses, so URL shape,
66
- locale validation, hreflang alternates, and the language switcher share
67
- a single source of truth:
86
+ const { default: RootLayout, generateStaticParams } = createRootLayoutExports(
87
+ { config, importMap, routing },
88
+ { getServerSideURL }
89
+ )
68
90
 
69
- ```ts
70
- // src/i18n/routing.ts
71
- import { defineRouting } from 'next-intl/routing'
72
-
73
- export const routing = defineRouting({
74
- locales: ['en', 'uk'],
75
- defaultLocale: 'en',
76
- // 'as-needed' means `/about` for the default locale and
77
- // `/uk/about` for the others — the lib mirrors this in
78
- // hreflang alternates and the language switcher.
79
- localePrefix: 'as-needed',
80
- labels: { en: 'English', uk: 'Українська' }
81
- })
91
+ export default RootLayout
92
+ export { generateStaticParams }
82
93
  ```
83
94
 
84
- ### 3. Page exports
85
-
86
95
  ```ts
87
- // app/(frontend)/[locale]/page.tsx — home
88
- // app/(frontend)/[locale]/[...slug]/page.tsx — catch-all
96
+ // app/(frontend)/[locale]/[[...slug]]/page.tsx — Pages home + catch-all
89
97
  import { createCollectionPageExports } from '@justanarthur/payload-www/render-pages'
90
- import configPromise from '@payload-config'
91
- import { importMap } from '@/app/(payload)/admin/importMap.js'
98
+ import config from '@payload-config'
99
+ import { importMap } from '@/app/(payload)/admin/importMap'
92
100
  import { routing } from '@/i18n/routing'
93
-
94
- import { getServerSideURL } from '@/utilities/getURL'
95
-
96
- const generateMeta = async ({ doc }) => ({ title: doc?.title })
97
-
98
- const { default: Page, generateMetadata, generateStaticParams } =
99
- createCollectionPageExports(
100
- { config: configPromise, importMap, routing },
101
- { getServerSideURL, generateMeta }
102
- )
101
+ import { getServerSideURL } from '@/lib/utils/getURL'
102
+
103
+ const {
104
+ default: Page,
105
+ generateMetadata,
106
+ generateStaticParams,
107
+ generateSitemap
108
+ } = createCollectionPageExports(
109
+ { config, importMap, routing, slugShape: 'catch-all' },
110
+ { getServerSideURL }
111
+ )
103
112
 
104
113
  export default Page
105
- export { generateMetadata, generateStaticParams }
114
+ export { generateMetadata, generateStaticParams, generateSitemap }
106
115
  ```
107
116
 
108
- The lib auto-mounts its `LivePreviewListener` (loaded via `React.lazy`
109
- so the server dist stays free of `'use client'` imports) whenever
110
- Next.js draft mode is on. No opt-in required.
111
-
112
- #### Showcase sidebar + home extras
113
-
114
- The home route can render inside a `<PageShowcase>` two-column layout
115
- (sidebar with metadata, JSON-LD, and a language switcher) and append a
116
- `homeExtras` block (recent pages, recent posts, etc.):
117
+ `slugShape` is `'single'` (default) or `'catch-all'`. Use `'catch-all'` if your route segment is
118
+ `[[...slug]]` (Pages-style), `'single'` for `[slug]` (Posts-style file convention).
117
119
 
118
120
  ```ts
119
- const { default: Page, generateMetadata, generateStaticParams } =
120
- createCollectionPageExports(
121
- { config: configPromise, importMap, routing },
122
- {
123
- getServerSideURL,
124
- generateMeta,
125
- showcase: { enabled: true }, // wrap in <PageShowcase>
126
- homeExtras: async ({ locale }) => {
127
- const { pages, posts } = await fetchRecent(locale)
128
- return <RecentLists pages={pages} posts={posts} />
129
- }
130
- }
131
- )
121
+ // app/(frontend)/[locale]/posts/[slug]/page.tsx
122
+ const { default: PostPage, generateMetadata, generateStaticParams } = createCollectionPageExports(
123
+ { config, importMap, routing, slug: 'posts', slugShape: 'single' },
124
+ { getServerSideURL }
125
+ )
132
126
  ```
133
127
 
134
- `<PageShowcase>` and `<LocaleSwitcher>` are also exported individually
135
- (from `/render-components` and `/render-utils` respectively) for hosts
136
- that want to drop them in their own layouts.
128
+ ### `createCollectionPageExports(args, deps)` args
129
+
130
+ | arg | type | default | notes |
131
+ |---|---|---|---|
132
+ | `config` | `Promise<SanitizedConfig>` | required | the host's `payload.config.ts` |
133
+ | `importMap` | `ImportMap` | required | the host's `app/(payload)/admin/importMap` |
134
+ | `routing` | `RoutingConfig` | required | `{ locales, defaultLocale, localePrefix, labels? }` from `next-intl/routing`'s `defineRouting` |
135
+ | `slug` | `string` | `'pages'` | collection slug to query |
136
+ | `slugShape` | `'single' \| 'catch-all'` | `'single'` | drives `generateStaticParams` + slug segmentation |
137
137
 
138
- ### 4. Route handlers preview + sitemap
138
+ ### `createCollectionPageExports(args, deps)`deps
139
+
140
+ | dep | type | notes |
141
+ |---|---|---|
142
+ | `getServerSideURL` | `() => string` | host's absolute-URL helper |
143
+ | `pagePathPrefix` | `string` | optional URL prefix for `generateSitemap` only — not used for rendering |
144
+
145
+ ### What you get back
139
146
 
140
147
  ```ts
141
- // src/proxy.ts (replaces the deprecated src/middleware.ts)
142
- import createMiddleware from 'next-intl/middleware'
143
- import { routing } from '@/i18n/routing'
144
- export default createMiddleware(routing)
145
- export const config = {
146
- matcher: ['/', '/((?!api|_next|_vercel|admin|next|.*\\..*).*)']
148
+ {
149
+ default: Page, // the page component
150
+ generateMetadata, // Next.js MetadataRoute hook
151
+ generateStaticParams, // Next.js static-params hook
152
+ generateSitemap // MetadataRoute.Sitemap producer for /sitemap.ts
147
153
  }
148
154
  ```
149
155
 
150
- ```ts
151
- // app/(payload)/next/preview/route.ts
152
- import { createPreviewHandler } from '@justanarthur/payload-www/render-utils'
153
- export const GET = createPreviewHandler()
154
- ```
156
+ ### `createRootLayoutExports(args, deps)`
157
+
158
+ | arg | type | notes |
159
+ |---|---|---|
160
+ | `config` | `Promise<SanitizedConfig>` | required |
161
+ | `importMap` | `ImportMap` | required |
162
+ | `routing` | `RoutingConfig` | required |
163
+
164
+ | dep | type | notes |
165
+ |---|---|---|
166
+ | `getServerSideURL` | `() => string` | if provided, the SEO plugin's `RootJsonLd` (`Organization` / `WebSite` JSON-LD) is auto-injected as the first child of `<html>` |
167
+ | `providers` | `(args) => ReactNode` | wraps `{children}` between the rendered Header and Footer |
168
+ | `htmlAttrs` | `(locale) => HTMLAttributes<HTMLHtmlElement>` | extra `<html>` attributes per locale (defaults: `lang={locale}`, `suppressHydrationWarning`) |
169
+
170
+ The layout reads `header` + `footer` globals in parallel and wraps the children in
171
+ `<NextIntlClientProvider>`. Locale is pulled from the `[locale]` route segment and validated against
172
+ `routing.locales` — unknown locales trigger `notFound()`.
173
+
174
+ ## Sitemap
155
175
 
156
176
  ```ts
157
177
  // app/(frontend)/sitemap.ts
158
- import { createSitemapFile } from '@justanarthur/payload-www/render-utils'
159
- import configPromise from '@payload-config'
160
- import { getServerSideURL } from '@/utilities/getURL'
161
-
162
- // One file, all collections, all locales — Next.js serves it at
163
- // /sitemap.xml. The Pages collection's `afterChange` hook invalidates
164
- // the `pages-sitemap` tag the factory reads from, so edits refresh
165
- // the sitemap automatically.
166
- export default createSitemapFile({
167
- collections: ['pages', 'posts'],
168
- config: configPromise,
178
+ import { createSitemapFromCollections } from '@justanarthur/payload-www/sitemap'
179
+ import config from '@payload-config'
180
+ import { getServerSideURL } from '@/lib/utils/getURL'
181
+
182
+ export default createSitemapFromCollections({
183
+ getServerSideURL,
184
+ pagePathPrefix: '' // empty for root-mounted (Pages)
185
+ // pagePathPrefix: '/posts' // use this for the Posts catch-all
186
+ }, {
169
187
  getServerSideURL,
170
- // 'as-needed' makes the default locale render without a prefix
171
- // (`/about`), other locales prefixed (`/uk/about`). Matches the
172
- // host's next-intl `localePrefix` so sitemap URLs match the
173
- // actual route shape.
174
- localePrefix: 'as-needed',
175
- // Collections mounted under a sub-route (here: Posts at
176
- // `/posts/[...slug]`) need their sitemap URLs prefixed so they
177
- // match the real route. Pages live at the root, so no prefix.
178
- urlPrefixes: { posts: '/posts' }
188
+ pagePathPrefix: '/posts'
179
189
  })
180
190
  ```
181
191
 
182
- The Pages collection's `afterChange` hook revalidates the
183
- `pages-sitemap` tag the sitemap factory reads from.
192
+ `createSitemapFromCollections(...args)` takes one or more deps-shaped objects (the same shape
193
+ `createCollectionPageExports`'s deps accept) and returns a Next.js `MetadataRoute.Sitemap`-compatible
194
+ function. Mount one per collection under a sub-route, or a single call for the root.
184
195
 
185
- ### 5. System pages (404, 500, search-empty, )
196
+ The lib's Pages `afterChange` hook fires `revalidateTag(collection_pages_<slug>_<locale>, 'max')`,
197
+ which is the key `createCollectionPageExports` / `createSitemapFromCollections` reads from — so
198
+ edits refresh the sitemap without manual rebuilds.
186
199
 
187
- The lib ships a `StaticPages` collection for pages that don't map to a
188
- slug-based URL. One row per `kind` discriminator (`'not-found'`,
189
- `'server-error'`, `'search-empty'`, `'offline'`). The host's route
190
- file fetches the row and renders it via `createStaticPageExports`
191
- the same shape as `createCollectionPageExports`, minus the
192
- metadata / sitemap / static-params plumbing (system pages have no
193
- URL):
200
+ ## Static pages (404 / 500 / system)
201
+
202
+ There's no dedicated `createStaticPageExports` (yet). System pages render via a normal
203
+ `createCollectionPageExports({ slug: 'static-pages', slugShape: 'single' })` mount, addressed by a
204
+ discriminator instead of a slug:
194
205
 
195
206
  ```ts
196
207
  // app/(frontend)/[locale]/not-found.tsx
197
- import configPromise from '@payload-config'
198
- import { createStaticPageExports } from '@justanarthur/payload-www/render-pages'
199
- import { importMap } from '@/app/(payload)/admin/importMap.js'
200
-
201
- const { default: NotFound } = createStaticPageExports({
202
- config: configPromise,
203
- importMap,
204
- })
208
+ const { default: NotFound } = createCollectionPageExports(
209
+ { config, importMap, routing, slug: 'static-pages', slugShape: 'single' },
210
+ { getServerSideURL }
211
+ )
205
212
 
206
213
  export default NotFound
207
214
  ```
208
215
 
209
- `createStaticPageExports` reads the active locale via
210
- `getLocale()` from `next-intl/server` (Next.js passes no props to
211
- not-found components, so the URL-segment locale comes from the
212
- request config — middleware sets it, the host's `i18n/request.ts`
213
- falls back to `defaultLocale` for invalid values). Adding a
214
- `server-error.tsx` is the same shape with `kind: 'server-error'`.
216
+ The `static-pages` collection is built via `createWWWCollectionGlobal({...}, { slug: 'static-pages', renderPath: '@/components/StaticPage/Component#StaticPage', isDraft: false })`. Editors pick a `kind` (`'not-found'`, `'server-error'`, `'search-empty'`, `'offline'`), populate the `blocks` tab with the same block set you passed to `createWWWConfig`, and the host's not-found / server-error route renders the row. `populatePublishedAt` and the revalidation hooks are wired automatically.
217
+
218
+ ## Collections
219
+
220
+ ```ts
221
+ import {
222
+ createWWWCollectionGlobal, // generic factory (used internally for static-pages)
223
+ createRevalidateCollectionGlobalHook, // the afterChange/afterDelete pair
224
+ createCollectionCacheKey, // produces the tag string
225
+ queryDoc // server-side helper used by renderers
226
+ } from '@justanarthur/payload-www/collections'
227
+ ```
228
+
229
+ `createWWWCollectionGlobal(fields, { slug, renderPath, isGlobalConfig?, isDraft? })`:
230
+
231
+ | arg | type | notes |
232
+ |---|---|---|
233
+ | `fields` | `Field[]` | the collection's field set (the factory adds `slug`, `publishedAt`, access, hooks) |
234
+ | `slug` | `string` | collection slug |
235
+ | `renderPath` | `string` | import-map path to the render component (`'@/components/Foo/Component#Foo'`) |
236
+ | `isGlobalConfig` | `boolean` | `true` for globals, `false` (default) for collections |
237
+ | `isDraft` | `boolean` | `true` (default) enables Payload's drafts + autosave; `false` for system pages |
215
238
 
216
- Editors create the row in admin (under the `System` group), pick a
217
- `kind`, and populate the `blocks` tab using the same block set you
218
- passed to `createWWWConfig({ blocks })`. The `title` field is
219
- admin-only — not rendered. `kind` is `unique`, so the database
220
- enforces one row per system page. Drafts + autosave mirror `pages`.
221
- The translator plugin includes `'static-pages'` in its default
222
- `collections` list, so SK content fills automatically when you expand
223
- `localization.locales`.
239
+ The factory wires `custom[packageName] = { path: renderPath }`, access (`create`/`update`/`delete`
240
+ require auth, `read` is `anyone` or `authenticatedOrPublished` depending on `isDraft`), the
241
+ `afterChange`/`afterDelete` revalidation hook, and `populatePublishedAt` (`beforeChange`).
224
242
 
225
- Revalidation uses the `pathMode: 'tag-only'` branch of
226
- `createRevalidateCollectionHook` — no URL fan-out (system pages have
227
- no slug), but the per-id tag (`collection_static-pages_<id>`) and the
228
- collection-wide tag (`static-pages` via the `sitemapTag` override)
229
- fire on every change.
243
+ ### Revalidation
230
244
 
231
- ## Public API
245
+ `createRevalidateCollectionGlobalHook()` returns `{ afterChange, afterDelete }` — the same function
246
+ used for both. On every save / delete of a published doc it fires:
232
247
 
233
- The root import gives you the full surface:
248
+ ```
249
+ revalidateTag(`<slug><slug>_<locale>`, 'max') // collections
250
+ revalidateTag(`<globalSlug>_<locale>`, 'max') // globals
251
+ ```
252
+
253
+ `createCollectionCacheKey({ collectionSlug, slug, locale })` produces the same key string — use it
254
+ in your own `unstable_cache` / `fetch` cache keys if you want them invalidated by the lib's hooks.
255
+
256
+ > The `// @ts-expect-error` and `// todo sitemap caching and revalidation` markers in
257
+ > `createRevalidateCollectionGlobalHook.ts` are intentional, not bugs — the hook covers tag-based
258
+ > invalidation; URL revalidation and the unified sitemap tag are still TODO.
259
+
260
+ ## Fields
261
+
262
+ ```ts
263
+ import { link, linkGroup, appearanceOptions, slugField } from '@justanarthur/payload-www/fields'
264
+
265
+ // link({ extraFields: [...] }) — append host fields (description, navHover, …)
266
+ // linkGroup({ appearances: ['default', 'outline'] })
267
+ // slugField({ localized: false, nested: true })
268
+ // appearanceOptions — for selects that should match link `appearances`
269
+ ```
270
+
271
+ `link({ extraFields })` is the extension point for host-specific nav-link shapes. The lib's
272
+ `createHeaderGlobal` / `createFooterGlobal` accept `navColumnLinkFields` / `navItemLinkFields` that
273
+ are forwarded into their `navColumn` / `navItem` blocks.
274
+
275
+ ## Access
276
+
277
+ ```ts
278
+ import { anyone, authenticated, authenticatedOrPublished } from '@justanarthur/payload-www/access'
279
+ ```
280
+
281
+ - `anyone` — always true.
282
+ - `authenticated` — true when the request has a user.
283
+ - `authenticatedOrPublished` — true when authenticated **or** the doc is `_status: 'published'`.
284
+
285
+ ## Metadata
234
286
 
235
287
  ```ts
236
288
  import {
237
- createWWWConfig, // composer (default export)
238
- createPagesCollection, // collection factories
239
- createPostsCollection,
240
- createStaticPagesCollection,
241
- createHeaderGlobal,
242
- createFooterGlobal,
243
- generatePreviewPath, // admin.preview URL builder
244
- HOME_PAGE_SLUG, PAGES_SLUG, POSTS_SLUG, STATIC_PAGES_SLUG,
245
- link, linkGroup, // fields
246
- appearanceOptions,
247
- anyone, authenticated, // access
248
- authenticatedOrPublished,
249
- createRevalidatePageHooks, // hooks
250
- createRevalidateCollectionHook, // hooks (canonical; createRevalidatePageHooks is a deprecated Pages preset)
251
- createRevalidateGlobalHook,
252
- buildArticleLd, // metadata
253
- buildBreadcrumbsLd,
289
+ buildArticleLd, // Article JSON-LD
290
+ buildBreadcrumbsLd, // BreadcrumbList JSON-LD
254
291
  buildOrganizationLd,
255
- buildHreflangAlternates,
256
- queryDocBySlug, queryAllDocs, queryAllLocaleSlugs,
257
- segmentsToStoredSlug, segmentsToUrlPath, storedSlugToSegments,
258
- getUrlPath, buildCanonicalUrl,
259
- createCollectionPageExports, // page factory
260
- addCollectionsToSitemap,
261
- LivePreviewListener, RenderBlocks, // components
262
- PageShowcase, LocaleSwitcher, // demo / preview components
263
- getFromImportMap, generateImportName, renderCollectionModule, // utils
264
- createBaseSeed, createTestPayload, // dev
265
- // constants
266
- PAGES_RENDER_PATH, HEADER_RENDER_PATH, FOOTER_RENDER_PATH, PAGES_SITEMAP_TAG
267
- } from '@justanarthur/payload-www'
292
+ buildWebSiteLd,
293
+ buildProductLd,
294
+ buildRootJsonLd, // combined Organization + WebSite + Product (used by RootJsonLd)
295
+ queryDocBySlug,
296
+ queryAllDocs, // for generateStaticParams
297
+ queryAllLocaleSlugs, // for hreflang alternates
298
+ paramsSlugToSlug, // turn [locale]/[[...slug]] params → stored slug
299
+ slugToParamsSlug // turn stored slug → params for generateStaticParams
300
+ } from '@justanarthur/payload-www/metadata'
301
+ ```
302
+
303
+ ## Blocks
304
+
305
+ ```tsx
306
+ import { RenderBlocks } from '@justanarthur/payload-www/blocks'
307
+
308
+ export function PageBody({ blocks }) {
309
+ return <RenderBlocks blocks={blocks} />
310
+ }
311
+ ```
312
+
313
+ `RenderBlocks` reads each block's `custom[packageName].path` from Payload and dynamically imports
314
+ the matching component from the host's `importMap`.
315
+
316
+ ## Plugin re-exports
317
+
318
+ ```ts
319
+ import { imageHashPlugin } from '@justanarthur/payload-www/imagehash'
320
+ import { translator } from '@justanarthur/payload-www/translator'
321
+ import { seoPlugin } from '@justanarthur/payload-plugin-seo' // no re-export here, import directly
322
+ import { mcpPlugin } from '@payloadcms/plugin-mcp' // no re-export here, import directly
268
323
  ```
269
324
 
270
- Subpath imports:
271
-
272
- | Subpath | What's there |
273
- |----------------------------------|-------------------------------------------------------------|
274
- | `@justanarthur/payload-www` | root: `LivePreviewListener` only (client-safe) |
275
- | `@justanarthur/payload-www/server` | everything else |
276
- | `@justanarthur/payload-www/with-www-config` | `createWWWConfig` (default export) |
277
- | `@justanarthur/payload-www/collections` | `createPagesCollection`, `createHeaderGlobal`, `createFooterGlobal` |
278
- | `@justanarthur/payload-www/globals` | `createHeaderGlobal`, `createFooterGlobal` |
279
- | `@justanarthur/payload-www/hooks` | revalidation hooks |
280
- | `@justanarthur/payload-www/pages` | `createCollectionPageExports`, `addCollectionsToSitemap` |
281
- | `@justanarthur/payload-www/render-pages` | same as `/pages` + `PagesPage` / `HeaderPage` / `FooterPage` / `PageShowcase` |
282
- | `@justanarthur/payload-www/render-utils` | `createPreviewHandler`, `createSitemapFile`, `LivePreviewListener`, `LocaleSwitcher` |
283
- | `@justanarthur/payload-www/render-components` | `LivePreviewListener` |
284
- | `@justanarthur/payload-www/render-metadata` | JSON-LD + hreflang + slug utilities |
285
- | `@justanarthur/payload-www/metadata` | same as `/render-metadata` |
286
- | `@justanarthur/payload-www/fields` | `link`, `linkGroup`, `appearanceOptions` |
287
- | `@justanarthur/payload-www/access` | `anyone`, `authenticated`, `authenticatedOrPublished` |
288
- | `@justanarthur/payload-www/blocks` | `RenderBlocks` |
289
- | `@justanarthur/payload-www/components` | `LivePreviewListener` |
290
- | `@justanarthur/payload-www/utils` | `getFromImportMap`, `generateImportName`, `renderCollectionModule` |
291
- | `@justanarthur/payload-www/seed` | `createBaseSeed` |
292
- | `@justanarthur/payload-www/test` | `createTestPayload` |
293
- | `@justanarthur/payload-www/data-seed` | same as `/seed` |
294
- | `@justanarthur/payload-www/data-test` | same as `/test` |
295
- | `@justanarthur/payload-www/data-collections` | collection factories |
296
- | `@justanarthur/payload-www/config` | `createWWWConfig` (default export) |
297
- | `@justanarthur/payload-www/imagehash` | `imageHashPlugin` re-export |
298
- | `@justanarthur/payload-www/translator` | `translator` re-export |
299
-
300
- ## Configuration reference
301
-
302
- `createWWWConfig({ ... })`:
303
-
304
- | Option | Type | Required | Description |
305
- |------------------|-----------------------------------|----------|------------------------------------------------------------------------|
306
- | `locales` | `string[]` | yes | Locale list. First entry is the default locale (translation source). |
307
- | `routing` | `PageRouting` | no | The host's next-intl `defineRouting({...})` result. When passed, `locales`, `defaultLocale`, and `localePrefix` are read from this object. `localePrefix` accepts both the simple string form and next-intl's verbose `{ mode, prefixes? }` shape (normalized internally). |
308
- | `blocks` | `Block[]` | yes | Blocks the Pages collection accepts. |
309
- | `linkRelationTo` | `string[]` | no | Collection slugs the Header / Footer nav links can reference. Default: `['pages']`. |
310
- | `registerPosts` | `boolean` | no | Register the lib's Posts collection. Default `true`. |
311
- | `nested` | `boolean` | no | Enable nested Pages slugs (`about_us` → `/about/us`). Relaxes the Pages slug validation to accept the `_` divider. Pass the same `nested: true` to `createCollectionPageExports` on the Pages route (and `nested: { pages: true }` to `createSitemapFile`). Default `false`. |
312
- | StaticPages | — | yes | Always registered (every site has 404). Hosts filter it out in `collections:` to opt. |
313
- | `defaultPlugins` | `(defaults: Plugin[]) => Plugin[]`| no | Final say on the default `[seoPlugin, imageHashPlugin, translator]` list. |
314
-
315
- `createRevalidateCollectionHook({ collectionSlug, urlPathPrefix?, sitemapTag?, localePrefix?, defaultLocale? })`:
316
-
317
- | Option | Type | Description |
318
- |------------------|-----------------------------------|------------------------------------------------------------------------|
319
- | `collectionSlug` | `string` | Required. The collection's slug. Used in the `collection_<slug>_<id>` tag. |
320
- | `urlPathPrefix` | `string` | URL path prefix. `''` for root-mounted (Pages), `'/posts'` for Posts. Default `''`. |
321
- | `sitemapTag` | `string \| false` | Tag fired alongside paths. Default `${collectionSlug}-sitemap`. Pass `false` to opt out. |
322
- | `localePrefix` | `'always' \| 'as-needed' \| 'never'` | Mirrors next-intl. Default `'always'`. Set to `'as-needed'` (with `defaultLocale`) when the host uses as-needed routing. |
323
- | `defaultLocale` | `string` | Default locale for `localePrefix: 'as-needed'`. Falls back to `req.payload.config.localization.defaultLocale`. |
324
- | `pathMode` | `'url' \| 'tag-only'` | Default `'url'` — fans out `revalidatePath` per locale × slug. Set `'tag-only'` for collections without a URL (e.g. `staticPages`); the hook still fires the per-id tag (`collection_<slug>_<id>`) and the collection-wide tag (via `sitemapTag`). |
325
-
326
- The canonical hook for all collections — Pages, Posts, and host-defined. Pages internally uses this with `collectionSlug: 'pages'`, `urlPathPrefix: ''`. Fires `revalidatePath` for **every** declared locale (not just the request locale), handles slug renames while published, fires `revalidateTag('collection_<slug>_<id>', 'max')` for hosts that cache by id, and respects `req.context.disableRevalidate` for seed scripts.
327
-
328
- `createRevalidatePageHooks()` is a deprecated alias for `createRevalidateCollectionHook({ collectionSlug: 'pages', urlPathPrefix: '' })` — kept for backward compat with hosts that imported this name directly.
329
-
330
- `createCollectionPageExports({ config, importMap, slug?, renderPath?, routing }, deps, options?)`:
331
-
332
- | Param | Type | Description |
333
- |--------------|-----------------|------------------------------------------------------------------------|
334
- | `config` | `Promise<SanitizedConfig>` | Resolved Payload config from `payload.config.ts` |
335
- | `importMap` | `ImportMap` | The host's `app/(payload)/admin/importMap.js` |
336
- | `slug` | `string` | Default `'pages'` |
337
- | `renderPath` | `string` | Override the lib's `PAGES_RENDER_PATH` |
338
- | `routing` | `PageRouting` | The host's `defineRouting({...})` result — drives URL building, hreflang, and the language switcher |
339
- | `deps.getServerSideURL` | `() => string` | Host's absolute URL helper |
340
- | `deps.generateMeta` | `(args) => Promise<Metadata>` | Host's metadata composer |
341
- | `deps.notFoundOnMissing` | `boolean` | Default `true` — render 404 for unknown slugs |
342
- | `deps.showcase` | `ShowcaseOptions \| false` | When `{ enabled: true }`, wraps the body in `<PageShowcase>` |
343
- | `deps.homeExtras` | `(args) => ReactNode \| Promise<ReactNode>` | Content appended to the home route (slug = `''`) |
344
- | `options.jsonLd` | `boolean \| JsonLdEntry[]` | Default `{ type: 'website' }` for every page |
345
- | `options.changefreq` | `string` | Default `'weekly'` |
346
- | `options.priority` | `number` | Default `0.5` |
347
- | `options.websiteName` | `string` | Override the auto-generated `WebSite` JSON-LD `name` |
348
-
349
- `createSitemapFile({ ... })`:
350
-
351
- | Option | Type | Description |
352
- |-----------------|-----------------|------------------------------------------------------------------------|
353
- | `collections` | `string[]` | Collection slugs whose docs appear in the sitemap |
354
- | `config` | `Promise<any>` | The Payload config promise |
355
- | `getServerSideURL` | `() => string` | Host's absolute URL helper |
356
- | `localePrefix` | `'always' \| 'as-needed' \| 'never'` | Default `'always'`. Mirrors next-intl's `localePrefix` so the sitemap URLs match the host's route shape. With `'as-needed'`, the default locale renders without a prefix. |
357
- | `locales` | `string[]` | Optional locale filter. Defaults to every `config.localization.locales`. |
358
- | `urlPrefixes` | `Record<string, string>` | Per-collection URL path prefix. Default `''`. Pass `{ posts: '/posts' }` for a collection mounted under a sub-route. |
359
- | `perCollection` | `Record<string, { priority?, changefreq? }>` | Per-collection overrides |
360
-
361
- ## Migration notes
362
-
363
- - `src/middleware.ts` is deprecated in favor of `src/proxy.ts` (next-intl ≥4). The lib doesn't ship a middleware — wire `createMiddleware(routing)` in your own `proxy.ts`.
364
- - `PageRouting` is a new required arg on `createCollectionPageExports`. It's a structural subset of the next-intl `defineRouting` result (locales, defaultLocale, localePrefix, labels), so passing your routing object directly works.
365
- - `createBaseSeed` now sets `_status: 'published'` on created / updated pages and posts by default. Pass `status: 'draft'` per entry to keep it as a draft.
325
+ Use these if you want to compose the default plugin set manually outside `createWWWConfig`. Full
326
+ plugin docs:
327
+
328
+ - [`@justanarthur/payload-plugin-seo`](../../plugins/seo/README.md)
329
+ - [`@justanarthur/payload-imagehash-plugin`](../../plugins/imagehash/README.md)
330
+ - [`@justanarthur/payload-plugin-translator`](../../plugins/translate/README.md)
331
+
332
+ ## Public subpath exports
333
+
334
+ The package's `package.json#exports` map:
335
+
336
+ | Subpath | What's there |
337
+ |---|---|
338
+ | `@justanarthur/payload-www/config` | `createWWWConfig`, `WWWConfigApi`, `WWWInputConfig` |
339
+ | `@justanarthur/payload-www/render-pages` | `createCollectionPageExports`, `createRootLayoutExports`, `PagesPage`, `PostsPage`, `HeaderPage`, `FooterPage`, `RootJsonLd` + types |
340
+ | `@justanarthur/payload-www/pages` | subset of `/render-pages` (no `createRootLayoutExports`, no `PostsPage`, no `RootJsonLd`) |
341
+ | `@justanarthur/payload-www/sitemap` | `createSitemapFromCollections` |
342
+ | `@justanarthur/payload-www/blocks` | `RenderBlocks`, `RenderBlocksProps` |
343
+ | `@justanarthur/payload-www/collections` | `createWWWCollectionGlobal`, `createRevalidateCollectionGlobalHook`, `createCollectionCacheKey`, `queryDoc` |
344
+ | `@justanarthur/payload-www/fields` | `link`, `linkGroup`, `appearanceOptions`, `slugField`, `LinkAppearances`, `LinkOptions` |
345
+ | `@justanarthur/payload-www/access` | `anyone`, `authenticated`, `authenticatedOrPublished` |
346
+ | `@justanarthur/payload-www/metadata` | `buildArticleLd`, `buildBreadcrumbsLd`, `buildOrganizationLd`, `buildWebSiteLd`, `buildProductLd`, `buildRootJsonLd`, `queryDocBySlug`, `queryAllDocs`, `queryAllLocaleSlugs`, `paramsSlugToSlug`, `slugToParamsSlug` + types |
347
+ | `@justanarthur/payload-www/utils` | `generateImportName`, `getFromImportMap` |
348
+ | `@justanarthur/payload-www/imagehash` | `imageHashPlugin`, `BlurhashPluginOptions` (re-export of `@justanarthur/payload-imagehash-plugin`) |
349
+ | `@justanarthur/payload-www/translator` | `translator` (re-export of `@justanarthur/payload-plugin-translator`) |
350
+ | `@justanarthur/payload-www/import-map-provider` | `setImportMapProvider`, `getImportMap` (stubs in the current build — reserved for future use) |
351
+
352
+ There is **no root import** (`.`) and **no `/server`, `/with-www-config`, `/globals`, `/hooks`,
353
+ `/render-utils`, `/render-components`, `/render-metadata`, `/components`, `/seed`, `/test`,
354
+ `/data-seed`, `/data-test`, `/data-collections`** in the published exports — paths the previous
355
+ README advertised that will fail at import time.
356
+
357
+ ## What the previous README got wrong
358
+
359
+ For agents migrating from older docs:
360
+
361
+ - `createWWWConfig({ locales, blocks })` → now `createWWWConfig()` (no args). Locales come from your
362
+ Payload `localization` config; `blocks` come from the `WWWInputConfig.blocks` field.
363
+ - `defaultPlugins` callback → now `defaultPluginsConfigs: { seo, imageHash, translator, mcp }` map
364
+ on `WWWInputConfig`.
365
+ - `createRevalidateCollectionHook({ collectionSlug, urlPathPrefix, })` (CHANGELOG `[Unreleased]`)
366
+ **not implemented yet**. The current hook is `createRevalidateCollectionGlobalHook()` (no args),
367
+ exported from `/collections`. Per-locale URL fan-out + `revalidatePath` does not happen only
368
+ `revalidateTag` fires.
369
+ - `createSitemapFile` → **not implemented**. The current export is `createSitemapFromCollections`,
370
+ used per-collection from your `app/(frontend)/sitemap.ts`.
371
+ - `createPreviewHandler`, `LocaleSwitcher`, `LivePreviewListener`, `PageShowcase`, `HomePage`,
372
+ `createStaticPageExports`, `createStaticPagesCollection` **none of these exist** as exports in
373
+ the current build. System pages render through a normal `createCollectionPageExports` mount (see
374
+ [Static pages](#static-pages-404--500--system) above).
375
+ - `Pages / Posts slug` is `localized: true` by default slug lives in `<collection>_locales`. Pass
376
+ `slugField({ localized: false })` to opt out per-collection.
366
377
 
367
378
  ## Building
368
379
 
369
380
  ```bash
370
381
  bun install
371
- bun run build # produces dist/ via bunup
382
+ bun run build # bunup + scripts/strip-createRequire.mjs
372
383
  bun run typecheck # tsc --noEmit
373
384
  bun run test # vitest run
374
385
  ```
375
386
 
376
- The lib is built with the same `bunup` + `exports` plugin pattern as
377
- the monorepo's other plugins. One shim file per subpath under
378
- `src/exports/`, each re-exports from the implementation.
387
+ The lib uses the same `bunup` + `src/exports/*` shim pattern as the sibling plugins. One shim file
388
+ per subpath under `src/exports/`, each re-exporting from the implementation.
379
389
 
380
- ## License
390
+ ## Licence
381
391
 
382
392
  MIT