@payload-onpage/plugin 0.0.0-stage → 0.1.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
package/src/paths.ts ADDED
@@ -0,0 +1,59 @@
1
+ /** Where a document lives on the site. */
2
+ import { safePath, type DocRef } from '@payload-onpage/core'
3
+ import type { PayloadRequest } from 'payload'
4
+ import { originOf } from './http.ts'
5
+ import type { Entry } from './options.ts'
6
+
7
+ /**
8
+ * The site path that shows a document: the entry's `url` option, else the
9
+ * collection's `admin.preview` URL, else `/<slug>`; globals default to "/".
10
+ * Absolute URLs must be same-origin. Returns null when there's no page.
11
+ */
12
+ export async function resolvePath(
13
+ req: PayloadRequest,
14
+ ref: DocRef,
15
+ entry: Entry,
16
+ locale?: string,
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)
40
+ if (!doc) return null
41
+
42
+ 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
46
+ if (preview) url = await preview(doc, { locale: locale ?? '', req, token: null })
47
+ if (!url && typeof doc.slug === 'string') url = `/${doc.slug.replace(/^\/+/, '')}`
48
+ } else url = '/'
49
+ if (!url) return null
50
+
51
+ const origin = originOf(req)
52
+ try {
53
+ const u = new URL(url, origin)
54
+ if (u.origin !== origin) return null
55
+ return safePath(u.pathname + u.search)
56
+ } catch {
57
+ return null
58
+ }
59
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Builds the editor's JSON schema from Payload field configs, using Payload's
3
+ * own field predicates and translations. Payload's client config can't be used
4
+ * instead: it strips `defaultValue` and `custom`, which the editor needs.
5
+ */
6
+ import { getTranslation } from '@payloadcms/translations'
7
+ import type { DocumentSchema, SchemaBlock, SchemaField } from '@payload-onpage/core'
8
+ import type { Block, Field, PayloadRequest, SanitizedFieldsPermissions } from 'payload'
9
+ import { getDefaultValue } from 'payload'
10
+ import {
11
+ fieldAffectsData,
12
+ fieldIsHiddenOrDisabled,
13
+ fieldIsVirtual,
14
+ tabHasName,
15
+ formatLabels,
16
+ toWords,
17
+ } from 'payload/shared'
18
+
19
+ /** Fields Payload manages itself; never editable on the page. */
20
+ const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', '_status', 'deletedAt'])
21
+ /** Data fields with no meaningful on-page value. */
22
+ const SKIP_TYPES = new Set(['join', 'ui'])
23
+
24
+ /** The optional, type-specific field properties the serializer reads. */
25
+ interface LooseField {
26
+ label?: unknown
27
+ labels?: { singular?: unknown; plural?: unknown }
28
+ localized?: boolean
29
+ required?: boolean
30
+ hasMany?: boolean
31
+ defaultValue?: unknown
32
+ options?: unknown[]
33
+ relationTo?: string | string[]
34
+ minRows?: number
35
+ maxRows?: number
36
+ min?: number
37
+ max?: number
38
+ custom?: { onpage?: { editor?: unknown } }
39
+ admin?: {
40
+ description?: unknown
41
+ custom?: { onpage?: { editor?: unknown } }
42
+ date?: { pickerAppearance?: unknown }
43
+ }
44
+ }
45
+
46
+ type I18nLike = Parameters<typeof getTranslation>[1]
47
+ type AnyLabel = Parameters<typeof getTranslation>[0]
48
+ type Permissions = SanitizedFieldsPermissions | undefined
49
+
50
+ export interface SerializeContext {
51
+ /** Translates labels into the admin language. Without it, plain-string labels or the field name are used. */
52
+ i18n?: I18nLike
53
+ /** Global blocks (`config.blocks`), to resolve `blockReferences`. */
54
+ blocks?: Block[]
55
+ /** Field-level permissions: fields the user can't update are left out. */
56
+ permissions?: Permissions
57
+ /** Collects function defaults to evaluate for this request. */
58
+ defaults?: { req: PayloadRequest; pending: Promise<void>[] }
59
+ }
60
+
61
+ const label = (value: unknown, fallback: string, ctx: SerializeContext): string => {
62
+ if (value == null || value === false) return fallback
63
+ if (typeof value === 'string') return value || fallback
64
+ if (!ctx.i18n) return fallback
65
+ try {
66
+ const out = getTranslation(value as AnyLabel, ctx.i18n)
67
+ return typeof out === 'string' && out ? out : fallback
68
+ } catch {
69
+ return fallback
70
+ }
71
+ }
72
+
73
+ /** Sanitized permissions: `true` = everything; a missing entry = denied. */
74
+ function childPermissions(perms: Permissions, name: string): Permissions | false {
75
+ if (perms === undefined || perms === true) return perms
76
+ const entry = perms[name]
77
+ if (!entry) return false
78
+ if (entry === true) return true
79
+ if (entry.update !== true) return false
80
+ return entry.fields ?? true
81
+ }
82
+
83
+ function blockPermissions(perms: Permissions, name: string, slug: string): Permissions {
84
+ if (perms === undefined || perms === true) return perms
85
+ const entry = perms[name]
86
+ if (!entry || entry === true) return true
87
+ const block = (
88
+ entry.blocks as Record<string, { fields?: Permissions } | true | undefined> | undefined
89
+ )?.[slug]
90
+ // No block-level rule: the blocks field's own permission (already checked) applies.
91
+ if (!block || block === true) return true
92
+ return block.fields ?? true
93
+ }
94
+
95
+ const isJsonSafe = (v: unknown): boolean => {
96
+ if (v === undefined || typeof v === 'function') return false
97
+ try {
98
+ JSON.stringify(v)
99
+ return true
100
+ } catch {
101
+ return false
102
+ }
103
+ }
104
+
105
+ function serializeBlock(block: Block, perms: Permissions, ctx: SerializeContext): SchemaBlock {
106
+ return {
107
+ slug: block.slug,
108
+ labels: {
109
+ singular: label(block.labels?.singular, toWords(block.slug), ctx),
110
+ plural: label(block.labels?.plural, toWords(block.slug), ctx),
111
+ },
112
+ fields: serializeFields(block.fields, { ...ctx, permissions: perms }),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Serialize Payload field configs into the editor schema. Presentational
118
+ * containers (row, collapsible, unnamed group/tab) are flattened into their
119
+ * parent; named tabs become groups; hidden, disabled, virtual, system and
120
+ * join/ui fields are left out.
121
+ */
122
+ export function serializeFields(
123
+ fields: Field[] | undefined,
124
+ ctx: SerializeContext = {},
125
+ ): SchemaField[] {
126
+ const out: SchemaField[] = []
127
+ for (const f of fields ?? []) {
128
+ if (!f || typeof f !== 'object' || SKIP_TYPES.has(f.type)) continue
129
+ if (f.type === 'tabs') {
130
+ for (const tab of f.tabs ?? []) {
131
+ if (!tabHasName(tab)) {
132
+ out.push(...serializeFields(tab.fields, ctx))
133
+ continue
134
+ }
135
+ const perms = childPermissions(ctx.permissions, tab.name)
136
+ if (perms === false) continue
137
+ out.push({
138
+ name: tab.name,
139
+ type: 'group',
140
+ label: label(tab.label, toWords(tab.name), ctx),
141
+ fields: serializeFields(tab.fields, { ...ctx, permissions: perms }),
142
+ ...(tab.localized ? { localized: true } : {}),
143
+ })
144
+ }
145
+ continue
146
+ }
147
+ if (!fieldAffectsData(f)) {
148
+ // row, collapsible, unnamed group: their children belong to the parent.
149
+ if ('fields' in f && Array.isArray(f.fields)) out.push(...serializeFields(f.fields, ctx))
150
+ continue
151
+ }
152
+ // Hidden from the admin (admin.hidden) or the API (hidden), disabled, virtual: not editable here either.
153
+ if (
154
+ SYSTEM_FIELDS.has(f.name) ||
155
+ fieldIsHiddenOrDisabled(f) ||
156
+ fieldIsVirtual(f) ||
157
+ f.admin?.hidden
158
+ )
159
+ continue
160
+ const perms = childPermissions(ctx.permissions, f.name)
161
+ if (perms === false) continue
162
+
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 = {
166
+ name: f.name,
167
+ type: f.type,
168
+ label: label(anyField.label, toWords(f.name), ctx),
169
+ }
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') {
174
+ if (ctx.defaults) {
175
+ const { req, pending } = ctx.defaults
176
+ pending.push(
177
+ getDefaultValue({
178
+ defaultValue: anyField.defaultValue,
179
+ locale: req.locale ?? undefined,
180
+ req,
181
+ user: req.user,
182
+ })
183
+ .then((v) => {
184
+ if (isJsonSafe(v)) field.defaultValue = v
185
+ })
186
+ .catch(() => undefined),
187
+ )
188
+ }
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
+ )
233
+ }
234
+ out.push(field)
235
+ }
236
+ return out
237
+ }
238
+
239
+ /** Every collection a relationship or upload field in `fields` points to. */
240
+ export function relationTargets(fields: SchemaField[], into = new Set<string>()): Set<string> {
241
+ for (const f of fields) {
242
+ if ((f.type === 'relationship' || f.type === 'upload') && f.relationTo) {
243
+ 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)
247
+ }
248
+ return into
249
+ }
250
+
251
+ export type { DocumentSchema }
package/src/tokens.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Preview-link tokens: sealed (AES-GCM) and expiring, bound to one document
3
+ * and path. Opaque to the holder, so the link reveals nothing about the draft.
4
+ */
5
+ import { SEAL_PURPOSE, seal, unseal } from '@payload-onpage/core'
6
+
7
+ export interface PreviewTokenPayload {
8
+ /** refKey of the shared document */
9
+ ref: string
10
+ /** Site path the link opens. */
11
+ path: string
12
+ locale?: string
13
+ /** Issued at / expires at, seconds since epoch. */
14
+ iat: number
15
+ exp: number
16
+ /** Unique id, for audit logs and revocation lists. */
17
+ jti: string
18
+ }
19
+
20
+ export async function createPreviewToken(
21
+ payload: Pick<PreviewTokenPayload, 'ref' | 'path' | 'locale'>,
22
+ secret: string,
23
+ ttlSeconds: number,
24
+ now = Date.now(),
25
+ ): Promise<{ token: string; payload: PreviewTokenPayload }> {
26
+ const iat = Math.floor(now / 1000)
27
+ const full: PreviewTokenPayload = {
28
+ ...payload,
29
+ iat,
30
+ exp: iat + ttlSeconds,
31
+ jti: crypto.randomUUID(),
32
+ }
33
+ return { token: await seal(full, secret, SEAL_PURPOSE.previewLink), payload: full }
34
+ }
35
+
36
+ export async function verifyPreviewToken(
37
+ token: string,
38
+ secret: string,
39
+ now = Date.now(),
40
+ ): Promise<PreviewTokenPayload | null> {
41
+ const p = await unseal<PreviewTokenPayload>(token, secret, SEAL_PURPOSE.previewLink)
42
+ if (!p || typeof p.exp !== 'number' || p.exp * 1000 <= now) return null
43
+ if (typeof p.path !== 'string' || typeof p.ref !== 'string' || typeof p.jti !== 'string')
44
+ return null
45
+ return p
46
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Admin UI strings, registered with Payload's i18n under the `onpage` namespace
3
+ * (so the admin language applies). Add or override languages with
4
+ * `i18n.translations` in your Payload config.
5
+ */
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
+ },
15
+ },
16
+ es: {
17
+ onpage: {
18
+ editOnPage: 'Editar en la página',
19
+ copyPreviewLink: 'Copiar enlace de vista previa',
20
+ copyPreviewLinkHint: 'Copiar un enlace de solo lectura a este borrador',
21
+ linkCopied: 'Enlace copiado: válido hasta {{date}}',
22
+ linkFailed: 'No se pudo crear el enlace: {{message}}',
23
+ },
24
+ },
25
+ fr: {
26
+ onpage: {
27
+ editOnPage: 'Modifier sur la page',
28
+ copyPreviewLink: 'Copier le lien d’aperçu',
29
+ copyPreviewLinkHint: 'Copier un lien en lecture seule vers ce brouillon',
30
+ linkCopied: 'Lien copié — valide jusqu’au {{date}}',
31
+ linkFailed: 'Impossible de créer le lien : {{message}}',
32
+ },
33
+ },
34
+ de: {
35
+ onpage: {
36
+ editOnPage: 'Auf der Seite bearbeiten',
37
+ copyPreviewLink: 'Vorschaulink kopieren',
38
+ copyPreviewLinkHint: 'Einen schreibgeschützten Link zu diesem Entwurf kopieren',
39
+ linkCopied: 'Vorschaulink kopiert – gültig bis {{date}}',
40
+ linkFailed: 'Vorschaulink konnte nicht erstellt werden: {{message}}',
41
+ },
42
+ },
43
+ }
44
+
45
+ export type OnPageTranslationKey = `onpage:${keyof (typeof translations)['en']['onpage']}`