@unitninety/site-kit 1.6.2 → 1.6.3

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 (41) hide show
  1. package/README.md +26 -1
  2. package/package.json +1 -1
  3. package/src/bridge/DraftBlocks.tsx +14 -7
  4. package/src/bridge/U90Bridge.tsx +7 -4
  5. package/src/bridge/client.tsx +3 -2
  6. package/src/bridge/createBridge.ts +47 -12
  7. package/src/bridge/hooks.tsx +9 -8
  8. package/src/bridge/index.ts +6 -0
  9. package/src/bridge/nativeDraft.ts +177 -0
  10. package/src/bridge/protocol.ts +45 -7
  11. package/src/bridge/registry.ts +6 -1
  12. package/src/bridge/selection.ts +134 -0
  13. package/src/bridge/styles.ts +10 -1
  14. package/src/bridge/useDraftContent.tsx +151 -0
  15. package/src/elements/containers/group.ts +1 -1
  16. package/src/elements/containers/zone.ts +13 -2
  17. package/src/elements/declaration.ts +89 -2
  18. package/src/elements/define.ts +22 -3
  19. package/src/elements/jsonSchema.ts +4 -3
  20. package/src/elements/leaves/query.ts +6 -1
  21. package/src/elements/leaves/relation.ts +3 -0
  22. package/src/elements/leaves/selection.ts +5 -4
  23. package/src/elements/ops/expand.ts +5 -4
  24. package/src/elements/ops/seed.ts +25 -6
  25. package/src/elements/ops/validate.ts +31 -2
  26. package/src/legacy/cli.mjs +2 -2
  27. package/src/legacy/content.ts +1 -1
  28. package/src/seo/robots.ts +8 -8
  29. package/src/seo/seeds.ts +61 -0
  30. package/src/server/render.tsx +3 -3
  31. package/src/site/assemble.ts +1 -1
  32. package/src/site/defineSite.ts +2 -2
  33. package/src/site/documents.ts +4 -4
  34. package/src/site/fields.ts +5 -5
  35. package/src/site/formView.ts +175 -0
  36. package/src/site/index.ts +3 -0
  37. package/src/site/replies.ts +5 -4
  38. package/src/site/types.ts +3 -1
  39. package/src/site/version.ts +1 -1
  40. package/src/site/wire.ts +1 -1
  41. package/src/testing/fake.ts +4 -4
@@ -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()`. No content push, no client-side merge, no `fields` message.
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
- * Deliberately presentational. It carries no fields, no sections and no content tree, which is what
50
- * keeps it from growing back into the client-side merge v4 removed: there is nothing here to merge.
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 PreviewInstruction =
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
@@ -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
- blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; move(path: string, before: string | null): boolean }>
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
+ }
@@ -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
+ }
@@ -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: block?.fields ?? {}, order: block?.order } as GroupDecl,
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
- }).superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
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 }