@payload-onpage/plugin 0.2.1 → 0.3.1

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 (52) hide show
  1. package/README.md +6 -0
  2. package/dist/access.d.ts +7 -4
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +23 -20
  5. package/dist/access.js.map +1 -1
  6. package/dist/admin/OnPageAdminControls.d.ts +3 -2
  7. package/dist/admin/OnPageAdminControls.d.ts.map +1 -1
  8. package/dist/admin/OnPageAdminControls.js +12 -8
  9. package/dist/admin/OnPageAdminControls.js.map +1 -1
  10. package/dist/endpoints.d.ts +22 -6
  11. package/dist/endpoints.d.ts.map +1 -1
  12. package/dist/endpoints.js +87 -80
  13. package/dist/endpoints.js.map +1 -1
  14. package/dist/hooks.d.ts +11 -5
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/hooks.js +2 -2
  17. package/dist/hooks.js.map +1 -1
  18. package/dist/http.d.ts +18 -4
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +30 -16
  21. package/dist/http.js.map +1 -1
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +61 -50
  25. package/dist/index.js.map +1 -1
  26. package/dist/options.d.ts +22 -11
  27. package/dist/options.d.ts.map +1 -1
  28. package/dist/options.js +11 -3
  29. package/dist/options.js.map +1 -1
  30. package/dist/paths.d.ts +2 -2
  31. package/dist/paths.d.ts.map +1 -1
  32. package/dist/paths.js +11 -24
  33. package/dist/paths.js.map +1 -1
  34. package/dist/schema.d.ts +6 -4
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +115 -62
  37. package/dist/schema.js.map +1 -1
  38. package/dist/translations.d.ts +12 -1
  39. package/dist/translations.d.ts.map +1 -1
  40. package/dist/translations.js +10 -9
  41. package/dist/translations.js.map +1 -1
  42. package/package.json +15 -4
  43. package/src/access.ts +26 -19
  44. package/src/admin/OnPageAdminControls.tsx +16 -16
  45. package/src/endpoints.ts +105 -98
  46. package/src/hooks.ts +14 -5
  47. package/src/http.ts +51 -15
  48. package/src/index.ts +77 -64
  49. package/src/options.ts +29 -17
  50. package/src/paths.ts +14 -27
  51. package/src/schema.ts +137 -79
  52. package/src/translations.ts +17 -11
package/src/index.ts CHANGED
@@ -9,8 +9,15 @@
9
9
  * Payload's REST API, so access control, hooks, validation, drafts and versions
10
10
  * apply unchanged.
11
11
  */
12
- import { DEFAULTS, type SchemaField } from '@payload-onpage/core'
13
- import type { CollectionConfig, Config, GlobalConfig, Plugin } from 'payload'
12
+ import { DEFAULTS, type DocRef, type SchemaField } from '@payload-onpage/core'
13
+ import {
14
+ InvalidConfiguration,
15
+ type CollectionConfig,
16
+ type Config,
17
+ type GlobalConfig,
18
+ type Plugin,
19
+ } from 'payload'
20
+ import { deepMergeSimple } from 'payload/shared'
14
21
  import { createEndpoints } from './endpoints.ts'
15
22
  import { encodeOnRead, guardStaleWrite, stripMarkers } from './hooks.ts'
16
23
  import { normalizeEntries, type Entry, type OnPageOptions } from './options.ts'
@@ -19,56 +26,73 @@ import { translations } from './translations.ts'
19
26
 
20
27
  const APPLIED = 'payload-onpage'
21
28
 
22
- type Translations = Record<string, Record<string, unknown>>
23
- /** Ours first; the site's own translations (any language, any key) win. */
24
- function mergeTranslations(
25
- ours: Translations,
26
- theirs: unknown,
27
- ): NonNullable<Config['i18n']>['translations'] {
28
- const out: Translations = { ...ours }
29
- for (const [lang, table] of Object.entries((theirs ?? {}) as Translations)) {
30
- const mine = out[lang] ?? {}
31
- out[lang] = {
32
- ...mine,
33
- ...table,
34
- onpage: { ...(mine.onpage as object), ...(table.onpage ?? {}) },
35
- }
36
- }
37
- return out
38
- }
39
-
40
29
  export function onpage(options: OnPageOptions): Plugin {
41
30
  return (incoming: Config): Config => {
42
31
  if (options.enabled === false || incoming.custom?.[APPLIED]) return incoming
32
+ const { session } = options
33
+ if (!session)
34
+ throw new InvalidConfiguration(
35
+ 'payload-onpage: pass `session` (e.g. nextSession() from @payload-onpage/next/session). The enter, mode, exit and preview endpoints need it.',
36
+ )
43
37
  const prefix = options.routePrefix ?? DEFAULTS.routePrefix
44
- const apiRoute = incoming.routes?.api ?? DEFAULTS.apiRoute
45
- const entries = [
46
- ...normalizeEntries(options.collections, 'collection'),
47
- ...normalizeEntries(options.globals, 'global'),
48
- ]
49
- const byKey = new Map(entries.map((e) => [`${e.kind}:${e.slug}`, e]))
38
+ const entries = normalizeEntries(options)
39
+ const keyOf = (kind: DocRef['kind'], slug: string) => `${kind}:${slug}`
40
+ const byKey = new Map(entries.map((e) => [keyOf(e.kind, e.slug), e]))
50
41
 
51
- const withHooks = <T extends CollectionConfig | GlobalConfig>(cfg: T, entry: Entry): T => {
42
+ const missing = entries.filter((e) =>
43
+ e.kind === 'global'
44
+ ? !incoming.globals?.some((g) => g.slug === e.slug)
45
+ : !incoming.collections?.some((c) => c.slug === e.slug),
46
+ )
47
+ if (missing.length) {
48
+ throw new InvalidConfiguration(
49
+ `payload-onpage: unknown ${missing.map((e) => `${e.kind} "${e.slug}"`).join(', ')}`,
50
+ )
51
+ }
52
+
53
+ /** The hooks every enabled document gets (see ./hooks). Encoding serializes the fields once, lazily. */
54
+ const hooksFor = (cfg: CollectionConfig | GlobalConfig, entry: Entry) => {
52
55
  let fields: SchemaField[] | undefined
53
- // Encoding only needs names, types and editors: serialize once, lazily, without request context.
54
- const lazyFields = () => (fields ??= serializeFields(cfg.fields, { blocks: incoming.blocks }))
55
- const hooks = { ...(cfg.hooks ?? {}) } as Record<string, unknown[]>
56
- hooks.beforeValidate = [...(hooks.beforeValidate ?? []), stripMarkers]
57
- hooks.beforeChange = [...(hooks.beforeChange ?? []), stripMarkers, guardStaleWrite]
58
- hooks.afterRead = [
59
- ...(hooks.afterRead ?? []),
60
- encodeOnRead({
61
- kind: entry.kind,
62
- slug: cfg.slug,
63
- fields: lazyFields,
64
- only: entry.options.fields,
65
- filter: options.encode,
66
- }),
67
- ]
68
- return { ...cfg, hooks }
56
+ return {
57
+ beforeValidate: [stripMarkers],
58
+ beforeChange: [stripMarkers, guardStaleWrite],
59
+ afterRead: [
60
+ encodeOnRead({
61
+ kind: entry.kind,
62
+ slug: cfg.slug,
63
+ fields: () => (fields ??= serializeFields(cfg.fields, { blocks: incoming.blocks })),
64
+ ...(entry.options.fields ? { only: entry.options.fields } : {}),
65
+ ...(options.encode ? { filter: options.encode } : {}),
66
+ }),
67
+ ],
68
+ }
69
+ }
70
+ const withCollectionHooks = (c: CollectionConfig, entry: Entry): CollectionConfig => {
71
+ const add = hooksFor(c, entry)
72
+ return {
73
+ ...c,
74
+ hooks: {
75
+ ...c.hooks,
76
+ beforeValidate: [...(c.hooks?.beforeValidate ?? []), ...add.beforeValidate],
77
+ beforeChange: [...(c.hooks?.beforeChange ?? []), ...add.beforeChange],
78
+ afterRead: [...(c.hooks?.afterRead ?? []), ...add.afterRead],
79
+ },
80
+ }
81
+ }
82
+ const withGlobalHooks = (g: GlobalConfig, entry: Entry): GlobalConfig => {
83
+ const add = hooksFor(g, entry)
84
+ return {
85
+ ...g,
86
+ hooks: {
87
+ ...g.hooks,
88
+ beforeValidate: [...(g.hooks?.beforeValidate ?? []), ...add.beforeValidate],
89
+ beforeChange: [...(g.hooks?.beforeChange ?? []), ...add.beforeChange],
90
+ afterRead: [...(g.hooks?.afterRead ?? []), ...add.afterRead],
91
+ },
92
+ }
69
93
  }
70
94
 
71
- const control = (kind: Entry['kind'], slug: string) => ({
95
+ const control = (kind: DocRef['kind'], slug: string) => ({
72
96
  path: '@payload-onpage/plugin/admin#OnPageAdminControls',
73
97
  clientProps: { kind, slug, prefix },
74
98
  })
@@ -112,39 +136,28 @@ export function onpage(options: OnPageOptions): Plugin {
112
136
  }
113
137
  }
114
138
 
115
- const missing = entries.filter((e) =>
116
- e.kind === 'global'
117
- ? !incoming.globals?.some((g) => g.slug === e.slug)
118
- : !incoming.collections?.some((c) => c.slug === e.slug),
119
- )
120
- if (missing.length) {
121
- throw new Error(
122
- `payload-onpage: unknown ${missing.map((e) => `${e.kind} "${e.slug}"`).join(', ')}`,
123
- )
124
- }
125
-
126
139
  return {
127
140
  ...incoming,
128
141
  custom: { ...incoming.custom, [APPLIED]: true },
129
142
  i18n: {
130
143
  ...incoming.i18n,
131
- translations: mergeTranslations(translations, incoming.i18n?.translations),
144
+ // Ours first; the site's own translations (any language, any key) win.
145
+ translations: deepMergeSimple(translations, incoming.i18n?.translations ?? {}),
132
146
  },
133
147
  collections: (incoming.collections ?? []).map((c) => {
134
- const entry = byKey.get(`collection:${c.slug}`)
135
- return entry ? withCollectionAdmin(withHooks(c, entry)) : c
148
+ const entry = byKey.get(keyOf('collection', c.slug))
149
+ return entry ? withCollectionAdmin(withCollectionHooks(c, entry)) : c
136
150
  }),
137
151
  globals: (incoming.globals ?? []).map((g) => {
138
- const entry = byKey.get(`global:${g.slug}`)
139
- return entry ? withGlobalAdmin(withHooks(g, entry)) : g
152
+ const entry = byKey.get(keyOf('global', g.slug))
153
+ return entry ? withGlobalAdmin(withGlobalHooks(g, entry)) : g
140
154
  }),
141
155
  endpoints: [
142
156
  ...(incoming.endpoints ?? []),
143
157
  ...createEndpoints({
144
- options,
158
+ options: { ...options, session },
145
159
  prefix,
146
- apiRoute,
147
- entry: (kind, slug) => byKey.get(`${kind}:${slug}`),
160
+ entry: (kind, slug) => byKey.get(keyOf(kind, slug)),
148
161
  }),
149
162
  ],
150
163
  onInit: async (payload) => {
@@ -159,7 +172,7 @@ export function onpage(options: OnPageOptions): Plugin {
159
172
  }
160
173
  }
161
174
 
162
- export type { OnPageDocOptions, OnPageOptions } from './options.ts'
175
+ export type { OnPageDocOptions, OnPageOptions, OnPageTargets } from './options.ts'
163
176
  export { canEditDocument, canUseOnPage } from './access.ts'
164
177
  export { createPreviewToken, verifyPreviewToken, type PreviewTokenPayload } from './tokens.ts'
165
178
  export { serializeFields, type SerializeContext } from './schema.ts'
package/src/options.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DocRef, EncodeFilter, SessionAdapter } from '@payload-onpage/core'
2
- import type { PayloadRequest } from 'payload'
2
+ import type { CollectionSlug, GlobalSlug, PayloadRequest } from 'payload'
3
3
 
4
4
  export interface OnPageDocOptions {
5
5
  /** Top-level fields editable on the page. Default: every top-level field the user may update. */
@@ -10,21 +10,29 @@ export interface OnPageDocOptions {
10
10
  */
11
11
  url?: (args: {
12
12
  doc: Record<string, unknown>
13
- locale?: string
13
+ locale?: string | undefined
14
14
  req: PayloadRequest
15
15
  }) => string | null | Promise<string | null>
16
16
  }
17
17
 
18
+ /**
19
+ * Collections or globals to enable: a list of slugs, or slugs mapped to options.
20
+ * Slugs are checked against your generated Payload types (`CollectionSlug` /
21
+ * `GlobalSlug`), so a typo is a compile error.
22
+ */
23
+ export type OnPageTargets<Slug extends string> =
24
+ Slug[] | Partial<Record<Slug, OnPageDocOptions | true>>
25
+
18
26
  export interface OnPageOptions {
19
- collections?: string[] | Record<string, OnPageDocOptions | true>
20
- globals?: string[] | Record<string, OnPageDocOptions | true>
27
+ collections?: OnPageTargets<CollectionSlug>
28
+ globals?: OnPageTargets<GlobalSlug>
21
29
  /**
22
30
  * Who may use on-page editing (`ref` undefined) and edit a given document.
23
31
  * Default: users of the admin user collection, with the document's own
24
32
  * `update` access (evaluated by Payload, including row-level rules).
25
33
  */
26
34
  access?: (args: { req: PayloadRequest; ref?: DocRef }) => boolean | Promise<boolean>
27
- /** Framework adapter that switches edit/preview mode (e.g. `nextSession()`). Required for the enter/exit/preview endpoints. */
35
+ /** Framework adapter that switches edit/preview mode (e.g. `nextSession()`). Required for the enter/mode/exit/preview endpoints. */
28
36
  session?: SessionAdapter
29
37
  /** Narrow which strings get auto-mapping markers. */
30
38
  encode?: EncodeFilter
@@ -46,20 +54,24 @@ export interface OnPageOptions {
46
54
  /** Auth cookie an editor's session is bound to. Default `${cookiePrefix}-token` ("payload-token"). */
47
55
  authCookie?: string
48
56
  /** Endpoint prefix under the API route. Default "/onpage". */
49
- routePrefix?: string
57
+ routePrefix?: `/${string}`
50
58
  enabled?: boolean
51
59
  }
52
60
 
53
- export interface Entry {
54
- kind: 'collection' | 'global'
55
- slug: string
56
- options: OnPageDocOptions
61
+ /** One enabled collection or global, with its slug typed by kind. */
62
+ export type Entry =
63
+ | { kind: 'collection'; slug: CollectionSlug; options: OnPageDocOptions }
64
+ | { kind: 'global'; slug: GlobalSlug; options: OnPageDocOptions }
65
+
66
+ function entriesOf<Slug extends string>(input: OnPageTargets<Slug> | undefined) {
67
+ return Array.isArray(input)
68
+ ? input.map((slug) => ({ slug, options: {} }))
69
+ : (Object.entries(input ?? {}) as Array<[Slug, OnPageDocOptions | true | undefined]>)
70
+ .filter(([, o]) => o !== undefined)
71
+ .map(([slug, o]) => ({ slug, options: o === true || !o ? {} : o }))
57
72
  }
58
73
 
59
- export const normalizeEntries = (
60
- input: OnPageOptions['collections'],
61
- kind: Entry['kind'],
62
- ): Entry[] =>
63
- Array.isArray(input)
64
- ? input.map((slug) => ({ kind, slug, options: {} }))
65
- : Object.entries(input ?? {}).map(([slug, o]) => ({ kind, slug, options: o === true ? {} : o }))
74
+ export const normalizeEntries = (options: OnPageOptions): Entry[] => [
75
+ ...entriesOf(options.collections).map((e) => ({ kind: 'collection' as const, ...e })),
76
+ ...entriesOf(options.globals).map((e) => ({ kind: 'global' as const, ...e })),
77
+ ]
package/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Where a document lives on the site. */
2
2
  import { safePath, type DocRef } from '@payload-onpage/core'
3
- import type { PayloadRequest } from 'payload'
3
+ import type { PayloadRequest, TypedLocale } from 'payload'
4
4
  import { originOf } from './http.ts'
5
5
  import type { Entry } from './options.ts'
6
6
 
@@ -13,36 +13,23 @@ export async function resolvePath(
13
13
  req: PayloadRequest,
14
14
  ref: DocRef,
15
15
  entry: Entry,
16
- locale?: string,
16
+ locale?: TypedLocale,
17
17
  ): Promise<string | null> {
18
- const localeArg = locale ? { locale: locale as never } : {}
19
- const doc =
20
- ref.kind === 'global'
21
- ? await req.payload
22
- .findGlobal({
23
- slug: ref.slug as never,
24
- draft: true,
25
- depth: 0,
26
- overrideAccess: true,
27
- ...localeArg,
28
- })
29
- .catch(() => null)
30
- : await req.payload
31
- .findByID({
32
- collection: ref.slug as never,
33
- id: ref.id,
34
- draft: true,
35
- depth: 0,
36
- overrideAccess: true,
37
- ...localeArg,
38
- })
39
- .catch(() => null)
18
+ const read = { draft: true, depth: 0, overrideAccess: true, ...(locale ? { locale } : {}) }
19
+ const doc: Record<string, unknown> | null =
20
+ entry.kind === 'global'
21
+ ? await req.payload.findGlobal({ slug: entry.slug, ...read }).catch(() => null)
22
+ : ref.kind === 'collection'
23
+ ? await req.payload
24
+ .findByID({ collection: entry.slug, id: ref.id, ...read })
25
+ .catch(() => null)
26
+ : null
40
27
  if (!doc) return null
41
28
 
42
29
  let url: string | null | undefined
43
- if (entry.options.url) url = await entry.options.url({ doc, locale, req })
44
- else if (ref.kind === 'collection') {
45
- const preview = req.payload.collections[ref.slug]?.config.admin?.preview
30
+ if (entry.options.url) url = await entry.options.url({ doc, locale: locale ?? undefined, req })
31
+ else if (entry.kind === 'collection') {
32
+ const preview = req.payload.collections[entry.slug]?.config.admin?.preview
46
33
  if (preview) url = await preview(doc, { locale: locale ?? '', req, token: null })
47
34
  if (!url && typeof doc.slug === 'string') url = `/${doc.slug.replace(/^\/+/, '')}`
48
35
  } else url = '/'
package/src/schema.ts CHANGED
@@ -21,44 +21,45 @@ const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', '_status', 'delet
21
21
  /** Data fields with no meaningful on-page value. */
22
22
  const SKIP_TYPES = new Set(['join', 'ui'])
23
23
 
24
- /** The optional, type-specific field properties the serializer reads. */
25
- interface LooseField {
24
+ /**
25
+ * The properties every data field may have. Payload's `Field` union declares
26
+ * them per member, so they're read through this one view; everything specific
27
+ * to a field type is read from Payload's narrowed type in `serializeFields`.
28
+ */
29
+ interface CommonField {
26
30
  label?: unknown
27
- labels?: { singular?: unknown; plural?: unknown }
28
31
  localized?: boolean
29
32
  required?: boolean
30
- hasMany?: boolean
31
33
  defaultValue?: unknown
32
- options?: unknown[]
33
- relationTo?: string | string[]
34
- minRows?: number
35
- maxRows?: number
36
- min?: number
37
- max?: number
38
34
  custom?: { onpage?: { editor?: unknown } }
39
- admin?: {
40
- description?: unknown
41
- custom?: { onpage?: { editor?: unknown } }
42
- date?: { pickerAppearance?: unknown }
43
- }
35
+ admin?: { description?: unknown; custom?: { onpage?: { editor?: unknown } } }
44
36
  }
45
37
 
38
+ /** `{ key: n }` when n is a number, else nothing (for spreading optional limits). */
39
+ const num = <K extends string>(key: K, n: unknown): Partial<Record<K, number>> =>
40
+ typeof n === 'number' ? ({ [key]: n } as Record<K, number>) : {}
41
+
46
42
  type I18nLike = Parameters<typeof getTranslation>[1]
47
43
  type AnyLabel = Parameters<typeof getTranslation>[0]
48
44
  type Permissions = SanitizedFieldsPermissions | undefined
49
45
 
50
46
  export interface SerializeContext {
51
47
  /** Translates labels into the admin language. Without it, plain-string labels or the field name are used. */
52
- i18n?: I18nLike
48
+ i18n?: I18nLike | undefined
53
49
  /** Global blocks (`config.blocks`), to resolve `blockReferences`. */
54
- blocks?: Block[]
50
+ blocks?: Block[] | undefined
55
51
  /** Field-level permissions: fields the user can't update are left out. */
56
- permissions?: Permissions
52
+ permissions?: Permissions | undefined
57
53
  /** Collects function defaults to evaluate for this request. */
58
- defaults?: { req: PayloadRequest; pending: Promise<void>[] }
54
+ defaults?: { req: PayloadRequest; pending: Promise<void>[] } | undefined
59
55
  }
60
56
 
61
- const label = (value: unknown, fallback: string, ctx: SerializeContext): string => {
57
+ /** A config label (string, translations or function) in the request's language, else `fallback`. */
58
+ export const label = (
59
+ value: unknown,
60
+ fallback: string,
61
+ ctx: Pick<SerializeContext, 'i18n'>,
62
+ ): string => {
62
63
  if (value == null || value === false) return fallback
63
64
  if (typeof value === 'string') return value || fallback
64
65
  if (!ctx.i18n) return fallback
@@ -160,76 +161,134 @@ export function serializeFields(
160
161
  const perms = childPermissions(ctx.permissions, f.name)
161
162
  if (perms === false) continue
162
163
 
163
- // Field is a large union; read the optional, type-specific properties through one loose view.
164
- const anyField = f as unknown as LooseField
165
- const field: SchemaField = {
164
+ const common = f as CommonField
165
+ const base = {
166
166
  name: f.name,
167
- type: f.type,
168
- label: label(anyField.label, toWords(f.name), ctx),
167
+ label: label(common.label, toWords(f.name), ctx),
168
+ ...(common.localized ? { localized: true } : {}),
169
+ ...(common.required ? { required: true } : {}),
170
+ }
171
+ const description = label(common.admin?.description, '', ctx)
172
+ const editor = common.admin?.custom?.onpage?.editor ?? common.custom?.onpage?.editor
173
+ const childCtx = { ...ctx, permissions: perms }
174
+ const rows = (
175
+ labels: { singular?: unknown; plural?: unknown } | undefined,
176
+ min: unknown,
177
+ max: unknown,
178
+ ) => {
179
+ const fallback = formatLabels(f.name)
180
+ return {
181
+ labels: {
182
+ singular: label(labels?.singular, fallback.singular, ctx),
183
+ plural: label(labels?.plural, fallback.plural, ctx),
184
+ },
185
+ ...num('minRows', min),
186
+ ...num('maxRows', max),
187
+ }
188
+ }
189
+
190
+ let field: SchemaField
191
+ switch (f.type) {
192
+ case 'text':
193
+ case 'textarea':
194
+ case 'email':
195
+ case 'code':
196
+ case 'json':
197
+ case 'checkbox':
198
+ case 'point':
199
+ case 'richText':
200
+ field = { ...base, type: f.type, ...('hasMany' in f && f.hasMany ? { hasMany: true } : {}) }
201
+ break
202
+ case 'number':
203
+ field = {
204
+ ...base,
205
+ type: 'number',
206
+ ...(f.hasMany ? { hasMany: true } : {}),
207
+ ...num('min', f.min),
208
+ ...num('max', f.max),
209
+ }
210
+ break
211
+ case 'date': {
212
+ const picker = f.admin?.date?.pickerAppearance
213
+ field = {
214
+ ...base,
215
+ type: 'date',
216
+ ...(typeof picker === 'string' ? { pickerAppearance: picker } : {}),
217
+ }
218
+ break
219
+ }
220
+ case 'select':
221
+ case 'radio':
222
+ field = {
223
+ ...base,
224
+ type: f.type,
225
+ options: f.options.map((o) =>
226
+ typeof o === 'string'
227
+ ? { label: o, value: o }
228
+ : { label: label(o.label, String(o.value), ctx), value: String(o.value) },
229
+ ),
230
+ ...('hasMany' in f && f.hasMany ? { hasMany: true } : {}),
231
+ }
232
+ break
233
+ case 'relationship':
234
+ case 'upload':
235
+ field = {
236
+ ...base,
237
+ type: f.type,
238
+ relationTo: f.relationTo,
239
+ ...(f.hasMany ? { hasMany: true } : {}),
240
+ }
241
+ break
242
+ case 'group':
243
+ field = { ...base, type: 'group', fields: serializeFields(f.fields, childCtx) }
244
+ break
245
+ case 'array':
246
+ field = {
247
+ ...base,
248
+ type: 'array',
249
+ fields: serializeFields(f.fields, childCtx),
250
+ ...rows(f.labels, f.minRows, f.maxRows),
251
+ }
252
+ break
253
+ case 'blocks': {
254
+ const refs = (f.blockReferences ?? [])
255
+ .map((b) => (typeof b === 'string' ? ctx.blocks?.find((x) => x.slug === b) : b))
256
+ .filter((b): b is Block => !!b)
257
+ field = {
258
+ ...base,
259
+ type: 'blocks',
260
+ blocks: [...f.blocks, ...refs].map((b) =>
261
+ serializeBlock(b, blockPermissions(ctx.permissions, f.name, b.slug), ctx),
262
+ ),
263
+ ...rows(f.labels, f.minRows, f.maxRows),
264
+ }
265
+ break
266
+ }
267
+ default:
268
+ // join/ui are skipped above; anything newer than this plugin isn't editable on the page.
269
+ continue
169
270
  }
170
- if (anyField.localized) field.localized = true
171
- if (anyField.required) field.required = true
172
- if (anyField.hasMany) field.hasMany = true
173
- if (typeof anyField.defaultValue === 'function') {
271
+ if (description) field.description = description
272
+ if (typeof editor === 'string') field.editor = editor
273
+ if (typeof common.defaultValue === 'function') {
174
274
  if (ctx.defaults) {
175
275
  const { req, pending } = ctx.defaults
276
+ const target = field
176
277
  pending.push(
177
278
  getDefaultValue({
178
- defaultValue: anyField.defaultValue,
279
+ defaultValue: common.defaultValue,
179
280
  locale: req.locale ?? undefined,
180
281
  req,
181
282
  user: req.user,
182
283
  })
183
284
  .then((v) => {
184
- if (isJsonSafe(v)) field.defaultValue = v
285
+ if (isJsonSafe(v)) target.defaultValue = v
185
286
  })
186
287
  .catch(() => undefined),
187
288
  )
188
289
  }
189
- } else if (isJsonSafe(anyField.defaultValue)) {
190
- field.defaultValue = anyField.defaultValue
191
- }
192
- const description = label(anyField.admin?.description, '', ctx)
193
- if (description) field.description = description
194
- if (Array.isArray(anyField.options)) {
195
- field.options = anyField.options.map((o: unknown) =>
196
- typeof o === 'string'
197
- ? { label: o, value: o }
198
- : {
199
- label: label(
200
- (o as { label?: unknown }).label,
201
- String((o as { value: unknown }).value),
202
- ctx,
203
- ),
204
- value: String((o as { value: unknown }).value),
205
- },
206
- )
207
- }
208
- if (anyField.relationTo) field.relationTo = anyField.relationTo
209
- for (const k of ['minRows', 'maxRows', 'min', 'max'] as const) {
210
- const n = anyField[k]
211
- if (typeof n === 'number') field[k] = n
212
- }
213
- const editor = anyField.admin?.custom?.onpage?.editor ?? anyField.custom?.onpage?.editor
214
- if (typeof editor === 'string') field.editor = editor
215
- const picker = anyField.admin?.date?.pickerAppearance
216
- if (typeof picker === 'string') field.pickerAppearance = picker
217
- const childCtx = { ...ctx, permissions: perms }
218
- if (f.type === 'group' || f.type === 'array') field.fields = serializeFields(f.fields, childCtx)
219
- if (f.type === 'array') {
220
- const fallback = formatLabels(f.name)
221
- field.labels = {
222
- singular: label(anyField.labels?.singular, fallback.singular, ctx),
223
- plural: label(anyField.labels?.plural, fallback.plural, ctx),
224
- }
225
- }
226
- if (f.type === 'blocks') {
227
- const refs = (f.blockReferences ?? [])
228
- .map((b) => (typeof b === 'string' ? ctx.blocks?.find((x) => x.slug === b) : b))
229
- .filter((b): b is Block => !!b)
230
- field.blocks = [...(f.blocks ?? []), ...refs].map((b) =>
231
- serializeBlock(b, blockPermissions(ctx.permissions, f.name, b.slug), ctx),
232
- )
290
+ } else if (isJsonSafe(common.defaultValue)) {
291
+ field.defaultValue = common.defaultValue
233
292
  }
234
293
  out.push(field)
235
294
  }
@@ -239,11 +298,10 @@ export function serializeFields(
239
298
  /** Every collection a relationship or upload field in `fields` points to. */
240
299
  export function relationTargets(fields: SchemaField[], into = new Set<string>()): Set<string> {
241
300
  for (const f of fields) {
242
- if ((f.type === 'relationship' || f.type === 'upload') && f.relationTo) {
301
+ if (f.type === 'relationship' || f.type === 'upload')
243
302
  for (const slug of Array.isArray(f.relationTo) ? f.relationTo : [f.relationTo]) into.add(slug)
244
- }
245
- if (f.fields) relationTargets(f.fields, into)
246
- for (const b of f.blocks ?? []) relationTargets(b.fields, into)
303
+ if (f.type === 'group' || f.type === 'array') relationTargets(f.fields, into)
304
+ if (f.type === 'blocks') for (const b of f.blocks) relationTargets(b.fields, into)
247
305
  }
248
306
  return into
249
307
  }
@@ -1,18 +1,24 @@
1
+ import type { NestedKeysStripped } from '@payloadcms/translations'
1
2
  /**
2
3
  * Admin UI strings, registered with Payload's i18n under the `onpage` namespace
3
4
  * (so the admin language applies). Add or override languages with
4
5
  * `i18n.translations` in your Payload config.
5
6
  */
6
- export const translations = {
7
- en: {
8
- onpage: {
9
- editOnPage: 'Edit on page',
10
- copyPreviewLink: 'Copy preview link',
11
- copyPreviewLinkHint: 'Copy a read-only link to this draft',
12
- linkCopied: 'Preview link copied — valid until {{date}}',
13
- linkFailed: "Couldn't create a preview link: {{message}}",
14
- },
7
+ const en = {
8
+ onpage: {
9
+ editOnPage: 'Edit on page',
10
+ copyPreviewLink: 'Copy preview link',
11
+ copyPreviewLinkHint: 'Copy a read-only link to this draft',
12
+ linkCopied: 'Preview link copied — valid until {{date}}',
13
+ linkFailed: "Couldn't create a preview link: {{message}}",
15
14
  },
15
+ }
16
+
17
+ /** Every language must translate every key: a missing or extra one is a compile error. */
18
+ type Table = { onpage: Record<keyof (typeof en)['onpage'], string> }
19
+
20
+ export const translations = {
21
+ en,
16
22
  es: {
17
23
  onpage: {
18
24
  editOnPage: 'Editar en la página',
@@ -40,6 +46,6 @@ export const translations = {
40
46
  linkFailed: 'Vorschaulink konnte nicht erstellt werden: {{message}}',
41
47
  },
42
48
  },
43
- }
49
+ } satisfies Record<string, Table>
44
50
 
45
- export type OnPageTranslationKey = `onpage:${keyof (typeof translations)['en']['onpage']}`
51
+ export type OnPageTranslationKey = NestedKeysStripped<typeof en>