@unitninety/site-kit 1.6.2 → 1.6.3
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 +26 -1
- package/package.json +1 -1
- package/src/bridge/DraftBlocks.tsx +14 -7
- package/src/bridge/U90Bridge.tsx +7 -4
- package/src/bridge/client.tsx +3 -2
- package/src/bridge/createBridge.ts +47 -12
- package/src/bridge/hooks.tsx +9 -8
- package/src/bridge/index.ts +6 -0
- package/src/bridge/nativeDraft.ts +177 -0
- package/src/bridge/protocol.ts +45 -7
- package/src/bridge/registry.ts +6 -1
- package/src/bridge/selection.ts +134 -0
- package/src/bridge/styles.ts +10 -1
- package/src/bridge/useDraftContent.tsx +151 -0
- package/src/elements/containers/group.ts +1 -1
- package/src/elements/containers/zone.ts +13 -2
- package/src/elements/declaration.ts +89 -2
- package/src/elements/define.ts +22 -3
- package/src/elements/jsonSchema.ts +4 -3
- package/src/elements/leaves/query.ts +6 -1
- package/src/elements/leaves/relation.ts +3 -0
- package/src/elements/leaves/selection.ts +5 -4
- package/src/elements/ops/expand.ts +5 -4
- package/src/elements/ops/seed.ts +25 -6
- package/src/elements/ops/validate.ts +31 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/seo/robots.ts +8 -8
- package/src/seo/seeds.ts +61 -0
- package/src/server/render.tsx +3 -3
- package/src/site/assemble.ts +1 -1
- package/src/site/defineSite.ts +2 -2
- package/src/site/documents.ts +4 -4
- package/src/site/fields.ts +5 -5
- package/src/site/formView.ts +175 -0
- package/src/site/index.ts +3 -0
- package/src/site/replies.ts +5 -4
- package/src/site/types.ts +3 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +1 -1
- 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.
|
|
8
|
+
npx --yes @unitninety/u90@1.0.3 --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.
|
|
3
|
+
"version": "1.6.3",
|
|
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 {
|
|
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
|
|
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 ?
|
|
43
|
-
:
|
|
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) => <
|
|
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
|
}
|
package/src/bridge/U90Bridge.tsx
CHANGED
|
@@ -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
|
}
|
package/src/bridge/client.tsx
CHANGED
|
@@ -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
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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)
|
package/src/bridge/hooks.tsx
CHANGED
|
@@ -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 [
|
|
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={
|
|
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
|
/**
|
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
+
}
|