@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.
Files changed (54) hide show
  1. package/README.md +104 -1
  2. package/package.json +4 -3
  3. package/src/bridge/DraftBlocks.tsx +10 -0
  4. package/src/bridge/createBridge.ts +11 -10
  5. package/src/bridge/index.ts +1 -0
  6. package/src/bridge/nativeDraft.ts +22 -4
  7. package/src/bridge/nativeOrder.ts +68 -0
  8. package/src/bridge/registry.ts +3 -2
  9. package/src/bridge/selection.ts +59 -10
  10. package/src/bridge/useDraftContent.tsx +35 -22
  11. package/src/components/BlockLink.tsx +28 -0
  12. package/src/components/Link.tsx +8 -1
  13. package/src/components/block-navigation.ts +24 -0
  14. package/src/custom-code/fragments.ts +64 -0
  15. package/src/custom-code/index.ts +30 -0
  16. package/src/elements/blockAnchor.ts +7 -0
  17. package/src/elements/declaration.ts +1 -1
  18. package/src/elements/define.ts +1 -1
  19. package/src/elements/index.ts +1 -0
  20. package/src/elements/ops/validate.ts +9 -2
  21. package/src/features/client/README.md +1 -2
  22. package/src/features/client/SiteFeatures.tsx +15 -25
  23. package/src/features/client/index.ts +0 -6
  24. package/src/features/client/verification.ts +3 -0
  25. package/src/features/contracts/base.ts +1 -5
  26. package/src/features/contracts/index.ts +0 -20
  27. package/src/features/contracts/verificationConfig.ts +7 -0
  28. package/src/features/server/gateway.ts +1 -3
  29. package/src/features/server/index.ts +0 -2
  30. package/src/legacy/cli.mjs +2 -2
  31. package/src/legacy/content.ts +1 -1
  32. package/src/server/BlockAnchors.tsx +92 -0
  33. package/src/server/CustomCode.tsx +29 -0
  34. package/src/server/index.ts +1 -0
  35. package/src/server/read.ts +13 -0
  36. package/src/server/render.tsx +11 -5
  37. package/src/site/defineSite.ts +6 -3
  38. package/src/site/documents.ts +2 -2
  39. package/src/site/forms.ts +32 -0
  40. package/src/site/index.ts +2 -0
  41. package/src/site/types.ts +4 -1
  42. package/src/site/version.ts +1 -1
  43. package/src/site/wire.ts +3 -0
  44. package/src/features/client/ConsentRuntime.tsx +0 -95
  45. package/src/features/client/useConsent.ts +0 -15
  46. package/src/features/consent/README.md +0 -70
  47. package/src/features/consent/cookieScript.ts +0 -53
  48. package/src/features/consent/custom.ts +0 -89
  49. package/src/features/consent/defaults.ts +0 -40
  50. package/src/features/consent/drivers.ts +0 -233
  51. package/src/features/consent/loader.ts +0 -248
  52. package/src/features/consent/registry.ts +0 -398
  53. package/src/features/contracts/consent.ts +0 -150
  54. 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.4 --version
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.4",
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 { createSelection } from './selection'
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 ancestors: string[] = []
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
- for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft) || handled
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 && owner.move(instruction.path, instruction.before)) return
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()) {
@@ -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 [path, value] of this.pending) this.applyPatch(path, value)
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.keys()) if (key === path || key.startsWith(`${path}/`)) this.pending.delete(key)
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
+ }
@@ -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(),
@@ -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
- ? doc.querySelector<HTMLElement>(
16
- `[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`,
17
- )
18
- : null
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?.setAttribute('data-u90-selected', '')
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
- target()?.setAttribute('data-u90-selected', '')
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
- export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch): T {
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, NativeDraftPatch>() })
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, NativeDraftPatch>()
108
+ const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
109
109
  current = source
110
- for (const patch of overrides.values()) current = apply(current, patch)
110
+ for (const patch of overrides.values()) current = applyPreviewPatch(current, patch, apply, reorder)
111
111
  setShown({ source, value: current, owner, overrides })
112
112
  }
113
- for (const patch of shown.overrides.values()) cache.patch(patch.path, patch.value)
114
- const latest = useRef({ current, source, references, apply })
115
- latest.current = { current, source, references, apply }
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(value, patch)
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 patch = { path: snapshot.path, value: cache.expand(snapshot) }
134
- cache.patch(patch.path, patch.value)
135
- const next = latest.current.apply(latest.current.current, patch)
136
- if (next === latest.current.current) return false
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
- for (const key of overrides.keys()) if (key === patch.path || key.startsWith(`${patch.path}/`)) overrides.delete(key)
141
- overrides.set(patch.path, patch)
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
+ }