@unitninety/site-kit 1.7.0 → 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,7 +5,7 @@ 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.1.0 --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.
@@ -84,9 +84,43 @@ Call the hook on native reads before converting them for a presentation componen
84
84
 
85
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.
86
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
+
87
121
  ### Stable block reorder previews
88
122
 
89
- 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.
90
124
 
91
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:
92
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "1.7.0",
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
+ }
@@ -1,23 +1,36 @@
1
1
  import { rememberDraftList } from '../elements/ops/draftPaths'
2
2
  import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
3
3
  import type { DraftSnapshot } from './protocol'
4
+ import type { FieldDecl } from '../elements/define'
4
5
 
5
6
  export type DraftOrder = { path: string; ids: string[] }
6
7
  export type NativeDraftOrder = DraftOrder
7
8
  const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
8
9
 
9
- /** A move's snapshot may start above its zone (for example a zone inside a group).
10
- * 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. */
11
12
  export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
12
13
  const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
13
14
  if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
14
15
  let value: unknown = snapshot.value
16
+ let field: FieldDecl | undefined = snapshot.field
15
17
  for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
16
18
  if (unsafe.has(key)) return
17
- value = Array.isArray(value) ? value.find(item => item?._id === key)
18
- : 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
+ }
19
31
  }
20
- 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
21
34
  const ids = value.map(item => item._id as string)
22
35
  if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
23
36
  return { path, ids }
@@ -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
@@ -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
+ }
@@ -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'
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.1.0 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.1.0 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.1.0 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
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.7.0'
1
+ export const SITE_KIT_VERSION = '1.8.0'