@payload-onpage/core 0.2.1 → 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.
- package/dist/autosave.d.ts +2 -2
- package/dist/autosave.d.ts.map +1 -1
- package/dist/autosave.js +2 -1
- package/dist/autosave.js.map +1 -1
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +14 -0
- package/dist/constants.js.map +1 -1
- package/dist/docref.d.ts +18 -3
- package/dist/docref.d.ts.map +1 -1
- package/dist/docref.js +13 -4
- package/dist/docref.js.map +1 -1
- package/dist/encode.d.ts +16 -4
- package/dist/encode.d.ts.map +1 -1
- package/dist/encode.js +42 -62
- package/dist/encode.js.map +1 -1
- package/dist/fields.d.ts +39 -8
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +111 -97
- package/dist/fields.js.map +1 -1
- package/dist/index.d.ts +16 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -12
- package/dist/index.js.map +1 -1
- package/dist/path.d.ts +9 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +27 -8
- package/dist/path.js.map +1 -1
- package/dist/payload.d.ts +5 -3
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +34 -23
- package/dist/payload.js.map +1 -1
- package/dist/ports.d.ts +43 -28
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +11 -1
- package/dist/ports.js.map +1 -1
- package/dist/schema.d.ts +71 -26
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -6
- package/dist/schema.js.map +1 -1
- package/dist/seal.d.ts +17 -0
- package/dist/seal.d.ts.map +1 -1
- package/dist/seal.js +13 -0
- package/dist/seal.js.map +1 -1
- package/dist/session.d.ts +34 -18
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +20 -13
- package/dist/session.js.map +1 -1
- package/dist/stega.d.ts +6 -2
- package/dist/stega.d.ts.map +1 -1
- package/dist/stega.js +18 -13
- package/dist/stega.js.map +1 -1
- package/dist/util.d.ts +9 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +11 -0
- package/dist/util.js.map +1 -0
- package/package.json +1 -1
- package/src/autosave.ts +4 -3
- package/src/constants.ts +16 -0
- package/src/docref.ts +26 -6
- package/src/encode.ts +47 -55
- package/src/fields.ts +166 -98
- package/src/index.ts +125 -12
- package/src/path.ts +32 -10
- package/src/payload.ts +54 -26
- package/src/ports.ts +50 -22
- package/src/schema.ts +100 -17
- package/src/seal.ts +31 -0
- package/src/session.ts +67 -47
- package/src/stega.ts +25 -17
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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(
|
|
210
|
+
export function payloadUploads(
|
|
211
|
+
opts: PayloadUploadsOptions = {},
|
|
212
|
+
): MediaLibrary & Required<Pick<MediaLibrary, 'upload'>> {
|
|
181
213
|
const api = opts.apiBase ?? DEFAULTS.apiRoute
|
|
182
|
-
const f
|
|
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}`, {
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
130
|
+
kind: DocRef['kind']
|
|
48
131
|
slug: string
|
|
49
|
-
labels:
|
|
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
|
+
}
|