@unitninety/site-kit 1.6.6 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,13 +5,42 @@ The library a UnitNinety site is built on. It reads the site's content from Unit
5
5
  You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
6
6
 
7
7
  ```bash
8
- npx --yes @unitninety/u90@1.0.6 --version
8
+ npx --yes @unitninety/u90@1.2.0 --version
9
9
  ```
10
10
 
11
11
  UnitNinety imports packages made for that version only, so keep the version the tool names.
12
12
 
13
13
  Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
14
14
 
15
+ ### Keep cleared fields editable
16
+
17
+ Site Kit 1.7.0 preserves real draft addresses on groups and block props. Nullable images still read as `null`; pass their owner address separately so Canvas can select them after a clear or reload:
18
+
19
+ ```tsx
20
+ import { Image, Text, draftFieldPath } from '@unitninety/site-kit/components'
21
+
22
+ export function Hero(props) {
23
+ return <section>
24
+ <Text as="h1" value={props.title} />
25
+ <div style={{ position: 'relative', aspectRatio: '4 / 3' }}>
26
+ <Image value={props.image} draftPath={draftFieldPath(props, 'image')} fill />
27
+ </div>
28
+ </section>
29
+ }
30
+ ```
31
+
32
+ For a nested group or list row, pass that group or row to `draftFieldPath`. For a collection image, pass `entry.fields`. Keep the complete native object, including reserved `$` metadata, when handing it to another component. Iterate declared fields rather than every group property. A null leaf has no identity of its own: prefer `list(group({ image: image() }))` for editable image lists so each row retains its stable address, including when its image is empty.
33
+
34
+ For an existing `list(image())`, use `draftListItemPath(props, 'photos', index)` as the image's `draftPath`. This reads the original stable item ID from the owner's draft metadata, including after clearing and reloading; the index only identifies the current rendered item, not its saved address. Keep the owner metadata with the list. A detached array of null values alone cannot identify its fields. Shared Single aliases likewise retain their canonical addresses separately from null image values.
35
+
36
+ The standard image renderer keeps a bounded missing-image target only when it has a real draft address. Published empty images render nothing. Custom image components must preserve the same target, respect literal clearing, and never reintroduce a seed image as fallback for an explicitly empty field. The bridge also allows blank declared text, rich-text and link-label fields to resume direct editing. Formatted dates and derived values can remain inspector-only.
37
+
38
+ Use the real root `U90Bridge`; inline editing is enabled by default. Deliberate inspector-only integrations may set `inlineEditing={false}` and verify with `u90 check --canvas-mode inspector`. Ordinary checks require inline mode. The bridge exposes only its effective mode booleans in a draft-only diagnostic marker; no session or credentials are included.
39
+
40
+ The local checker verifies the actual root mode, exact field bindings, and observed image replacement → literal clear → restore against isolated content. New packages must record these checks. This does not prove client hydration or CSS hit targets: in a browser, click and type into real headings, clear and replace images, and repeat on mobile and inactive tabs/carousels. Decorative overlays must not intercept editable images; enable pointer events only on their actual interactive content. Preserve test content and report untested states explicitly.
41
+
42
+ For a custom integration's browser diagnostics, `inspectCanvas(document)` from `@unitninety/site-kit/bridge` reads the existing frame without starting another bridge or editing content. It reports the root mode, mounted bridge stylesheet, sampled click targets and blockers. Hidden, offscreen and zero-size fields are reported separately; a reachable sample does not prove typing, saving, or another viewport. Run it inside the site frame, not the outer dashboard.
43
+
15
44
  ### Fixed field values
16
45
 
17
46
  Use `lock: ['$']` on a leaf such as `number({ lock: ['$'] })` to retain a site-owned value without offering a content editor for it. Updates cannot change, clear, or remove that field, including when its previous value was absent. The stored value remains available to site reads and exports. Existing locks such as `lock: ['to']` on a link continue to protect only that subpath.
@@ -55,9 +84,43 @@ Call the hook on native reads before converting them for a presentation componen
55
84
 
56
85
  Canvas prepares locally resolvable list defaults with a stable random item ID, previews the same list in both inspector and site, then saves through the normal validated queue. Nested lists, empty lists and duplicate responsive renderings work without DOM cloning. Later edits survive older refresh responses; rejected or ambiguous structural saves reconcile with an authoritative read. Draft IDs cannot collide with an existing item. Media/reference defaults requiring resolution still use the server path, and references continue using their existing entry previews. This opt-in requires the updated Site Kit; older bridges retain their ordinary DOM previews and refresh behavior.
57
86
 
87
+ ### Immediate ordinary-list reordering
88
+
89
+ Use `List` for repeated content such as service lines, navigation links and FAQ rows. It
90
+ keeps existing rows and their media mounted while their order changes, before saving finishes:
91
+
92
+ ```tsx
93
+ import { List, Text, draftFieldPath } from '@unitninety/site-kit/components'
94
+
95
+ export function Services(props) {
96
+ return <List as="ul" value={props.lines} draftPath={draftFieldPath(props, 'lines')}>
97
+ {line => <li><Text value={line.name} as="h3" /></li>}
98
+ </List>
99
+ }
100
+ ```
101
+
102
+ The callback runs in the component that renders `List`; only rendered slots cross the client
103
+ boundary. Server-only imports and async card children can stay on the server. `List` replaces the existing container: set `as` to its original tag and keep its attributes.
104
+ It adds no extra DOM wrapper, so list, table and grid markup retain their structure. Published
105
+ lists remain ordinary server markup. Group rows retain their native `_id` and `$path`; nullable
106
+ leaf lists can pass `itemPaths={props.$listPaths?.photos}` when their paths live on the owner.
107
+ Never use a row's label or array index as its draft identity.
108
+
109
+ `List` immediately reorders existing items. New items and content replacements still arrive
110
+ from the authoritative server render. For immediate additions and full field previews, use a
111
+ client component with `useDraftPreview(props)` before reading its fields. Use `Entries` for
112
+ collection placements. A plain server-side `.map()` has no live list owner and waits for the
113
+ save/refresh even with the latest Site Kit installed. If row content depends on the current
114
+ array index (such as a numbered label), use `useDraftPreview` so it recomputes immediately.
115
+
116
+ Verify with the response held back: move a row, confirm the visible order immediately, move
117
+ another row during the pending save, then release the response and confirm the saved order.
118
+ Retain image nodes and dimensions throughout. Failed saves must restore the saved order.
119
+ Server HTML checks alone cannot establish hydration or immediate browser updates.
120
+
58
121
  ### Stable block reorder previews
59
122
 
60
- Site Kit 1.6.6 keeps existing block content mounted while Canvas changes its order, including hidden destinations and repeated responsive views. The default `useDraftPreview(source)` preserves native field objects and pending edits instead of expanding a whole zone snapshot again.
123
+ Site Kit keeps existing block content mounted while Canvas changes its order, including hidden destinations and repeated responsive views. Since 1.8.0, the same order-only path covers ordinary and nested lists, preserving resolved images and presentation values. The default `useDraftPreview(source)` preserves native field objects and pending edits instead of expanding a whole structural snapshot again.
61
124
 
62
125
  Custom presentation adapters retain their existing snapshot behavior. If every rendered block collection keeps its native `$path` values, opt into the shared order adapter:
63
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "1.6.6",
3
+ "version": "1.8.0",
4
4
  "description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { createElement, Fragment, useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from 'react'
4
+ import { useDraftPreview } from './useDraftContent'
5
+ import { applyNativeOrder, type DraftOrder } from './nativeOrder'
6
+ import { PreviewCommitBoundary } from './PreviewCommitBoundary'
7
+ import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
8
+ import { registry } from './registry'
9
+
10
+ type Item = { id: string; $path: string; content: ReactNode }
11
+ type Slots = { $path: string; $fieldPaths: { items: string }; items: Item[] }
12
+ export type ListTag = keyof HTMLElementTagNameMap & keyof JSX.IntrinsicElements
13
+ // Server-rendered slots have no client renderer for new values. Only a pure order
14
+ // changes locally; membership and content continue to arrive from the saved render.
15
+ const keepSlots = <T,>(source: T): T => source
16
+
17
+ export function DraftList({ as, attributes, path, items }: { as: ListTag; attributes: Record<string, unknown>; path: string; items: Item[] }) {
18
+ const host = useRef<HTMLElement | null>(null)
19
+ const reload = useRef(false)
20
+ const attach = useCallback((element: HTMLElement | null) => {
21
+ host.current = element
22
+ const cleanup = preserveBlockMoves(element)
23
+ return () => { cleanup?.(); if (host.current === element) host.current = null }
24
+ }, [])
25
+ const reorder = useCallback((source: Slots, order: DraftOrder): Slots => {
26
+ const result = applyNativeOrder(source, order)
27
+ if (!result.matched) return source
28
+ if (result.value !== source && host.current && needsEmbedReload(host.current)) {
29
+ reload.current = true
30
+ // Acknowledge this order without moving a live iframe. The bridge reloads
31
+ // the saved page instead of allowing its legacy DOM fallback to move it.
32
+ return { ...source }
33
+ }
34
+ // Custom adapters signal ownership by returning a new value, even if the
35
+ // requested order is already visible (for example a duplicate snapshot).
36
+ return result.value === source ? { ...source } : result.value
37
+ }, [])
38
+ const source = useMemo(() => ({ $path: path, $fieldPaths: { items: path }, items }), [path, items])
39
+ const live = useDraftPreview(source, keepSlots, reorder)
40
+ useLayoutEffect(() => {
41
+ const owner = { document: host.current?.ownerDocument ?? document, beforeRefresh() {}, needsReload: () => reload.current, move: () => false }
42
+ registry.blockOrders.add(owner)
43
+ return () => { registry.blockOrders.delete(owner) }
44
+ }, [])
45
+ return <PreviewCommitBoundary source={items}>{createElement(as, { ...attributes, ref: attach }, live.items.map(item => <Fragment key={item.id}>{item.content}</Fragment>))}</PreviewCommitBoundary>
46
+ }
@@ -22,8 +22,10 @@ import { EditingProvider } from './hooks'
22
22
  type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; inlineEditing?: boolean; testPreview?: () => Promise<TestPreviewContext | null> }
23
23
  async function Gate({ platformOrigin, children, draft, testPreview, inlineEditing }: BridgeProps) {
24
24
  const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
25
- const props = await resolveBridgePreview({ platformOrigin, draft, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
26
- return <EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider>
25
+ const state = await draft()
26
+ const props = await resolveBridgePreview({ platformOrigin, draft: async () => state, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
27
+ const diagnostic = state.enabled || props.testPreview ? { version: 1, editing: props.editing, inlineEditing: inlineEditing !== false, testPreview: props.testPreview } : null
28
+ return <>{diagnostic ? <span hidden data-u90-bridge={JSON.stringify(diagnostic)} /> : null}<EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider></>
27
29
  }
28
30
 
29
31
  /**
@@ -89,12 +89,13 @@ export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
89
89
  */
90
90
  export const isEditableLeaf = (element: HTMLElement): boolean => {
91
91
  if (element.matches(MEDIA)) return false
92
- if (element.getAttribute('data-u90-edit') === 'rich') return Boolean(element.textContent?.trim())
92
+ const kind = element.getAttribute('data-u90-edit')
93
+ if (kind === 'rich') return true
93
94
 
94
95
  return (
95
96
  !element.querySelector(`[${FIELD_ATTR}]`) &&
96
97
  !element.querySelector(NON_TEXT) &&
97
- Boolean(element.textContent?.trim())
98
+ (Boolean(element.textContent?.trim()) || kind === 'text' || kind === 'link-label')
98
99
  )
99
100
  }
100
101
 
@@ -110,10 +111,8 @@ export const isEditableLeaf = (element: HTMLElement): boolean => {
110
111
  * `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
111
112
  * a picture, rich or not, and the caret has no business there either.
112
113
  *
113
- * The one clause CSS still cannot express is "and it has some text in it" — an empty marked
114
- * element therefore offers an I-beam and does nothing when clicked, which is the least
115
- * interesting way for the two to fall out of step and beats making every site component
116
- * carry a class.
114
+ * Explicit native edit kinds remain editable when blank. The bridge marks those empty
115
+ * surfaces so draft CSS can give them a clickable box without adding saved placeholder text.
117
116
  *
118
117
  * v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
119
118
  * becomes the value selector `[data-u90-edit="rich"]`.
@@ -235,8 +234,8 @@ const endOf = (element: HTMLElement): Range => {
235
234
  const last = lastWordsIn(element)
236
235
 
237
236
  if (!last) {
238
- // No text at all. `isEditableLeaf` rules this out for the click path; a direct caller
239
- // gets the old behaviour, which is the only thing available.
237
+ // An empty native field has no text node yet. Place the caret in its element;
238
+ // the browser creates the first text node when the person types.
240
239
  range.selectNodeContents(element)
241
240
  range.collapse(false)
242
241
  return range
@@ -99,6 +99,15 @@ export function createBridge(win: Window, opts: BridgeOptions) {
99
99
 
100
100
  let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
101
101
  let timer: ReturnType<typeof setTimeout> | null = null
102
+ let emptyFields: MutationObserver | null = null
103
+ const markEmptyField = (el: HTMLElement) => {
104
+ const kind = el.getAttribute('data-u90-edit')
105
+ const empty = (kind === 'text' || kind === 'rich' || kind === 'link-label') && !el.textContent?.trim() && isEditableLeaf(el)
106
+ el.toggleAttribute('data-u90-empty', empty)
107
+ }
108
+ const markEmptyFields = () => {
109
+ for (const el of doc.querySelectorAll<HTMLElement>('[data-u90-field][data-u90-edit], [data-u90-empty]')) markEmptyField(el)
110
+ }
102
111
  const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
103
112
  const flush = () => {
104
113
  if (timer) clearTimeout(timer)
@@ -404,6 +413,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
404
413
  if (opts.inlineEditing === false) return
405
414
  const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
406
415
  if (el) {
416
+ markEmptyField(el)
407
417
  schedule(el)
408
418
  // The Canvas owns save debouncing. Its inspector needs each input immediately, including
409
419
  // during continuous typing and just before focus crosses into the parent document.
@@ -963,6 +973,12 @@ export function createBridge(win: Window, opts: BridgeOptions) {
963
973
 
964
974
  doc.head.appendChild(style)
965
975
  if (opts.inlineEditing === false) doc.documentElement.setAttribute('data-u90-inspector-only', '')
976
+ markEmptyFields()
977
+ const Observer = (win as Window & typeof globalThis).MutationObserver
978
+ if (Observer) {
979
+ emptyFields = new Observer(markEmptyFields)
980
+ emptyFields.observe(doc.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['data-u90-field', 'data-u90-edit'] })
981
+ }
966
982
  selection.start()
967
983
  applyDraftLanguage()
968
984
  doc.addEventListener('click', onClick, true)
@@ -997,6 +1013,9 @@ export function createBridge(win: Window, opts: BridgeOptions) {
997
1013
  stopped = true
998
1014
  endEditing() // what was typed goes out, and no element is left contenteditable
999
1015
  flush()
1016
+ emptyFields?.disconnect()
1017
+ emptyFields = null
1018
+ doc.querySelectorAll('[data-u90-empty]').forEach(el => el.removeAttribute('data-u90-empty'))
1000
1019
  releaseViewport()
1001
1020
  style.remove()
1002
1021
  if (opts.inlineEditing === false) doc.documentElement.removeAttribute('data-u90-inspector-only')
@@ -0,0 +1,61 @@
1
+ type Runtime = { version: 1; editing: boolean; inlineEditing: boolean; testPreview: boolean }
2
+ export type CanvasFieldDiagnostic = {
3
+ path: string
4
+ status: 'reachable' | 'occluded' | 'hidden' | 'no-box' | 'offscreen' | 'untested'
5
+ samples: number
6
+ hits: number
7
+ blockers: string[]
8
+ }
9
+ export type CanvasDiagnostic = {
10
+ runtime: Runtime | null
11
+ bridgeMounted: boolean
12
+ viewport: { width: number; height: number }
13
+ fields: CanvasFieldDiagnostic[]
14
+ }
15
+
16
+ /** Read-only snapshot of the existing frame. It neither starts a bridge nor changes selection.
17
+ * Reachability samples only the current viewport/state, not typing, saving or hidden slides. */
18
+ export function inspectCanvas(doc: Document = document): CanvasDiagnostic {
19
+ const win = doc.defaultView
20
+ const viewport = { width: win?.innerWidth ?? 0, height: win?.innerHeight ?? 0 }
21
+ let runtime: Runtime | null = null
22
+ const markers = doc.querySelectorAll('[data-u90-bridge]')
23
+ if (markers.length === 1) try {
24
+ const value = JSON.parse(markers[0].getAttribute('data-u90-bridge') ?? '')
25
+ if (value.version === 1 && ['editing', 'inlineEditing', 'testPreview'].every(key => typeof value[key] === 'boolean'))
26
+ runtime = { version: 1, editing: value.editing, inlineEditing: value.inlineEditing, testPreview: value.testPreview }
27
+ } catch { /* An absent or invalid root is not a verified runtime. */ }
28
+ const fields = Array.from(doc.querySelectorAll<HTMLElement>('[data-u90-field]'), el => {
29
+ const path = el.getAttribute('data-u90-field') ?? ''
30
+ const result: CanvasFieldDiagnostic = { path, status: 'untested', samples: 0, hits: 0, blockers: [] }
31
+ // Visibility is inherited but can be overridden by a visible descendant.
32
+ const visibility = win?.getComputedStyle(el).visibility
33
+ if (visibility === 'hidden' || visibility === 'collapse') return { ...result, status: 'hidden' as const }
34
+ for (let node: HTMLElement | null = el; node; node = node.parentElement) {
35
+ const style = win?.getComputedStyle(node)
36
+ if (node.hidden || style?.display === 'none' || style?.opacity === '0') return { ...result, status: 'hidden' as const }
37
+ }
38
+ let rects = Array.from(el.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0)
39
+ if (!rects.length && win?.getComputedStyle(el).display === 'contents') {
40
+ rects = Array.from(el.querySelectorAll('*')).filter(child => child.closest('[data-u90-field]') === el)
41
+ .flatMap(child => Array.from(child.getClientRects())).filter(rect => rect.width > 0 && rect.height > 0)
42
+ }
43
+ if (!rects.length) return { ...result, status: 'no-box' as const }
44
+ const visible = rects.map(rect => ({ left: Math.max(0, rect.left), right: Math.min(viewport.width, rect.right), top: Math.max(0, rect.top), bottom: Math.min(viewport.height, rect.bottom) })).filter(rect => rect.right > rect.left && rect.bottom > rect.top)
45
+ if (!visible.length) return { ...result, status: 'offscreen' as const }
46
+ if (typeof doc.elementFromPoint !== 'function') return result
47
+ const blockers = new Set<string>()
48
+ for (const rect of visible.slice(0, 3)) for (const x of [.2, .5, .8]) for (const y of [.2, .5, .8]) {
49
+ result.samples++
50
+ const hit = doc.elementFromPoint(rect.left + (rect.right - rect.left) * x, rect.top + (rect.bottom - rect.top) * y)
51
+ const selected = hit?.closest('[data-u90-field], [data-u90-block]')
52
+ if (selected?.getAttribute('data-u90-field') === path) result.hits++
53
+ else if (hit) blockers.add(`${hit.tagName.toLowerCase()}${Array.from(hit.classList).slice(0, 3).map(name => `.${name}`).join('')}`)
54
+ else blockers.add('(no element)')
55
+ }
56
+ result.blockers = [...blockers].slice(0, 8)
57
+ result.status = result.hits ? 'reachable' : 'occluded'
58
+ return result
59
+ })
60
+ return { runtime, bridgeMounted: !!doc.querySelector('head style[data-u90-editing]'), viewport, fields }
61
+ }
@@ -14,3 +14,5 @@ export { DraftBlocks } from './DraftBlocks'
14
14
  /** React-owned previews for client components reading list content inside a draft block. */
15
15
  export { useDraftContent, useDraftPreview, DraftReferences, applyNativeDraft, type NativeDraftPatch } from './useDraftContent'
16
16
  export { reorderNativeDraft, type NativeDraftOrder } from './nativeOrder'
17
+ /** Read-only diagnostics for the current frame; hidden/offscreen states remain untested. */
18
+ export { inspectCanvas, type CanvasDiagnostic, type CanvasFieldDiagnostic } from './diagnostics'
@@ -1,52 +1,38 @@
1
1
  import { isLeaf, type EntryRead, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../elements/define'
2
2
  import { getContainer } from '../elements/registry'
3
3
  import { expandField } from '../elements/ops/expand'
4
+ import { draftGroupMetadata, rememberDraftList } from '../elements/ops/draftPaths'
4
5
  import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
5
6
  import type { EntriesRead } from '../elements/leaves/selection'
6
7
  import type { DraftSnapshot } from './protocol'
7
8
  import { reorderNativeDraft, type DraftOrder } from './nativeOrder'
9
+ import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
10
+ export { nativePath, safeDraftPath } from './nativePath'
8
11
 
9
12
  const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
10
13
  const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
11
- export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
12
-
13
- /** Native groups do not carry paths; their children do. Infer only when the children agree. */
14
- export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
15
- if (!value || typeof value !== 'object' || seen.has(value)) return
16
- seen.add(value)
17
- if (object(value) && typeof value.$path === 'string') return safeDraftPath(value.$path) ? value.$path : undefined
18
- const candidates = new Set<string>()
19
- for (const [key, child] of Object.entries(value)) {
20
- if (unsafe.has(key) || key.startsWith('$')) continue
21
- const at = nativePath(child, seen)
22
- if (!at) continue
23
- if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
24
- else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
25
- }
26
- return candidates.size === 1 ? [...candidates][0] : undefined
27
- }
28
-
29
14
  /** Immutable path replacement across any mixture of native document, entry and field reads. */
30
- export function applyNativeDraft<T>(source: T, path: string, replacement: unknown): T {
15
+ export function applyNativeDraft<T>(source: T, path: string, replacement: unknown, rootPath?: string): T {
31
16
  if (!safeDraftPath(path)) return source
32
- const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
17
+ const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
33
18
  const at = nativePath(value) ?? parent
34
19
  if (at === path || parent === path) return replacement
35
20
  if (!value || typeof value !== 'object' || seen.has(value)) return value
36
21
  const nextSeen = new Set(seen).add(value)
22
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
37
23
  let result: any = value
38
24
  for (const [key, child] of Object.entries(value)) {
39
25
  if (unsafe.has(key) || key.startsWith('$')) continue
40
- const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
41
- const next = visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined, nextSeen)
26
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
27
+ const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
42
28
  if (next !== child) {
43
- if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
29
+ if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
44
30
  result[key] = next
45
31
  }
46
32
  }
47
33
  return result
48
34
  }
49
- return visit(source) as T
35
+ return visit(source, nativePath(source) ?? (rootPath && safeDraftPath(rootPath) ? rootPath : undefined)) as T
50
36
  }
51
37
 
52
38
  /** A local index of real native reads. Missing references remain missing until the server answers. */
@@ -61,11 +47,14 @@ export class NativeDraftReferences {
61
47
 
62
48
  add(values: unknown): void {
63
49
  const seen = new Set<object>()
64
- const visit = (value: unknown, parent?: string) => {
50
+ const visit = (value: unknown, parent?: string, inheritedListPaths?: readonly string[]) => {
65
51
  if (parent) this.reads.set(parent, value)
52
+ if (parent?.startsWith('single:') && !parent.includes('/')) this.singles.set(parent.slice(7), value)
66
53
  if (!value || typeof value !== 'object' || seen.has(value)) return
67
54
  seen.add(value)
68
55
  const at = nativePath(value) ?? parent
56
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
57
+ if (Array.isArray(value)) rememberDraftList(value, paths)
69
58
  if (at) this.reads.set(at, value)
70
59
  if (object(value)) {
71
60
  if (at?.startsWith('single:') && !at.includes('/')) this.singles.set(at.slice(7), value)
@@ -97,8 +86,8 @@ export class NativeDraftReferences {
97
86
  }
98
87
  for (const [key, child] of Object.entries(value)) {
99
88
  if (unsafe.has(key) || key.startsWith('$')) continue
100
- const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
101
- visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined)
89
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
90
+ visit(child, childPath, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
102
91
  }
103
92
  }
104
93
  visit(values)
@@ -142,9 +131,9 @@ export class NativeDraftReferences {
142
131
  }
143
132
 
144
133
  private applyPatch(path: string, value: unknown): void {
145
- for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value))
146
- for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value))
147
- for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value))
134
+ for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value, key))
135
+ for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value, `entry:${entry.id}`))
136
+ for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value, `single:${key}`))
148
137
  for (const [key, query] of this.queries) this.queries.set(key, applyNativeDraft(query, path, value))
149
138
  }
150
139
 
@@ -180,8 +169,11 @@ export class NativeDraftReferences {
180
169
  return expandField(schema, field, value, ctx(at))
181
170
  }
182
171
  const children = getContainer(field.container).children(field, field.container === 'u90/group' && !object(value) ? {} : value, schema)
183
- if (field.container === 'u90/group') return Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
184
- return children.map((child, index) => {
172
+ if (field.container === 'u90/group') {
173
+ const fields = Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
174
+ return { ...draftGroupMetadata(field.fields, at, fields), ...fields }
175
+ }
176
+ const expanded = children.map((child, index) => {
185
177
  const path = `${at}/${child.key}`
186
178
  const inner = expand(child.decl, child.value, path)
187
179
  if (field.container === 'u90/list') return isLeaf(field.item) ? inner : { _id: child.key, $path: path, ...inner as object }
@@ -189,6 +181,7 @@ export class NativeDraftReferences {
189
181
  const metadata = { _id: child.key, _block: item._block, ...(typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}) }
190
182
  return Object.hasOwn(schema.blocks, String(item._block)) ? { ...metadata, $path: path, ...inner as object } : metadata
191
183
  })
184
+ return field.container === 'u90/list' ? rememberDraftList(expanded, children.map(child => `${at}/${child.key}`)) : expanded
192
185
  }
193
186
  return expand(snapshot.field, snapshot.value, snapshot.path)
194
187
  }
@@ -1,22 +1,36 @@
1
- import { nativePath, safeDraftPath } from './nativeDraft'
1
+ import { rememberDraftList } from '../elements/ops/draftPaths'
2
+ import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
2
3
  import type { DraftSnapshot } from './protocol'
4
+ import type { FieldDecl } from '../elements/define'
3
5
 
4
6
  export type DraftOrder = { path: string; ids: string[] }
5
7
  export type NativeDraftOrder = DraftOrder
6
8
  const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
7
9
 
8
- /** A move's snapshot may start above its zone (for example a zone inside a group).
9
- * Read only the target's IDs; reconstructing its fields would discard site adapters. */
10
+ /** A move's snapshot may start above its list/zone. Read only stable IDs:
11
+ * expanding a reorder would replace already-resolved media and presentation values. */
10
12
  export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
11
13
  const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
12
14
  if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
13
15
  let value: unknown = snapshot.value
16
+ let field: FieldDecl | undefined = snapshot.field
14
17
  for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
15
18
  if (unsafe.has(key)) return
16
- value = Array.isArray(value) ? value.find(item => item?._id === key)
17
- : value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined
19
+ if (Array.isArray(value)) {
20
+ value = value.find(item => item?._id === key)
21
+ if (field && 'container' in field && field.container === 'u90/list') {
22
+ field = field.item
23
+ } else if (field && 'container' in field && field.container === 'u90/zone') {
24
+ const block = value && typeof value === 'object' && '_block' in value ? snapshot.blocks?.[String(value._block)] : undefined
25
+ field = block ? { container: 'u90/group', fields: block.fields } : undefined
26
+ } else field = undefined
27
+ } else {
28
+ value = value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined
29
+ field = field && 'container' in field && field.container === 'u90/group' && Object.hasOwn(field.fields, key) ? field.fields[key] : undefined
30
+ }
18
31
  }
19
- if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && typeof item._block === 'string')) return
32
+ const list = field && 'container' in field && field.container === 'u90/list'
33
+ if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && (list || typeof item._block === 'string'))) return
20
34
  const ids = value.map(item => item._id as string)
21
35
  if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
22
36
  return { path, ids }
@@ -35,25 +49,28 @@ export function ordered<T>(items: T[], ids: string[], id: (item: T) => string |
35
49
  export function applyNativeOrder<T>(source: T, order: DraftOrder): { value: T; matched: boolean } {
36
50
  if (!safeDraftPath(order.path)) return { value: source, matched: false }
37
51
  let matched = false
38
- const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
52
+ const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
39
53
  if (!value || typeof value !== 'object' || seen.has(value) || '$$typeof' in value) return value
40
54
  const at = nativePath(value) ?? parent
55
+ const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
41
56
  if (Array.isArray(value) && at === order.path) {
42
57
  matched = true
43
- return ordered(value, order.ids, item => {
44
- const path = nativePath(item)
58
+ const indexed = value.map((item, index) => ({ item, index }))
59
+ const sorted = ordered(indexed, order.ids, ({ item, index }) => {
60
+ const path = paths?.[index] ?? nativePath(item)
45
61
  return path?.startsWith(`${order.path}/`) ? path.slice(order.path.length + 1)
46
62
  : typeof item?._id === 'string' ? item._id : undefined
47
63
  })
64
+ return sorted === indexed ? value : rememberDraftList(sorted.map(({ item }) => item), paths ? sorted.map(({ index }) => paths[index]!) : undefined)
48
65
  }
49
66
  const nextSeen = new Set(seen).add(value)
50
67
  let result: any = value
51
68
  for (const [key, child] of Object.entries(value)) {
52
69
  if (unsafe.has(key) || key.startsWith('$')) continue
53
- const id = Array.isArray(value) ? child?._id : key
54
- const next = visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined, nextSeen)
70
+ const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
71
+ const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
55
72
  if (next !== child) {
56
- if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
73
+ if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
57
74
  result[key] = next
58
75
  }
59
76
  }
@@ -0,0 +1,45 @@
1
+ import { draftListPaths } from '../elements/ops/draftPaths'
2
+
3
+ const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
4
+ export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
5
+
6
+ /** Older native groups carry only child paths. Infer their parent only when those paths agree. */
7
+ export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
8
+ if (!value || typeof value !== 'object' || seen.has(value)) return
9
+ seen.add(value)
10
+ const own = (value as { $path?: unknown }).$path
11
+ if (typeof own === 'string') return safeDraftPath(own) ? own : undefined
12
+ const candidates = new Set<string>()
13
+ for (const [key, child] of Object.entries(value)) {
14
+ if (unsafe.has(key) || key.startsWith('$')) continue
15
+ const at = nativePath(child, seen)
16
+ if (!at) continue
17
+ if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
18
+ else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
19
+ }
20
+ return candidates.size === 1 ? [...candidates][0] : undefined
21
+ }
22
+
23
+ /** A declared Single remains canonical even while its scalar read is null. */
24
+ export function nativeChildPath(value: object, key: string, at = nativePath(value)): string | undefined {
25
+ const aliases = (value as { $fieldPaths?: unknown }).$fieldPaths
26
+ const alias = aliases && typeof aliases === 'object' && Object.hasOwn(aliases, key) ? (aliases as Record<string, unknown>)[key] : undefined
27
+ return typeof alias === 'string' && safeDraftPath(alias) ? alias : at ? `${at}/${key}` : undefined
28
+ }
29
+ /** Snapshot metadata supersedes older owner metadata after a list replacement. */
30
+ export function nativeListPaths(value: unknown[], owner?: object, key?: string): readonly string[] | undefined {
31
+ const own = draftListPaths(value)
32
+ const lists = (owner as { $listPaths?: unknown } | undefined)?.$listPaths
33
+ const stored = lists && typeof lists === 'object' && key !== undefined && Object.hasOwn(lists, key) ? (lists as Record<string, unknown>)[key] : undefined
34
+ const paths = own ?? stored
35
+ return Array.isArray(paths) && paths.length === value.length && paths.every(path => typeof path === 'string' && safeDraftPath(path)) ? paths : undefined
36
+ }
37
+
38
+ export function nativeListItemPath(value: unknown[], index: number, paths = nativeListPaths(value), at = nativePath(value)): string | undefined {
39
+ const child = value[index]
40
+ const own = nativePath(child)
41
+ if (own) return own
42
+ if (paths?.[index]) return paths[index]
43
+ const id = child && typeof child === 'object' ? (child as { _id?: unknown })._id : undefined
44
+ return typeof id === 'string' && at && safeDraftPath(`${at}/${id}`) ? `${at}/${id}` : undefined
45
+ }
@@ -53,7 +53,7 @@ function restoreSheets(snapshots: SheetSnapshot[]) {
53
53
  * prototype changes. Atomic moves keep iframes/focus alive where supported. Both
54
54
  * kinds of move need the CSSOM safeguard for styles in an embedded shadow root.
55
55
  */
56
- export function preserveBlockMoves(host: HTMLDivElement | null): void | (() => void) {
56
+ export function preserveBlockMoves(host: HTMLElement | null): void | (() => void) {
57
57
  if (!host) return
58
58
  const atomic = host as AtomicHost
59
59
  const insert = host.insertBefore
@@ -46,6 +46,18 @@ export const EDITING_CSS = `
46
46
  outline-offset: 4px;
47
47
  }
48
48
 
49
+ /* An empty native value stays empty; this draft-only box is never saved as copy. */
50
+ [data-u90-field][data-u90-empty] {
51
+ min-width: 1.5em;
52
+ min-height: 1em;
53
+ }
54
+ [data-u90-field][data-u90-empty]::before {
55
+ display: inline-block;
56
+ min-width: 1.5em;
57
+ min-height: 1em;
58
+ content: '\\00a0';
59
+ }
60
+
49
61
  /* a Single inside editable copy: one chip the caret skips, saved back as its {key} (R38) */
50
62
  [data-u90-field] [data-single-key] {
51
63
  border-radius: 4px;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
4
  import { applyNativeDraft, NativeDraftReferences, safeDraftPath } from './nativeDraft'
5
+ import { nativePath } from './nativePath'
5
6
  import type { DraftSnapshot } from './protocol'
6
7
  import { registry } from './registry'
7
8
  import { applyNativeOrder, reorderNativeDraft, type DraftOrder } from './nativeOrder'
@@ -83,6 +84,14 @@ const sameSource = (a: unknown, b: unknown): boolean => {
83
84
 
84
85
  export type NativeDraftPatch = { path: string; value: unknown }
85
86
  type PreviewPatch = { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }
87
+ const supersede = (patches: Map<string, PreviewPatch>, patch: PreviewPatch) => {
88
+ if (patch.kind === 'value') for (const [key, previous] of patches) if (previous.path === patch.path || previous.path.startsWith(`${patch.path}/`)) patches.delete(key)
89
+ patches.delete(`${patch.kind}:${patch.path}`)
90
+ }
91
+ const remember = (patches: Map<string, PreviewPatch>, patch: PreviewPatch) => {
92
+ supersede(patches, patch)
93
+ patches.set(`${patch.kind}:${patch.path}`, patch)
94
+ }
86
95
  const applyPreviewPatch = <T,>(source: T, patch: PreviewPatch, apply: (source: T, patch: NativeDraftPatch) => T, reorder: (source: T, order: DraftOrder) => T = reorderNativeDraft): T =>
87
96
  patch.kind === 'order' ? reorder(source, patch) : apply(source, patch)
88
97
  const applyPatch = <T,>(source: T, patch: NativeDraftPatch): T => applyNativeDraft(source, patch.path, patch.value)
@@ -100,17 +109,20 @@ const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
100
109
  export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch, reorder = apply === applyPatch ? reorderNativeDraft<T> : undefined): T {
101
110
  const owner = useContext(DraftContentPath)
102
111
  const references = useContext(ReferenceValues)
112
+ const native = apply === applyPatch
113
+ const rootPath = nativePath(source) ?? owner
114
+ const applyValue = useMemo(() => native ? (value: T, patch: NativeDraftPatch) => applyNativeDraft(value, patch.path, patch.value, rootPath) : apply, [native, rootPath, apply])
103
115
  const [cache] = useState(() => new NativeDraftReferences())
104
116
  const enabled = !!owner || hasNativePath(source)
105
- const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, PreviewPatch>() })
117
+ const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, PreviewPatch>(), settling: new Map<string, PreviewPatch>() })
106
118
  let current = shown.value
107
119
  if (!sameSource(shown.source, source) || shown.owner !== owner) {
108
120
  const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
109
121
  current = source
110
- for (const patch of overrides.values()) current = applyPreviewPatch(current, patch, apply, reorder)
111
- setShown({ source, value: current, owner, overrides })
122
+ for (const patch of overrides.values()) current = applyPreviewPatch(current, patch, applyValue, reorder)
123
+ setShown({ source, value: current, owner, overrides, settling: new Map() })
112
124
  }
113
- const latest = useRef({ current, source, references, apply, reorder })
125
+ const latest = useRef({ current, source, references, apply: applyValue, reorder, native, overrides: shown.overrides, settling: shown.settling })
114
126
  useLayoutEffect(() => {
115
127
  cache.add(references)
116
128
  cache.add(source)
@@ -118,22 +130,29 @@ export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDr
118
130
  if (patch.kind === 'value') cache.patch(patch.path, patch.value)
119
131
  else cache.order(patch)
120
132
  }
121
- latest.current = { current, source, references, apply, reorder }
122
- }, [cache, current, source, references, apply, reorder, shown.overrides])
133
+ latest.current = { current, source, references, apply: applyValue, reorder, native, overrides: shown.overrides, settling: shown.settling }
134
+ }, [cache, current, source, references, applyValue, reorder, native, shown.overrides, shown.settling])
123
135
  useLayoutEffect(() => {
124
136
  if (!enabled) return
125
137
  const handler = {
126
138
  document,
127
139
  beforeRefresh: () => {
128
140
  cache.beforeRefresh()
129
- setShown(value => ({ ...value, overrides: new Map() }))
141
+ // Keep the optimistic baseline visible while this request is pending. It is
142
+ // discarded on a new source/commit, never replayed over the server's answer.
143
+ const settling = new Map(latest.current.settling)
144
+ for (const patch of latest.current.overrides.values()) remember(settling, patch)
145
+ latest.current.overrides = new Map()
146
+ latest.current.settling = settling
147
+ setShown(value => ({ ...value, overrides: new Map(), settling }))
130
148
  },
131
149
  afterRefresh: () => setShown(previous => {
132
150
  cache.settle([latest.current.references, latest.current.source])
133
151
  let value = latest.current.source
134
152
  for (const patch of previous.overrides.values()) value = applyPreviewPatch(value, patch, latest.current.apply, latest.current.reorder)
135
153
  latest.current.current = value
136
- return { ...previous, source: latest.current.source, value }
154
+ latest.current.settling = new Map()
155
+ return { ...previous, source: latest.current.source, value, settling: new Map() }
137
156
  }),
138
157
  preview: (snapshot: DraftSnapshot, order?: DraftOrder) => {
139
158
  if (!safeDraftPath(snapshot.path)) return false
@@ -141,19 +160,26 @@ export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDr
141
160
  const patch: PreviewPatch = useOrder ? { kind: 'order', ...order } : { kind: 'value', path: snapshot.path, value: cache.expand(snapshot) }
142
161
  if (patch.kind === 'value') cache.patch(patch.path, patch.value)
143
162
  else cache.order(patch)
144
- const result = useOrder === reorderNativeDraft ? applyNativeOrder(latest.current.current, order!) : undefined
145
- const next = result ? result.value : applyPreviewPatch(latest.current.current, patch, latest.current.apply, latest.current.reorder)
146
- if (result ? !result.matched : next === latest.current.current) return false
163
+ const overrides = new Map(latest.current.overrides)
164
+ // A replacement supersedes its earlier clear. Replay native values from the real
165
+ // source so a null leaf never has to invent the image/list identity it just lost.
166
+ supersede(overrides, patch)
167
+ const key = `${patch.kind}:${patch.path}`
168
+ let base = latest.current.current
169
+ if (latest.current.native) {
170
+ const history = new Map(latest.current.settling)
171
+ for (const previous of overrides.values()) remember(history, previous)
172
+ supersede(history, patch)
173
+ base = latest.current.source
174
+ for (const previous of history.values()) base = applyPreviewPatch(base, previous, latest.current.apply, latest.current.reorder)
175
+ }
176
+ const result = useOrder === reorderNativeDraft ? applyNativeOrder(base, order!) : undefined
177
+ const next = result ? result.value : applyPreviewPatch(base, patch, latest.current.apply, latest.current.reorder)
178
+ if (result ? !result.matched : next === base) return false
179
+ overrides.set(key, patch)
147
180
  latest.current.current = next
148
- setShown(previous => {
149
- const overrides = new Map(previous.overrides)
150
- // A move changes only order: pending child edits and earlier inserts still apply.
151
- if (patch.kind === 'value') for (const [key, previous] of overrides) if (previous.path === patch.path || previous.path.startsWith(`${patch.path}/`)) overrides.delete(key)
152
- const key = `${patch.kind}:${patch.path}`
153
- overrides.delete(key)
154
- overrides.set(key, patch)
155
- return { ...previous, value: next, overrides }
156
- })
181
+ latest.current.overrides = overrides
182
+ setShown(previous => ({ ...previous, value: next, overrides }))
157
183
  return true
158
184
  },
159
185
  }
@@ -2,6 +2,7 @@ import NextImage, { getImageProps } from 'next/image'
2
2
  import { createElement } from 'react'
3
3
  import type { ImageRead } from '../elements'
4
4
  import { draftAttrs } from './draft'
5
+ import { safeDraftPath } from '../bridge/nativePath'
5
6
  import { imageProps, isGrantPath, pictureSources, type ImageProps } from './imageProps'
6
7
  /**
7
8
  * One declared variant's `<source>`, built with `next/image`'s own `getImageProps` so its `srcSet`
@@ -17,11 +18,14 @@ export const sourceProps = (props: ImageProps, s: ReturnType<typeof pictureSourc
17
18
  // An unoptimised image has no generated srcSet, so the raw path is the candidate.
18
19
  return { key: s.name, media: s.media, srcSet: p.srcSet ?? p.src, sizes: p.sizes }
19
20
  }
20
- export function Image({ value, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
21
- if (!value) return null
22
- const attrs = draftAttrs(value)
21
+ export function Image({ value, draftPath, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; draftPath?: string; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
22
+ const explicit = draftPath && safeDraftPath(draftPath) ? draftPath : undefined
23
+ const path = value?.$path && safeDraftPath(value.$path) ? value.$path : explicit
24
+ const attrs = { ...draftAttrs(value), ...draftAttrs(path ? { $path: path } : null), ...(explicit ? { 'data-u90-empty-target': 'image' } : {}) }
25
+ const missing = () => path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image', style: { minWidth: 48, minHeight: 48, ...(fill ? { position: 'absolute' as const, inset: 0 } : {}) } }, 'Image missing') : null
26
+ if (!value) return missing()
23
27
  const props = imageProps(value, { sizes, fill, priority })
24
- if (!props) return value.$path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image' }, 'Image missing') : null
28
+ if (!props) return missing()
25
29
  const sources = pictureSources(value, variants)
26
30
  const img = sources.length
27
31
  ? createElement('picture', null, ...sources.map((s) => createElement('source', sourceProps(props, s))), createElement(NextImage, { ...props, className, ...attrs }))
@@ -0,0 +1,26 @@
1
+ import { createElement, Fragment, type ComponentPropsWithoutRef, type ReactNode } from 'react'
2
+ import { DraftList, type ListTag } from '../bridge/DraftList'
3
+ import { nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from '../bridge/nativePath'
4
+
5
+ type Props<T, Tag extends ListTag> = { as: Tag; value: T[]; draftPath?: string; itemPaths?: readonly string[]; children: (item: T, index: number) => ReactNode }
6
+ & Omit<ComponentPropsWithoutRef<Tag>, 'as' | 'value' | 'children' | 'dangerouslySetInnerHTML'>
7
+
8
+ /** Server-safe list rendering with immediate draft ordering. Replace the existing
9
+ * container with List as="ul" (or its original tag), preserving its attributes.
10
+ * The item renderer runs here; only rendered slots cross the client boundary. */
11
+ export function List<T, Tag extends ListTag>({ as, value, draftPath, itemPaths, children, ...attributes }: Props<T, Tag>) {
12
+ const paths = itemPaths ?? nativeListPaths(value)
13
+ const first = paths?.[0]
14
+ const path = draftPath ?? (first ? first.slice(0, first.lastIndexOf('/')) : nativePath(value))
15
+ if (!path) return createElement(as, attributes, value.map((item, index) => <Fragment key={(item as { _id?: string } | null)?._id ?? index}>{children(item, index)}</Fragment>))
16
+ if (!safeDraftPath(path)) throw new Error('List requires a valid draft path.')
17
+ const seen = new Set<string>()
18
+ const items = value.map((item, index) => {
19
+ const at = nativeListItemPath(value, index, paths, path)
20
+ const id = at?.startsWith(`${path}/`) ? at.slice(path.length + 1) : undefined
21
+ if (!id || id.includes('/') || seen.has(id)) throw new Error('Draft List items require unique stable paths. Pass itemPaths for nullable leaf lists.')
22
+ seen.add(id)
23
+ return { id, $path: at!, content: children(item, index) }
24
+ })
25
+ return <DraftList key={path} as={as} attributes={attributes} path={path} items={items}/>
26
+ }
@@ -7,3 +7,22 @@ export const draftAttrs = (value: Marked, edit?: 'text' | 'rich' | 'link-label')
7
7
  value && value.$path ? { 'data-u90-field': value.$path, ...(edit ? { 'data-u90-edit': edit } : {}), ...(value.$fallback ? { 'data-u90-fallback': '' as const } : {}) } : {}
8
8
  export const editable = (value: Marked): DraftAttrs => draftAttrs(value, value && 'text' in value ? 'text' : undefined)
9
9
  export const editableRich = (value: Marked): DraftAttrs => draftAttrs(value, 'rich')
10
+ import { nativeChildPath, nativeListItemPath, nativeListPaths, safeDraftPath } from '../bridge/nativePath'
11
+
12
+
13
+ /** An actual draft address for a declared child, including a nullable child with no own path. */
14
+ export function draftFieldPath(parent: unknown, key: string): string | undefined {
15
+ if (!parent || typeof parent !== 'object' || Array.isArray(parent) || !Object.hasOwn(parent, key) || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) || ['__proto__', 'constructor', 'prototype'].includes(key)) return
16
+ const child = (parent as Record<string, unknown>)[key]
17
+ const own = child && typeof child === 'object' ? (child as { $path?: unknown }).$path : undefined
18
+ if (typeof own === 'string' && safeDraftPath(own)) return own
19
+ return nativeChildPath(parent, key)
20
+ }
21
+
22
+ /** A nullable leaf-list item's stable address, carried separately from its read value. */
23
+ export function draftListItemPath(parent: unknown, key: string, index: number): string | undefined {
24
+ if (!parent || typeof parent !== 'object' || Array.isArray(parent) || !Object.hasOwn(parent, key) || ['__proto__', 'constructor', 'prototype'].includes(key) || !Number.isInteger(index) || index < 0) return
25
+ const values = (parent as Record<string, unknown>)[key]
26
+ if (!Array.isArray(values) || index >= values.length) return
27
+ return nativeListItemPath(values, index, nativeListPaths(values, parent, key), draftFieldPath(parent, key))
28
+ }
@@ -1,4 +1,5 @@
1
1
  export { Entries } from './Entries'
2
+ export { List } from './List'
2
3
  export { Text } from './Text'
3
4
  export { RichText, richTextNodes } from './RichText'
4
5
  export { Link, isInternal } from './Link'
@@ -9,5 +10,5 @@ export { Image } from './Image'
9
10
  export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
10
11
  export { Video } from './Video'
11
12
  export { videoProps, type VideoHTMLProps } from './videoProps'
12
- export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
13
+ export { draftAttrs, draftFieldPath, draftListItemPath, editable, editableRich, type DraftAttrs } from './draft'
13
14
  export { LanguageSwitcher, useLanguage, rememberLanguage } from '../switcher/LanguageSwitcher'
@@ -0,0 +1,23 @@
1
+ import type { FieldDecl } from '../define'
2
+
3
+ // Arrays keep their ordinary leaf values, including null. Live snapshots retain IDs
4
+ // separately; enclosing groups serialize the same paths across the RSC boundary.
5
+ const lists = new WeakMap<readonly unknown[], readonly string[]>()
6
+ export const draftListPaths = (value: unknown): readonly string[] | undefined => Array.isArray(value) ? lists.get(value) : undefined
7
+ export function rememberDraftList<T>(value: T[], paths: readonly string[] | undefined): T[] {
8
+ if (paths?.length === value.length) lists.set(value, paths)
9
+ return value
10
+ }
11
+
12
+ /** Real declaration addresses for nullable Single aliases, separate from their nullable values. */
13
+ export function draftGroupMetadata(fields: Record<string, FieldDecl>, at: string, values: Record<string, unknown> = {}): { $path: string; $fieldPaths?: Record<string, string>; $listPaths?: Record<string, readonly string[]> } {
14
+ const aliases = Object.entries(fields).flatMap(([key, field]) => {
15
+ const single = 'element' in field && field.element === 'u90/single' ? field.options?.key : undefined
16
+ return typeof single === 'string' && /^[a-z][a-zA-Z0-9_-]*$/.test(single) ? [[key, `single:${single}`]] : []
17
+ })
18
+ const listPaths = Object.keys(fields).flatMap(key => {
19
+ const paths = draftListPaths(values[key])
20
+ return paths?.length ? [[key, paths]] : []
21
+ })
22
+ return { $path: at, ...(aliases.length ? { $fieldPaths: Object.fromEntries(aliases) } : {}), ...(listPaths.length ? { $listPaths: Object.fromEntries(listPaths) } : {}) }
23
+ }
@@ -4,6 +4,7 @@ import { path } from '../path'
4
4
  import { getContainer, getElement } from '../registry'
5
5
  import { record } from '../containers/group'
6
6
  import { prefixedRoute } from '../prefix'
7
+ import { draftGroupMetadata, rememberDraftList } from './draftPaths'
7
8
 
8
9
  export type Resolved = {
9
10
  media?: Map<string, MediaRecord>
@@ -53,9 +54,12 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
53
54
  if (isLeaf(decl)) return expandLeaf(getElement(decl.element), decl, value, ctx)
54
55
  const container = getContainer(decl.container)
55
56
  const children = container.children(decl, decl.container === 'u90/group' ? (record(value) ? value : {}) : value, schema)
56
- if (decl.container === 'u90/group') return Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
57
+ if (decl.container === 'u90/group') {
58
+ const fields = Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
59
+ return { ...(ctx.draft ? draftGroupMetadata(decl.fields, ctx.at, fields) : {}), ...fields }
60
+ }
57
61
  const items = Array.isArray(value) ? value : []
58
- return children.map((c, i) => {
62
+ const expanded = children.map((c, i) => {
59
63
  const inner = expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))
60
64
  // A leaf item reads as its element's value (which carries its own `$path`); a group item, like a zone item, carries the item's path in draft.
61
65
  if (decl.container === 'u90/list') return isLeaf(decl.item) ? inner : { _id: c.key, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
@@ -65,6 +69,7 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
65
69
  if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
66
70
  return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
67
71
  })
72
+ return ctx.draft && decl.container === 'u90/list' ? rememberDraftList(expanded, children.map(c => path.child(ctx.at, c.key))) : expanded
68
73
  }
69
74
 
70
75
  /**
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.0.6 pair <code>` — the one command an operator hands over. The u90 tool
5
+ * `npx --yes @unitninety/u90@1.2.0 pair <code>` — the one command an operator hands over. The u90 tool
6
6
  * runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
7
7
  * repository with no local bin, fetches whatever package npm holds under that name.
8
8
  *
@@ -115,7 +115,7 @@ const run = async () => {
115
115
  const args = process.argv.slice(2)
116
116
 
117
117
  if (args[0] !== 'pair' || !args[1]) {
118
- fail('Usage: npx --yes @unitninety/u90@1.0.6 pair <code> --site https://example.com [--platform https://cms…]')
118
+ fail('Usage: npx --yes @unitninety/u90@1.2.0 pair <code> --site https://example.com [--platform https://cms…]')
119
119
  }
120
120
 
121
121
  const code = args[1]
@@ -360,7 +360,7 @@ export const createContentClient = ({
360
360
  : !platformUrl
361
361
  ? 'PLATFORM_URL is unset'
362
362
  : 'PAYLOAD_API_KEY is unset'
363
- }. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.6 pair\` writes both.`,
363
+ }. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.2.0 pair\` writes both.`,
364
364
  )
365
365
  }
366
366
  return true
@@ -21,7 +21,7 @@ export function createRender(site: Site) {
21
21
  if (!def) return item.$path ? createElement('div', { key: item._id, 'data-u90-block': item.$path, 'data-u90-missing': item._block }, `Block "${item._block}" was removed`) : null
22
22
  const { _id, _block, _hidden, $path, ...fields } = item
23
23
  // zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
24
- const props: Record<string, unknown> = { ...fields }
24
+ const props: Record<string, unknown> = { ...fields, ...($path ? { $path } : {}) }
25
25
  for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry, anchors })
26
26
  // a block declared with `entry:` renders inside that collection's entry pages and receives the entry itself
27
27
  if (def.entry) props.entry = entry
@@ -1,5 +1,5 @@
1
1
  import type { BooleanRead, ChoiceReadValue, DateRead, EntriesRead, EntryRead, FileRead, ImageRead, LinkRead, NumberRead, RichTextRead, TextRead, VideoRead } from '../elements'
2
- import type { BlockDef, Decl, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
2
+ import type { BlockDef, Decl, DraftReadMetadata, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
3
3
 
4
4
  type Common = { label?: string; description?: string; required?: boolean; translatable?: boolean; lock?: string[]; renamedFrom?: string }
5
5
  /** Options that are part of the field declaration itself go top-level; the rest is the element's `options`. */
@@ -35,7 +35,7 @@ export const selection = (to: string, o: Common & { default?: unknown; many?: bo
35
35
  export const query = (to: string, o: Common & { default?: unknown; previewFields?: string[] } = {}) => leaf<EntriesRead>('u90/query', o, { to, previewFields: o.previewFields })
36
36
  export const single = <R>(s: SingleDef<R>, o: Common = {}): Decl<R> => leaf<R>('u90/single', o, { key: s.key })
37
37
 
38
- export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<{ [K in keyof F]: Read<F[K]> }> & { readonly __group: true } =>
38
+ export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata>> & { readonly __group: true } =>
39
39
  ({ container: 'u90/group', ...(o.label ? { label: o.label } : {}), fields, order: Object.keys(fields) }) as never
40
40
  export const list = <D extends Decl<unknown>>(
41
41
  item: D,
package/src/site/types.ts CHANGED
@@ -7,6 +7,8 @@ import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDec
7
7
  export type Decl<R> = FieldDecl & { readonly __read?: R }
8
8
  export type Read<D> = D extends Decl<infer R> ? R : never
9
9
  export type Fields = Record<string, Decl<unknown>>
10
+ /** Present only on native draft reads, never authored content or published props. */
11
+ export type DraftReadMetadata = { $path?: string; $fieldPaths?: Record<string, string>; $listPaths?: Record<string, readonly string[]> }
10
12
  /**
11
13
  * Flattens an intersection into a single mapped object type. The strict `Equal` check used by
12
14
  * `types.test-d.ts` treats an intersection (`{ _id: string } & Read<D>`) as distinct from the
@@ -72,9 +74,9 @@ export type SiteManifest = {
72
74
  * collection's declaration. Not `EntryRead`: an entry page's entry has no `collection` or `status`
73
75
  * (finding 21).
74
76
  */
75
- export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } } : never
77
+ export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } & DraftReadMetadata } : never
76
78
  export type BlockProps<B> = B extends BlockDef<infer F, infer C>
77
- ? Simplify<{ [K in keyof F]: Read<F[K]> } & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
79
+ ? Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
78
80
  : never
79
81
  export type { FieldDecl, LeafDecl, GroupDecl, ListDecl, ZoneDecl, BlockDecl, CollectionDecl, SingleDecl, Schema }
80
82
  export type { CollectionAuthoring, CollectionAuthoringAnchor, CollectionAuthoringSections } from '../elements'
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.6'
1
+ export const SITE_KIT_VERSION = '1.8.0'