@payload-onpage/core 0.2.0 → 0.3.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 (71) hide show
  1. package/dist/autosave.d.ts +2 -2
  2. package/dist/autosave.d.ts.map +1 -1
  3. package/dist/autosave.js +2 -1
  4. package/dist/autosave.js.map +1 -1
  5. package/dist/constants.d.ts +14 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +14 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/docref.d.ts +18 -3
  10. package/dist/docref.d.ts.map +1 -1
  11. package/dist/docref.js +13 -4
  12. package/dist/docref.js.map +1 -1
  13. package/dist/encode.d.ts +16 -4
  14. package/dist/encode.d.ts.map +1 -1
  15. package/dist/encode.js +42 -62
  16. package/dist/encode.js.map +1 -1
  17. package/dist/fields.d.ts +39 -8
  18. package/dist/fields.d.ts.map +1 -1
  19. package/dist/fields.js +111 -97
  20. package/dist/fields.js.map +1 -1
  21. package/dist/index.d.ts +16 -12
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +16 -12
  24. package/dist/index.js.map +1 -1
  25. package/dist/path.d.ts +9 -1
  26. package/dist/path.d.ts.map +1 -1
  27. package/dist/path.js +27 -8
  28. package/dist/path.js.map +1 -1
  29. package/dist/payload.d.ts +5 -3
  30. package/dist/payload.d.ts.map +1 -1
  31. package/dist/payload.js +34 -23
  32. package/dist/payload.js.map +1 -1
  33. package/dist/ports.d.ts +43 -28
  34. package/dist/ports.d.ts.map +1 -1
  35. package/dist/ports.js +11 -1
  36. package/dist/ports.js.map +1 -1
  37. package/dist/schema.d.ts +71 -26
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +1 -6
  40. package/dist/schema.js.map +1 -1
  41. package/dist/seal.d.ts +17 -0
  42. package/dist/seal.d.ts.map +1 -1
  43. package/dist/seal.js +13 -0
  44. package/dist/seal.js.map +1 -1
  45. package/dist/session.d.ts +34 -18
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +20 -13
  48. package/dist/session.js.map +1 -1
  49. package/dist/stega.d.ts +6 -2
  50. package/dist/stega.d.ts.map +1 -1
  51. package/dist/stega.js +18 -13
  52. package/dist/stega.js.map +1 -1
  53. package/dist/util.d.ts +9 -0
  54. package/dist/util.d.ts.map +1 -0
  55. package/dist/util.js +11 -0
  56. package/dist/util.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/autosave.ts +4 -3
  59. package/src/constants.ts +16 -0
  60. package/src/docref.ts +26 -6
  61. package/src/encode.ts +47 -55
  62. package/src/fields.ts +166 -98
  63. package/src/index.ts +125 -12
  64. package/src/path.ts +32 -10
  65. package/src/payload.ts +54 -26
  66. package/src/ports.ts +50 -22
  67. package/src/schema.ts +100 -17
  68. package/src/seal.ts +31 -0
  69. package/src/session.ts +67 -47
  70. package/src/stega.ts +25 -17
  71. package/src/util.ts +12 -0
package/src/path.ts CHANGED
@@ -5,23 +5,34 @@
5
5
 
6
6
  export type PathSegment = string | number
7
7
 
8
+ /** "3" in "sections.3.title": a row index. */
9
+ export const isIndexSegment = (segment: string): boolean => /^\d+$/.test(segment)
10
+
11
+ /** Segments that would reach an object's prototype instead of its own data. */
12
+ const UNSAFE_SEGMENTS: ReadonlySet<PathSegment> = new Set(['__proto__', 'constructor', 'prototype'])
13
+
8
14
  export function parsePath(path: string | PathSegment[]): PathSegment[] {
9
- if (Array.isArray(path)) return path
10
- return path
11
- .split('.')
12
- .filter((s) => s !== '')
13
- .map((s) => (/^\d+$/.test(s) ? Number(s) : s))
15
+ const segs = Array.isArray(path)
16
+ ? path
17
+ : path
18
+ .split('.')
19
+ .filter((s) => s !== '')
20
+ .map((s) => (isIndexSegment(s) ? Number(s) : s))
21
+ if (segs.some((s) => UNSAFE_SEGMENTS.has(s)))
22
+ throw new Error(`Unsupported path: ${segs.join('.')}`)
23
+ return segs
14
24
  }
15
25
 
16
26
  type Container = Record<string | number, unknown>
17
27
 
18
- export function getAt<T = unknown>(obj: unknown, path: string | PathSegment[]): T | undefined {
28
+ /** The value at `path`, or undefined. Narrow the result where it's used (`Array.isArray`, `typeof`…). */
29
+ export function getAt(obj: unknown, path: string | PathSegment[]): unknown {
19
30
  let cur: unknown = obj
20
31
  for (const seg of parsePath(path)) {
21
32
  if (cur == null || typeof cur !== 'object') return undefined
22
33
  cur = (cur as Container)[seg]
23
34
  }
24
- return cur as T | undefined
35
+ return cur
25
36
  }
26
37
 
27
38
  /** Returns a copy of `obj` with `value` at `path`; untouched branches are shared. */
@@ -41,7 +52,7 @@ export function updateArray<T>(
41
52
  path: string | PathSegment[],
42
53
  fn: (arr: unknown[]) => unknown[],
43
54
  ): T {
44
- const current = getAt<unknown[]>(obj, path)
55
+ const current = getAt(obj, path)
45
56
  return setAt(obj, path, fn(Array.isArray(current) ? current : []))
46
57
  }
47
58
 
@@ -49,8 +60,7 @@ export function moveItem<T>(arr: T[], from: number, to: number): T[] {
49
60
  if (from === to || from < 0 || from >= arr.length) return arr
50
61
  const target = Math.max(0, Math.min(arr.length - 1, to))
51
62
  const copy = arr.slice()
52
- const [item] = copy.splice(from, 1)
53
- copy.splice(target, 0, item)
63
+ copy.splice(target, 0, ...copy.splice(from, 1))
54
64
  return copy
55
65
  }
56
66
 
@@ -75,6 +85,18 @@ export function splitItemPath(path: string): [string, number] | null {
75
85
  return [segs.slice(0, -1).join('.'), last]
76
86
  }
77
87
 
88
+ /**
89
+ * The rows that enclose a path, outermost first: "sections.2.items.0.title" →
90
+ * ["sections.2", "sections.2.items.0"]. A path that is itself a row is not included.
91
+ */
92
+ export function rowPrefixes(path: string): string[] {
93
+ const segs = path.split('.')
94
+ const out: string[] = []
95
+ for (let i = 1; i < segs.length - 1; i++)
96
+ if (isIndexSegment(segs[i] ?? '')) out.push(segs.slice(0, i + 1).join('.'))
97
+ return out
98
+ }
99
+
78
100
  /** A 24-hex id, the shape Payload uses for array and block rows. */
79
101
  export function newRowId(): string {
80
102
  const bytes = new Uint8Array(12)
package/src/payload.ts CHANGED
@@ -22,7 +22,7 @@ export interface PayloadRestOptions {
22
22
  /** API base, default "/api". */
23
23
  apiBase?: string
24
24
  /** Plugin endpoint prefix, default "/onpage". */
25
- prefix?: string
25
+ prefix?: `/${string}`
26
26
  /** Extra request init (headers for a custom auth scheme, etc.). */
27
27
  init?: RequestInit
28
28
  fetch?: typeof fetch
@@ -51,23 +51,39 @@ export async function errorMessage(res: Response): Promise<string> {
51
51
  }
52
52
  }
53
53
 
54
- export function payloadRest(opts: PayloadRestOptions = {}): DataSource {
54
+ interface ClientOptions {
55
+ init?: RequestInit
56
+ fetch?: typeof fetch
57
+ }
58
+
59
+ /**
60
+ * The fetch and request init both adapters use: cookie auth by default, plus the
61
+ * caller's `init` (custom auth headers…) on every request. Headers are merged
62
+ * through `Headers`, so a `Headers` instance or tuple list works as well as an object.
63
+ */
64
+ function client(opts: ClientOptions) {
65
+ const f = opts.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
66
+ const init = (extra: RequestInit = {}): RequestInit => {
67
+ const headers = new Headers(opts.init?.headers)
68
+ new Headers(extra.headers).forEach((v, k) => headers.set(k, v))
69
+ return { credentials: 'include', ...opts.init, ...extra, headers }
70
+ }
71
+ return { f, init }
72
+ }
73
+
74
+ export function payloadRest(
75
+ opts: PayloadRestOptions = {},
76
+ ): DataSource & Required<Pick<DataSource, 'createPreviewLink'>> {
55
77
  const api = opts.apiBase ?? DEFAULTS.apiRoute
56
78
  const prefix = opts.prefix ?? DEFAULTS.routePrefix
57
- const f = opts.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
58
- const init = (extra: RequestInit = {}): RequestInit => ({
59
- credentials: 'include',
60
- ...opts.init,
61
- ...extra,
62
- headers: { ...(opts.init?.headers ?? {}), ...(extra.headers ?? {}) },
63
- })
79
+ const { f, init } = client(opts)
64
80
  const docUrl = (ref: DocRef) =>
65
81
  ref.kind === 'global' ? `${api}/globals/${ref.slug}` : `${api}/${ref.slug}/${ref.id}`
66
82
 
67
83
  const write = async (
68
84
  ref: DocRef,
69
85
  data: Doc,
70
- o: { locale?: string; updatedAt?: string },
86
+ o: { locale?: string | undefined; updatedAt?: string | undefined },
71
87
  publish: boolean,
72
88
  ): Promise<SaveResult> => {
73
89
  const query = qs({
@@ -121,7 +137,7 @@ export function payloadRest(opts: PayloadRestOptions = {}): DataSource {
121
137
  if (o.locale) p.set('locale', o.locale)
122
138
  if (o.query)
123
139
  p.set(`where[${titleField}][${titleField === 'id' ? 'equals' : 'like'}]`, o.query)
124
- const res = await f(`${api}/${collection}?${p}`, init())
140
+ const res = await f(`${api}/${collection}?${p}`, init(o.signal ? { signal: o.signal } : {}))
125
141
  if (!res.ok) throw new Error(await errorMessage(res))
126
142
  const body = (await res.json()) as { docs?: Doc[] }
127
143
  return (body.docs ?? []).map((doc) => {
@@ -156,16 +172,30 @@ export function payloadRest(opts: PayloadRestOptions = {}): DataSource {
156
172
  export interface PayloadUploadsOptions {
157
173
  apiBase?: string
158
174
  pageSize?: number
175
+ /** Extra request init (headers for a custom auth scheme, etc.), as for `payloadRest`. */
176
+ init?: RequestInit
159
177
  fetch?: typeof fetch
160
178
  }
161
179
 
180
+ /** Smallest image size at least this wide is used for picker thumbnails. */
181
+ const THUMB_WIDTH = 240
182
+
183
+ /**
184
+ * A thumbnail URL: Payload's `thumbnailURL` (the collection's adminThumbnail),
185
+ * else the smallest generated size that is still wide enough, whatever the
186
+ * sizes are named, else the original.
187
+ */
162
188
  function thumbOf(doc: Doc): string | undefined {
163
- const sizes = (doc.sizes ?? {}) as Record<string, { url?: string | null } | undefined>
164
- for (const key of ['thumbnail', 'small', 'card', 'medium']) {
165
- const u = sizes[key]?.url
166
- if (u) return u
167
- }
168
- return (doc.url as string | undefined) ?? undefined
189
+ if (typeof doc.thumbnailURL === 'string' && doc.thumbnailURL) return doc.thumbnailURL
190
+ const sizes = Object.values(
191
+ (doc.sizes ?? {}) as Record<string, { url?: string | null; width?: number | null } | undefined>,
192
+ )
193
+ .filter(
194
+ (s): s is { url: string; width?: number | null } => typeof s?.url === 'string' && !!s.url,
195
+ )
196
+ .sort((a, b) => (a.width ?? Infinity) - (b.width ?? Infinity))
197
+ const fit = sizes.find((s) => (s.width ?? 0) >= THUMB_WIDTH) ?? sizes.at(-1)
198
+ return fit?.url ?? (typeof doc.url === 'string' ? doc.url : undefined)
169
199
  }
170
200
 
171
201
  const toItem = (doc: Doc): MediaItem => ({
@@ -177,11 +207,13 @@ const toItem = (doc: Doc): MediaItem => ({
177
207
  doc,
178
208
  })
179
209
 
180
- export function payloadUploads(opts: PayloadUploadsOptions = {}): MediaLibrary {
210
+ export function payloadUploads(
211
+ opts: PayloadUploadsOptions = {},
212
+ ): MediaLibrary & Required<Pick<MediaLibrary, 'upload'>> {
181
213
  const api = opts.apiBase ?? DEFAULTS.apiRoute
182
- const f = opts.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
214
+ const { f, init } = client(opts)
183
215
  return {
184
- async search({ query, page = 1, relationTo }) {
216
+ async search({ query, page = 1, relationTo, signal }) {
185
217
  const p = new URLSearchParams({
186
218
  depth: '0',
187
219
  limit: String(opts.pageSize ?? 30),
@@ -193,7 +225,7 @@ export function payloadUploads(opts: PayloadUploadsOptions = {}): MediaLibrary {
193
225
  p.set('where[and][1][or][0][filename][like]', query)
194
226
  p.set('where[and][1][or][1][alt][like]', query)
195
227
  }
196
- const res = await f(`${api}/${relationTo}?${p}`, { credentials: 'include' })
228
+ const res = await f(`${api}/${relationTo}?${p}`, init(signal ? { signal } : {}))
197
229
  if (!res.ok) throw new Error(await errorMessage(res))
198
230
  const body = (await res.json()) as { docs?: Doc[]; hasNextPage?: boolean }
199
231
  return { items: (body.docs ?? []).map(toItem), hasMore: Boolean(body.hasNextPage) }
@@ -206,11 +238,7 @@ export function payloadUploads(opts: PayloadUploadsOptions = {}): MediaLibrary {
206
238
  '_payload',
207
239
  JSON.stringify({ alt: alt ?? file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ') }),
208
240
  )
209
- const res = await f(`${api}/${relationTo}`, {
210
- method: 'POST',
211
- credentials: 'include',
212
- body: form,
213
- })
241
+ const res = await f(`${api}/${relationTo}`, init({ method: 'POST', body: form }))
214
242
  if (!res.ok) throw new Error(await errorMessage(res))
215
243
  return toItem(((await res.json()) as { doc: Doc }).doc)
216
244
  },
package/src/ports.ts CHANGED
@@ -11,42 +11,60 @@
11
11
  * Field editors are registered in @payload-onpage/react.
12
12
  */
13
13
  import type { DocRef } from './docref.ts'
14
- import type { MarkerKind } from './stega.ts'
14
+ import { MARKER_KINDS } from './stega.ts'
15
15
  import type { DocumentSchema } from './schema.ts'
16
16
 
17
17
  export type Doc = Record<string, unknown>
18
18
 
19
- export interface SaveResult {
20
- ok: boolean
21
- /** HTTP-like status on failure; 409 means someone else saved first. */
22
- status?: number
23
- message?: string
24
- /** The document's new `updatedAt` after a successful save. */
25
- updatedAt?: string
26
- }
19
+ /** The outcome of a save: success carries the new version, failure its status. */
20
+ export type SaveResult =
21
+ | {
22
+ ok: true
23
+ /** The document's new `updatedAt`. */
24
+ updatedAt?: string | undefined
25
+ }
26
+ | {
27
+ ok: false
28
+ /** HTTP-like status: 409 means someone else saved first, 0 a network failure. */
29
+ status: number
30
+ message?: string | undefined
31
+ }
32
+
33
+ /** Options callers often fill from other optional values, so `undefined` is accepted. */
34
+ type Opt<T> = T | undefined
27
35
 
28
36
  export interface DataSource {
29
- loadSchema(ref: DocRef, opts: { locale?: string }): Promise<DocumentSchema>
30
- loadDocument(ref: DocRef, opts: { draft: boolean; locale?: string; depth?: number }): Promise<Doc>
37
+ loadSchema(ref: DocRef, opts: { locale?: Opt<string> }): Promise<DocumentSchema>
38
+ loadDocument(
39
+ ref: DocRef,
40
+ opts: { draft: boolean; locale?: Opt<string>; depth?: Opt<number> },
41
+ ): Promise<Doc>
31
42
  /** Save fields to the rolling draft. `updatedAt` enables stale-write detection. */
32
43
  saveDraft(
33
44
  ref: DocRef,
34
45
  data: Doc,
35
- opts: { locale?: string; updatedAt?: string },
46
+ opts: { locale?: Opt<string>; updatedAt?: Opt<string> },
36
47
  ): Promise<SaveResult>
37
48
  /** Make the given values live. */
38
49
  publish(
39
50
  ref: DocRef,
40
51
  data: Doc,
41
- opts: { locale?: string; updatedAt?: string },
52
+ opts: { locale?: Opt<string>; updatedAt?: Opt<string> },
42
53
  ): Promise<SaveResult>
43
54
  /** Find documents to link to (relationship fields), by their title field. Optional. */
44
55
  search?(
45
56
  collection: string,
46
- opts: { query?: string; titleField?: string; locale?: string; limit?: number },
57
+ opts: {
58
+ query?: Opt<string>
59
+ titleField?: Opt<string>
60
+ locale?: Opt<string>
61
+ limit?: Opt<number>
62
+ /** Aborted when the search is superseded (the user kept typing). */
63
+ signal?: Opt<AbortSignal>
64
+ },
47
65
  ): Promise<SearchHit[]>
48
66
  /** Create a read-only preview link for a document. Optional. */
49
- createPreviewLink?(ref: DocRef, opts: { locale?: string }): Promise<PreviewLink>
67
+ createPreviewLink?(ref: DocRef, opts: { locale?: Opt<string> }): Promise<PreviewLink>
50
68
  }
51
69
 
52
70
  export interface PreviewLink {
@@ -74,11 +92,13 @@ export interface MediaItem {
74
92
 
75
93
  export interface MediaLibrary {
76
94
  search(opts: {
77
- query?: string
78
- page?: number
95
+ query?: Opt<string>
96
+ page?: Opt<number>
79
97
  relationTo: string
98
+ /** Aborted when the search is superseded. */
99
+ signal?: Opt<AbortSignal>
80
100
  }): Promise<{ items: MediaItem[]; hasMore: boolean }>
81
- upload?(file: File, opts: { relationTo: string; alt?: string }): Promise<MediaItem>
101
+ upload?(file: File, opts: { relationTo: string; alt?: Opt<string> }): Promise<MediaItem>
82
102
  }
83
103
 
84
104
  /**
@@ -86,16 +106,21 @@ export interface MediaLibrary {
86
106
  * the kinds auto-mapping markers carry; `field` = resolve from the schema), a
87
107
  * row of an array/blocks field (`item`), or the element holding a list (`list`).
88
108
  */
89
- export type TargetKind = MarkerKind | 'field' | 'item' | 'list'
109
+ /** What a page element shows: a marker's kind, or an annotation's field / row item / list. */
110
+ export const TARGET_KINDS = [...MARKER_KINDS, 'field', 'item', 'list'] as const
111
+ export type TargetKind = (typeof TARGET_KINDS)[number]
112
+ export const isTargetKind = (value: unknown): value is TargetKind =>
113
+ (TARGET_KINDS as readonly unknown[]).includes(value)
90
114
 
91
115
  export interface Target {
92
116
  el: Element
93
117
  ref: DocRef
94
118
  path: string
95
119
  kind: TargetKind
96
- label?: string
120
+ label?: Opt<string>
97
121
  /** Which locator produced it ("annotation" wins over inferred ones). */
98
- source: string
122
+ /** Which locator found it. */
123
+ source: 'annotation' | 'stega' | (string & {})
99
124
  }
100
125
 
101
126
  export interface Locator {
@@ -115,7 +140,10 @@ export interface Renderer {
115
140
  live: boolean
116
141
  }
117
142
 
118
- export type OnPageMode = 'edit' | 'view'
143
+ export const ONPAGE_MODES = ['edit', 'view'] as const
144
+ export type OnPageMode = (typeof ONPAGE_MODES)[number]
145
+ export const isOnPageMode = (value: unknown): value is OnPageMode =>
146
+ (ONPAGE_MODES as readonly unknown[]).includes(value)
119
147
 
120
148
  /**
121
149
  * Server side: turns a browser's edit/preview access on and off. Framework
package/src/schema.ts CHANGED
@@ -2,51 +2,134 @@
2
2
  * A JSON-safe description of a document's editable fields, produced on the
3
3
  * server by the plugin's schema endpoint (`@payload-onpage/plugin`) and consumed
4
4
  * by the editor to build settings forms, new rows and save payloads.
5
+ *
6
+ * `SchemaField` is a union discriminated on `type`: each kind carries only its
7
+ * own properties, so `field.options` exists only after checking for a select,
8
+ * and a `switch (field.type)` can be checked for exhaustiveness.
5
9
  */
10
+ import type { DocRef } from './docref.ts'
6
11
 
7
12
  export interface SchemaOption {
8
13
  label: string
9
14
  value: string
10
15
  }
11
16
 
12
- export interface SchemaField {
17
+ /** Singular and plural names ("Feature" / "Features"). */
18
+ export interface SchemaLabels {
19
+ singular: string
20
+ plural: string
21
+ }
22
+
23
+ /** What every field has. */
24
+ interface FieldBase {
13
25
  name: string
14
- type: string
15
26
  label: string
16
27
  localized?: boolean
17
28
  required?: boolean
18
- hasMany?: boolean
19
29
  defaultValue?: unknown
20
30
  description?: string
21
- options?: SchemaOption[]
22
- relationTo?: string | string[]
23
- minRows?: number
24
- /** array: what one row and many rows are called (\"Feature\" / \"Features\"). */
25
- labels?: { singular: string; plural: string }
26
- maxRows?: number
27
- /** group / array / named tab */
28
- fields?: SchemaField[]
29
- /** blocks */
30
- blocks?: SchemaBlock[]
31
31
  /** Field-editor override: `admin.custom.onpage.editor` (e.g. "color"). */
32
32
  editor?: string
33
- /** Number limits / date picker appearance, when set on the field. */
33
+ }
34
+
35
+ /** Fields whose value is a scalar or opaque JSON, edited as a whole. */
36
+ export type ScalarFieldType =
37
+ 'text' | 'textarea' | 'email' | 'code' | 'json' | 'checkbox' | 'point' | 'richText'
38
+
39
+ export interface ScalarField extends FieldBase {
40
+ type: ScalarFieldType
41
+ /** A list of values (text/textarea/email with `hasMany`): not edited inline. */
42
+ hasMany?: boolean
43
+ }
44
+
45
+ export interface NumberField extends FieldBase {
46
+ type: 'number'
47
+ hasMany?: boolean
34
48
  min?: number
35
49
  max?: number
50
+ }
51
+
52
+ export interface DateField extends FieldBase {
53
+ type: 'date'
54
+ /** Payload's `admin.date.pickerAppearance` ("dayOnly", "dayAndTime"…). */
36
55
  pickerAppearance?: string
37
56
  }
38
57
 
58
+ export interface ChoiceField extends FieldBase {
59
+ type: 'select' | 'radio'
60
+ options: SchemaOption[]
61
+ hasMany?: boolean
62
+ }
63
+
64
+ export interface RelationField extends FieldBase {
65
+ type: 'relationship' | 'upload'
66
+ relationTo: string | string[]
67
+ hasMany?: boolean
68
+ }
69
+
70
+ export interface GroupField extends FieldBase {
71
+ type: 'group'
72
+ fields: SchemaField[]
73
+ }
74
+
75
+ interface RowsField extends FieldBase {
76
+ labels?: SchemaLabels
77
+ minRows?: number
78
+ maxRows?: number
79
+ }
80
+
81
+ export interface ArrayField extends RowsField {
82
+ type: 'array'
83
+ fields: SchemaField[]
84
+ }
85
+
86
+ export interface BlocksField extends RowsField {
87
+ type: 'blocks'
88
+ blocks: SchemaBlock[]
89
+ }
90
+
91
+ export type SchemaField =
92
+ | ScalarField
93
+ | NumberField
94
+ | DateField
95
+ | ChoiceField
96
+ | RelationField
97
+ | GroupField
98
+ | ArrayField
99
+ | BlocksField
100
+
101
+ export type SchemaFieldType = SchemaField['type']
102
+
103
+ /**
104
+ * The field kinds that can have one of the given types: `FieldOf<'select'>` is
105
+ * `ChoiceField`, `FieldOf<'text' | 'email'>` is `ScalarField`. (A plain
106
+ * `Extract` would give `never`, since a kind's `type` is itself a union.)
107
+ */
108
+ export type FieldOf<T extends SchemaFieldType> = SchemaField extends infer F
109
+ ? F extends { type: infer U }
110
+ ? [Extract<U, T>] extends [never]
111
+ ? never
112
+ : F
113
+ : never
114
+ : never
115
+
116
+ /** An array or blocks field: one that holds a list of rows. */
117
+ export type ListField = ArrayField | BlocksField
118
+
119
+ export const isListField = (field: SchemaField): field is ListField =>
120
+ field.type === 'array' || field.type === 'blocks'
121
+
39
122
  export interface SchemaBlock {
40
123
  slug: string
41
- labels: { singular: string; plural: string }
124
+ labels: SchemaLabels
42
125
  fields: SchemaField[]
43
126
  }
44
127
 
45
128
  /** The editable shape of one collection or global, as served by the plugin. */
46
129
  export interface DocumentSchema {
47
- kind: 'collection' | 'global'
130
+ kind: DocRef['kind']
48
131
  slug: string
49
- labels: { singular: string; plural: string }
132
+ labels: SchemaLabels
50
133
  /** Top-level field names the editor may change. */
51
134
  editable: string[]
52
135
  fields: SchemaField[]
package/src/seal.ts CHANGED
@@ -100,3 +100,34 @@ export async function sha256(input: string): Promise<string> {
100
100
  const d = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(input)))
101
101
  return Array.from(d, (b) => b.toString(16).padStart(2, '0')).join('')
102
102
  }
103
+
104
+ /**
105
+ * The sealed `onpage_preview` cookie a share link sets: which page it opens,
106
+ * until when, and Next's draft secret to inject for that page only. Written by
107
+ * the plugin, read by the Next proxy.
108
+ */
109
+ export interface PreviewGrant {
110
+ /** The page's pathname. */
111
+ path: string
112
+ /** The document's ref key. */
113
+ ref: string
114
+ /** Expiry, seconds since the epoch. */
115
+ exp: number
116
+ /** Next's draft-mode secret. */
117
+ draft: string
118
+ }
119
+
120
+ /** A well-formed grant that hasn't expired. Anything else, including a missing `exp`, is refused. */
121
+ export function isLivePreviewGrant(value: unknown, now = Date.now()): value is PreviewGrant {
122
+ if (!value || typeof value !== 'object') return false
123
+ const g = value as Partial<Record<keyof PreviewGrant, unknown>>
124
+ return (
125
+ typeof g.path === 'string' &&
126
+ typeof g.ref === 'string' &&
127
+ typeof g.draft === 'string' &&
128
+ !!g.draft &&
129
+ typeof g.exp === 'number' &&
130
+ Number.isFinite(g.exp) &&
131
+ g.exp * 1000 > now
132
+ )
133
+ }