@unitninety/site-kit 1.6.2 → 1.6.4

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 (44) hide show
  1. package/README.md +26 -1
  2. package/package.json +1 -1
  3. package/src/bridge/DraftBlocks.tsx +14 -7
  4. package/src/bridge/U90Bridge.tsx +7 -4
  5. package/src/bridge/client.tsx +3 -2
  6. package/src/bridge/createBridge.ts +47 -12
  7. package/src/bridge/hooks.tsx +9 -8
  8. package/src/bridge/index.ts +6 -0
  9. package/src/bridge/nativeDraft.ts +177 -0
  10. package/src/bridge/protocol.ts +45 -7
  11. package/src/bridge/registry.ts +6 -1
  12. package/src/bridge/selection.ts +134 -0
  13. package/src/bridge/styles.ts +10 -1
  14. package/src/bridge/useDraftContent.tsx +151 -0
  15. package/src/components/Link.tsx +1 -1
  16. package/src/elements/containers/group.ts +1 -1
  17. package/src/elements/containers/zone.ts +13 -2
  18. package/src/elements/declaration.ts +89 -2
  19. package/src/elements/define.ts +22 -3
  20. package/src/elements/jsonSchema.ts +4 -3
  21. package/src/elements/leaves/link.ts +17 -2
  22. package/src/elements/leaves/query.ts +6 -1
  23. package/src/elements/leaves/relation.ts +3 -0
  24. package/src/elements/leaves/rich-text.ts +32 -16
  25. package/src/elements/leaves/selection.ts +5 -4
  26. package/src/elements/ops/expand.ts +5 -4
  27. package/src/elements/ops/seed.ts +25 -6
  28. package/src/elements/ops/validate.ts +31 -2
  29. package/src/legacy/cli.mjs +2 -2
  30. package/src/legacy/content.ts +1 -1
  31. package/src/seo/robots.ts +8 -8
  32. package/src/seo/seeds.ts +61 -0
  33. package/src/server/render.tsx +3 -3
  34. package/src/site/assemble.ts +1 -1
  35. package/src/site/defineSite.ts +2 -2
  36. package/src/site/documents.ts +4 -4
  37. package/src/site/fields.ts +6 -6
  38. package/src/site/formView.ts +175 -0
  39. package/src/site/index.ts +3 -0
  40. package/src/site/replies.ts +5 -4
  41. package/src/site/types.ts +3 -1
  42. package/src/site/version.ts +1 -1
  43. package/src/site/wire.ts +1 -1
  44. package/src/testing/fake.ts +4 -4
package/README.md CHANGED
@@ -5,13 +5,38 @@ 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.1 --version
8
+ npx --yes @unitninety/u90@1.0.4 --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
+ ### Fixed field values
16
+
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.
18
+
19
+ Initial document creation and imports may initialise locked values, as may a new block or list item. Once that item exists, its locked fields are fixed; removing the entire editable item still follows the containing zone or list's rules. A whole-field lock does not supply a value, so declare a default when new entries need one.
20
+
21
+ ### Short collection creation forms
22
+
23
+ Choose the text fields a client fills in before opening a new entry in Canvas:
24
+
25
+ ```ts
26
+ const capabilities = collection('capabilities', {
27
+ route: '/capabilities/:slug',
28
+ fields: {
29
+ name: text({ label: 'Capability name' }),
30
+ shortSummary: text({ label: 'Summary', multiline: true }),
31
+ },
32
+ authoring: { create: { fields: ['name', 'shortSummary'] } },
33
+ })
34
+ ```
35
+
36
+ The form uses each field's label, default and text constraints. The list must contain 1–8 unique, editable text fields and start with the collection's first declared text field (its title). Locked, unknown or non-text fields are rejected. Collections without this optional metadata keep their title-only creation form. The metadata survives templates and manifest/package round trips; it does not alter stored content or the page design.
37
+
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
+
15
40
  ### Immediate collection-list previews
16
41
 
17
42
  Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
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": {
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { Fragment, useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
3
+ import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
4
4
 
5
+ import { DraftContentPath } from './useDraftContent'
5
6
  import { registry } from './registry'
6
7
  import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
7
8
  import { PreviewCommitBoundary } from './PreviewCommitBoundary'
@@ -33,21 +34,27 @@ export function DraftBlocks({ items }: { items: Item[] }) {
33
34
  host.current = node
34
35
  return preserveBlockMoves(node)
35
36
  }, [])
36
- const [shown, setShown] = useState({ source: items, items, order: null as string[] | null })
37
+ const [shown, setShown] = useState({ source: items, items, order: null as string[] | null, removed: new Set<string>() })
37
38
  // Keep the desired order, not a log to replay: the server response may already
38
39
  // contain later moves, and applying a sequence twice is not generally idempotent.
39
40
  const ranks = new Map(shown.order?.map((id, index) => [id, index]))
40
- const known = items.filter((item) => ranks.has(item.id)).sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!)
41
+ const available = items.filter(item => !item.path || !shown.removed.has(item.path))
42
+ const known = available.filter((item) => ranks.has(item.id)).sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!)
41
43
  let knownIndex = 0
42
- const current = shown.source === items ? shown.items : shown.order === null ? items
43
- : items.map((item) => ranks.has(item.id) ? known[knownIndex++]! : item)
44
+ const current = shown.source === items ? shown.items : shown.order === null ? available
45
+ : available.map((item) => ranks.has(item.id) ? known[knownIndex++]! : item)
44
46
  if (shown.source !== items) setShown({ ...shown, source: items, items: current })
45
47
 
46
48
  useLayoutEffect(() => {
47
49
  const handler = {
48
50
  document,
49
51
  needsReload: () => reload.current,
50
- beforeRefresh: () => setShown((value) => ({ ...value, order: null })),
52
+ beforeRefresh: () => setShown((value) => ({ ...value, order: null, removed: new Set() })),
53
+ remove: (path: string) => {
54
+ if (!current.some(item => item.path === path)) return false
55
+ setShown(value => ({ ...value, items: value.items.filter(item => item.path !== path), removed: new Set([...value.removed, path]) }))
56
+ return true
57
+ },
51
58
  move: (path: string, before: string | null) => {
52
59
  if (!current.some((item) => item.path === path)) return false
53
60
  if (before !== null && !current.some((item) => item.path === before)) return false
@@ -66,5 +73,5 @@ export function DraftBlocks({ items }: { items: Item[] }) {
66
73
  return () => { registry.blockOrders.delete(handler) }
67
74
  }, [current])
68
75
 
69
- return <PreviewCommitBoundary source={items}><div data-u90-draft-zone="" style={{ display: 'contents' }} ref={attachHost}>{current.map((item) => <Fragment key={item.id}>{item.content}</Fragment>)}</div></PreviewCommitBoundary>
76
+ return <PreviewCommitBoundary source={items}><div data-u90-draft-zone="" style={{ display: 'contents' }} ref={attachHost}>{current.map((item) => <DraftContentPath.Provider key={item.id} value={item.path}>{item.content}</DraftContentPath.Provider>)}</div></PreviewCommitBoundary>
70
77
  }
@@ -19,11 +19,11 @@ import { EditingProvider } from './hooks'
19
19
  * Ordinary visitors perform no I/O here. Test previews additionally verify their session with the
20
20
  * source trust response; pass `u90.testPreview` to share the connection's bounded trust cache.
21
21
  */
22
- type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; testPreview?: () => Promise<TestPreviewContext | null> }
23
- async function Gate({ platformOrigin, children, draft, testPreview }: BridgeProps) {
22
+ type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; inlineEditing?: boolean; testPreview?: () => Promise<TestPreviewContext | null> }
23
+ async function Gate({ platformOrigin, children, draft, testPreview, inlineEditing }: BridgeProps) {
24
24
  const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
25
25
  const props = await resolveBridgePreview({ platformOrigin, draft, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
26
- return <EditingProvider {...props}>{children}</EditingProvider>
26
+ return <EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider>
27
27
  }
28
28
 
29
29
  /**
@@ -42,13 +42,16 @@ export function U90Bridge({
42
42
  children,
43
43
  draft = nextAdapters.draft,
44
44
  testPreview,
45
+ inlineEditing = true,
45
46
  platformOrigin = process.env.PLATFORM_URL ? new URL(process.env.PLATFORM_URL).origin : '',
46
47
  }: {
47
48
  children: ReactNode
48
49
  platformOrigin?: string
50
+ /** Set false to edit through the inspector while canvas clicks only select fields. */
51
+ inlineEditing?: boolean
49
52
  draft?: NextAdapters['draft']
50
53
  /** Pass u90.testPreview to share its configured connection and injected adapters. */
51
54
  testPreview?: () => Promise<TestPreviewContext | null>
52
55
  }) {
53
- return <Suspense fallback={null}><Gate platformOrigin={platformOrigin} draft={draft} testPreview={testPreview}>{children}</Gate></Suspense>
56
+ return <Suspense fallback={null}><Gate platformOrigin={platformOrigin} draft={draft} testPreview={testPreview} inlineEditing={inlineEditing}>{children}</Gate></Suspense>
54
57
  }
@@ -13,7 +13,7 @@ import { createBridge, type Bridge } from './createBridge'
13
13
  * Renders nothing; all of its work is the `createBridge` instance it starts and stops, plus the
14
14
  * `usePathname()` effect below that reports a client-side navigation as `u90:path`.
15
15
  */
16
- export function BridgeClient({ platformOrigin, onSelect }: { platformOrigin: string; onSelect?: (path: string | null) => void }) {
16
+ export function BridgeClient({ platformOrigin, onSelect, inlineEditing = true }: { platformOrigin: string; onSelect?: (path: string | null, via?: string[]) => void; inlineEditing?: boolean }) {
17
17
  const router = useRouter()
18
18
  const bridgeRef = useRef<Bridge | null>(null)
19
19
  /*
@@ -32,6 +32,7 @@ export function BridgeClient({ platformOrigin, onSelect }: { platformOrigin: str
32
32
  parent: window.parent,
33
33
  refresh: () => startTransition(() => router.refresh()),
34
34
  onSelect,
35
+ inlineEditing,
35
36
  })
36
37
  bridgeRef.current = bridge
37
38
  bridge.start()
@@ -39,7 +40,7 @@ export function BridgeClient({ platformOrigin, onSelect }: { platformOrigin: str
39
40
  bridge.stop()
40
41
  bridgeRef.current = null
41
42
  }
42
- }, [platformOrigin, router, onSelect])
43
+ }, [platformOrigin, router, onSelect, inlineEditing])
43
44
 
44
45
  // Only the end matters: the bridge marks the start itself. A slow refresh warns; only a real completion releases its boundary.
45
46
  useEffect(() => {
@@ -4,13 +4,16 @@ 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
8
  import { EDITING_CSS } from './styles'
8
9
 
9
10
  export type BridgeOptions = {
10
11
  platformOrigin: string
11
12
  parent: Pick<Window, 'postMessage'>
12
13
  refresh: () => void
13
- onSelect?: (path: string | null) => void
14
+ onSelect?: (path: string | null, via?: string[]) => void
15
+ /** False selects canvas fields without enabling browser typing; inspector previews still apply. */
16
+ inlineEditing?: boolean
14
17
  }
15
18
 
16
19
  const DEBOUNCE = 150
@@ -244,10 +247,10 @@ export function createBridge(win: Window, opts: BridgeOptions) {
244
247
  }
245
248
 
246
249
  /** Outline the element at `path` (field or block) and clear any previous outline. */
247
- const markSelected = (path: string | null) => {
248
- doc.querySelectorAll('[data-u90-selected]').forEach((e) => e.removeAttribute('data-u90-selected'))
249
- if (path) doc.querySelector(`[data-u90-field="${q(path)}"], [data-u90-block="${q(path)}"]`)?.setAttribute('data-u90-selected', '')
250
- opts.onSelect?.(path)
250
+ const selection = createSelection(win)
251
+ const markSelected = (path: string | null, scroll = false, via?: string[]) => {
252
+ selection.select(path, scroll)
253
+ opts.onSelect?.(path, via)
251
254
  }
252
255
 
253
256
  /** `u90:select` from the Canvas also fires any `useReveal` listener whose prefix matches. */
@@ -383,10 +386,16 @@ export function createBridge(win: Window, opts: BridgeOptions) {
383
386
  // (a menu toggle) still works while the Canvas is open.
384
387
  e.preventDefault()
385
388
  }
386
- markSelected(path)
387
- send({ type: 'u90:select', path })
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
395
+ markSelected(path, false, via)
396
+ send({ type: 'u90:select', path, ...(via ? {via} : {}) })
388
397
  const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
389
- if (el && edit && isEditableLeaf(el)) {
398
+ if (opts.inlineEditing !== false && el && edit && isEditableLeaf(el)) {
390
399
  // Resolve the caret target before the element becomes editable, apply it after —
391
400
  // `contenteditable` changes hit-testing (see `caret.ts`'s `caretTargetIn` doc).
392
401
  const target = caretTargetIn(el, mouse.clientX, mouse.clientY)
@@ -396,6 +405,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
396
405
  }
397
406
 
398
407
  const onInput = (e: Event) => {
408
+ if (opts.inlineEditing === false) return
399
409
  const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
400
410
  if (el) {
401
411
  schedule(el)
@@ -515,6 +525,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
515
525
  */
516
526
  const REFRESH_CEILING_MS = 5000
517
527
  const setRefreshing = (value: boolean) => {
528
+ const committed = refreshing && !value
518
529
  refreshing = value
519
530
  if (refreshingTimer) clearTimeout(refreshingTimer)
520
531
  refreshingTimer = null
@@ -526,10 +537,13 @@ export function createBridge(win: Window, opts: BridgeOptions) {
526
537
  // A pending timer must not keep a Node process alive (tests); browsers return a number.
527
538
  ;(refreshingTimer as unknown as { unref?: () => void }).unref?.()
528
539
  }
529
- else if (refreshAgain) requestRefresh()
540
+ else {
541
+ if (committed) for (const owner of registry.draftValues) if (owner.document === doc) owner.afterRefresh()
542
+ if (refreshAgain) requestRefresh()
543
+ }
530
544
  }
531
545
  function requestRefresh() {
532
- if (refreshing && [...registry.blockOrders, ...registry.entryLists].some((owner) => owner.document === doc)) {
546
+ if (refreshing && [...registry.blockOrders, ...registry.entryLists, ...registry.draftContents, ...registry.draftValues].some((owner) => owner.document === doc)) {
533
547
  refreshAgain = true
534
548
  return
535
549
  }
@@ -540,7 +554,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
540
554
  }
541
555
  // One refresh boundary at a time: newer moves stay visible through the older render.
542
556
  // Clear only the moves included when THIS request starts, never when another is queued.
543
- for (const owner of [...registry.blockOrders, ...registry.entryLists]) if (owner.document === doc) owner.beforeRefresh()
557
+ for (const owner of [...registry.blockOrders, ...registry.entryLists, ...registry.draftContents, ...registry.draftValues]) if (owner.document === doc) owner.beforeRefresh()
544
558
  const restoreEditing = preserveEditing()
545
559
  try {
546
560
  undoPreviews()
@@ -554,11 +568,28 @@ export function createBridge(win: Window, opts: BridgeOptions) {
554
568
  const preview = (instruction: PreviewInstruction) => {
555
569
  // React-owned order is safe even while a refresh is pending. Only legacy DOM moves
556
570
  // still need the guard below; they can disagree with React's tree during a commit.
571
+ const draft = instruction.kind === 'value' ? instruction : instruction.draft
572
+ if (draft) {
573
+ let handled = false
574
+ for (const owner of registry.draftValues) if (owner.document === doc) handled = owner.preview(draft) || handled
575
+ if (handled || instruction.kind === 'value') return
576
+ }
577
+ const list = instruction.kind === 'list' ? instruction : instruction.list
578
+ if (list) {
579
+ let handled = false
580
+ for (const owner of registry.draftContents) if (owner.document === doc) handled = owner.preview(list.path, list.value) || handled
581
+ if (handled || instruction.kind === 'list') return
582
+ }
557
583
  if (instruction.kind === 'entries') {
558
584
  // All renderings of this placement update, including duplicate responsive layouts.
559
585
  for (const owner of registry.entryLists) if (owner.document === doc) owner.pick(instruction.path, instruction)
560
586
  return
561
587
  }
588
+ if (instruction.kind === 'remove') {
589
+ let handled = false
590
+ for (const owner of registry.blockOrders) if (owner.document === doc) handled = !!owner.remove?.(instruction.path) || handled
591
+ if (handled) return
592
+ }
562
593
  if (instruction.kind === 'move') {
563
594
  for (const owner of registry.blockOrders) {
564
595
  if (owner.document === doc && owner.move(instruction.path, instruction.before)) return
@@ -658,8 +689,8 @@ export function createBridge(win: Window, opts: BridgeOptions) {
658
689
  if (!refreshing) void loadFragment(m.instruction)
659
690
  } else preview(m.instruction)
660
691
  } else if (m.type === 'u90:select') {
661
- markSelected(m.path)
662
692
  reveal(m.path)
693
+ markSelected(m.path, m.reveal === true, m.via)
663
694
  } else if (m.type === 'u90:flush') {
664
695
  flush()
665
696
  send({ type: 'u90:flushed', requestId: m.requestId })
@@ -930,6 +961,8 @@ export function createBridge(win: Window, opts: BridgeOptions) {
930
961
  send({ type: 'u90:ready', path: win.location.pathname })
931
962
 
932
963
  doc.head.appendChild(style)
964
+ if (opts.inlineEditing === false) doc.documentElement.setAttribute('data-u90-inspector-only', '')
965
+ selection.start()
933
966
  applyDraftLanguage()
934
967
  doc.addEventListener('click', onClick, true)
935
968
  doc.addEventListener('input', onInput, true)
@@ -965,6 +998,8 @@ export function createBridge(win: Window, opts: BridgeOptions) {
965
998
  flush()
966
999
  releaseViewport()
967
1000
  style.remove()
1001
+ if (opts.inlineEditing === false) doc.documentElement.removeAttribute('data-u90-inspector-only')
1002
+ selection.stop()
968
1003
  doc.removeEventListener('click', onClick, true)
969
1004
  doc.removeEventListener('input', onInput, true)
970
1005
  doc.removeEventListener('focusout', onBlur, true)
@@ -1,14 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { createContext, lazy, Suspense, useContext, useEffect, useState, type ReactNode } from 'react'
3
+ import { createContext, lazy, Suspense, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
4
4
 
5
5
  import { registry } from './registry'
6
6
 
7
7
  const LazyBridgeClient = lazy(() => import('./client').then((m) => ({ default: m.BridgeClient })))
8
8
 
9
- type EditingContextValue = { editing: boolean; selected: string | null; testPreview: boolean }
9
+ type EditingContextValue = { editing: boolean; selected: string | null; selectedVia: readonly string[]; testPreview: boolean }
10
10
 
11
- const Ctx = createContext<EditingContextValue>({ editing: false, selected: null, testPreview: false })
11
+ const Ctx = createContext<EditingContextValue>({ editing: false, selected: null, selectedVia: [], testPreview: false })
12
12
 
13
13
  /**
14
14
  * Mounted once, at the root, by `U90Bridge` — always mounted so `useEditing()` works from
@@ -19,22 +19,23 @@ const Ctx = createContext<EditingContextValue>({ editing: false, selected: null,
19
19
  * `import('./client')` pulls in `next/navigation` and the whole DOM-level bridge, so a production
20
20
  * (non-draft) render never fetches that chunk at all.
21
21
  */
22
- export function EditingProvider({ editing, platformOrigin, testPreview = false, children }: { editing: boolean; platformOrigin: string; testPreview?: boolean; children: ReactNode }) {
23
- const [selected, setSelected] = useState<string | null>(null)
22
+ export function EditingProvider({ editing, platformOrigin, testPreview = false, inlineEditing = true, children }: { editing: boolean; platformOrigin: string; testPreview?: boolean; inlineEditing?: boolean; children: ReactNode }) {
23
+ const [selection, setSelection] = useState<{path: string | null; via: string[]}>({path: null, via: []})
24
+ const onSelect = useCallback((path: string | null, via?: string[]) => setSelection({path, via: via ?? []}), [])
24
25
 
25
26
  return (
26
- <Ctx.Provider value={{ editing, selected, testPreview }}>
27
+ <Ctx.Provider value={{ editing, selected: selection.path, selectedVia: selection.via, testPreview }}>
27
28
  {children}
28
29
  {editing ? (
29
30
  <Suspense fallback={null}>
30
- <LazyBridgeClient platformOrigin={platformOrigin} onSelect={setSelected} />
31
+ <LazyBridgeClient platformOrigin={platformOrigin} onSelect={onSelect} inlineEditing={inlineEditing} />
31
32
  </Suspense>
32
33
  ) : null}
33
34
  </Ctx.Provider>
34
35
  )
35
36
  }
36
37
 
37
- /** `{ editing, selected }` — `selected` is the path last reported by `u90:select`, either way. */
38
+ /** `selectedVia` identifies the reference fields leading to the selected entry. `{ editing, selected }` — `selected` is the path last reported by `u90:select`, either way. */
38
39
  export const useEditing = (): EditingContextValue => useContext(Ctx)
39
40
 
40
41
  /**
@@ -7,3 +7,9 @@
7
7
  export { BridgeClient } from './client'
8
8
  export { EditingProvider, useEditing, useReveal } from './hooks'
9
9
  export { BRIDGE_VERSION, isSiteMessage, parseCanvasMessage, type BridgeStamp, type CanvasMessage, type PreviewInstruction, type SiteMessage } from './protocol'
10
+
11
+ /** Stable preview ordering for custom server-rendered sections with native block paths. */
12
+ export { DraftBlocks } from './DraftBlocks'
13
+
14
+ /** React-owned previews for client components reading list content inside a draft block. */
15
+ export { useDraftContent, useDraftPreview, DraftReferences, applyNativeDraft, type NativeDraftPatch } from './useDraftContent'
@@ -0,0 +1,177 @@
1
+ import { isLeaf, type EntryRead, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../elements/define'
2
+ import { getContainer } from '../elements/registry'
3
+ import { expandField } from '../elements/ops/expand'
4
+ import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
5
+ import type { EntriesRead } from '../elements/leaves/selection'
6
+ import type { DraftSnapshot } from './protocol'
7
+
8
+ const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
9
+ const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
10
+ export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
11
+
12
+ /** Native groups do not carry paths; their children do. Infer only when the children agree. */
13
+ export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
14
+ if (!value || typeof value !== 'object' || seen.has(value)) return
15
+ seen.add(value)
16
+ if (object(value) && typeof value.$path === 'string') return safeDraftPath(value.$path) ? value.$path : undefined
17
+ const candidates = new Set<string>()
18
+ for (const [key, child] of Object.entries(value)) {
19
+ if (unsafe.has(key) || key.startsWith('$')) continue
20
+ const at = nativePath(child, seen)
21
+ if (!at) continue
22
+ if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
23
+ else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
24
+ }
25
+ return candidates.size === 1 ? [...candidates][0] : undefined
26
+ }
27
+
28
+ /** Immutable path replacement across any mixture of native document, entry and field reads. */
29
+ export function applyNativeDraft<T>(source: T, path: string, replacement: unknown): T {
30
+ if (!safeDraftPath(path)) return source
31
+ const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
32
+ const at = nativePath(value) ?? parent
33
+ if (at === path || parent === path) return replacement
34
+ if (!value || typeof value !== 'object' || seen.has(value)) return value
35
+ const nextSeen = new Set(seen).add(value)
36
+ let result: any = value
37
+ for (const [key, child] of Object.entries(value)) {
38
+ if (unsafe.has(key) || key.startsWith('$')) continue
39
+ const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
40
+ const next = visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined, nextSeen)
41
+ if (next !== child) {
42
+ if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
43
+ result[key] = next
44
+ }
45
+ }
46
+ return result
47
+ }
48
+ return visit(source) as T
49
+ }
50
+
51
+ /** A local index of real native reads. Missing references remain missing until the server answers. */
52
+ export class NativeDraftReferences {
53
+ private reads = new Map<string, unknown>()
54
+ private pending = new Map<string, unknown>()
55
+ private media = new Map<string, MediaRecord>()
56
+ private pages = new Map<string, PageRecord>()
57
+ private entries = new Map<string, EntryRead>()
58
+ private singles = new Map<string, unknown>()
59
+ private queries = new Map<string, EntriesRead>()
60
+
61
+ add(values: unknown): void {
62
+ const seen = new Set<object>()
63
+ const visit = (value: unknown, parent?: string) => {
64
+ if (parent) this.reads.set(parent, value)
65
+ if (!value || typeof value !== 'object' || seen.has(value)) return
66
+ seen.add(value)
67
+ const at = nativePath(value) ?? parent
68
+ if (at) this.reads.set(at, value)
69
+ if (object(value)) {
70
+ if (at?.startsWith('single:') && !at.includes('/')) this.singles.set(at.slice(7), value)
71
+ if (value.id !== undefined && typeof value.url === 'string' && typeof value.mimeType === 'string') {
72
+ const known = this.media.get(String(value.id))
73
+ this.media.set(String(value.id), { ...value, renditions: value.renditions ?? known?.renditions } as MediaRecord)
74
+ }
75
+ if (value.id !== undefined && typeof value.route === 'string' && typeof value.title === 'string') this.pages.set(String(value.id), value as PageRecord)
76
+ if (value.id !== undefined && typeof value.collection === 'string' && (value.status === 'ready' || value.status === 'missing')) {
77
+ const key = `${value.collection}:${value.id}`
78
+ const known = this.entries.get(key)
79
+ if (!known?.fields || value.fields) this.entries.set(key, value as EntryRead)
80
+ }
81
+ if (typeof value.$path === 'string' && Array.isArray(value.entries)) this.queries.set(value.$path, value as EntriesRead)
82
+ if (object(value.to) && typeof value.href === 'string' && value.href.startsWith('/')) {
83
+ const route = value.href.split(/[?#]/)[0]
84
+ if (value.to.page !== undefined) this.pages.set(String(value.to.page), { id: value.to.page, route, title: value.label ?? '' })
85
+ if (object(value.to.entry)) {
86
+ const key = `${value.to.entry.collection}:${value.to.entry.id}`
87
+ if (!this.entries.has(key)) this.entries.set(key, { ...value.to.entry, route, status: 'ready' } as EntryRead)
88
+ }
89
+ }
90
+ // Preserve the already produced crop rendition when all we have is a read asset.
91
+ if (object(value.asset) && object(value.rendition)) {
92
+ const asset = value.asset as MediaRecord
93
+ const previous = this.media.get(String(asset.id))
94
+ this.media.set(String(asset.id), { ...asset, renditions: [...(previous?.renditions ?? []), { url: value.rendition.url, width: value.rendition.width, height: value.rendition.height, ...(value.rendition.blurDataURL ? { blurDataURL: value.rendition.blurDataURL } : {}), crop: value.crop ?? null, status: 'ready' }] })
95
+ }
96
+ }
97
+ for (const [key, child] of Object.entries(value)) {
98
+ if (unsafe.has(key) || key.startsWith('$')) continue
99
+ const id = Array.isArray(value) ? (object(child) ? child._id : undefined) : key
100
+ visit(child, at && typeof id === 'string' ? `${at}/${id}` : undefined)
101
+ }
102
+ }
103
+ visit(values)
104
+ for (const [path, value] of this.pending) this.applyPatch(path, value)
105
+ }
106
+
107
+ patch(path: string, value: unknown): void {
108
+ 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)
111
+ this.applyPatch(path, value)
112
+ }
113
+
114
+ beforeRefresh(): void { this.pending.clear() }
115
+
116
+ settle(values: unknown): void {
117
+ this.reads.clear()
118
+ this.entries.clear()
119
+ this.media.clear()
120
+ this.pages.clear()
121
+ this.singles.clear()
122
+ this.queries.clear()
123
+ this.add(values)
124
+ }
125
+
126
+ private applyPatch(path: string, value: unknown): void {
127
+ for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value))
128
+ for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value))
129
+ for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value))
130
+ for (const [key, query] of this.queries) this.queries.set(key, applyNativeDraft(query, path, value))
131
+ }
132
+
133
+ expand(snapshot: DraftSnapshot): unknown {
134
+ for (const media of snapshot.media ?? []) this.media.set(String(media.id), { ...media, renditions: media.renditions ?? this.media.get(String(media.id))?.renditions })
135
+ this.add(snapshot.entries)
136
+ const entry = (collection: string, id: Id): EntryRead => this.entries.get(`${collection}:${id}`) ?? { id, collection, status: 'missing' }
137
+ const ctx = (at: string): ExpandCtx => ({
138
+ at, draft: true,
139
+ media: id => this.media.get(String(id)), page: id => this.pages.get(String(id)), single: key => this.singles.get(key),
140
+ entry,
141
+ query: (collection, query) => {
142
+ const source = this.queries.get(at)
143
+ // Old reads lack rule metadata. Preserve their known matches; never guess membership.
144
+ const matches = source?.$query?.matches ?? (source?.entries ?? []).map(item => item.id)
145
+ const picked = applyQueryPicks(matches, query as QueryStored, id => entry(collection, id).status === 'ready')
146
+ return { entries: picked.ids.map(id => entry(collection, id)), total: picked.total, ...(source?.$query ? { matches } : {}) }
147
+ },
148
+ inverse: () => {
149
+ const value = this.reads.get(at)
150
+ return (Array.isArray(value) ? value : value ? [value] : []).filter(item => object(item) && typeof item.collection === 'string' && item.id !== undefined) as EntryRead[]
151
+ },
152
+ })
153
+ const schema: Schema = { blocks: snapshot.blocks ?? {}, collections: {}, singles: {} }
154
+ const expand = (field: FieldDecl, value: unknown, at: string): unknown => {
155
+ if (isLeaf(field)) {
156
+ if (field.element === 'u90/query') {
157
+ const previous = this.queries.get(at)
158
+ const baseline = previous?.$query
159
+ const q = object(value) ? value : {}
160
+ if (baseline && (JSON.stringify(baseline.where) !== JSON.stringify(q.where ?? []) || JSON.stringify(baseline.sort) !== JSON.stringify(q.sort ?? null) || baseline.limit !== (q.limit ?? null))) return previous
161
+ }
162
+ return expandField(schema, field, value, ctx(at))
163
+ }
164
+ const children = getContainer(field.container).children(field, field.container === 'u90/group' && !object(value) ? {} : value, schema)
165
+ if (field.container === 'u90/group') return Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
166
+ return children.map((child, index) => {
167
+ const path = `${at}/${child.key}`
168
+ const inner = expand(child.decl, child.value, path)
169
+ if (field.container === 'u90/list') return isLeaf(field.item) ? inner : { _id: child.key, $path: path, ...inner as object }
170
+ const item = Array.isArray(value) && object(value[index]) ? value[index] : {}
171
+ const metadata = { _id: child.key, _block: item._block, ...(typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}) }
172
+ return Object.hasOwn(schema.blocks, String(item._block)) ? { ...metadata, $path: path, ...inner as object } : metadata
173
+ })
174
+ }
175
+ return expand(snapshot.field, snapshot.value, snapshot.path)
176
+ }
177
+ }