@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 +36 -2
- package/package.json +1 -1
- package/src/bridge/DraftList.tsx +46 -0
- package/src/bridge/nativeOrder.ts +18 -5
- package/src/bridge/preserveBlockMoves.ts +1 -1
- package/src/components/List.tsx +26 -0
- package/src/components/index.ts +1 -0
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/site/version.ts +1 -1
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.
|
|
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
|
|
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.
|
|
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
|
|
10
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.
|
|
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.
|
|
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]
|
package/src/legacy/content.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.8.0'
|