@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.
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/session.ts CHANGED
@@ -8,18 +8,20 @@
8
8
  * touched. Publish, discard and reload run one at a time.
9
9
  */
10
10
  import { AutosaveQueue, type SaveState } from './autosave.ts'
11
- import { refKey, type DocRef } from './docref.ts'
11
+ import { refKey, type DocRef, type RefKey } from './docref.ts'
12
12
  import { buildPatch, changedKeys } from './fields.ts'
13
13
  import { setAt } from './path.ts'
14
14
  import type { DataSource, Doc } from './ports.ts'
15
15
  import type { DocumentSchema } from './schema.ts'
16
16
  import { stegaCleanDeep } from './stega.ts'
17
+ import { errorText } from './util.ts'
17
18
 
18
19
  export interface DocumentState {
19
- key: string
20
+ key: RefKey
20
21
  ref: DocRef
21
22
  status: 'loading' | 'ready' | 'error'
22
- error?: string
23
+ /** Why loading failed (status "error"). */
24
+ error?: string | undefined
23
25
  schema: DocumentSchema | null
24
26
  doc: Doc | null
25
27
  save: SaveState
@@ -27,8 +29,19 @@ export interface DocumentState {
27
29
  edited: boolean
28
30
  }
29
31
 
32
+ /** A document that can be edited: loaded, with its schema and data. */
33
+ export type ReadyDocumentState = DocumentState & {
34
+ status: 'ready'
35
+ schema: DocumentSchema
36
+ doc: Doc
37
+ }
38
+
39
+ /** Narrows a document's state to one that can be edited (instead of checking `schema && doc` by hand). */
40
+ export const isReady = (state: DocumentState | undefined): state is ReadyDocumentState =>
41
+ state?.status === 'ready' && !!state.schema && !!state.doc
42
+
30
43
  export interface SessionSnapshot {
31
- docs: Record<string, DocumentState>
44
+ docs: Record<RefKey, DocumentState>
32
45
  canUndo: boolean
33
46
  canRedo: boolean
34
47
  /** A publish, discard or reload is running. */
@@ -36,44 +49,47 @@ export interface SessionSnapshot {
36
49
  }
37
50
 
38
51
  interface HistoryEntry {
39
- key: string
52
+ key: RefKey
40
53
  before: Doc
41
54
  after: Doc
42
55
  }
43
56
 
44
57
  export interface EditorSessionOptions {
45
58
  dataSource: DataSource
46
- locale?: string
59
+ locale?: string | undefined
47
60
  /** Autosave debounce, ms. Default: the collection's autosave interval, else 800. */
48
- autosaveDelay?: number
61
+ autosaveDelay?: number | undefined
49
62
  /** Called after a document's edits were saved; `seq` increments per save. Renderers refresh here. */
50
- onSaved?: (ref: DocRef, seq: number) => void
63
+ onSaved?: ((ref: DocRef, seq: number) => void) | undefined
51
64
  }
52
65
 
53
66
  export interface PublishResult {
54
67
  ok: boolean
55
- message?: string
68
+ message?: string | undefined
56
69
  published: DocRef[]
57
70
  }
58
71
 
72
+ /** Schemas are per collection or global, not per document. */
73
+ type SchemaKey = `${DocRef['kind']}:${string}`
74
+ const schemaKey = (ref: DocRef): SchemaKey => `${ref.kind}:${ref.slug}`
75
+
59
76
  const HISTORY_LIMIT = 200
60
77
  const IDLE: SaveState = { status: 'idle', seq: 0 }
61
- const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e))
62
78
 
63
79
  export class EditorSession {
64
80
  readonly dataSource: DataSource
65
- readonly locale?: string
66
- private docs = new Map<string, DocumentState>()
67
- private queues = new Map<string, AutosaveQueue>()
68
- private updatedAt = new Map<string, string | undefined>()
81
+ readonly locale: string | undefined
82
+ private docs = new Map<RefKey, DocumentState>()
83
+ private queues = new Map<RefKey, AutosaveQueue>()
84
+ private updatedAt = new Map<RefKey, string | undefined>()
69
85
  /** Last value queued for saving, per document (diff base for the next save). */
70
- private saved = new Map<string, Doc>()
86
+ private saved = new Map<RefKey, Doc>()
71
87
  /** Value before the current run of transient edits, per document. */
72
- private anchors = new Map<string, Doc>()
88
+ private anchors = new Map<RefKey, Doc>()
73
89
  /** Documents whose value came from the page (client mode); a late server load must not replace it. */
74
- private fromPage = new Set<string>()
75
- private loading = new Map<string, Promise<void>>()
76
- private schemaCache = new Map<string, Promise<DocumentSchema>>()
90
+ private fromPage = new Set<RefKey>()
91
+ private loading = new Map<RefKey, Promise<void>>()
92
+ private schemaCache = new Map<SchemaKey, Promise<DocumentSchema>>()
77
93
  private past: HistoryEntry[] = []
78
94
  private future: HistoryEntry[] = []
79
95
  private listeners = new Set<() => void>()
@@ -82,7 +98,7 @@ export class EditorSession {
82
98
  private snapshot: SessionSnapshot = { docs: {}, canUndo: false, canRedo: false, busy: false }
83
99
  private disposed = false
84
100
  private readonly autosaveDelay: number | undefined
85
- private readonly onSaved?: (ref: DocRef, seq: number) => void
101
+ private readonly onSaved: ((ref: DocRef, seq: number) => void) | undefined
86
102
 
87
103
  constructor(opts: EditorSessionOptions) {
88
104
  this.dataSource = opts.dataSource
@@ -110,14 +126,20 @@ export class EditorSession {
110
126
  for (const fn of this.listeners) fn()
111
127
  }
112
128
 
113
- private patchState(key: string, patch: Partial<DocumentState>) {
129
+ /** After a publish: no unpublished edits, and the local copy says so. */
130
+ private markPublished(key: RefKey) {
131
+ const doc = this.docs.get(key)?.doc
132
+ this.patchState(key, { edited: false, doc: doc ? { ...doc, _status: 'published' } : null })
133
+ }
134
+
135
+ private patchState(key: RefKey, patch: Partial<DocumentState>) {
114
136
  const cur = this.docs.get(key)
115
137
  if (!cur) return
116
138
  this.docs.set(key, { ...cur, ...patch })
117
139
  this.emit()
118
140
  }
119
141
 
120
- get(key: string): DocumentState | undefined {
142
+ get(key: RefKey): DocumentState | undefined {
121
143
  return this.docs.get(key)
122
144
  }
123
145
 
@@ -139,7 +161,7 @@ export class EditorSession {
139
161
 
140
162
  // ── loading ─────────────────────────────────────────────────────────────
141
163
  private schemaFor(ref: DocRef): Promise<DocumentSchema> {
142
- const k = `${ref.kind}:${ref.slug}`
164
+ const k = schemaKey(ref)
143
165
  let p = this.schemaCache.get(k)
144
166
  if (!p) {
145
167
  p = this.dataSource.loadSchema(ref, { locale: this.locale })
@@ -202,7 +224,7 @@ export class EditorSession {
202
224
  }
203
225
 
204
226
  /** Try loading a document again after an error. */
205
- retry(key: string): Promise<void> {
227
+ retry(key: RefKey): Promise<void> {
206
228
  const state = this.docs.get(key)
207
229
  if (!state) return Promise.resolve()
208
230
  if (state.status === 'error') return this.open(state.ref)
@@ -210,7 +232,7 @@ export class EditorSession {
210
232
  return Promise.resolve()
211
233
  }
212
234
 
213
- private adopt(key: string, raw: Doc) {
235
+ private adopt(key: RefKey, raw: Doc) {
214
236
  // A page rendered with auto-mapping markers may hand us its marked copy; edit clean values.
215
237
  const doc = stegaCleanDeep(raw)
216
238
  this.saved.set(key, doc)
@@ -219,10 +241,11 @@ export class EditorSession {
219
241
  if (cur) this.docs.set(key, { ...cur, doc })
220
242
  }
221
243
 
222
- private queueFor(key: string): AutosaveQueue {
244
+ private queueFor(key: RefKey): AutosaveQueue {
223
245
  let q = this.queues.get(key)
224
246
  if (q) return q
225
- const state = this.docs.get(key)!
247
+ const state = this.docs.get(key)
248
+ if (!state) throw new Error(`payload-onpage: ${key} is not open in this session`)
226
249
  q = new AutosaveQueue({
227
250
  delay: this.autosaveDelay ?? state.schema?.autosaveInterval ?? 800,
228
251
  onState: (save) => {
@@ -243,7 +266,7 @@ export class EditorSession {
243
266
  }
244
267
 
245
268
  /** Schedule the editable top-level fields that changed since the last save. */
246
- private queueChanges(key: string, next: Doc) {
269
+ private queueChanges(key: RefKey, next: Doc) {
247
270
  const schema = this.docs.get(key)?.schema
248
271
  if (!schema) return
249
272
  const keys = changedKeys(this.saved.get(key) ?? {}, next, schema.editable)
@@ -254,7 +277,7 @@ export class EditorSession {
254
277
 
255
278
  // ── editing ─────────────────────────────────────────────────────────────
256
279
  /** Change a document. `transient` updates the view only (live typing): no history, no save. */
257
- apply(key: string, fn: (doc: Doc) => Doc, opts: { transient?: boolean } = {}) {
280
+ apply(key: RefKey, fn: (doc: Doc) => Doc, opts: { transient?: boolean } = {}) {
258
281
  const state = this.docs.get(key)
259
282
  if (!state?.doc) return
260
283
  const before = state.doc
@@ -276,12 +299,12 @@ export class EditorSession {
276
299
  this.emit()
277
300
  }
278
301
 
279
- setValue(key: string, path: string, value: unknown, opts: { transient?: boolean } = {}) {
302
+ setValue(key: RefKey, path: string, value: unknown, opts: { transient?: boolean } = {}) {
280
303
  this.apply(key, (d) => setAt(d, path, value), opts)
281
304
  }
282
305
 
283
306
  /** Drop transient changes (Escape while typing). */
284
- cancelTransient(key: string) {
307
+ cancelTransient(key: RefKey) {
285
308
  const anchor = this.anchors.get(key)
286
309
  const state = this.docs.get(key)
287
310
  if (!anchor || !state) return
@@ -290,7 +313,7 @@ export class EditorSession {
290
313
  this.emit()
291
314
  }
292
315
 
293
- private restore(key: string, doc: Doc) {
316
+ private restore(key: RefKey, doc: Doc) {
294
317
  const state = this.docs.get(key)
295
318
  if (!state) return
296
319
  this.anchors.delete(key)
@@ -328,14 +351,14 @@ export class EditorSession {
328
351
  return state.edited || state.doc?._status === 'draft'
329
352
  }
330
353
 
331
- private selected(keys?: string[]): DocumentState[] {
354
+ private selected(keys?: RefKey[]): DocumentState[] {
332
355
  return [...this.docs.values()].filter(
333
356
  (s) => (!keys || keys.includes(s.key)) && s.doc && s.schema && this.unpublished(s),
334
357
  )
335
358
  }
336
359
 
337
360
  /** Publish every document with unpublished changes (edited here, or saved as a draft before). */
338
- publish(opts: { keys?: string[] } = {}): Promise<PublishResult> {
361
+ publish(opts: { keys?: RefKey[] } = {}): Promise<PublishResult> {
339
362
  return this.serial(async () => {
340
363
  await this.flush()
341
364
  const published: DocRef[] = []
@@ -372,11 +395,7 @@ export class EditorSession {
372
395
  published,
373
396
  }
374
397
  if (res.updatedAt) this.updatedAt.set(key, res.updatedAt)
375
- const now = this.docs.get(key)!
376
- this.patchState(key, {
377
- edited: false,
378
- doc: now.doc ? { ...now.doc, _status: 'published' } : now.doc,
379
- })
398
+ this.markPublished(key)
380
399
  published.push(state.ref)
381
400
  }
382
401
  return { ok: true, published }
@@ -384,7 +403,12 @@ export class EditorSession {
384
403
  }
385
404
 
386
405
  /** Reset drafts with unpublished changes to their published versions (also clears a conflict). */
387
- discard(opts: { keys?: string[] } = {}): Promise<void> {
406
+ /**
407
+ * Reset the drafts to their published values. Done as an edit, so it lands on
408
+ * the undo stack: undo right after a discard brings the changes back (and
409
+ * autosave saves them again).
410
+ */
411
+ discard(opts: { keys?: RefKey[] } = {}): Promise<void> {
388
412
  return this.serial(async () => {
389
413
  await this.flush()
390
414
  for (const { key, ref } of this.selected(opts.keys)) {
@@ -393,7 +417,7 @@ export class EditorSession {
393
417
  this.dataSource.loadDocument(ref, { draft: true, locale: this.locale, depth: 2 }),
394
418
  ])
395
419
  const state = this.docs.get(key)
396
- if (!state?.schema || !state.doc) continue
420
+ if (!isReady(state)) continue
397
421
  // Continue from the server's latest draft (so the stale-write guard accepts the reset).
398
422
  this.adopt(key, latest)
399
423
  this.queues.get(key)?.reset()
@@ -401,17 +425,13 @@ export class EditorSession {
401
425
  for (const k of state.schema.editable) next[k] = published[k]
402
426
  this.apply(key, () => next)
403
427
  await this.queues.get(key)?.flush()
404
- const now = this.docs.get(key)!
405
- this.patchState(key, {
406
- edited: false,
407
- doc: now.doc ? { ...now.doc, _status: 'published' } : now.doc,
408
- })
428
+ this.markPublished(key)
409
429
  }
410
430
  })
411
431
  }
412
432
 
413
433
  /** After a conflict: load the latest draft of a document and continue from it (local edits are dropped). */
414
- reload(key: string): Promise<void> {
434
+ reload(key: RefKey): Promise<void> {
415
435
  return this.serial(async () => {
416
436
  const state = this.docs.get(key)
417
437
  if (!state) return
package/src/stega.ts CHANGED
@@ -14,32 +14,33 @@
14
14
  * All of these render with zero width and are ignored by `trim()`-insensitive
15
15
  * layout; they are stripped before anything is written back to Payload.
16
16
  */
17
+ import { isRefKey, type RefKey } from './docref.ts'
17
18
 
18
- export type MarkerKind = 'text' | 'textarea' | 'richtext' | 'media'
19
+ export const MARKER_KINDS = ['text', 'textarea', 'richtext', 'media'] as const
20
+ export type MarkerKind = (typeof MARKER_KINDS)[number]
19
21
 
20
22
  export interface Marker {
21
- ref: string // refKey
23
+ ref: RefKey
22
24
  path: string
23
25
  kind: MarkerKind
24
26
  }
25
27
 
26
- const DIGITS = ['\u200b', '\u200c', '\u200d', '\u2060'] as const
28
+ /** The four invisible code points that carry base-4 digits, in digit order. */
29
+ const DIGITS = '\u200b\u200c\u200d\u2060'
27
30
  const START = '\u2063\u2062'
28
31
  const END = '\u2064'
29
- const KIND_CODE: Record<MarkerKind, string> = {
32
+ const KIND_CODE = {
30
33
  text: 't',
31
34
  textarea: 'a',
32
35
  richtext: 'r',
33
36
  media: 'm',
34
- }
35
- const CODE_KIND: Record<string, MarkerKind> = {
36
- t: 'text',
37
- a: 'textarea',
38
- r: 'richtext',
39
- m: 'media',
40
- }
37
+ } as const satisfies Record<MarkerKind, string>
38
+ /** Derived from KIND_CODE, so the two can't disagree. */
39
+ const CODE_KIND = new Map<string, MarkerKind>(
40
+ (Object.entries(KIND_CODE) as Array<[MarkerKind, string]>).map(([kind, code]) => [code, kind]),
41
+ )
41
42
 
42
- const DIGIT_VALUE = new Map<string, number>(DIGITS.map((c, i) => [c, i]))
43
+ const DIGIT_VALUE = new Map<string, number>([...DIGITS].map((c, i) => [c, i]))
43
44
  // The codec's characters are single invisible code points (not combining sequences).
44
45
  // eslint-disable-next-line no-misleading-character-class
45
46
  const MARKER_RE = /\u2063\u2062([\u200b\u200c\u200d\u2060]*)\u2064/g
@@ -53,7 +54,11 @@ export function encodeMarker(marker: Marker): string {
53
54
  const bytes = encoder.encode(`${marker.ref}|${marker.path}|${KIND_CODE[marker.kind]}`)
54
55
  let body = ''
55
56
  for (const b of bytes)
56
- body += DIGITS[(b >> 6) & 3] + DIGITS[(b >> 4) & 3] + DIGITS[(b >> 2) & 3] + DIGITS[b & 3]
57
+ body +=
58
+ DIGITS.charAt((b >> 6) & 3) +
59
+ DIGITS.charAt((b >> 4) & 3) +
60
+ DIGITS.charAt((b >> 2) & 3) +
61
+ DIGITS.charAt(b & 3)
57
62
  return START + body + END
58
63
  }
59
64
 
@@ -63,7 +68,7 @@ function decodeBody(body: string): Marker | null {
63
68
  for (let i = 0; i < bytes.length; i++) {
64
69
  let b = 0
65
70
  for (let j = 0; j < 4; j++) {
66
- const v = DIGIT_VALUE.get(body[i * 4 + j])
71
+ const v = DIGIT_VALUE.get(body.charAt(i * 4 + j))
67
72
  if (v === undefined) return null
68
73
  b = (b << 2) | v
69
74
  }
@@ -76,11 +81,14 @@ function decodeBody(body: string): Marker | null {
76
81
  return null
77
82
  }
78
83
  const [ref, path, code, ...extra] = text.split('|')
79
- const kind = CODE_KIND[code]
80
- if (!ref || !path || !kind || extra.length) return null
84
+ const kind = code === undefined ? undefined : CODE_KIND.get(code)
85
+ if (!ref || !isRefKey(ref) || !path || !kind || extra.length) return null
81
86
  return { ref, path, kind }
82
87
  }
83
88
 
89
+ /** Does this string carry a marker? (A cheap test before decoding, e.g. while scanning a page.) */
90
+ export const hasStegaMarker = (value: string): boolean => value.includes(START)
91
+
84
92
  /** Append a marker to a string (an already-marked string is re-marked, not doubled). */
85
93
  export function stegaEncode(value: string, marker: Marker): string {
86
94
  return stegaClean(value) + encodeMarker(marker)
@@ -91,7 +99,7 @@ export function stegaDecode(value: string): Marker[] {
91
99
  const out: Marker[] = []
92
100
  if (!value) return out
93
101
  for (const m of value.matchAll(MARKER_RE)) {
94
- const marker = decodeBody(m[1])
102
+ const marker = decodeBody(m[1] ?? '')
95
103
  if (marker) out.push(marker)
96
104
  }
97
105
  return out
package/src/util.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** Small type-level and error helpers shared by every package. */
2
+
3
+ /** A thrown value's message (anything can be thrown, not only `Error`s). */
4
+ export const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e))
5
+
6
+ /**
7
+ * Exhaustiveness check: call in the `default` of a switch over a union. Adding a
8
+ * member to the union then fails to compile until every switch handles it.
9
+ */
10
+ export function assertNever(value: never, what = 'value'): never {
11
+ throw new Error(`Unexpected ${what}: ${JSON.stringify(value)}`)
12
+ }