@unitninety/site-kit 1.6.5 → 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 +42 -7
- package/package.json +5 -4
- 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/index.ts +1 -0
- package/src/elements/ops/expand.ts +1 -4
- 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/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,19 +5,13 @@ 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.
|
|
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
|
-
### Layout reordering in Canvas
|
|
16
|
-
|
|
17
|
-
Site Kit 1.6.5 gives every draft layout Slot its own preview address. Moving the Slot or placing a block before it updates immediately through the shared renderer, including nested layouts. The optimistic order stays visible while saving, and older responses cannot undo a newer move. Published output is unchanged.
|
|
18
|
-
|
|
19
|
-
Use Site Kit 1.6.5 with `@unitninety/u90@1.0.5` for new sites. Existing sites must update their Site Kit dependency and redeploy to receive the fix; updating the platform alone does not update a site's bundled renderer.
|
|
20
|
-
|
|
21
15
|
### Fixed field values
|
|
22
16
|
|
|
23
17
|
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.
|
|
@@ -61,6 +55,19 @@ Call the hook on native reads before converting them for a presentation componen
|
|
|
61
55
|
|
|
62
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.
|
|
63
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
|
+
|
|
64
71
|
### Immediate collection-list previews
|
|
65
72
|
|
|
66
73
|
Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
|
|
@@ -122,3 +129,31 @@ For example, KAW's Home declares `query('industries', { previewFields: ['name',
|
|
|
122
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.
|
|
123
130
|
|
|
124
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,7 +60,8 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"
|
|
63
|
+
"parse5": "7.3.0",
|
|
64
|
+
"zod": "4.4.3"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
67
|
"react": ">=19",
|
|
@@ -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
|
+
}
|
package/src/components/Link.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import NextLink from 'next/link'
|
|
|
2
2
|
import { Children, createElement, type ReactNode } from 'react'
|
|
3
3
|
import type { LinkRead } from '../elements'
|
|
4
4
|
import { draftAttrs } from './draft'
|
|
5
|
+
import { BlockLink } from './BlockLink'
|
|
6
|
+
import { ID_RE } from '../elements/ids'
|
|
5
7
|
export const isInternal = (href: string) => href.startsWith('/') && !href.startsWith('//')
|
|
6
8
|
export function Link({ value, children, ...rest }: { value: LinkRead | null | undefined; children?: ReactNode } & Record<string, unknown>) {
|
|
7
9
|
if (!value) return null
|
|
@@ -20,5 +22,10 @@ export function Link({ value, children, ...rest }: { value: LinkRead | null | un
|
|
|
20
22
|
// element's props pass through). The href is always the value's.
|
|
21
23
|
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
24
|
for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
|
|
23
|
-
|
|
25
|
+
let generated = false
|
|
26
|
+
try {
|
|
27
|
+
const hash = decodeURIComponent(new URL(value.href, 'https://u90.invalid').hash.slice(1))
|
|
28
|
+
generated = hash.startsWith('u90-block-') && ID_RE.test(hash.slice(10))
|
|
29
|
+
} catch { /* malformed legacy fragments remain ordinary links */ }
|
|
30
|
+
return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(generated ? BlockLink : NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
31
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A generated link's accepted navigation, shared with the destination renderer. */
|
|
2
|
+
type BlockNavigation = { url: URL; scroll: boolean; expires: number }
|
|
3
|
+
const pending = new WeakMap<Window, BlockNavigation>()
|
|
4
|
+
|
|
5
|
+
export function requestBlockNavigation(win: Window, url: URL, scroll: boolean) {
|
|
6
|
+
pending.set(win, { url, scroll, expires: Date.now() + 10_000 })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function clearBlockNavigation(win: Window) {
|
|
10
|
+
pending.delete(win)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function arrivingBlockNavigation(win: Window): BlockNavigation | undefined {
|
|
14
|
+
const request = pending.get(win)
|
|
15
|
+
if (!request) return
|
|
16
|
+
if (Date.now() >= request.expires) { pending.delete(win); return }
|
|
17
|
+
const actual = new URL(win.location.href)
|
|
18
|
+
const expected = request.url
|
|
19
|
+
if (actual.origin !== expected.origin || actual.pathname !== expected.pathname || actual.search !== expected.search) return
|
|
20
|
+
// Next 16.4 caches the initial document fragment in its canonical route URL,
|
|
21
|
+
// then appends the requested fragment on a return navigation. Correct only a
|
|
22
|
+
// still-pending explicit request, never arbitrary fragment-containing URLs.
|
|
23
|
+
if (actual.hash === expected.hash || actual.hash.endsWith(expected.hash)) return request
|
|
24
|
+
}
|