@unitninety/site-kit 1.6.4 → 1.6.6
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 +104 -1
- package/package.json +4 -3
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/createBridge.ts +11 -10
- package/src/bridge/index.ts +1 -0
- package/src/bridge/nativeDraft.ts +22 -4
- package/src/bridge/nativeOrder.ts +68 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/useDraftContent.tsx +35 -22
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/custom-code/fragments.ts +64 -0
- package/src/custom-code/index.ts +30 -0
- package/src/elements/blockAnchor.ts +7 -0
- package/src/elements/declaration.ts +1 -1
- package/src/elements/define.ts +1 -1
- package/src/elements/index.ts +1 -0
- package/src/elements/ops/validate.ts +9 -2
- package/src/features/client/README.md +1 -2
- package/src/features/client/SiteFeatures.tsx +15 -25
- package/src/features/client/index.ts +0 -6
- package/src/features/client/verification.ts +3 -0
- package/src/features/contracts/base.ts +1 -5
- package/src/features/contracts/index.ts +0 -20
- package/src/features/contracts/verificationConfig.ts +7 -0
- package/src/features/server/gateway.ts +1 -3
- package/src/features/server/index.ts +0 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/BlockAnchors.tsx +92 -0
- package/src/server/CustomCode.tsx +29 -0
- package/src/server/index.ts +1 -0
- package/src/server/read.ts +13 -0
- package/src/server/render.tsx +11 -5
- package/src/site/defineSite.ts +6 -3
- package/src/site/documents.ts +2 -2
- package/src/site/forms.ts +32 -0
- package/src/site/index.ts +2 -0
- package/src/site/types.ts +4 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +3 -0
- package/src/features/client/ConsentRuntime.tsx +0 -95
- package/src/features/client/useConsent.ts +0 -15
- package/src/features/consent/README.md +0 -70
- package/src/features/consent/cookieScript.ts +0 -53
- package/src/features/consent/custom.ts +0 -89
- package/src/features/consent/defaults.ts +0 -40
- package/src/features/consent/drivers.ts +0 -233
- package/src/features/consent/loader.ts +0 -248
- package/src/features/consent/registry.ts +0 -398
- package/src/features/contracts/consent.ts +0 -150
- package/src/features/server/ConsentDefaults.tsx +0 -15
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.0.
|
|
8
|
+
npx --yes @unitninety/u90@1.0.6 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
UnitNinety imports packages made for that version only, so keep the version the tool names.
|
|
@@ -37,6 +37,37 @@ The form uses each field's label, default and text constraints. The list must co
|
|
|
37
37
|
|
|
38
38
|
Creation saves a draft with the normal schema defaults and approved template. Routed entries open Canvas for images and sections; collections without pages keep their content editor in the CMS. The matching platform/Site Kit implementation must be deployed together before using this new metadata in an uploaded package.
|
|
39
39
|
|
|
40
|
+
### Immediate authored-list previews
|
|
41
|
+
|
|
42
|
+
Client components inside a draft block can opt in with `useDraftContent`:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
'use client'
|
|
46
|
+
import { useDraftContent } from '@unitninety/site-kit/bridge'
|
|
47
|
+
|
|
48
|
+
export function Header({ navigation }) {
|
|
49
|
+
const draft = useDraftContent(navigation, 'navigation')
|
|
50
|
+
return <nav>{draft.menuItems.map(item => <a key={item._id} href={item.href.href ?? '#'}>{item.label.text}</a>)}</nav>
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Call the hook on native reads before converting them for a presentation component. `renderBlocks` supplies the draft block path automatically; custom `DraftBlocks` renderers must supply each item's native `path`. Key rows by `_id`, not their editable label, URL or position. Existing server-only published content is unchanged.
|
|
55
|
+
|
|
56
|
+
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
|
+
|
|
58
|
+
### Stable block reorder previews
|
|
59
|
+
|
|
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.
|
|
61
|
+
|
|
62
|
+
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
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { useDraftPreview, reorderNativeDraft } from '@unitninety/site-kit/bridge'
|
|
66
|
+
const current = useDraftPreview(source, applyPresentationPatch, reorderNativeDraft)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For plain presentation arrays without native paths, provide a third callback `(source, { path, ids }) => source` that reorders the existing objects by stable block ID. Keep membership, content and unrelated arrays unchanged. Test both the instant preview and the later server refresh; a complete zone snapshot must never replace formatted presentation props with raw native blocks.
|
|
70
|
+
|
|
40
71
|
### Immediate collection-list previews
|
|
41
72
|
|
|
42
73
|
Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
|
|
@@ -54,3 +85,75 @@ pending order/removal through slow refreshes; rejected saves reconcile to the se
|
|
|
54
85
|
Published output is an ordinary div and server-rendered cards. Newly added entries whose markup
|
|
55
86
|
is not already present appear when the save's server refresh arrives. Query results rendered
|
|
56
87
|
with a plain `.map()` still work, but wait for that refresh to change their order or membership.
|
|
88
|
+
|
|
89
|
+
### Shared blocks with page-only copies
|
|
90
|
+
|
|
91
|
+
A shared block uses one content group whose declaration matches a Single:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
const enquiryContent = group({ heading: text(), contact: link() })
|
|
95
|
+
const singles = defineSingles({ enquiry: enquiryContent })
|
|
96
|
+
const enquiry = block('enquiry', {
|
|
97
|
+
label: 'Enquiry',
|
|
98
|
+
fields: { content: enquiryContent },
|
|
99
|
+
shared: { single: 'enquiry', field: 'content' },
|
|
100
|
+
component: Enquiry,
|
|
101
|
+
})
|
|
102
|
+
// Include enquiry in a normal zone's allow/default lists.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A shared block may retain additional design fields only when every leaf is locked with `lock: ['$']`. Optional `shared.previewFields` lists the content keys this placement actually renders (1–30 unique existing keys); it filters both linked and page-specific trees without deleting stored data. Shared content uses the same accordion and selected-field editor as ordinary blocks.
|
|
106
|
+
|
|
107
|
+
The default `content: null` follows the Single. A content object is an independent copy owned by that block instance. Both render through the same component and retain the same block ID and controls. The Inspector labels the scope and offers shared editing or “Customise for this page”; customising copies the latest saved shared value. Templates seed the initial blocks, while an authored empty zone remains empty. Converting an existing hardcoded placement requires migrating its documents and renderer together.
|
|
108
|
+
|
|
109
|
+
### Inspector-only canvas editing
|
|
110
|
+
|
|
111
|
+
Pass `inlineEditing={false}` to `U90Bridge` in the root layout when clients should type only in the inspector. Canvas clicks still select and highlight fields; inspector text, rich-text and link-label previews remain immediate. The bridge keeps the `data-u90-edit` metadata for previews but does not add `contenteditable` or send canvas input edits. The option defaults to `true` for existing sites.
|
|
112
|
+
|
|
113
|
+
### Featured-entry fields in the block tree
|
|
114
|
+
|
|
115
|
+
Selections, relations and queries can declare the collection fields that their component displays:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
selection('projects', {
|
|
119
|
+
many: true,
|
|
120
|
+
depth: 2,
|
|
121
|
+
previewFields: ['title', 'heroMedia', 'industry', 'client', 'date', 'location'],
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`previewFields` is optional editor metadata: unique top-level field keys (1–20). It does not change stored references, query rules, ordering or read shapes. The same expandable rows work on pages, collection entries and layouts. A referenced entry expands inside its block; choosing a leaf opens its primitive editor above the tree. A referenced field can itself declare `previewFields` to expose a nested list. Cycles and deep chains fall back to the ordinary reference editor. Derived relations retain their existing read-only editor.
|
|
126
|
+
|
|
127
|
+
For example, KAW's Home declares `query('industries', { previewFields: ['name', 'description', 'capabilities'] })`. The Industries collection declares `relation('capabilities', { many: true, previewFields: ['name', 'heroMedia'] })`. Industry names, introductions, capability names and images still write to their original entry paths and update everywhere. Membership edits only change the owning reference. Queries preserve their rules and use pin/exclude/order controls; the plus control uses the existing tenant-scoped picker.
|
|
128
|
+
|
|
129
|
+
The site component must reveal the selected entry in its own tabs/carousels, including when its data arrives after selection. `useEditing()` exposes `selected` and `selectedVia`: the latter contains outer-to-inner reference field paths, disambiguating an entry reused in several parents. Mark a displayed list container with `data-u90-reference={reference.$path}` in draft mode to preserve this context when selecting its content on the canvas. Keep `data-u90-field` on each entry's own fields and a root marker on the revealed entry for row highlighting. A relation's target entry path is not its parent relation field path. These attributes do not introduce visitor-facing editing controls.
|
|
130
|
+
|
|
131
|
+
Deploy matching Site Kit/platform code before importing this metadata, and restart a running dev server after changing registry options so Payload validation hooks reload.
|
|
132
|
+
## Site-wide custom code
|
|
133
|
+
|
|
134
|
+
Settings → Custom code stores three embed fields as a draft. The workspace’s existing Publish control makes that draft live and revalidates the cached site read. Saving a snippet never rebuilds the website. Editing requires Code permission; disabling Code also hides these settings.
|
|
135
|
+
|
|
136
|
+
Install the server renderer in each root layout, using one snapshot for all three placements. Keep the site’s existing head content, body attributes, providers, and children:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { U90Bridge, U90CustomCode } from '@unitninety/site-kit/server'
|
|
140
|
+
import { u90 } from '@/u90'
|
|
141
|
+
|
|
142
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
143
|
+
const customCode = await u90.read.customCode()
|
|
144
|
+
return (
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head><U90CustomCode code={customCode} placement="head" /></head>
|
|
147
|
+
<body>
|
|
148
|
+
<U90CustomCode code={customCode} placement="bodyStart" />
|
|
149
|
+
<U90Bridge>{children}</U90Bridge>
|
|
150
|
+
<U90CustomCode code={customCode} placement="bodyEnd" />
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Keep these slots as direct children of the native head and body; do not wrap them in an HTML container or load the code after hydration. `read.customCode()` returns only published code and suppresses output in authenticated draft and test previews, including Canvas. The website’s existing Content Security Policy continues to apply.
|
|
158
|
+
|
|
159
|
+
Existing sites need one initial code deployment to install a compatible Site Kit and these slots in every root layout. Subsequent custom-code changes use Publish and on-demand revalidation, without another deployment. New converter output includes the installation automatically.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.6",
|
|
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": {
|
|
7
7
|
".": "./src/site/index.ts",
|
|
8
8
|
"./server": "./src/server/index.ts",
|
|
9
|
+
"./custom-code": "./src/custom-code/index.ts",
|
|
9
10
|
"./features/server": "./src/features/server/index.ts",
|
|
10
11
|
"./features/contracts": "./src/features/contracts/index.ts",
|
|
11
12
|
"./preview-lock": "./src/preview-lock/index.ts",
|
|
@@ -46,8 +47,7 @@
|
|
|
46
47
|
"!src/elements/fixtures",
|
|
47
48
|
"!src/elements/harness.ts",
|
|
48
49
|
"!src/site/fixtures",
|
|
49
|
-
"!src/test-preview/fixtures.ts"
|
|
50
|
-
"!src/features/consent/test-fixtures.ts"
|
|
50
|
+
"!src/test-preview/fixtures.ts"
|
|
51
51
|
],
|
|
52
52
|
"license": "UNLICENSED",
|
|
53
53
|
"homepage": "https://unitninety.com",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
+
"parse5": "7.3.0",
|
|
63
64
|
"zod": "4.4.3"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
@@ -6,6 +6,7 @@ import { DraftContentPath } from './useDraftContent'
|
|
|
6
6
|
import { registry } from './registry'
|
|
7
7
|
import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
|
|
8
8
|
import { PreviewCommitBoundary } from './PreviewCommitBoundary'
|
|
9
|
+
import { ordered, type DraftOrder } from './nativeOrder'
|
|
9
10
|
|
|
10
11
|
type Item = { id: string; path?: string; content: ReactNode }
|
|
11
12
|
type Move = { path: string; before: string | null }
|
|
@@ -55,6 +56,15 @@ export function DraftBlocks({ items }: { items: Item[] }) {
|
|
|
55
56
|
setShown(value => ({ ...value, items: value.items.filter(item => item.path !== path), removed: new Set([...value.removed, path]) }))
|
|
56
57
|
return true
|
|
57
58
|
},
|
|
59
|
+
reorder: ({ path, ids }: DraftOrder) => {
|
|
60
|
+
if (!current.some(item => item.path === `${path}/${item.id}`)) return false
|
|
61
|
+
if (host.current && needsEmbedReload(host.current)) {
|
|
62
|
+
reload.current = true
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
setShown(value => ({ ...value, items: ordered(value.items, ids, item => item.id), order: ids }))
|
|
66
|
+
return true
|
|
67
|
+
},
|
|
58
68
|
move: (path: string, before: string | null) => {
|
|
59
69
|
if (!current.some((item) => item.path === path)) return false
|
|
60
70
|
if (before !== null && !current.some((item) => item.path === before)) return false
|
|
@@ -4,7 +4,8 @@ import { toRichText, type DomLike } from './lexicalDom'
|
|
|
4
4
|
import { applyPreview, type Undo } from './preview'
|
|
5
5
|
import { BRIDGE_VERSION, MAX_OFFENDER_LENGTH, MAX_OFFENDERS, parseCanvasMessage, type BridgeStamp, type PreviewInstruction, type SiteMessage } from './protocol'
|
|
6
6
|
import { registry } from './registry'
|
|
7
|
-
import {
|
|
7
|
+
import { snapshotOrder } from './nativeOrder'
|
|
8
|
+
import { createSelection, selectionVia } from './selection'
|
|
8
9
|
import { EDITING_CSS } from './styles'
|
|
9
10
|
|
|
10
11
|
export type BridgeOptions = {
|
|
@@ -249,7 +250,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
249
250
|
/** Outline the element at `path` (field or block) and clear any previous outline. */
|
|
250
251
|
const selection = createSelection(win)
|
|
251
252
|
const markSelected = (path: string | null, scroll = false, via?: string[]) => {
|
|
252
|
-
selection.select(path, scroll)
|
|
253
|
+
selection.select(path, scroll, via)
|
|
253
254
|
opts.onSelect?.(path, via)
|
|
254
255
|
}
|
|
255
256
|
|
|
@@ -386,12 +387,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
386
387
|
// (a menu toggle) still works while the Canvas is open.
|
|
387
388
|
e.preventDefault()
|
|
388
389
|
}
|
|
389
|
-
const
|
|
390
|
-
for (let node: Element | null = el ?? null; node; node = node.parentElement) {
|
|
391
|
-
const reference = node.getAttribute('data-u90-reference')
|
|
392
|
-
if (reference && reference !== path) ancestors.unshift(reference)
|
|
393
|
-
}
|
|
394
|
-
const via = ancestors.length > 0 && ancestors.length <= 5 ? ancestors : undefined
|
|
390
|
+
const via = selectionVia(el ?? null, path)
|
|
395
391
|
markSelected(path, false, via)
|
|
396
392
|
send({ type: 'u90:select', path, ...(via ? {via} : {}) })
|
|
397
393
|
const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
|
|
@@ -571,7 +567,10 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
571
567
|
const draft = instruction.kind === 'value' ? instruction : instruction.draft
|
|
572
568
|
if (draft) {
|
|
573
569
|
let handled = false
|
|
574
|
-
|
|
570
|
+
const order = instruction.kind === 'move' ? snapshotOrder(draft, instruction.path) : undefined
|
|
571
|
+
for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft, order) || handled
|
|
572
|
+
// Update every rendered instance; hidden destinations only exist in the full snapshot.
|
|
573
|
+
if (order) for (const owner of registry.blockOrders) if (owner.document === doc) handled = !!owner.reorder?.(order) || handled
|
|
575
574
|
if (handled || instruction.kind === 'value') return
|
|
576
575
|
}
|
|
577
576
|
const list = instruction.kind === 'list' ? instruction : instruction.list
|
|
@@ -591,9 +590,11 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
591
590
|
if (handled) return
|
|
592
591
|
}
|
|
593
592
|
if (instruction.kind === 'move') {
|
|
593
|
+
let handled = false
|
|
594
594
|
for (const owner of registry.blockOrders) {
|
|
595
|
-
if (owner.document === doc
|
|
595
|
+
if (owner.document === doc) handled = owner.move(instruction.path, instruction.before) || handled
|
|
596
596
|
}
|
|
597
|
+
if (handled) return
|
|
597
598
|
}
|
|
598
599
|
if (refreshing && MOVES_NODES.has(instruction.kind)) return
|
|
599
600
|
if ((instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label') && editing?.getAttribute('data-u90-field') === instruction.path && !doc.hasFocus()) {
|
package/src/bridge/index.ts
CHANGED
|
@@ -13,3 +13,4 @@ export { DraftBlocks } from './DraftBlocks'
|
|
|
13
13
|
|
|
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
|
+
export { reorderNativeDraft, type NativeDraftOrder } from './nativeOrder'
|
|
@@ -4,6 +4,7 @@ import { expandField } from '../elements/ops/expand'
|
|
|
4
4
|
import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
|
|
5
5
|
import type { EntriesRead } from '../elements/leaves/selection'
|
|
6
6
|
import type { DraftSnapshot } from './protocol'
|
|
7
|
+
import { reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
7
8
|
|
|
8
9
|
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
9
10
|
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -51,7 +52,7 @@ export function applyNativeDraft<T>(source: T, path: string, replacement: unknow
|
|
|
51
52
|
/** A local index of real native reads. Missing references remain missing until the server answers. */
|
|
52
53
|
export class NativeDraftReferences {
|
|
53
54
|
private reads = new Map<string, unknown>()
|
|
54
|
-
private pending = new Map<string, unknown>()
|
|
55
|
+
private pending = new Map<string, { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }>()
|
|
55
56
|
private media = new Map<string, MediaRecord>()
|
|
56
57
|
private pages = new Map<string, PageRecord>()
|
|
57
58
|
private entries = new Map<string, EntryRead>()
|
|
@@ -101,16 +102,33 @@ export class NativeDraftReferences {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
visit(values)
|
|
104
|
-
for (const
|
|
105
|
+
for (const patch of this.pending.values()) {
|
|
106
|
+
if (patch.kind === 'value') this.applyPatch(patch.path, patch.value)
|
|
107
|
+
else this.applyOrder(patch)
|
|
108
|
+
}
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
patch(path: string, value: unknown): void {
|
|
108
112
|
if (!path.startsWith('entry:') && !path.startsWith('single:')) return
|
|
109
|
-
for (const key of this.pending
|
|
110
|
-
this.pending.set(path, value)
|
|
113
|
+
for (const [key, patch] of this.pending) if (patch.path === path || patch.path.startsWith(`${path}/`)) this.pending.delete(key)
|
|
114
|
+
this.pending.set(`value:${path}`, { kind: 'value', path, value })
|
|
111
115
|
this.applyPatch(path, value)
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
order(order: DraftOrder): void {
|
|
119
|
+
const key = `order:${order.path}`
|
|
120
|
+
this.pending.delete(key)
|
|
121
|
+
this.pending.set(key, { kind: 'order', ...order })
|
|
122
|
+
this.applyOrder(order)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private applyOrder(order: DraftOrder): void {
|
|
126
|
+
for (const [key, read] of this.reads) this.reads.set(key, reorderNativeDraft(read, order))
|
|
127
|
+
for (const [key, entry] of this.entries) this.entries.set(key, reorderNativeDraft(entry, order))
|
|
128
|
+
for (const [key, single] of this.singles) this.singles.set(key, reorderNativeDraft(single, order))
|
|
129
|
+
for (const [key, query] of this.queries) this.queries.set(key, reorderNativeDraft(query, order))
|
|
130
|
+
}
|
|
131
|
+
|
|
114
132
|
beforeRefresh(): void { this.pending.clear() }
|
|
115
133
|
|
|
116
134
|
settle(values: unknown): void {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { nativePath, safeDraftPath } from './nativeDraft'
|
|
2
|
+
import type { DraftSnapshot } from './protocol'
|
|
3
|
+
|
|
4
|
+
export type DraftOrder = { path: string; ids: string[] }
|
|
5
|
+
export type NativeDraftOrder = DraftOrder
|
|
6
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
7
|
+
|
|
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
|
+
export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
|
|
11
|
+
const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
|
|
12
|
+
if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
|
|
13
|
+
let value: unknown = snapshot.value
|
|
14
|
+
for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
|
|
15
|
+
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
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && typeof item._block === 'string')) return
|
|
20
|
+
const ids = value.map(item => item._id as string)
|
|
21
|
+
if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
|
|
22
|
+
return { path, ids }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Preserve membership and object identity, including items a newer render introduced. */
|
|
26
|
+
export function ordered<T>(items: T[], ids: string[], id: (item: T) => string | undefined): T[] {
|
|
27
|
+
const ranks = new Map(ids.map((key, index) => [key, index]))
|
|
28
|
+
const known = items.filter(item => ranks.has(id(item)!)).sort((a, b) => ranks.get(id(a)!)! - ranks.get(id(b)!)!)
|
|
29
|
+
let index = 0
|
|
30
|
+
const next = items.map(item => ranks.has(id(item)!) ? known[index++]! : item)
|
|
31
|
+
return next.every((item, i) => item === items[i]) ? items : next
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reorder each native/presentation alias of a zone, leaving its content intact. */
|
|
35
|
+
export function applyNativeOrder<T>(source: T, order: DraftOrder): { value: T; matched: boolean } {
|
|
36
|
+
if (!safeDraftPath(order.path)) return { value: source, matched: false }
|
|
37
|
+
let matched = false
|
|
38
|
+
const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
|
|
39
|
+
if (!value || typeof value !== 'object' || seen.has(value) || '$$typeof' in value) return value
|
|
40
|
+
const at = nativePath(value) ?? parent
|
|
41
|
+
if (Array.isArray(value) && at === order.path) {
|
|
42
|
+
matched = true
|
|
43
|
+
return ordered(value, order.ids, item => {
|
|
44
|
+
const path = nativePath(item)
|
|
45
|
+
return path?.startsWith(`${order.path}/`) ? path.slice(order.path.length + 1)
|
|
46
|
+
: typeof item?._id === 'string' ? item._id : undefined
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
const nextSeen = new Set(seen).add(value)
|
|
50
|
+
let result: any = value
|
|
51
|
+
for (const [key, child] of Object.entries(value)) {
|
|
52
|
+
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)
|
|
55
|
+
if (next !== child) {
|
|
56
|
+
if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
|
|
57
|
+
result[key] = next
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
return { value: visit(source) as T, matched }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Opt-in reorder adapter for presentation collections whose blocks retain native $path values. */
|
|
66
|
+
export function reorderNativeDraft<T>(source: T, order: NativeDraftOrder): T {
|
|
67
|
+
return applyNativeOrder(source, order).value
|
|
68
|
+
}
|
package/src/bridge/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntryPicks } from './DraftEntries'
|
|
2
2
|
import type { DraftSnapshot } from './protocol'
|
|
3
3
|
import type { Bridge } from './createBridge'
|
|
4
|
+
import type { DraftOrder } from './nativeOrder'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The one shared, module-level handle onto the live bridge instance and its reveal listeners.
|
|
@@ -22,9 +23,9 @@ export const registry: {
|
|
|
22
23
|
current: Bridge | null
|
|
23
24
|
reveals: Set<{ prefix: string; fn: () => void }>
|
|
24
25
|
entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
|
|
25
|
-
draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot): boolean }>
|
|
26
|
+
draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot, order?: DraftOrder): boolean }>
|
|
26
27
|
draftContents: Set<{ document: Document; beforeRefresh(): void; preview(path: string, value: unknown[]): boolean }>
|
|
27
|
-
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; move(path: string, before: string | null): boolean }>
|
|
28
|
+
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; reorder?(order: DraftOrder): boolean; move(path: string, before: string | null): boolean }>
|
|
28
29
|
} = {
|
|
29
30
|
current: null,
|
|
30
31
|
reveals: new Set(),
|
package/src/bridge/selection.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/** Explicit references win; a directly rendered Single otherwise belongs to its enclosing block. */
|
|
2
|
+
function selectionContext(element: Element | null, path: string | null): { via?: string[]; block?: string } {
|
|
3
|
+
const references: string[] = []
|
|
4
|
+
let block: string | undefined
|
|
5
|
+
for (let node = element; node; node = node.parentElement) {
|
|
6
|
+
const reference = node.getAttribute('data-u90-reference')
|
|
7
|
+
if (reference && reference !== path) references.unshift(reference)
|
|
8
|
+
if (!block && path?.startsWith('single:')) {
|
|
9
|
+
const placement = node.getAttribute('data-u90-block')
|
|
10
|
+
if (placement && placement.length <= 512 && /^(page|entry|layout):[^/\s]+\/blocks\/[^/\s]+(?:\/[^/\s]+)*$/.test(placement)) block = placement
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (references.length) return { via: references.length <= 5 ? references : undefined }
|
|
14
|
+
return block ? { via: [block], block } : {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const selectionVia = (element: Element | null, path: string | null): string[] | undefined => selectionContext(element, path).via
|
|
18
|
+
|
|
1
19
|
/** Draft-only selection geometry. Contents wrappers have no CSS box of their own. */
|
|
2
20
|
export function createSelection(win: Window) {
|
|
3
21
|
const doc = win.document
|
|
@@ -6,16 +24,46 @@ export function createSelection(win: Window) {
|
|
|
6
24
|
overlay.setAttribute('aria-hidden', 'true')
|
|
7
25
|
overlay.hidden = true
|
|
8
26
|
let path: string | null = null
|
|
27
|
+
let via: readonly string[] = []
|
|
9
28
|
let shouldReveal = false
|
|
10
29
|
let frame: number | null = null
|
|
11
30
|
let observer: MutationObserver | null = null
|
|
31
|
+
let selectedElement: HTMLElement | null = null
|
|
12
32
|
const escaped = (value: string) => value.replace(/["\\]/g, '\\$&')
|
|
13
|
-
const target = () =>
|
|
14
|
-
path
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
const target = () => {
|
|
34
|
+
if (!path) return null
|
|
35
|
+
const selector = `[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`
|
|
36
|
+
if (!path.startsWith('single:') || !via.length) return doc.querySelector<HTMLElement>(selector)
|
|
37
|
+
// Re-resolve after React commits, but never silently select a different occurrence if the
|
|
38
|
+
// requested placement has disappeared. Context-free selections retain their old behavior.
|
|
39
|
+
const exact: HTMLElement[] = []
|
|
40
|
+
const fallback: HTMLElement[] = []
|
|
41
|
+
let fallbackBlockLength = 0
|
|
42
|
+
for (const element of doc.querySelectorAll<HTMLElement>(selector)) {
|
|
43
|
+
const context = selectionContext(element, path)
|
|
44
|
+
if (context.via?.length === via.length && context.via.every((ancestor, index) => ancestor === via[index])) exact.push(element)
|
|
45
|
+
// Older markup names only the enclosing block, while the inspector can name its Single
|
|
46
|
+
// field. Explicit references must still match exactly and take precedence over fallback.
|
|
47
|
+
else if (context.block && via.length === 1 && via[0]!.startsWith(`${context.block}/`) && via[0]!.length > context.block.length + 1) {
|
|
48
|
+
// A nested block and its outer block can both render this Single. Use the nearest
|
|
49
|
+
// placement named by the inspector, not another occurrence higher in that subtree.
|
|
50
|
+
if (context.block.length > fallbackBlockLength) {
|
|
51
|
+
fallback.length = 0
|
|
52
|
+
fallbackBlockLength = context.block.length
|
|
53
|
+
}
|
|
54
|
+
if (context.block.length === fallbackBlockLength) fallback.push(element)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const candidates = exact.length ? exact : fallback
|
|
58
|
+
return candidates.find((element) => bounds(element) !== null) ?? candidates[0] ?? null
|
|
59
|
+
}
|
|
60
|
+
const markTarget = () => {
|
|
61
|
+
const element = target()
|
|
62
|
+
if (selectedElement !== element) selectedElement?.removeAttribute('data-u90-selected')
|
|
63
|
+
selectedElement = element
|
|
64
|
+
element?.setAttribute('data-u90-selected', '')
|
|
65
|
+
return element
|
|
66
|
+
}
|
|
19
67
|
type Box = {
|
|
20
68
|
left: number
|
|
21
69
|
top: number
|
|
@@ -42,9 +90,8 @@ export function createSelection(win: Window) {
|
|
|
42
90
|
}
|
|
43
91
|
const paint = () => {
|
|
44
92
|
frame = null
|
|
45
|
-
const element = target()
|
|
46
93
|
// Server refreshes can replace the selected node without changing its path.
|
|
47
|
-
element
|
|
94
|
+
const element = markTarget()
|
|
48
95
|
let box = element ? bounds(element) : null
|
|
49
96
|
if (!element || !box) {
|
|
50
97
|
overlay.hidden = true
|
|
@@ -81,13 +128,14 @@ export function createSelection(win: Window) {
|
|
|
81
128
|
if (path && frame === null) frame = win.requestAnimationFrame(paint)
|
|
82
129
|
}
|
|
83
130
|
return {
|
|
84
|
-
select(next: string | null, reveal = false) {
|
|
131
|
+
select(next: string | null, reveal = false, nextVia?: readonly string[]) {
|
|
85
132
|
doc
|
|
86
133
|
.querySelectorAll('[data-u90-selected]')
|
|
87
134
|
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
|
88
135
|
path = next
|
|
136
|
+
via = nextVia ? [...nextVia] : []
|
|
89
137
|
shouldReveal = reveal
|
|
90
|
-
|
|
138
|
+
markTarget()
|
|
91
139
|
overlay.hidden = true
|
|
92
140
|
schedule()
|
|
93
141
|
},
|
|
@@ -125,6 +173,7 @@ export function createSelection(win: Window) {
|
|
|
125
173
|
if (frame !== null) win.cancelAnimationFrame(frame)
|
|
126
174
|
frame = null
|
|
127
175
|
path = null
|
|
176
|
+
selectedElement = null
|
|
128
177
|
doc
|
|
129
178
|
.querySelectorAll('[data-u90-selected]')
|
|
130
179
|
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
|
@@ -4,6 +4,7 @@ import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState,
|
|
|
4
4
|
import { applyNativeDraft, NativeDraftReferences, safeDraftPath } from './nativeDraft'
|
|
5
5
|
import type { DraftSnapshot } from './protocol'
|
|
6
6
|
import { registry } from './registry'
|
|
7
|
+
import { applyNativeOrder, reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
7
8
|
|
|
8
9
|
/** Set by draft block rendering; published components have no editing owner. */
|
|
9
10
|
export const DraftContentPath = createContext<string | undefined>(undefined)
|
|
@@ -81,6 +82,9 @@ const sameSource = (a: unknown, b: unknown): boolean => {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export type NativeDraftPatch = { path: string; value: unknown }
|
|
85
|
+
type PreviewPatch = { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }
|
|
86
|
+
const applyPreviewPatch = <T,>(source: T, patch: PreviewPatch, apply: (source: T, patch: NativeDraftPatch) => T, reorder: (source: T, order: DraftOrder) => T = reorderNativeDraft): T =>
|
|
87
|
+
patch.kind === 'order' ? reorder(source, patch) : apply(source, patch)
|
|
84
88
|
const applyPatch = <T,>(source: T, patch: NativeDraftPatch): T => applyNativeDraft(source, patch.path, patch.value)
|
|
85
89
|
const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
86
90
|
if (!value || typeof value !== 'object' || seen.has(value)) return false
|
|
@@ -90,29 +94,32 @@ const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** React owns native previews throughout saving; a later refresh settles the server's result.
|
|
93
|
-
* The optional adapter maps a native patch into a site's existing plain presentation props.
|
|
94
|
-
|
|
97
|
+
* The optional adapter maps a native patch into a site's existing plain presentation props.
|
|
98
|
+
* Custom adapters keep receiving complete snapshots unless they supply a third, order-only
|
|
99
|
+
* adapter. Use reorderNativeDraft for collections retaining native block $path values. */
|
|
100
|
+
export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch, reorder = apply === applyPatch ? reorderNativeDraft<T> : undefined): T {
|
|
95
101
|
const owner = useContext(DraftContentPath)
|
|
96
102
|
const references = useContext(ReferenceValues)
|
|
97
103
|
const [cache] = useState(() => new NativeDraftReferences())
|
|
98
|
-
const indexed = useRef<{ source: T; references: readonly unknown[] } | null>(null)
|
|
99
|
-
if (!indexed.current || !sameSource(indexed.current.source, source) || indexed.current.references !== references) {
|
|
100
|
-
cache.add(references)
|
|
101
|
-
cache.add(source)
|
|
102
|
-
indexed.current = { source, references }
|
|
103
|
-
}
|
|
104
104
|
const enabled = !!owner || hasNativePath(source)
|
|
105
|
-
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string,
|
|
105
|
+
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, PreviewPatch>() })
|
|
106
106
|
let current = shown.value
|
|
107
107
|
if (!sameSource(shown.source, source) || shown.owner !== owner) {
|
|
108
|
-
const overrides = shown.owner === owner ? shown.overrides : new Map<string,
|
|
108
|
+
const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
|
|
109
109
|
current = source
|
|
110
|
-
for (const patch of overrides.values()) current =
|
|
110
|
+
for (const patch of overrides.values()) current = applyPreviewPatch(current, patch, apply, reorder)
|
|
111
111
|
setShown({ source, value: current, owner, overrides })
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const latest = useRef({ current, source, references, apply, reorder })
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
cache.add(references)
|
|
116
|
+
cache.add(source)
|
|
117
|
+
for (const patch of shown.overrides.values()) {
|
|
118
|
+
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
119
|
+
else cache.order(patch)
|
|
120
|
+
}
|
|
121
|
+
latest.current = { current, source, references, apply, reorder }
|
|
122
|
+
}, [cache, current, source, references, apply, reorder, shown.overrides])
|
|
116
123
|
useLayoutEffect(() => {
|
|
117
124
|
if (!enabled) return
|
|
118
125
|
const handler = {
|
|
@@ -124,21 +131,27 @@ export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDr
|
|
|
124
131
|
afterRefresh: () => setShown(previous => {
|
|
125
132
|
cache.settle([latest.current.references, latest.current.source])
|
|
126
133
|
let value = latest.current.source
|
|
127
|
-
for (const patch of previous.overrides.values()) value = latest.current.apply
|
|
134
|
+
for (const patch of previous.overrides.values()) value = applyPreviewPatch(value, patch, latest.current.apply, latest.current.reorder)
|
|
128
135
|
latest.current.current = value
|
|
129
136
|
return { ...previous, source: latest.current.source, value }
|
|
130
137
|
}),
|
|
131
|
-
preview: (snapshot: DraftSnapshot) => {
|
|
138
|
+
preview: (snapshot: DraftSnapshot, order?: DraftOrder) => {
|
|
132
139
|
if (!safeDraftPath(snapshot.path)) return false
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
const useOrder = order && latest.current.reorder
|
|
141
|
+
const patch: PreviewPatch = useOrder ? { kind: 'order', ...order } : { kind: 'value', path: snapshot.path, value: cache.expand(snapshot) }
|
|
142
|
+
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
143
|
+
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
|
|
137
147
|
latest.current.current = next
|
|
138
148
|
setShown(previous => {
|
|
139
149
|
const overrides = new Map(previous.overrides)
|
|
140
|
-
|
|
141
|
-
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)
|
|
142
155
|
return { ...previous, value: next, overrides }
|
|
143
156
|
})
|
|
144
157
|
return true
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import NextLink from 'next/link'
|
|
4
|
+
import { type ComponentProps } from 'react'
|
|
5
|
+
import { clearBlockNavigation, requestBlockNavigation } from './block-navigation'
|
|
6
|
+
|
|
7
|
+
/** Only generated SDK destinations need cooperation with NextLink's navigation lifecycle. */
|
|
8
|
+
export function BlockLink({ onNavigate, href, replace, scroll, ...props }: ComponentProps<typeof NextLink>) {
|
|
9
|
+
return <NextLink {...props} href={href} replace={replace} scroll={scroll} onNavigate={event => {
|
|
10
|
+
let cancelled = false
|
|
11
|
+
onNavigate?.({ preventDefault: () => { cancelled = true; event.preventDefault() } })
|
|
12
|
+
if (cancelled || typeof href !== 'string') return
|
|
13
|
+
const url = new URL(href, window.location.href)
|
|
14
|
+
if (url.origin !== window.location.origin) return
|
|
15
|
+
if (url.pathname !== window.location.pathname || url.search !== window.location.search) {
|
|
16
|
+
requestBlockNavigation(window, url, scroll !== false)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
clearBlockNavigation(window)
|
|
20
|
+
// NextLink has already honored onClick, modifiers, download and target; the
|
|
21
|
+
// caller's onNavigate also ran before committing generated same-page navigation.
|
|
22
|
+
event.preventDefault()
|
|
23
|
+
// Next's native history integration copies its own router state. Passing that
|
|
24
|
+
// private state back would mark this as an internal update and skip URL sync.
|
|
25
|
+
if (replace || url.href !== window.location.href) window.history[replace ? 'replaceState' : 'pushState'](null, '', url.href)
|
|
26
|
+
if (scroll !== false) window.document.dispatchEvent(new window.CustomEvent('u90:block-navigate'))
|
|
27
|
+
}} />
|
|
28
|
+
}
|