@payload-onpage/plugin 0.0.0-stage → 0.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/LICENSE +21 -0
  2. package/README.md +15 -2
  3. package/dist/access.d.ts +20 -0
  4. package/dist/access.d.ts.map +1 -0
  5. package/dist/access.js +51 -0
  6. package/dist/access.js.map +1 -0
  7. package/dist/admin/OnPageAdminControls.d.ts +9 -0
  8. package/dist/admin/OnPageAdminControls.d.ts.map +1 -0
  9. package/dist/admin/OnPageAdminControls.js +59 -0
  10. package/dist/admin/OnPageAdminControls.js.map +1 -0
  11. package/dist/admin/index.d.ts +3 -0
  12. package/dist/admin/index.d.ts.map +1 -0
  13. package/dist/admin/index.js +2 -0
  14. package/dist/admin/index.js.map +1 -0
  15. package/dist/endpoints.d.ts +11 -0
  16. package/dist/endpoints.d.ts.map +1 -0
  17. package/dist/endpoints.js +223 -0
  18. package/dist/endpoints.js.map +1 -0
  19. package/dist/hooks.d.ts +38 -0
  20. package/dist/hooks.d.ts.map +1 -0
  21. package/dist/hooks.js +42 -0
  22. package/dist/hooks.js.map +1 -0
  23. package/dist/http.d.ts +23 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +53 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +148 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/options.d.ts +59 -0
  32. package/dist/options.d.ts.map +1 -0
  33. package/dist/options.js +4 -0
  34. package/dist/options.js.map +1 -0
  35. package/dist/paths.d.ts +11 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +58 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/schema.d.ts +34 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +209 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/tokens.d.ts +18 -0
  44. package/dist/tokens.d.ts.map +1 -0
  45. package/dist/tokens.js +24 -0
  46. package/dist/tokens.js.map +1 -0
  47. package/dist/translations.d.ts +45 -0
  48. package/dist/translations.d.ts.map +1 -0
  49. package/dist/translations.js +44 -0
  50. package/dist/translations.js.map +1 -0
  51. package/package.json +65 -4
  52. package/src/access.ts +65 -0
  53. package/src/admin/OnPageAdminControls.tsx +99 -0
  54. package/src/admin/index.ts +2 -0
  55. package/src/endpoints.ts +269 -0
  56. package/src/hooks.ts +79 -0
  57. package/src/http.ts +57 -0
  58. package/src/index.ts +167 -0
  59. package/src/options.ts +65 -0
  60. package/src/paths.ts +59 -0
  61. package/src/schema.ts +251 -0
  62. package/src/tokens.ts +46 -0
  63. package/src/translations.ts +45 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The plugin's endpoints, under `${api}${routePrefix}` (default /api/onpage):
3
+ *
4
+ * GET /schema/:kind/:slug editable field schema for the signed-in editor
5
+ * POST /enter start editing or previewing a document's page
6
+ * POST /mode switch the current page between editing and previewing
7
+ * POST /exit leave edit/preview mode
8
+ * POST /preview-link create a sealed, expiring read-only link (JSON)
9
+ * GET /preview?token= open a preview link
10
+ *
11
+ * State-changing endpoints are same-origin POSTs (CSRF-safe). Every
12
+ * authorization decision goes through ./access (Payload's own access rules).
13
+ */
14
+ import {
15
+ parseRefKey,
16
+ pathnameOf,
17
+ refKey,
18
+ safePath,
19
+ samePath,
20
+ SEAL_PURPOSE,
21
+ seal,
22
+ type DocumentSchema,
23
+ type OnPageMode,
24
+ type SessionAdapter,
25
+ } from '@payload-onpage/core'
26
+ import type { CollectionConfig, Endpoint, GlobalConfig, PayloadRequest } from 'payload'
27
+ import { hasAutosaveEnabled, hasDraftsEnabled, getAutosaveInterval } from 'payload/shared'
28
+ import { canEditDocument, canUseOnPage, documentPermissions } from './access.ts'
29
+ import {
30
+ isSecure,
31
+ json,
32
+ originOf,
33
+ queryOf,
34
+ readRef,
35
+ redirectTo,
36
+ rejectCrossSite,
37
+ text,
38
+ } from './http.ts'
39
+ import type { Entry, OnPageOptions } from './options.ts'
40
+ import { resolvePath } from './paths.ts'
41
+ import { relationTargets, serializeFields } from './schema.ts'
42
+ import { createPreviewToken, verifyPreviewToken } from './tokens.ts'
43
+
44
+ export interface EndpointContext {
45
+ options: OnPageOptions
46
+ prefix: string
47
+ apiRoute: string
48
+ entry: (kind: string, slug: string) => Entry | undefined
49
+ }
50
+
51
+ const DAY = 24 * 3600
52
+
53
+ export const secretOf = (req: PayloadRequest, options: OnPageOptions): string =>
54
+ options.secret ?? req.payload.config.secret
55
+
56
+ /** Name of the auth cookie an editor's mode is bound to. */
57
+ const authCookieOf = (req: PayloadRequest, options: OnPageOptions): string =>
58
+ options.authCookie ?? `${req.payload.config.cookiePrefix || 'payload'}-token`
59
+
60
+ function requireSession(options: OnPageOptions): SessionAdapter {
61
+ if (!options.session)
62
+ throw new Error(
63
+ 'payload-onpage: pass `session` (e.g. nextSession()) to use the enter, mode, exit and preview endpoints',
64
+ )
65
+ return options.session
66
+ }
67
+
68
+ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
69
+ const { options, prefix, apiRoute } = ctx
70
+
71
+ const schema: Endpoint = {
72
+ path: `${prefix}/schema/:kind/:slug`,
73
+ method: 'get',
74
+ handler: async (req) => {
75
+ const kind = req.routeParams?.kind === 'global' ? 'global' : 'collection'
76
+ const slug = typeof req.routeParams?.slug === 'string' ? req.routeParams.slug : ''
77
+ const entry = ctx.entry(kind, slug)
78
+ const cfg: CollectionConfig | GlobalConfig | undefined =
79
+ kind === 'global'
80
+ ? req.payload.globals.config.find((g) => g.slug === slug)
81
+ : req.payload.collections[slug]?.config
82
+ if (!entry || !cfg) return json({ error: 'Not enabled for on-page editing' }, 404)
83
+ if (!(await canUseOnPage(req, options))) return json({ error: 'Forbidden' }, 403)
84
+
85
+ const perms = await documentPermissions(
86
+ req,
87
+ kind === 'global' ? { kind, slug } : { kind, slug },
88
+ )
89
+ const pending: Promise<void>[] = []
90
+ const all = serializeFields(cfg.fields, {
91
+ i18n: req.i18n,
92
+ blocks: req.payload.config.blocks,
93
+ permissions: perms?.fields,
94
+ defaults: { req, pending },
95
+ })
96
+ await Promise.all(pending)
97
+ const fields = entry.options.fields
98
+ ? all.filter((f) => entry.options.fields!.includes(f.name))
99
+ : all
100
+
101
+ const relations: NonNullable<DocumentSchema['relations']> = {}
102
+ for (const target of relationTargets(fields)) {
103
+ const c = req.payload.collections[target]?.config
104
+ if (!c) continue
105
+ relations[target] = {
106
+ titleField: c.admin?.useAsTitle ?? 'id',
107
+ label: typeof c.labels?.singular === 'string' ? c.labels.singular : target,
108
+ }
109
+ }
110
+ const localization = req.payload.config.localization
111
+ const labelOf = (v: unknown) => (typeof v === 'string' ? v : slug)
112
+ const body: DocumentSchema = {
113
+ kind,
114
+ slug,
115
+ labels:
116
+ kind === 'global'
117
+ ? {
118
+ singular: labelOf((cfg as GlobalConfig).label),
119
+ plural: labelOf((cfg as GlobalConfig).label),
120
+ }
121
+ : {
122
+ singular: labelOf((cfg as CollectionConfig).labels?.singular),
123
+ plural: labelOf((cfg as CollectionConfig).labels?.plural),
124
+ },
125
+ editable: fields.map((f) => f.name),
126
+ fields,
127
+ drafts: hasDraftsEnabled(cfg),
128
+ ...(hasAutosaveEnabled(cfg) ? { autosaveInterval: getAutosaveInterval(cfg) } : {}),
129
+ ...(kind === 'collection' && (cfg as CollectionConfig).admin?.useAsTitle
130
+ ? { useAsTitle: (cfg as CollectionConfig).admin!.useAsTitle }
131
+ : {}),
132
+ ...(Object.keys(relations).length ? { relations } : {}),
133
+ ...(localization
134
+ ? {
135
+ defaultLocale: localization.defaultLocale,
136
+ locales: localization.locales.map((l) => (typeof l === 'string' ? l : l.code)),
137
+ }
138
+ : {}),
139
+ }
140
+ return json(body)
141
+ },
142
+ }
143
+
144
+ const enter: Endpoint = {
145
+ path: `${prefix}/enter`,
146
+ method: 'post',
147
+ handler: async (req) => {
148
+ const refused = rejectCrossSite(req)
149
+ if (refused) return refused
150
+ const q = queryOf(req)
151
+ const ref = readRef({ kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
152
+ const entry = ref && ctx.entry(ref.kind, ref.slug)
153
+ if (!ref || !entry) return text('Unknown document', 400)
154
+ if (!req.user) return text('Sign in to the admin first', 401)
155
+ if (!(await canEditDocument(req, ref, options)))
156
+ return text('You cannot edit this document', 403)
157
+ const locale = q.get('locale') ?? undefined
158
+ const path = safePath(q.get('path')) ?? (await resolvePath(req, ref, entry, locale))
159
+ if (!path) return text('This document has no page to open', 404)
160
+ const mode: OnPageMode = q.get('mode') === 'view' ? 'view' : 'edit'
161
+ await requireSession(options).enable(mode, {
162
+ secure: isSecure(req),
163
+ bindTo: authCookieOf(req, options),
164
+ })
165
+ return redirectTo(req, path)
166
+ },
167
+ }
168
+
169
+ const mode: Endpoint = {
170
+ path: `${prefix}/mode`,
171
+ method: 'post',
172
+ handler: async (req) => {
173
+ const refused = rejectCrossSite(req)
174
+ if (refused) return refused
175
+ if (!(await canUseOnPage(req, options))) return text('Sign in as an editor first', 401)
176
+ const q = queryOf(req)
177
+ await requireSession(options).enable(q.get('mode') === 'view' ? 'view' : 'edit', {
178
+ secure: isSecure(req),
179
+ bindTo: authCookieOf(req, options),
180
+ })
181
+ return redirectTo(req, q.get('path'))
182
+ },
183
+ }
184
+
185
+ const exit: Endpoint = {
186
+ path: `${prefix}/exit`,
187
+ method: 'post',
188
+ handler: async (req) => {
189
+ const refused = rejectCrossSite(req)
190
+ if (refused) return refused
191
+ await requireSession(options).disable()
192
+ return redirectTo(req, queryOf(req).get('path'))
193
+ },
194
+ }
195
+
196
+ const previewLink: Endpoint = {
197
+ path: `${prefix}/preview-link`,
198
+ method: 'post',
199
+ handler: async (req) => {
200
+ const refused = rejectCrossSite(req)
201
+ if (refused) return refused
202
+ if (!req.headers.get('content-type')?.includes('application/json'))
203
+ return json({ error: 'Expected JSON' }, 415)
204
+ const body = ((await req.json?.().catch(() => null)) ?? {}) as Record<string, unknown>
205
+ const ref = readRef(body)
206
+ const entry = ref && ctx.entry(ref.kind, ref.slug)
207
+ if (!ref || !entry) return json({ error: 'Unknown document' }, 400)
208
+ if (!(await canEditDocument(req, ref, options))) return json({ error: 'Forbidden' }, 403)
209
+ const locale = typeof body.locale === 'string' ? body.locale : undefined
210
+ // The path is always derived on the server: a link opens this document's page and nothing else.
211
+ const path = await resolvePath(req, ref, entry, locale)
212
+ if (!path) return json({ error: 'This document has no page' }, 404)
213
+ const ttl = options.previewLinkTTL ?? DAY
214
+ const { token, payload } = await createPreviewToken(
215
+ { ref: refKey(ref), path, locale },
216
+ secretOf(req, options),
217
+ ttl,
218
+ )
219
+ return json({
220
+ url: `${originOf(req)}${apiRoute}${prefix}/preview?token=${encodeURIComponent(token)}`,
221
+ expiresAt: new Date(payload.exp * 1000).toISOString(),
222
+ id: payload.jti,
223
+ })
224
+ },
225
+ }
226
+
227
+ const preview: Endpoint = {
228
+ path: `${prefix}/preview`,
229
+ method: 'get',
230
+ handler: async (req) => {
231
+ const invalid = () => text('This preview link is invalid or has expired.', 403)
232
+ const secret = secretOf(req, options)
233
+ const token = await verifyPreviewToken(queryOf(req).get('token') ?? '', secret)
234
+ const ref = token && parseRefKey(token.ref)
235
+ const entry = ref && ctx.entry(ref.kind, ref.slug)
236
+ if (!token || !ref || !entry) return invalid()
237
+ // Still the document's page? (It may have been deleted, or its slug changed.)
238
+ const current = await resolvePath(req, ref, entry, token.locale)
239
+ if (!current || !samePath(current, token.path)) return invalid()
240
+ if (
241
+ options.validatePreviewLink &&
242
+ !(await options.validatePreviewLink({
243
+ req,
244
+ ref,
245
+ path: token.path,
246
+ issuedAt: token.iat,
247
+ id: token.jti,
248
+ }))
249
+ ) {
250
+ return invalid()
251
+ }
252
+ const expiresAt = new Date(token.exp * 1000)
253
+ await requireSession(options).enableLink({
254
+ secure: isSecure(req),
255
+ path: pathnameOf(token.path),
256
+ expiresAt,
257
+ seal: (draftSecret) =>
258
+ seal(
259
+ { path: pathnameOf(token.path), ref: token.ref, exp: token.exp, draft: draftSecret },
260
+ secret,
261
+ SEAL_PURPOSE.previewCookie,
262
+ ),
263
+ })
264
+ return redirectTo(req, token.path, 307)
265
+ },
266
+ }
267
+
268
+ return [schema, enter, mode, exit, previewLink, preview]
269
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Collection/global hooks added by the plugin:
3
+ * - afterRead: auto-mapping markers, only on reads that ask (`context.onpage`)
4
+ * - beforeValidate/beforeChange: strip markers from every write, so they can't be stored
5
+ * - beforeChange: stale-write guard (409) when the editor sends the version it last saw
6
+ */
7
+ import {
8
+ encodeDocument,
9
+ HEADER,
10
+ refKey,
11
+ stegaCleanDeep,
12
+ type EncodeFilter,
13
+ type SchemaField,
14
+ } from '@payload-onpage/core'
15
+ import { APIError, type PayloadRequest } from 'payload'
16
+
17
+ /** True when a read asked for auto-mapping markers (see `onpageContext()`). */
18
+ export const wantsMarkers = (context: unknown): boolean =>
19
+ !!(context && typeof context === 'object' && (context as Record<string, unknown>).onpage === true)
20
+
21
+ export const stripMarkers = ({ data }: { data?: unknown }) => (data ? stegaCleanDeep(data) : data)
22
+
23
+ export function encodeOnRead(opts: {
24
+ kind: 'collection' | 'global'
25
+ slug: string
26
+ fields: () => SchemaField[]
27
+ only?: string[]
28
+ filter?: EncodeFilter
29
+ }) {
30
+ return ({
31
+ doc,
32
+ req,
33
+ context,
34
+ }: {
35
+ doc: Record<string, unknown>
36
+ req: PayloadRequest
37
+ context: unknown
38
+ }) => {
39
+ if (!wantsMarkers(context) || req.locale === 'all' || !doc) return doc
40
+ const ref =
41
+ opts.kind === 'global'
42
+ ? refKey({ kind: 'global', slug: opts.slug })
43
+ : doc.id != null
44
+ ? refKey({ kind: 'collection', slug: opts.slug, id: doc.id as string | number })
45
+ : null
46
+ return ref
47
+ ? encodeDocument(doc, opts.fields(), ref, { only: opts.only, filter: opts.filter })
48
+ : doc
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Optimistic concurrency: the editor sends `x-onpage-if-unmodified-since` with
54
+ * the `updatedAt` it last saw; if the document changed since, the write fails
55
+ * with 409 and the editor offers to reload. Writes without the header (admin,
56
+ * API, other tools) are untouched.
57
+ */
58
+ export const guardStaleWrite = <T>({
59
+ data,
60
+ req,
61
+ originalDoc,
62
+ }: {
63
+ data: T
64
+ req: PayloadRequest
65
+ originalDoc?: { updatedAt?: unknown }
66
+ }): T => {
67
+ const seen = req.headers?.get(HEADER.ifUnmodifiedSince)
68
+ const current = originalDoc?.updatedAt
69
+ if (!seen || typeof current !== 'string') return data
70
+ if (new Date(seen).getTime() !== new Date(current).getTime()) {
71
+ throw new APIError(
72
+ 'This document was changed by someone else since you loaded it.',
73
+ 409,
74
+ undefined,
75
+ true,
76
+ )
77
+ }
78
+ return data
79
+ }
package/src/http.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** Request helpers shared by the endpoints. */
2
+ import { parseRefKey, safePath, type DocRef } from '@payload-onpage/core'
3
+ import type { PayloadRequest } from 'payload'
4
+
5
+ /** The site origin: `serverURL` when configured, else the request's own origin. */
6
+ export function originOf(req: PayloadRequest): string {
7
+ const configured = req.payload.config.serverURL
8
+ if (configured) return configured.replace(/\/$/, '')
9
+ try {
10
+ return new URL(req.url ?? '').origin
11
+ } catch {
12
+ const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost'
13
+ const proto = req.headers.get('x-forwarded-proto') ?? 'http'
14
+ return `${proto}://${host}`
15
+ }
16
+ }
17
+
18
+ export const isSecure = (req: PayloadRequest): boolean => originOf(req).startsWith('https:')
19
+
20
+ export const queryOf = (req: PayloadRequest): URLSearchParams =>
21
+ new URL(req.url ?? '/', 'http://localhost').searchParams
22
+
23
+ export const text = (body: string, status: number): Response =>
24
+ new Response(body, {
25
+ status,
26
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
27
+ })
28
+
29
+ export const json = (body: unknown, status = 200): Response =>
30
+ Response.json(body, { status, headers: { 'cache-control': 'private, no-store' } })
31
+
32
+ /** Redirect to a same-origin path (anything unsafe becomes "/"). 303 turns a POST into a GET. */
33
+ export const redirectTo = (req: PayloadRequest, path: unknown, status: 303 | 307 = 303): Response =>
34
+ Response.redirect(new URL(safePath(path) ?? '/', originOf(req)), status)
35
+
36
+ /**
37
+ * CSRF guard for state-changing endpoints: a POST from this site (Origin, or
38
+ * Sec-Fetch-Site when Origin is absent). Returns an error response or null.
39
+ */
40
+ export function rejectCrossSite(req: PayloadRequest): Response | null {
41
+ if (req.method?.toUpperCase() !== 'POST') return text('Method not allowed', 405)
42
+ const origin = req.headers.get('origin')
43
+ if (origin) return origin === originOf(req) ? null : text('Cross-site request refused', 403)
44
+ const site = req.headers.get('sec-fetch-site')
45
+ return site === 'same-origin' || site === 'none' ? null : text('Cross-site request refused', 403)
46
+ }
47
+
48
+ /** A document ref from `kind`, `slug`, `id` query/body values. */
49
+ export function readRef(values: { kind?: unknown; slug?: unknown; id?: unknown }): DocRef | null {
50
+ const slug = typeof values.slug === 'string' ? values.slug : ''
51
+ if (!slug) return null
52
+ if (values.kind === 'global') return { kind: 'global', slug }
53
+ const id = values.id
54
+ if (typeof id === 'number') return { kind: 'collection', slug, id }
55
+ if (typeof id !== 'string' || !id) return null
56
+ return parseRefKey(`c:${slug}:${id}`)
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @payload-onpage/plugin — the Payload side of payload-onpage.
3
+ *
4
+ * plugins: [onpage({ collections: ['pages'], globals: ['header'], session: nextSession() })]
5
+ *
6
+ * Adds the endpoints in ./endpoints, auto-mapping on reads that ask for it,
7
+ * marker stripping and a stale-write guard on every write, and "Edit on page" /
8
+ * "Copy preview link" controls in the admin. Saves from the editor go through
9
+ * Payload's REST API, so access control, hooks, validation, drafts and versions
10
+ * apply unchanged.
11
+ */
12
+ import { DEFAULTS, type SchemaField } from '@payload-onpage/core'
13
+ import type { CollectionConfig, Config, GlobalConfig, Plugin } from 'payload'
14
+ import { createEndpoints } from './endpoints.ts'
15
+ import { encodeOnRead, guardStaleWrite, stripMarkers } from './hooks.ts'
16
+ import { normalizeEntries, type Entry, type OnPageOptions } from './options.ts'
17
+ import { serializeFields } from './schema.ts'
18
+ import { translations } from './translations.ts'
19
+
20
+ const APPLIED = 'payload-onpage'
21
+
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
+ export function onpage(options: OnPageOptions): Plugin {
41
+ return (incoming: Config): Config => {
42
+ if (options.enabled === false || incoming.custom?.[APPLIED]) return incoming
43
+ 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]))
50
+
51
+ const withHooks = <T extends CollectionConfig | GlobalConfig>(cfg: T, entry: Entry): T => {
52
+ 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 }
69
+ }
70
+
71
+ const control = (kind: Entry['kind'], slug: string) => ({
72
+ path: '@payload-onpage/plugin/admin#OnPageAdminControls',
73
+ clientProps: { kind, slug, prefix },
74
+ })
75
+
76
+ const withCollectionAdmin = (c: CollectionConfig): CollectionConfig => {
77
+ const edit = c.admin?.components?.edit ?? {}
78
+ return {
79
+ ...c,
80
+ admin: {
81
+ ...c.admin,
82
+ components: {
83
+ ...c.admin?.components,
84
+ edit: {
85
+ ...edit,
86
+ beforeDocumentControls: [
87
+ ...(edit.beforeDocumentControls ?? []),
88
+ control('collection', c.slug),
89
+ ],
90
+ },
91
+ },
92
+ },
93
+ }
94
+ }
95
+ const withGlobalAdmin = (g: GlobalConfig): GlobalConfig => {
96
+ const elements = g.admin?.components?.elements ?? {}
97
+ return {
98
+ ...g,
99
+ admin: {
100
+ ...g.admin,
101
+ components: {
102
+ ...g.admin?.components,
103
+ elements: {
104
+ ...elements,
105
+ beforeDocumentControls: [
106
+ ...(elements.beforeDocumentControls ?? []),
107
+ control('global', g.slug),
108
+ ],
109
+ },
110
+ },
111
+ },
112
+ }
113
+ }
114
+
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
+ return {
127
+ ...incoming,
128
+ custom: { ...incoming.custom, [APPLIED]: true },
129
+ i18n: {
130
+ ...incoming.i18n,
131
+ translations: mergeTranslations(translations, incoming.i18n?.translations),
132
+ },
133
+ collections: (incoming.collections ?? []).map((c) => {
134
+ const entry = byKey.get(`collection:${c.slug}`)
135
+ return entry ? withCollectionAdmin(withHooks(c, entry)) : c
136
+ }),
137
+ globals: (incoming.globals ?? []).map((g) => {
138
+ const entry = byKey.get(`global:${g.slug}`)
139
+ return entry ? withGlobalAdmin(withHooks(g, entry)) : g
140
+ }),
141
+ endpoints: [
142
+ ...(incoming.endpoints ?? []),
143
+ ...createEndpoints({
144
+ options,
145
+ prefix,
146
+ apiRoute,
147
+ entry: (kind, slug) => byKey.get(`${kind}:${slug}`),
148
+ }),
149
+ ],
150
+ onInit: async (payload) => {
151
+ if (process.env.NODE_ENV === 'production' && !payload.config.serverURL) {
152
+ payload.logger.warn(
153
+ 'payload-onpage: set `serverURL` in production so redirects, preview links and Secure cookies use the public origin.',
154
+ )
155
+ }
156
+ await incoming.onInit?.(payload)
157
+ },
158
+ }
159
+ }
160
+ }
161
+
162
+ export type { OnPageDocOptions, OnPageOptions } from './options.ts'
163
+ export { canEditDocument, canUseOnPage } from './access.ts'
164
+ export { createPreviewToken, verifyPreviewToken, type PreviewTokenPayload } from './tokens.ts'
165
+ export { serializeFields, type SerializeContext } from './schema.ts'
166
+ export { wantsMarkers } from './hooks.ts'
167
+ export { translations } from './translations.ts'
package/src/options.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { DocRef, EncodeFilter, SessionAdapter } from '@payload-onpage/core'
2
+ import type { PayloadRequest } from 'payload'
3
+
4
+ export interface OnPageDocOptions {
5
+ /** Top-level fields editable on the page. Default: every top-level field the user may update. */
6
+ fields?: string[]
7
+ /**
8
+ * The site path that shows this document. Default for collections: the
9
+ * collection's `admin.preview` URL, else `/${doc.slug}`. Globals: "/".
10
+ */
11
+ url?: (args: {
12
+ doc: Record<string, unknown>
13
+ locale?: string
14
+ req: PayloadRequest
15
+ }) => string | null | Promise<string | null>
16
+ }
17
+
18
+ export interface OnPageOptions {
19
+ collections?: string[] | Record<string, OnPageDocOptions | true>
20
+ globals?: string[] | Record<string, OnPageDocOptions | true>
21
+ /**
22
+ * Who may use on-page editing (`ref` undefined) and edit a given document.
23
+ * Default: users of the admin user collection, with the document's own
24
+ * `update` access (evaluated by Payload, including row-level rules).
25
+ */
26
+ 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. */
28
+ session?: SessionAdapter
29
+ /** Narrow which strings get auto-mapping markers. */
30
+ encode?: EncodeFilter
31
+ /** Lifetime of shareable preview links, in seconds. Default 1 day. */
32
+ previewLinkTTL?: number
33
+ /** Extra check when a preview link is opened (e.g. a revocation list). Return false to refuse it. */
34
+ validatePreviewLink?: (args: {
35
+ req: PayloadRequest
36
+ ref: DocRef
37
+ path: string
38
+ issuedAt: number
39
+ id: string
40
+ }) => boolean | Promise<boolean>
41
+ /**
42
+ * Secret for preview links and the preview cookie. Default: Payload's `secret`
43
+ * (`PAYLOAD_SECRET`). The Next.js proxy must be given the same value.
44
+ */
45
+ secret?: string
46
+ /** Auth cookie an editor's session is bound to. Default `${cookiePrefix}-token` ("payload-token"). */
47
+ authCookie?: string
48
+ /** Endpoint prefix under the API route. Default "/onpage". */
49
+ routePrefix?: string
50
+ enabled?: boolean
51
+ }
52
+
53
+ export interface Entry {
54
+ kind: 'collection' | 'global'
55
+ slug: string
56
+ options: OnPageDocOptions
57
+ }
58
+
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 }))