@unitninety/site-kit 1.6.2 → 1.6.4
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/README.md +26 -1
- package/package.json +1 -1
- package/src/bridge/DraftBlocks.tsx +14 -7
- package/src/bridge/U90Bridge.tsx +7 -4
- package/src/bridge/client.tsx +3 -2
- package/src/bridge/createBridge.ts +47 -12
- package/src/bridge/hooks.tsx +9 -8
- package/src/bridge/index.ts +6 -0
- package/src/bridge/nativeDraft.ts +177 -0
- package/src/bridge/protocol.ts +45 -7
- package/src/bridge/registry.ts +6 -1
- package/src/bridge/selection.ts +134 -0
- package/src/bridge/styles.ts +10 -1
- package/src/bridge/useDraftContent.tsx +151 -0
- package/src/components/Link.tsx +1 -1
- package/src/elements/containers/group.ts +1 -1
- package/src/elements/containers/zone.ts +13 -2
- package/src/elements/declaration.ts +89 -2
- package/src/elements/define.ts +22 -3
- package/src/elements/jsonSchema.ts +4 -3
- package/src/elements/leaves/link.ts +17 -2
- package/src/elements/leaves/query.ts +6 -1
- package/src/elements/leaves/relation.ts +3 -0
- package/src/elements/leaves/rich-text.ts +32 -16
- package/src/elements/leaves/selection.ts +5 -4
- package/src/elements/ops/expand.ts +5 -4
- package/src/elements/ops/seed.ts +25 -6
- package/src/elements/ops/validate.ts +31 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/seo/robots.ts +8 -8
- package/src/seo/seeds.ts +61 -0
- package/src/server/render.tsx +3 -3
- package/src/site/assemble.ts +1 -1
- package/src/site/defineSite.ts +2 -2
- package/src/site/documents.ts +4 -4
- package/src/site/fields.ts +6 -6
- package/src/site/formView.ts +175 -0
- package/src/site/index.ts +3 -0
- package/src/site/replies.ts +5 -4
- package/src/site/types.ts +3 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +1 -1
- package/src/testing/fake.ts +4 -4
package/src/bridge/protocol.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Crop } from '../elements/leaves/image'
|
|
3
|
+
import { BlockDeclSchema, FieldDeclSchema } from '../elements/declaration'
|
|
4
|
+
import { EntryReadSchema, MediaAssetSchema, type EntryRead, type FieldDecl, type MediaRecord, type Schema } from '../elements/define'
|
|
1
5
|
import { isDocumentId } from '../elements/ids'
|
|
2
6
|
import { LinkTo } from '../elements/leaves/link'
|
|
3
7
|
|
|
@@ -7,7 +11,8 @@ import { LinkTo } from '../elements/leaves/link'
|
|
|
7
11
|
*
|
|
8
12
|
* The model: the page is server-rendered from draft content. Typing edits the DOM in place and
|
|
9
13
|
* is sent as `edit`; the Canvas saves; after any structural save the Canvas sends `refresh` and
|
|
10
|
-
* the site calls `router.refresh()`.
|
|
14
|
+
* the site calls `router.refresh()`. Opt-in client components can also render transient, path-scoped list snapshots before saving.
|
|
15
|
+
* Server renders remain authoritative; there is no persistent client content store or `fields` message.
|
|
11
16
|
*/
|
|
12
17
|
export const BRIDGE_VERSION = 4 as const
|
|
13
18
|
|
|
@@ -46,8 +51,9 @@ const isBridgeLinkTarget = (to: unknown): to is LinkTo => {
|
|
|
46
51
|
* A DOM-shaped instruction the Canvas asks a frame to apply to markup it has already rendered, so
|
|
47
52
|
* an edit shows before the server has re-rendered the page.
|
|
48
53
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
54
|
+
* DOM previews are presentational. A list snapshot contains native reads for one declared list;
|
|
55
|
+
* only an opt-in draft component consumes it. An embedded `list` lets older bridges keep handling
|
|
56
|
+
* the accompanying DOM instruction, while new list owners render through React.
|
|
51
57
|
* Every preview is transient. React owns draft block order and settles it from the next server render.
|
|
52
58
|
* For other previews the frame records an undo and takes them back, newest
|
|
53
59
|
* first, just before it asks for the `u90:refresh` that follows the save (`createBridge`'s journal), so
|
|
@@ -56,7 +62,11 @@ const isBridgeLinkTarget = (to: unknown): to is LinkTo => {
|
|
|
56
62
|
*
|
|
57
63
|
* `before` names the sibling to insert in front of; `null` appends.
|
|
58
64
|
*/
|
|
59
|
-
export type
|
|
65
|
+
export type DraftSnapshot = { path: string; field: FieldDecl; value: unknown; media?: MediaRecord[]; entries?: EntryRead[]; blocks?: Schema['blocks'] }
|
|
66
|
+
|
|
67
|
+
export type PreviewInstruction = (
|
|
68
|
+
| ({ kind: 'value' } & DraftSnapshot)
|
|
69
|
+
| { kind: 'list'; path: string; value: unknown[] }
|
|
60
70
|
| { kind: 'entries'; path: string; order: string[]; exclude: string[]; pin: string[] }
|
|
61
71
|
| { kind: 'move'; path: string; before: string | null }
|
|
62
72
|
| { kind: 'remove'; path: string }
|
|
@@ -75,15 +85,39 @@ export type PreviewInstruction =
|
|
|
75
85
|
* applied by `applyPreview`; `createBridge` handles it.
|
|
76
86
|
*/
|
|
77
87
|
| { kind: 'fragment'; path: string; block: string }
|
|
88
|
+
) & { list?: { path: string; value: unknown[] }; draft?: DraftSnapshot }
|
|
78
89
|
|
|
79
|
-
const PREVIEW_KINDS = new Set(['entries', 'move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
|
|
90
|
+
const PREVIEW_KINDS = new Set(['value', 'list', 'entries', 'move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
|
|
80
91
|
const isBefore = (v: unknown): boolean => v === null || typeof v === 'string'
|
|
81
92
|
|
|
93
|
+
const DraftMediaSchema = MediaAssetSchema.extend({ renditions: z.array(z.object({ crop: Crop.nullable(), url: z.string(), width: z.number().nullable(), height: z.number().nullable(), blurDataURL: z.string().optional(), status: z.enum(['ready', 'processing', 'error']) })).optional() })
|
|
94
|
+
const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype'])
|
|
95
|
+
function safeSnapshotValue(value: unknown, ancestors = new Set<object>()): boolean {
|
|
96
|
+
if (!value || typeof value !== 'object') return true
|
|
97
|
+
if (ancestors.has(value) || ancestors.size > 64) return false
|
|
98
|
+
const next = new Set(ancestors).add(value)
|
|
99
|
+
return Object.entries(value).every(([key, child]) => !unsafeKeys.has(key) && safeSnapshotValue(child, next))
|
|
100
|
+
}
|
|
101
|
+
const isDraftSnapshot = (value: unknown): value is DraftSnapshot => {
|
|
102
|
+
if (!value || typeof value !== 'object' || !safeSnapshotValue(value)) return false
|
|
103
|
+
const r = value as Record<string, unknown>
|
|
104
|
+
return typeof r.path === 'string' && /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(r.path)
|
|
105
|
+
&& !r.path.split('/').some(key => unsafeKeys.has(key)) && Object.hasOwn(r, 'value')
|
|
106
|
+
&& FieldDeclSchema.safeParse(r.field).success
|
|
107
|
+
&& (r.media === undefined || Array.isArray(r.media) && r.media.every(media => DraftMediaSchema.safeParse(media).success))
|
|
108
|
+
&& (r.entries === undefined || Array.isArray(r.entries) && r.entries.every(entry => EntryReadSchema.safeParse(entry).success))
|
|
109
|
+
&& (r.blocks === undefined || !!r.blocks && typeof r.blocks === 'object' && !Array.isArray(r.blocks) && Object.values(r.blocks).every(block => BlockDeclSchema.safeParse(block).success))
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/** Whether a `u90:preview`'s instruction has a known kind and a well-formed payload. */
|
|
83
113
|
const isPreviewInstruction = (value: unknown): value is PreviewInstruction => {
|
|
84
114
|
if (!(value && typeof value === 'object')) return false
|
|
85
115
|
const r = value as Record<string, unknown>
|
|
86
116
|
if (typeof r.kind !== 'string' || !PREVIEW_KINDS.has(r.kind) || typeof r.path !== 'string') return false
|
|
117
|
+
if (r.list !== undefined && (!r.list || typeof r.list !== 'object' || typeof (r.list as Record<string, unknown>).path !== 'string' || !Array.isArray((r.list as Record<string, unknown>).value))) return false
|
|
118
|
+
if (r.draft !== undefined && !isDraftSnapshot(r.draft)) return false
|
|
119
|
+
if (r.kind === 'value') return isDraftSnapshot(r)
|
|
120
|
+
if (r.kind === 'list') return Array.isArray(r.value)
|
|
87
121
|
if (r.kind === 'entries') return [r.order, r.exclude, r.pin].every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))
|
|
88
122
|
if (r.kind === 'media') return typeof r.src === 'string'
|
|
89
123
|
if (r.kind === 'text' || r.kind === 'link-label') return typeof r.value === 'string'
|
|
@@ -126,7 +160,7 @@ export type SiteMessage = Base &
|
|
|
126
160
|
(
|
|
127
161
|
| { type: 'u90:ready'; path: string }
|
|
128
162
|
| { type: 'u90:path'; path: string }
|
|
129
|
-
| { type: 'u90:select'; path: string | null }
|
|
163
|
+
| { type: 'u90:select'; path: string | null; via?: string[] }
|
|
130
164
|
| { type: 'u90:hover'; path: string | null }
|
|
131
165
|
| { type: 'u90:edit'; path: string; kind: 'text' | 'rich' | 'link-label'; value: unknown }
|
|
132
166
|
| {
|
|
@@ -158,7 +192,7 @@ export type SiteMessage = Base &
|
|
|
158
192
|
export type CanvasMessage = Base &
|
|
159
193
|
(
|
|
160
194
|
| { type: 'u90:refresh' }
|
|
161
|
-
| { type: 'u90:select'; path: string | null }
|
|
195
|
+
| { type: 'u90:select'; path: string | null; reveal?: boolean; via?: string[] }
|
|
162
196
|
| { type: 'u90:flush'; requestId: string }
|
|
163
197
|
| { type: 'u90:format'; action: string; url?: string; to?: LinkTo; newTab?: boolean }
|
|
164
198
|
| { type: 'u90:scroll'; to: 'top' | 'fraction'; fraction?: number }
|
|
@@ -209,12 +243,15 @@ const isValidFormat = (data: Record<string, unknown>): boolean => {
|
|
|
209
243
|
return true
|
|
210
244
|
}
|
|
211
245
|
|
|
246
|
+
const validReferenceTrail = (via: unknown): boolean => via === undefined || (Array.isArray(via) && via.length <= 5 && via.every(path => typeof path === 'string' && path.length <= 512 && /^(page|entry|layout|single):[^/]+\//.test(path)))
|
|
247
|
+
|
|
212
248
|
/** A message from the Canvas, accepted only at the current version and a known type. */
|
|
213
249
|
export const parseCanvasMessage = (data: unknown): CanvasMessage | null => {
|
|
214
250
|
if (!(data && typeof data === 'object' && (data as { version?: unknown }).version === 4)) return null
|
|
215
251
|
const record = data as Record<string, unknown>
|
|
216
252
|
if (!CANVAS_TYPES.has(String(record.type))) return null
|
|
217
253
|
if (stampState(record) === 'malformed') return null
|
|
254
|
+
if (record.type === 'u90:select' && !validReferenceTrail(record.via)) return null
|
|
218
255
|
if (record.type === 'u90:format' && !isValidFormat(record)) return null
|
|
219
256
|
if (record.type === 'u90:preview' && !isPreviewInstruction(record.instruction)) return null
|
|
220
257
|
return data as CanvasMessage
|
|
@@ -267,6 +304,7 @@ export const isSiteMessage = (data: unknown): data is SiteMessage => {
|
|
|
267
304
|
const record = data as Record<string, unknown>
|
|
268
305
|
if (!SITE_TYPES.has(String(record.type))) return false
|
|
269
306
|
if (stampState(record) === 'malformed') return false
|
|
307
|
+
if (record.type === 'u90:select' && !validReferenceTrail(record.via)) return false
|
|
270
308
|
if (record.type === 'u90:caret' && !isValidCaret(record)) return false
|
|
271
309
|
if (record.type === 'u90:direction' && !isValidDirection(record)) return false
|
|
272
310
|
return true
|
package/src/bridge/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntryPicks } from './DraftEntries'
|
|
2
|
+
import type { DraftSnapshot } from './protocol'
|
|
2
3
|
import type { Bridge } from './createBridge'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -21,10 +22,14 @@ export const registry: {
|
|
|
21
22
|
current: Bridge | null
|
|
22
23
|
reveals: Set<{ prefix: string; fn: () => void }>
|
|
23
24
|
entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
|
|
24
|
-
|
|
25
|
+
draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot): boolean }>
|
|
26
|
+
draftContents: Set<{ document: Document; beforeRefresh(): void; preview(path: string, value: unknown[]): boolean }>
|
|
27
|
+
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; move(path: string, before: string | null): boolean }>
|
|
25
28
|
} = {
|
|
26
29
|
current: null,
|
|
27
30
|
reveals: new Set(),
|
|
28
31
|
blockOrders: new Set(),
|
|
32
|
+
draftContents: new Set(),
|
|
33
|
+
draftValues: new Set(),
|
|
29
34
|
entryLists: new Set(),
|
|
30
35
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Draft-only selection geometry. Contents wrappers have no CSS box of their own. */
|
|
2
|
+
export function createSelection(win: Window) {
|
|
3
|
+
const doc = win.document
|
|
4
|
+
const overlay = doc.createElement('div')
|
|
5
|
+
overlay.setAttribute('data-u90-selection-overlay', '')
|
|
6
|
+
overlay.setAttribute('aria-hidden', 'true')
|
|
7
|
+
overlay.hidden = true
|
|
8
|
+
let path: string | null = null
|
|
9
|
+
let shouldReveal = false
|
|
10
|
+
let frame: number | null = null
|
|
11
|
+
let observer: MutationObserver | null = null
|
|
12
|
+
const escaped = (value: string) => value.replace(/["\\]/g, '\\$&')
|
|
13
|
+
const target = () =>
|
|
14
|
+
path
|
|
15
|
+
? doc.querySelector<HTMLElement>(
|
|
16
|
+
`[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`,
|
|
17
|
+
)
|
|
18
|
+
: null
|
|
19
|
+
type Box = {
|
|
20
|
+
left: number
|
|
21
|
+
top: number
|
|
22
|
+
right: number
|
|
23
|
+
bottom: number
|
|
24
|
+
width: number
|
|
25
|
+
height: number
|
|
26
|
+
}
|
|
27
|
+
const bounds = (element: Element): Box | null => {
|
|
28
|
+
const style = win.getComputedStyle(element)
|
|
29
|
+
if (style.display === 'none' || style.visibility === 'hidden') return null
|
|
30
|
+
const rect = element.getBoundingClientRect()
|
|
31
|
+
if (style.display !== 'contents' && rect.width > 0 && rect.height > 0) return rect
|
|
32
|
+
const children = [...element.children].flatMap((child) => {
|
|
33
|
+
const box = bounds(child)
|
|
34
|
+
return box ? [box] : []
|
|
35
|
+
})
|
|
36
|
+
if (!children.length) return null
|
|
37
|
+
const left = Math.min(...children.map((box) => box.left))
|
|
38
|
+
const top = Math.min(...children.map((box) => box.top))
|
|
39
|
+
const right = Math.max(...children.map((box) => box.right))
|
|
40
|
+
const bottom = Math.max(...children.map((box) => box.bottom))
|
|
41
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top }
|
|
42
|
+
}
|
|
43
|
+
const paint = () => {
|
|
44
|
+
frame = null
|
|
45
|
+
const element = target()
|
|
46
|
+
// Server refreshes can replace the selected node without changing its path.
|
|
47
|
+
element?.setAttribute('data-u90-selected', '')
|
|
48
|
+
let box = element ? bounds(element) : null
|
|
49
|
+
if (!element || !box) {
|
|
50
|
+
overlay.hidden = true
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (shouldReveal) {
|
|
54
|
+
shouldReveal = false
|
|
55
|
+
const margin = Math.min(80, win.innerHeight / 4)
|
|
56
|
+
if (
|
|
57
|
+
box.top < margin ||
|
|
58
|
+
box.top >= win.innerHeight - margin ||
|
|
59
|
+
(box.height <= win.innerHeight - margin * 2 && box.bottom > win.innerHeight - margin)
|
|
60
|
+
) {
|
|
61
|
+
win.scrollTo({ top: Math.max(0, win.scrollY + box.top - margin), behavior: 'instant' })
|
|
62
|
+
box = bounds(element) ?? box
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Ordinary fields keep their existing CSS outline. A contents wrapper needs an overlay.
|
|
66
|
+
const own = element.getBoundingClientRect()
|
|
67
|
+
if (own.width > 0 && own.height > 0 && win.getComputedStyle(element).display !== 'contents') {
|
|
68
|
+
overlay.hidden = true
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
Object.assign(overlay.style, {
|
|
72
|
+
left: `${box.left}px`,
|
|
73
|
+
top: `${box.top}px`,
|
|
74
|
+
width: `${box.width}px`,
|
|
75
|
+
height: `${box.height}px`,
|
|
76
|
+
})
|
|
77
|
+
if (!overlay.isConnected) doc.body.appendChild(overlay)
|
|
78
|
+
overlay.hidden = false
|
|
79
|
+
}
|
|
80
|
+
const schedule = () => {
|
|
81
|
+
if (path && frame === null) frame = win.requestAnimationFrame(paint)
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
select(next: string | null, reveal = false) {
|
|
85
|
+
doc
|
|
86
|
+
.querySelectorAll('[data-u90-selected]')
|
|
87
|
+
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
|
88
|
+
path = next
|
|
89
|
+
shouldReveal = reveal
|
|
90
|
+
target()?.setAttribute('data-u90-selected', '')
|
|
91
|
+
overlay.hidden = true
|
|
92
|
+
schedule()
|
|
93
|
+
},
|
|
94
|
+
start() {
|
|
95
|
+
win.addEventListener('scroll', schedule, true)
|
|
96
|
+
win.addEventListener('resize', schedule)
|
|
97
|
+
doc.addEventListener('load', schedule, true)
|
|
98
|
+
const Observer = (win as Window & typeof globalThis).MutationObserver
|
|
99
|
+
if (Observer) {
|
|
100
|
+
observer = new Observer((records) => {
|
|
101
|
+
if (
|
|
102
|
+
records.some(
|
|
103
|
+
(record) =>
|
|
104
|
+
record.target !== overlay &&
|
|
105
|
+
(record.type !== 'childList' ||
|
|
106
|
+
[...record.addedNodes, ...record.removedNodes].some((node) => node !== overlay)),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
schedule()
|
|
110
|
+
})
|
|
111
|
+
observer.observe(doc.body, {
|
|
112
|
+
subtree: true,
|
|
113
|
+
childList: true,
|
|
114
|
+
attributes: true,
|
|
115
|
+
attributeFilter: ['class', 'style', 'hidden'],
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
stop() {
|
|
120
|
+
observer?.disconnect()
|
|
121
|
+
observer = null
|
|
122
|
+
win.removeEventListener('scroll', schedule, true)
|
|
123
|
+
win.removeEventListener('resize', schedule)
|
|
124
|
+
doc.removeEventListener('load', schedule, true)
|
|
125
|
+
if (frame !== null) win.cancelAnimationFrame(frame)
|
|
126
|
+
frame = null
|
|
127
|
+
path = null
|
|
128
|
+
doc
|
|
129
|
+
.querySelectorAll('[data-u90-selected]')
|
|
130
|
+
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
|
131
|
+
overlay.remove()
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/bridge/styles.ts
CHANGED
|
@@ -84,10 +84,19 @@ html:not([data-u90-navigation]) a[href]:has([data-u90-field]):hover {
|
|
|
84
84
|
outline: 1px dashed #FF5D2E;
|
|
85
85
|
outline-offset: 3px;
|
|
86
86
|
}
|
|
87
|
-
html:not([data-u90-navigation]) ${EDITABLE_LEAF_SELECTOR} {
|
|
87
|
+
html:not([data-u90-navigation]):not([data-u90-inspector-only]) ${EDITABLE_LEAF_SELECTOR} {
|
|
88
88
|
cursor: text;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
[data-u90-selection-overlay] {
|
|
92
|
+
position: fixed;
|
|
93
|
+
pointer-events: none;
|
|
94
|
+
z-index: 2147483646;
|
|
95
|
+
box-sizing: border-box;
|
|
96
|
+
border: 3px solid #FF5D2E;
|
|
97
|
+
}
|
|
98
|
+
[data-u90-selection-overlay][hidden] { display: none; }
|
|
99
|
+
|
|
91
100
|
[data-u90-selected] {
|
|
92
101
|
outline: 2px solid #FF5D2E !important;
|
|
93
102
|
outline-offset: 5px;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import { applyNativeDraft, NativeDraftReferences, safeDraftPath } from './nativeDraft'
|
|
5
|
+
import type { DraftSnapshot } from './protocol'
|
|
6
|
+
import { registry } from './registry'
|
|
7
|
+
|
|
8
|
+
/** Set by draft block rendering; published components have no editing owner. */
|
|
9
|
+
export const DraftContentPath = createContext<string | undefined>(undefined)
|
|
10
|
+
|
|
11
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
12
|
+
function replaceAt(value: unknown, segments: string[], replacement: unknown[]): unknown {
|
|
13
|
+
if (!segments.length) return replacement
|
|
14
|
+
const [key, ...rest] = segments
|
|
15
|
+
if (unsafe.has(key)) return value
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
const index = value.findIndex(item => item && (item._id === key || item.$path?.split('/').at(-1) === key))
|
|
18
|
+
if (index < 0) return value
|
|
19
|
+
return value.map((item, at) => at === index ? replaceAt(item, rest, replacement) : item)
|
|
20
|
+
}
|
|
21
|
+
if (!value || typeof value !== 'object' || (rest.length && !Object.hasOwn(value, key))) return value
|
|
22
|
+
return { ...value, [key]: replaceAt((value as Record<string, unknown>)[key], rest, replacement) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render native field reads immediately in a client component, before the save/refresh.
|
|
26
|
+
* Only declared list snapshots under this draft block are accepted. React retains ownership
|
|
27
|
+
* of its children, keys, animation state and markup; published values pass through unchanged. */
|
|
28
|
+
export function useDraftContent<T>(source: T, field: string): T {
|
|
29
|
+
const block = useContext(DraftContentPath)
|
|
30
|
+
const path = block ? `${block}/${field}` : undefined
|
|
31
|
+
const [shown, setShown] = useState({ path, source, value: source, overrides: new Map<string, unknown[]>() })
|
|
32
|
+
let current = shown.value
|
|
33
|
+
if (shown.source !== source || shown.path !== path) {
|
|
34
|
+
current = source
|
|
35
|
+
const overrides = shown.path === path ? shown.overrides : new Map<string, unknown[]>()
|
|
36
|
+
for (const [at, value] of overrides) current = replaceAt(current, at ? at.split('/') : [], value) as T
|
|
37
|
+
setShown({ path, source, value: current, overrides })
|
|
38
|
+
}
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
if (!path) return
|
|
41
|
+
const handler = {
|
|
42
|
+
document,
|
|
43
|
+
// Retain visible content until the response arrives. Only edits made AFTER this
|
|
44
|
+
// refresh starts override its response, including when refreshes overlap.
|
|
45
|
+
beforeRefresh: () => setShown(value => ({ ...value, overrides: new Map() })),
|
|
46
|
+
preview: (at: string, value: unknown[]) => {
|
|
47
|
+
if (at !== path && !at.startsWith(`${path}/`)) return false
|
|
48
|
+
const relative = at === path ? '' : at.slice(path.length + 1)
|
|
49
|
+
if (relative.split('/').some(segment => unsafe.has(segment))) return false
|
|
50
|
+
setShown(previous => {
|
|
51
|
+
const overrides = new Map(previous.overrides)
|
|
52
|
+
// A complete parent snapshot supersedes earlier child snapshots.
|
|
53
|
+
for (const key of overrides.keys()) if (!relative || key === relative || key.startsWith(`${relative}/`)) overrides.delete(key)
|
|
54
|
+
overrides.set(relative, value)
|
|
55
|
+
return { ...previous, overrides, value: replaceAt(previous.value, relative ? relative.split('/') : [], value) as T }
|
|
56
|
+
})
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
registry.draftContents.add(handler)
|
|
61
|
+
return () => { registry.draftContents.delete(handler) }
|
|
62
|
+
}, [path])
|
|
63
|
+
return path ? current : source
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { applyNativeDraft, NativeDraftReferences } from './nativeDraft'
|
|
67
|
+
|
|
68
|
+
/** Additional native reads available to this part of the preview, including off-screen entries. */
|
|
69
|
+
const ReferenceValues = createContext<readonly unknown[]>([])
|
|
70
|
+
export function DraftReferences({ values, children }: { values: readonly unknown[]; children: ReactNode }) {
|
|
71
|
+
const inherited = useContext(ReferenceValues)
|
|
72
|
+
const combined = useMemo(() => [...inherited, ...values], [inherited, values])
|
|
73
|
+
return <ReferenceValues.Provider value={combined}>{children}</ReferenceValues.Provider>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sameSource = (a: unknown, b: unknown): boolean => {
|
|
77
|
+
if (Object.is(a, b)) return true
|
|
78
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false
|
|
79
|
+
const keys = Object.keys(a)
|
|
80
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type NativeDraftPatch = { path: string; value: unknown }
|
|
84
|
+
const applyPatch = <T,>(source: T, patch: NativeDraftPatch): T => applyNativeDraft(source, patch.path, patch.value)
|
|
85
|
+
const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
86
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return false
|
|
87
|
+
seen.add(value)
|
|
88
|
+
if (typeof (value as { $path?: unknown }).$path === 'string') return true
|
|
89
|
+
return Object.values(value).some(child => hasNativePath(child, seen))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** React owns native previews throughout saving; a later refresh settles the server's result.
|
|
93
|
+
* The optional adapter maps a native patch into a site's existing plain presentation props. */
|
|
94
|
+
export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch): T {
|
|
95
|
+
const owner = useContext(DraftContentPath)
|
|
96
|
+
const references = useContext(ReferenceValues)
|
|
97
|
+
const [cache] = useState(() => new NativeDraftReferences())
|
|
98
|
+
const indexed = useRef<{ source: T; references: readonly unknown[] } | null>(null)
|
|
99
|
+
if (!indexed.current || !sameSource(indexed.current.source, source) || indexed.current.references !== references) {
|
|
100
|
+
cache.add(references)
|
|
101
|
+
cache.add(source)
|
|
102
|
+
indexed.current = { source, references }
|
|
103
|
+
}
|
|
104
|
+
const enabled = !!owner || hasNativePath(source)
|
|
105
|
+
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, NativeDraftPatch>() })
|
|
106
|
+
let current = shown.value
|
|
107
|
+
if (!sameSource(shown.source, source) || shown.owner !== owner) {
|
|
108
|
+
const overrides = shown.owner === owner ? shown.overrides : new Map<string, NativeDraftPatch>()
|
|
109
|
+
current = source
|
|
110
|
+
for (const patch of overrides.values()) current = apply(current, patch)
|
|
111
|
+
setShown({ source, value: current, owner, overrides })
|
|
112
|
+
}
|
|
113
|
+
for (const patch of shown.overrides.values()) cache.patch(patch.path, patch.value)
|
|
114
|
+
const latest = useRef({ current, source, references, apply })
|
|
115
|
+
latest.current = { current, source, references, apply }
|
|
116
|
+
useLayoutEffect(() => {
|
|
117
|
+
if (!enabled) return
|
|
118
|
+
const handler = {
|
|
119
|
+
document,
|
|
120
|
+
beforeRefresh: () => {
|
|
121
|
+
cache.beforeRefresh()
|
|
122
|
+
setShown(value => ({ ...value, overrides: new Map() }))
|
|
123
|
+
},
|
|
124
|
+
afterRefresh: () => setShown(previous => {
|
|
125
|
+
cache.settle([latest.current.references, latest.current.source])
|
|
126
|
+
let value = latest.current.source
|
|
127
|
+
for (const patch of previous.overrides.values()) value = latest.current.apply(value, patch)
|
|
128
|
+
latest.current.current = value
|
|
129
|
+
return { ...previous, source: latest.current.source, value }
|
|
130
|
+
}),
|
|
131
|
+
preview: (snapshot: DraftSnapshot) => {
|
|
132
|
+
if (!safeDraftPath(snapshot.path)) return false
|
|
133
|
+
const patch = { path: snapshot.path, value: cache.expand(snapshot) }
|
|
134
|
+
cache.patch(patch.path, patch.value)
|
|
135
|
+
const next = latest.current.apply(latest.current.current, patch)
|
|
136
|
+
if (next === latest.current.current) return false
|
|
137
|
+
latest.current.current = next
|
|
138
|
+
setShown(previous => {
|
|
139
|
+
const overrides = new Map(previous.overrides)
|
|
140
|
+
for (const key of overrides.keys()) if (key === patch.path || key.startsWith(`${patch.path}/`)) overrides.delete(key)
|
|
141
|
+
overrides.set(patch.path, patch)
|
|
142
|
+
return { ...previous, value: next, overrides }
|
|
143
|
+
})
|
|
144
|
+
return true
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
registry.draftValues.add(handler)
|
|
148
|
+
return () => { registry.draftValues.delete(handler) }
|
|
149
|
+
}, [enabled, owner, cache])
|
|
150
|
+
return enabled ? current : source
|
|
151
|
+
}
|
package/src/components/Link.tsx
CHANGED
|
@@ -20,5 +20,5 @@ export function Link({ value, children, ...rest }: { value: LinkRead | null | un
|
|
|
20
20
|
// element's props pass through). The href is always the value's.
|
|
21
21
|
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
22
|
for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
|
|
23
|
-
return isInternal(value.href) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
23
|
+
return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
24
|
}
|
|
@@ -24,7 +24,7 @@ export const group = defineContainer<GroupDecl>({
|
|
|
24
24
|
enter: (decl, value) => {
|
|
25
25
|
if (value === undefined || value === null) return []
|
|
26
26
|
if (!record(value)) return [{ path: [], code: 'invalid_type', message: 'Expected an object.' }]
|
|
27
|
-
const known = new Set(['_id', '_block', ...Object.keys(decl.fields), ...Object.values(decl.fields).flatMap((f) => (isLeaf(f) && f.renamedFrom ? [f.renamedFrom] : []))])
|
|
27
|
+
const known = new Set(['_id', '_block', '_hidden', ...Object.keys(decl.fields), ...Object.values(decl.fields).flatMap((f) => (isLeaf(f) && f.renamedFrom ? [f.renamedFrom] : []))])
|
|
28
28
|
const issues: LeafIssue[] = []
|
|
29
29
|
for (const key of Object.keys(value))
|
|
30
30
|
if (!known.has(key)) issues.push({ path: [key], code: 'unknown-field', message: `No field "${key}" is declared here.`, severity: 'warning' })
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { SLOT, defineContainer, type GroupDecl, type LeafIssue, type ZoneDecl } from '../define'
|
|
1
|
+
import { SLOT, defineContainer, type BlockDecl, type GroupDecl, type LeafIssue, type ZoneDecl } from '../define'
|
|
2
2
|
import { ID_RE } from '../path'
|
|
3
3
|
import { record } from './group'
|
|
4
4
|
|
|
5
|
+
/** Null content follows the shared Single; an object is an independent page copy. */
|
|
6
|
+
export function blockFields(block: BlockDecl, value: unknown): GroupDecl {
|
|
7
|
+
const shared = block.shared
|
|
8
|
+
const linked = shared && (!record(value) || value[shared.field] == null)
|
|
9
|
+
return {
|
|
10
|
+
container: 'u90/group',
|
|
11
|
+
fields: linked ? { ...block.fields, [shared.field]: { element: 'u90/single', label: 'Content', options: { key: shared.single } } } : block.fields,
|
|
12
|
+
order: block.order,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
/** Every item becomes a group child — a known block's fields, or no fields for SLOT and unknown blocks — so order is kept and nothing under SLOT is visited. */
|
|
6
17
|
export const zone = defineContainer<ZoneDecl>({
|
|
7
18
|
id: 'u90/zone',
|
|
@@ -11,7 +22,7 @@ export const zone = defineContainer<ZoneDecl>({
|
|
|
11
22
|
const block = record(item) && typeof item._block === 'string' && item._block !== SLOT && Object.hasOwn(schema.blocks, item._block) ? schema.blocks[item._block] : undefined
|
|
12
23
|
return {
|
|
13
24
|
key: record(item) && typeof item._id === 'string' ? item._id : `#${i}`,
|
|
14
|
-
decl: { container: 'u90/group', fields:
|
|
25
|
+
decl: block ? blockFields(block, item) : { container: 'u90/group', fields: {} } as GroupDecl,
|
|
15
26
|
value: item,
|
|
16
27
|
set: (next: unknown) => { value[i] = next },
|
|
17
28
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canonicalJson } from '../site/wire'
|
|
1
2
|
import { z } from 'zod'
|
|
2
3
|
import type { BlockDecl, CollectionDecl, FieldDecl, Issue, LeafDecl, Schema, SingleDecl, ZoneDecl } from './define'
|
|
3
4
|
import { isLeaf } from './define'
|
|
@@ -48,19 +49,87 @@ export const FieldDeclSchema: z.ZodType<FieldDecl> = z.lazy(() =>
|
|
|
48
49
|
]),
|
|
49
50
|
) as z.ZodType<FieldDecl>
|
|
50
51
|
const ZoneDeclSchema = z.strictObject({ container: z.literal('u90/zone'), label: z.string().optional(), allow: z.array(z.string().min(1)).min(1), default: z.array(z.string().min(1)).optional(), fixed: z.boolean().optional() })
|
|
51
|
-
export const BlockDeclSchema = z.strictObject({ name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), entry: z.string().optional(), order: z.array(z.string()).optional() })
|
|
52
|
+
export const BlockDeclSchema = z.strictObject({ name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), entry: z.string().optional(), order: z.array(z.string()).optional(), shared: z.strictObject({ single: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: z.string().regex(NAME), previewFields: z.array(z.string().regex(NAME)).min(1).max(30).refine(keys => new Set(keys).size === keys.length).optional() }).optional() })
|
|
52
53
|
.superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
|
|
54
|
+
const AuthoringAnchorLabel = z.string().trim().min(1).max(80)
|
|
55
|
+
const AuthoringAnchorSchema = z.union([
|
|
56
|
+
z.strictObject({ label: AuthoringAnchorLabel, fields: z.array(z.string().regex(NAME)).min(1) }),
|
|
57
|
+
z.strictObject({ label: AuthoringAnchorLabel, single: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/) }),
|
|
58
|
+
])
|
|
53
59
|
export const CollectionDeclSchema = z.strictObject({
|
|
54
60
|
name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), template: ZoneDeclSchema.optional(),
|
|
55
61
|
defaultSort: z.union([z.strictObject({ field: z.string(), dir: z.enum(['asc', 'desc']) }), z.literal('manual')]).optional(),
|
|
56
62
|
order: z.array(z.string()).optional(),
|
|
57
|
-
|
|
63
|
+
authoring: z.strictObject({
|
|
64
|
+
create: z.strictObject({ fields: z.array(z.string().regex(NAME)).min(1).max(8) }).optional(),
|
|
65
|
+
sections: z.strictObject({
|
|
66
|
+
block: z.string().regex(NAME), field: z.string().regex(NAME),
|
|
67
|
+
before: z.array(AuthoringAnchorSchema).optional(), after: z.array(AuthoringAnchorSchema).optional(),
|
|
68
|
+
}).optional(),
|
|
69
|
+
}).optional(),
|
|
70
|
+
}).superRefine((obj, ctx) => {
|
|
71
|
+
checkOrder(obj.fields, obj.order, ctx)
|
|
72
|
+
const names = obj.authoring?.create?.fields
|
|
73
|
+
if (!names) return
|
|
74
|
+
const title = [...(obj.order ?? []), ...Object.keys(obj.fields)].find((name) => {
|
|
75
|
+
const field = obj.fields[name]
|
|
76
|
+
return field && isLeaf(field) && field.element === 'u90/text'
|
|
77
|
+
})
|
|
78
|
+
if (title && names[0] !== title) ctx.addIssue({ code: 'custom', path: ['authoring', 'create', 'fields'], message: 'The creation form must start with the collection title field.' })
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
names.forEach((name, index) => {
|
|
81
|
+
const field = Object.hasOwn(obj.fields, name) ? obj.fields[name] : undefined
|
|
82
|
+
if (seen.has(name) || !field || !isLeaf(field) || field.element !== 'u90/text' || field.lock?.length)
|
|
83
|
+
ctx.addIssue({ code: 'custom', path: ['authoring', 'create', 'fields', index], message: 'Creation fields must be unique, declared, editable text fields.' })
|
|
84
|
+
seen.add(name)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
58
87
|
export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema })
|
|
59
88
|
export const SchemaSchema = z.strictObject({ blocks: z.record(z.string(), BlockDeclSchema), collections: z.record(z.string(), CollectionDeclSchema), singles: z.record(z.string(), SingleDeclSchema) })
|
|
60
89
|
|
|
61
90
|
type Where = 'block' | 'collection' | 'single'
|
|
62
91
|
const issue = (path: string[], code: string, message: string): Issue => ({ path: path.join('.'), code, message, severity: 'error' })
|
|
63
92
|
|
|
93
|
+
/** A section wrapper may be hidden only when its other content remains reachable or fixed. */
|
|
94
|
+
const collectionSectionsIssues = (schema: Schema, collection: CollectionDecl, name: string): Issue[] => {
|
|
95
|
+
const sections = collection.authoring?.sections
|
|
96
|
+
if (!sections) return []
|
|
97
|
+
const at = ['collections', name, 'authoring', 'sections']
|
|
98
|
+
const issues: Issue[] = []
|
|
99
|
+
const template = collection.template
|
|
100
|
+
if (!template?.fixed || template.allow.length !== 1 || template.allow[0] !== sections.block || template.default?.length !== 1 || template.default[0] !== sections.block)
|
|
101
|
+
issues.push(issue(at, 'authoring-sections-template', 'Page sections require the named block to be the sole allowed and default block of a fixed collection template.'))
|
|
102
|
+
const wrapper = Object.hasOwn(schema.blocks, sections.block) ? schema.blocks[sections.block] : undefined
|
|
103
|
+
if (!wrapper || wrapper.entry !== name)
|
|
104
|
+
issues.push(issue([...at, 'block'], 'authoring-sections-block', 'The section wrapper must be a declared block whose entry is this collection.'))
|
|
105
|
+
if (wrapper) {
|
|
106
|
+
const zone = Object.hasOwn(wrapper.fields, sections.field) ? wrapper.fields[sections.field] : undefined
|
|
107
|
+
if (!zone || isLeaf(zone) || zone.container !== 'u90/zone')
|
|
108
|
+
issues.push(issue([...at, 'field'], 'authoring-sections-field', 'Page sections must name a direct zone field on the wrapper block.'))
|
|
109
|
+
if (Object.entries(wrapper.fields).some(([key, field]) => key !== sections.field && (!isLeaf(field) || !field.lock?.includes('$'))))
|
|
110
|
+
issues.push(issue([...at, 'block'], 'authoring-sections-block', 'The section wrapper may have no other editable fields; additional leaves must lock their whole value with "$".'))
|
|
111
|
+
}
|
|
112
|
+
const seenFields = new Set<string>()
|
|
113
|
+
const seenSingles = new Set<string>()
|
|
114
|
+
for (const position of ['before', 'after'] as const) {
|
|
115
|
+
for (const [index, anchor] of (sections[position] ?? []).entries()) {
|
|
116
|
+
const anchorAt = [...at, position, String(index)]
|
|
117
|
+
if ('fields' in anchor) {
|
|
118
|
+
anchor.fields.forEach((field, fieldIndex) => {
|
|
119
|
+
if (!Object.hasOwn(collection.fields, field) || seenFields.has(field))
|
|
120
|
+
issues.push(issue([...anchorAt, 'fields', String(fieldIndex)], 'authoring-anchor-field', 'Pinned fields must be declared collection fields and appear only once across section anchors.'))
|
|
121
|
+
seenFields.add(field)
|
|
122
|
+
})
|
|
123
|
+
} else {
|
|
124
|
+
if (!Object.hasOwn(schema.singles, anchor.single) || seenSingles.has(anchor.single))
|
|
125
|
+
issues.push(issue([...anchorAt, 'single'], 'authoring-anchor-single', 'Pinned singles must be declared singles and appear only once across section anchors.'))
|
|
126
|
+
seenSingles.add(anchor.single)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return issues
|
|
131
|
+
}
|
|
132
|
+
|
|
64
133
|
/** Parses, then checks everything the zod shape cannot: placement, cross-references, options, defaults, and fills query targetFields. */
|
|
65
134
|
export function validateSchema(input: unknown): { ok: true; schema: Schema } | { ok: false; issues: Issue[] } {
|
|
66
135
|
const parsed = SchemaSchema.safeParse(input)
|
|
@@ -105,13 +174,31 @@ export function validateSchema(input: unknown): { ok: true; schema: Schema } | {
|
|
|
105
174
|
}
|
|
106
175
|
zone(d, at)
|
|
107
176
|
}
|
|
177
|
+
// Additional fields may retain code-owned design settings; the shared editor only
|
|
178
|
+
// presents the Single's content, so editable extras would be unreachable.
|
|
179
|
+
const lockedDesign = (decl: FieldDecl): boolean => isLeaf(decl)
|
|
180
|
+
? !!decl.lock?.includes('$')
|
|
181
|
+
: decl.container === 'u90/group' && Object.values(decl.fields).every(lockedDesign)
|
|
108
182
|
for (const [n, b] of Object.entries(schema.blocks)) {
|
|
183
|
+
if (b.shared) {
|
|
184
|
+
const source = schema.singles[b.shared.single]?.field
|
|
185
|
+
const local = b.fields[b.shared.field]
|
|
186
|
+
if (!source || Object.entries(b.fields).some(([key, value]) => key !== b.shared!.field && !lockedDesign(value)) || !local || isLeaf(local) || local.container !== 'u90/group' || canonicalJson(source) !== canonicalJson(local))
|
|
187
|
+
issues.push(issue(['blocks', n, 'shared'], 'invalid-shared-block', 'A shared block must have a content group matching its declared Single; additional fields must be locked design settings.'))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (b.shared?.previewFields && b.shared.previewFields.some(key => {
|
|
191
|
+
const content = b.fields[b.shared!.field]
|
|
192
|
+
return !content || isLeaf(content) || content.container !== 'u90/group' || !Object.hasOwn(content.fields, key)
|
|
193
|
+
})) issues.push(issue(['blocks', n, 'shared', 'previewFields'], 'unknown-field', 'Shared preview fields must exist in the content group.'))
|
|
194
|
+
|
|
109
195
|
if (b.entry && !schema.collections[b.entry]) issues.push(issue(['blocks', n, 'entry'], 'unknown-collection', `No collection named "${b.entry}".`))
|
|
110
196
|
Object.entries(b.fields).forEach(([f, d]) => field(d, ['blocks', n, 'fields', f], 'block'))
|
|
111
197
|
}
|
|
112
198
|
for (const [n, c] of Object.entries(schema.collections)) {
|
|
113
199
|
Object.entries(c.fields).forEach(([f, d]) => field(d, ['collections', n, 'fields', f], 'collection'))
|
|
114
200
|
if (c.template) zone(c.template, ['collections', n, 'template'])
|
|
201
|
+
issues.push(...collectionSectionsIssues(schema, c, n))
|
|
115
202
|
}
|
|
116
203
|
for (const [n, s] of Object.entries(schema.singles)) field(s.field, ['singles', n, 'field'], 'single')
|
|
117
204
|
return issues.length ? { ok: false, issues } : { ok: true, schema }
|