@unitninety/site-kit 1.6.6 → 1.8.0
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 +65 -2
- package/package.json +1 -1
- package/src/bridge/DraftList.tsx +46 -0
- package/src/bridge/U90Bridge.tsx +4 -2
- package/src/bridge/caret.ts +7 -8
- package/src/bridge/createBridge.ts +19 -0
- package/src/bridge/diagnostics.ts +61 -0
- package/src/bridge/index.ts +2 -0
- package/src/bridge/nativeDraft.ts +25 -32
- package/src/bridge/nativeOrder.ts +29 -12
- package/src/bridge/nativePath.ts +45 -0
- package/src/bridge/preserveBlockMoves.ts +1 -1
- package/src/bridge/styles.ts +12 -0
- package/src/bridge/useDraftContent.tsx +46 -20
- package/src/components/Image.tsx +8 -4
- package/src/components/List.tsx +26 -0
- package/src/components/draft.ts +19 -0
- package/src/components/index.ts +2 -1
- package/src/elements/ops/draftPaths.ts +23 -0
- package/src/elements/ops/expand.ts +7 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/render.tsx +1 -1
- package/src/site/fields.ts +2 -2
- package/src/site/types.ts +4 -2
- package/src/site/version.ts +1 -1
package/README.md
CHANGED
|
@@ -5,13 +5,42 @@ 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.2.0 --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
|
+
### Keep cleared fields editable
|
|
16
|
+
|
|
17
|
+
Site Kit 1.7.0 preserves real draft addresses on groups and block props. Nullable images still read as `null`; pass their owner address separately so Canvas can select them after a clear or reload:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { Image, Text, draftFieldPath } from '@unitninety/site-kit/components'
|
|
21
|
+
|
|
22
|
+
export function Hero(props) {
|
|
23
|
+
return <section>
|
|
24
|
+
<Text as="h1" value={props.title} />
|
|
25
|
+
<div style={{ position: 'relative', aspectRatio: '4 / 3' }}>
|
|
26
|
+
<Image value={props.image} draftPath={draftFieldPath(props, 'image')} fill />
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For a nested group or list row, pass that group or row to `draftFieldPath`. For a collection image, pass `entry.fields`. Keep the complete native object, including reserved `$` metadata, when handing it to another component. Iterate declared fields rather than every group property. A null leaf has no identity of its own: prefer `list(group({ image: image() }))` for editable image lists so each row retains its stable address, including when its image is empty.
|
|
33
|
+
|
|
34
|
+
For an existing `list(image())`, use `draftListItemPath(props, 'photos', index)` as the image's `draftPath`. This reads the original stable item ID from the owner's draft metadata, including after clearing and reloading; the index only identifies the current rendered item, not its saved address. Keep the owner metadata with the list. A detached array of null values alone cannot identify its fields. Shared Single aliases likewise retain their canonical addresses separately from null image values.
|
|
35
|
+
|
|
36
|
+
The standard image renderer keeps a bounded missing-image target only when it has a real draft address. Published empty images render nothing. Custom image components must preserve the same target, respect literal clearing, and never reintroduce a seed image as fallback for an explicitly empty field. The bridge also allows blank declared text, rich-text and link-label fields to resume direct editing. Formatted dates and derived values can remain inspector-only.
|
|
37
|
+
|
|
38
|
+
Use the real root `U90Bridge`; inline editing is enabled by default. Deliberate inspector-only integrations may set `inlineEditing={false}` and verify with `u90 check --canvas-mode inspector`. Ordinary checks require inline mode. The bridge exposes only its effective mode booleans in a draft-only diagnostic marker; no session or credentials are included.
|
|
39
|
+
|
|
40
|
+
The local checker verifies the actual root mode, exact field bindings, and observed image replacement → literal clear → restore against isolated content. New packages must record these checks. This does not prove client hydration or CSS hit targets: in a browser, click and type into real headings, clear and replace images, and repeat on mobile and inactive tabs/carousels. Decorative overlays must not intercept editable images; enable pointer events only on their actual interactive content. Preserve test content and report untested states explicitly.
|
|
41
|
+
|
|
42
|
+
For a custom integration's browser diagnostics, `inspectCanvas(document)` from `@unitninety/site-kit/bridge` reads the existing frame without starting another bridge or editing content. It reports the root mode, mounted bridge stylesheet, sampled click targets and blockers. Hidden, offscreen and zero-size fields are reported separately; a reachable sample does not prove typing, saving, or another viewport. Run it inside the site frame, not the outer dashboard.
|
|
43
|
+
|
|
15
44
|
### Fixed field values
|
|
16
45
|
|
|
17
46
|
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.
|
|
@@ -55,9 +84,43 @@ Call the hook on native reads before converting them for a presentation componen
|
|
|
55
84
|
|
|
56
85
|
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
86
|
|
|
87
|
+
### Immediate ordinary-list reordering
|
|
88
|
+
|
|
89
|
+
Use `List` for repeated content such as service lines, navigation links and FAQ rows. It
|
|
90
|
+
keeps existing rows and their media mounted while their order changes, before saving finishes:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { List, Text, draftFieldPath } from '@unitninety/site-kit/components'
|
|
94
|
+
|
|
95
|
+
export function Services(props) {
|
|
96
|
+
return <List as="ul" value={props.lines} draftPath={draftFieldPath(props, 'lines')}>
|
|
97
|
+
{line => <li><Text value={line.name} as="h3" /></li>}
|
|
98
|
+
</List>
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The callback runs in the component that renders `List`; only rendered slots cross the client
|
|
103
|
+
boundary. Server-only imports and async card children can stay on the server. `List` replaces the existing container: set `as` to its original tag and keep its attributes.
|
|
104
|
+
It adds no extra DOM wrapper, so list, table and grid markup retain their structure. Published
|
|
105
|
+
lists remain ordinary server markup. Group rows retain their native `_id` and `$path`; nullable
|
|
106
|
+
leaf lists can pass `itemPaths={props.$listPaths?.photos}` when their paths live on the owner.
|
|
107
|
+
Never use a row's label or array index as its draft identity.
|
|
108
|
+
|
|
109
|
+
`List` immediately reorders existing items. New items and content replacements still arrive
|
|
110
|
+
from the authoritative server render. For immediate additions and full field previews, use a
|
|
111
|
+
client component with `useDraftPreview(props)` before reading its fields. Use `Entries` for
|
|
112
|
+
collection placements. A plain server-side `.map()` has no live list owner and waits for the
|
|
113
|
+
save/refresh even with the latest Site Kit installed. If row content depends on the current
|
|
114
|
+
array index (such as a numbered label), use `useDraftPreview` so it recomputes immediately.
|
|
115
|
+
|
|
116
|
+
Verify with the response held back: move a row, confirm the visible order immediately, move
|
|
117
|
+
another row during the pending save, then release the response and confirm the saved order.
|
|
118
|
+
Retain image nodes and dimensions throughout. Failed saves must restore the saved order.
|
|
119
|
+
Server HTML checks alone cannot establish hydration or immediate browser updates.
|
|
120
|
+
|
|
58
121
|
### Stable block reorder previews
|
|
59
122
|
|
|
60
|
-
Site Kit
|
|
123
|
+
Site Kit keeps existing block content mounted while Canvas changes its order, including hidden destinations and repeated responsive views. Since 1.8.0, the same order-only path covers ordinary and nested lists, preserving resolved images and presentation values. The default `useDraftPreview(source)` preserves native field objects and pending edits instead of expanding a whole structural snapshot again.
|
|
61
124
|
|
|
62
125
|
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
126
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
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": {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createElement, Fragment, useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from 'react'
|
|
4
|
+
import { useDraftPreview } from './useDraftContent'
|
|
5
|
+
import { applyNativeOrder, type DraftOrder } from './nativeOrder'
|
|
6
|
+
import { PreviewCommitBoundary } from './PreviewCommitBoundary'
|
|
7
|
+
import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
|
|
8
|
+
import { registry } from './registry'
|
|
9
|
+
|
|
10
|
+
type Item = { id: string; $path: string; content: ReactNode }
|
|
11
|
+
type Slots = { $path: string; $fieldPaths: { items: string }; items: Item[] }
|
|
12
|
+
export type ListTag = keyof HTMLElementTagNameMap & keyof JSX.IntrinsicElements
|
|
13
|
+
// Server-rendered slots have no client renderer for new values. Only a pure order
|
|
14
|
+
// changes locally; membership and content continue to arrive from the saved render.
|
|
15
|
+
const keepSlots = <T,>(source: T): T => source
|
|
16
|
+
|
|
17
|
+
export function DraftList({ as, attributes, path, items }: { as: ListTag; attributes: Record<string, unknown>; path: string; items: Item[] }) {
|
|
18
|
+
const host = useRef<HTMLElement | null>(null)
|
|
19
|
+
const reload = useRef(false)
|
|
20
|
+
const attach = useCallback((element: HTMLElement | null) => {
|
|
21
|
+
host.current = element
|
|
22
|
+
const cleanup = preserveBlockMoves(element)
|
|
23
|
+
return () => { cleanup?.(); if (host.current === element) host.current = null }
|
|
24
|
+
}, [])
|
|
25
|
+
const reorder = useCallback((source: Slots, order: DraftOrder): Slots => {
|
|
26
|
+
const result = applyNativeOrder(source, order)
|
|
27
|
+
if (!result.matched) return source
|
|
28
|
+
if (result.value !== source && host.current && needsEmbedReload(host.current)) {
|
|
29
|
+
reload.current = true
|
|
30
|
+
// Acknowledge this order without moving a live iframe. The bridge reloads
|
|
31
|
+
// the saved page instead of allowing its legacy DOM fallback to move it.
|
|
32
|
+
return { ...source }
|
|
33
|
+
}
|
|
34
|
+
// Custom adapters signal ownership by returning a new value, even if the
|
|
35
|
+
// requested order is already visible (for example a duplicate snapshot).
|
|
36
|
+
return result.value === source ? { ...source } : result.value
|
|
37
|
+
}, [])
|
|
38
|
+
const source = useMemo(() => ({ $path: path, $fieldPaths: { items: path }, items }), [path, items])
|
|
39
|
+
const live = useDraftPreview(source, keepSlots, reorder)
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const owner = { document: host.current?.ownerDocument ?? document, beforeRefresh() {}, needsReload: () => reload.current, move: () => false }
|
|
42
|
+
registry.blockOrders.add(owner)
|
|
43
|
+
return () => { registry.blockOrders.delete(owner) }
|
|
44
|
+
}, [])
|
|
45
|
+
return <PreviewCommitBoundary source={items}>{createElement(as, { ...attributes, ref: attach }, live.items.map(item => <Fragment key={item.id}>{item.content}</Fragment>))}</PreviewCommitBoundary>
|
|
46
|
+
}
|
package/src/bridge/U90Bridge.tsx
CHANGED
|
@@ -22,8 +22,10 @@ import { EditingProvider } from './hooks'
|
|
|
22
22
|
type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; inlineEditing?: boolean; testPreview?: () => Promise<TestPreviewContext | null> }
|
|
23
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
|
-
const
|
|
26
|
-
|
|
25
|
+
const state = await draft()
|
|
26
|
+
const props = await resolveBridgePreview({ platformOrigin, draft: async () => state, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
|
|
27
|
+
const diagnostic = state.enabled || props.testPreview ? { version: 1, editing: props.editing, inlineEditing: inlineEditing !== false, testPreview: props.testPreview } : null
|
|
28
|
+
return <>{diagnostic ? <span hidden data-u90-bridge={JSON.stringify(diagnostic)} /> : null}<EditingProvider {...props} inlineEditing={inlineEditing}>{children}</EditingProvider></>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
package/src/bridge/caret.ts
CHANGED
|
@@ -89,12 +89,13 @@ export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
|
|
|
89
89
|
*/
|
|
90
90
|
export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
91
91
|
if (element.matches(MEDIA)) return false
|
|
92
|
-
|
|
92
|
+
const kind = element.getAttribute('data-u90-edit')
|
|
93
|
+
if (kind === 'rich') return true
|
|
93
94
|
|
|
94
95
|
return (
|
|
95
96
|
!element.querySelector(`[${FIELD_ATTR}]`) &&
|
|
96
97
|
!element.querySelector(NON_TEXT) &&
|
|
97
|
-
Boolean(element.textContent?.trim())
|
|
98
|
+
(Boolean(element.textContent?.trim()) || kind === 'text' || kind === 'link-label')
|
|
98
99
|
)
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -110,10 +111,8 @@ export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
|
110
111
|
* `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
|
|
111
112
|
* a picture, rich or not, and the caret has no business there either.
|
|
112
113
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* interesting way for the two to fall out of step and beats making every site component
|
|
116
|
-
* carry a class.
|
|
114
|
+
* Explicit native edit kinds remain editable when blank. The bridge marks those empty
|
|
115
|
+
* surfaces so draft CSS can give them a clickable box without adding saved placeholder text.
|
|
117
116
|
*
|
|
118
117
|
* v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
|
|
119
118
|
* becomes the value selector `[data-u90-edit="rich"]`.
|
|
@@ -235,8 +234,8 @@ const endOf = (element: HTMLElement): Range => {
|
|
|
235
234
|
const last = lastWordsIn(element)
|
|
236
235
|
|
|
237
236
|
if (!last) {
|
|
238
|
-
//
|
|
239
|
-
//
|
|
237
|
+
// An empty native field has no text node yet. Place the caret in its element;
|
|
238
|
+
// the browser creates the first text node when the person types.
|
|
240
239
|
range.selectNodeContents(element)
|
|
241
240
|
range.collapse(false)
|
|
242
241
|
return range
|
|
@@ -99,6 +99,15 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
99
99
|
|
|
100
100
|
let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
|
|
101
101
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
102
|
+
let emptyFields: MutationObserver | null = null
|
|
103
|
+
const markEmptyField = (el: HTMLElement) => {
|
|
104
|
+
const kind = el.getAttribute('data-u90-edit')
|
|
105
|
+
const empty = (kind === 'text' || kind === 'rich' || kind === 'link-label') && !el.textContent?.trim() && isEditableLeaf(el)
|
|
106
|
+
el.toggleAttribute('data-u90-empty', empty)
|
|
107
|
+
}
|
|
108
|
+
const markEmptyFields = () => {
|
|
109
|
+
for (const el of doc.querySelectorAll<HTMLElement>('[data-u90-field][data-u90-edit], [data-u90-empty]')) markEmptyField(el)
|
|
110
|
+
}
|
|
102
111
|
const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
|
|
103
112
|
const flush = () => {
|
|
104
113
|
if (timer) clearTimeout(timer)
|
|
@@ -404,6 +413,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
404
413
|
if (opts.inlineEditing === false) return
|
|
405
414
|
const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
|
|
406
415
|
if (el) {
|
|
416
|
+
markEmptyField(el)
|
|
407
417
|
schedule(el)
|
|
408
418
|
// The Canvas owns save debouncing. Its inspector needs each input immediately, including
|
|
409
419
|
// during continuous typing and just before focus crosses into the parent document.
|
|
@@ -963,6 +973,12 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
963
973
|
|
|
964
974
|
doc.head.appendChild(style)
|
|
965
975
|
if (opts.inlineEditing === false) doc.documentElement.setAttribute('data-u90-inspector-only', '')
|
|
976
|
+
markEmptyFields()
|
|
977
|
+
const Observer = (win as Window & typeof globalThis).MutationObserver
|
|
978
|
+
if (Observer) {
|
|
979
|
+
emptyFields = new Observer(markEmptyFields)
|
|
980
|
+
emptyFields.observe(doc.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['data-u90-field', 'data-u90-edit'] })
|
|
981
|
+
}
|
|
966
982
|
selection.start()
|
|
967
983
|
applyDraftLanguage()
|
|
968
984
|
doc.addEventListener('click', onClick, true)
|
|
@@ -997,6 +1013,9 @@ export function createBridge(win: Window, opts: BridgeOptions) {
|
|
|
997
1013
|
stopped = true
|
|
998
1014
|
endEditing() // what was typed goes out, and no element is left contenteditable
|
|
999
1015
|
flush()
|
|
1016
|
+
emptyFields?.disconnect()
|
|
1017
|
+
emptyFields = null
|
|
1018
|
+
doc.querySelectorAll('[data-u90-empty]').forEach(el => el.removeAttribute('data-u90-empty'))
|
|
1000
1019
|
releaseViewport()
|
|
1001
1020
|
style.remove()
|
|
1002
1021
|
if (opts.inlineEditing === false) doc.documentElement.removeAttribute('data-u90-inspector-only')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type Runtime = { version: 1; editing: boolean; inlineEditing: boolean; testPreview: boolean }
|
|
2
|
+
export type CanvasFieldDiagnostic = {
|
|
3
|
+
path: string
|
|
4
|
+
status: 'reachable' | 'occluded' | 'hidden' | 'no-box' | 'offscreen' | 'untested'
|
|
5
|
+
samples: number
|
|
6
|
+
hits: number
|
|
7
|
+
blockers: string[]
|
|
8
|
+
}
|
|
9
|
+
export type CanvasDiagnostic = {
|
|
10
|
+
runtime: Runtime | null
|
|
11
|
+
bridgeMounted: boolean
|
|
12
|
+
viewport: { width: number; height: number }
|
|
13
|
+
fields: CanvasFieldDiagnostic[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Read-only snapshot of the existing frame. It neither starts a bridge nor changes selection.
|
|
17
|
+
* Reachability samples only the current viewport/state, not typing, saving or hidden slides. */
|
|
18
|
+
export function inspectCanvas(doc: Document = document): CanvasDiagnostic {
|
|
19
|
+
const win = doc.defaultView
|
|
20
|
+
const viewport = { width: win?.innerWidth ?? 0, height: win?.innerHeight ?? 0 }
|
|
21
|
+
let runtime: Runtime | null = null
|
|
22
|
+
const markers = doc.querySelectorAll('[data-u90-bridge]')
|
|
23
|
+
if (markers.length === 1) try {
|
|
24
|
+
const value = JSON.parse(markers[0].getAttribute('data-u90-bridge') ?? '')
|
|
25
|
+
if (value.version === 1 && ['editing', 'inlineEditing', 'testPreview'].every(key => typeof value[key] === 'boolean'))
|
|
26
|
+
runtime = { version: 1, editing: value.editing, inlineEditing: value.inlineEditing, testPreview: value.testPreview }
|
|
27
|
+
} catch { /* An absent or invalid root is not a verified runtime. */ }
|
|
28
|
+
const fields = Array.from(doc.querySelectorAll<HTMLElement>('[data-u90-field]'), el => {
|
|
29
|
+
const path = el.getAttribute('data-u90-field') ?? ''
|
|
30
|
+
const result: CanvasFieldDiagnostic = { path, status: 'untested', samples: 0, hits: 0, blockers: [] }
|
|
31
|
+
// Visibility is inherited but can be overridden by a visible descendant.
|
|
32
|
+
const visibility = win?.getComputedStyle(el).visibility
|
|
33
|
+
if (visibility === 'hidden' || visibility === 'collapse') return { ...result, status: 'hidden' as const }
|
|
34
|
+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
|
|
35
|
+
const style = win?.getComputedStyle(node)
|
|
36
|
+
if (node.hidden || style?.display === 'none' || style?.opacity === '0') return { ...result, status: 'hidden' as const }
|
|
37
|
+
}
|
|
38
|
+
let rects = Array.from(el.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0)
|
|
39
|
+
if (!rects.length && win?.getComputedStyle(el).display === 'contents') {
|
|
40
|
+
rects = Array.from(el.querySelectorAll('*')).filter(child => child.closest('[data-u90-field]') === el)
|
|
41
|
+
.flatMap(child => Array.from(child.getClientRects())).filter(rect => rect.width > 0 && rect.height > 0)
|
|
42
|
+
}
|
|
43
|
+
if (!rects.length) return { ...result, status: 'no-box' as const }
|
|
44
|
+
const visible = rects.map(rect => ({ left: Math.max(0, rect.left), right: Math.min(viewport.width, rect.right), top: Math.max(0, rect.top), bottom: Math.min(viewport.height, rect.bottom) })).filter(rect => rect.right > rect.left && rect.bottom > rect.top)
|
|
45
|
+
if (!visible.length) return { ...result, status: 'offscreen' as const }
|
|
46
|
+
if (typeof doc.elementFromPoint !== 'function') return result
|
|
47
|
+
const blockers = new Set<string>()
|
|
48
|
+
for (const rect of visible.slice(0, 3)) for (const x of [.2, .5, .8]) for (const y of [.2, .5, .8]) {
|
|
49
|
+
result.samples++
|
|
50
|
+
const hit = doc.elementFromPoint(rect.left + (rect.right - rect.left) * x, rect.top + (rect.bottom - rect.top) * y)
|
|
51
|
+
const selected = hit?.closest('[data-u90-field], [data-u90-block]')
|
|
52
|
+
if (selected?.getAttribute('data-u90-field') === path) result.hits++
|
|
53
|
+
else if (hit) blockers.add(`${hit.tagName.toLowerCase()}${Array.from(hit.classList).slice(0, 3).map(name => `.${name}`).join('')}`)
|
|
54
|
+
else blockers.add('(no element)')
|
|
55
|
+
}
|
|
56
|
+
result.blockers = [...blockers].slice(0, 8)
|
|
57
|
+
result.status = result.hits ? 'reachable' : 'occluded'
|
|
58
|
+
return result
|
|
59
|
+
})
|
|
60
|
+
return { runtime, bridgeMounted: !!doc.querySelector('head style[data-u90-editing]'), viewport, fields }
|
|
61
|
+
}
|
package/src/bridge/index.ts
CHANGED
|
@@ -14,3 +14,5 @@ export { DraftBlocks } from './DraftBlocks'
|
|
|
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
16
|
export { reorderNativeDraft, type NativeDraftOrder } from './nativeOrder'
|
|
17
|
+
/** Read-only diagnostics for the current frame; hidden/offscreen states remain untested. */
|
|
18
|
+
export { inspectCanvas, type CanvasDiagnostic, type CanvasFieldDiagnostic } from './diagnostics'
|
|
@@ -1,52 +1,38 @@
|
|
|
1
1
|
import { isLeaf, type EntryRead, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../elements/define'
|
|
2
2
|
import { getContainer } from '../elements/registry'
|
|
3
3
|
import { expandField } from '../elements/ops/expand'
|
|
4
|
+
import { draftGroupMetadata, rememberDraftList } from '../elements/ops/draftPaths'
|
|
4
5
|
import { applyQueryPicks, type QueryStored } from '../elements/leaves/query'
|
|
5
6
|
import type { EntriesRead } from '../elements/leaves/selection'
|
|
6
7
|
import type { DraftSnapshot } from './protocol'
|
|
7
8
|
import { reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
9
|
+
import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
|
|
10
|
+
export { nativePath, safeDraftPath } from './nativePath'
|
|
8
11
|
|
|
9
12
|
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
10
13
|
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
-
export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
|
|
12
|
-
|
|
13
|
-
/** Native groups do not carry paths; their children do. Infer only when the children agree. */
|
|
14
|
-
export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
|
|
15
|
-
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
16
|
-
seen.add(value)
|
|
17
|
-
if (object(value) && typeof value.$path === 'string') return safeDraftPath(value.$path) ? value.$path : undefined
|
|
18
|
-
const candidates = new Set<string>()
|
|
19
|
-
for (const [key, child] of Object.entries(value)) {
|
|
20
|
-
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
21
|
-
const at = nativePath(child, seen)
|
|
22
|
-
if (!at) continue
|
|
23
|
-
if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
|
|
24
|
-
else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
|
|
25
|
-
}
|
|
26
|
-
return candidates.size === 1 ? [...candidates][0] : undefined
|
|
27
|
-
}
|
|
28
|
-
|
|
29
14
|
/** Immutable path replacement across any mixture of native document, entry and field reads. */
|
|
30
|
-
export function applyNativeDraft<T>(source: T, path: string, replacement: unknown): T {
|
|
15
|
+
export function applyNativeDraft<T>(source: T, path: string, replacement: unknown, rootPath?: string): T {
|
|
31
16
|
if (!safeDraftPath(path)) return source
|
|
32
|
-
const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
|
|
17
|
+
const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
|
|
33
18
|
const at = nativePath(value) ?? parent
|
|
34
19
|
if (at === path || parent === path) return replacement
|
|
35
20
|
if (!value || typeof value !== 'object' || seen.has(value)) return value
|
|
36
21
|
const nextSeen = new Set(seen).add(value)
|
|
22
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
37
23
|
let result: any = value
|
|
38
24
|
for (const [key, child] of Object.entries(value)) {
|
|
39
25
|
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
40
|
-
const
|
|
41
|
-
const next = visit(child,
|
|
26
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
27
|
+
const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
42
28
|
if (next !== child) {
|
|
43
|
-
if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
|
|
29
|
+
if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
|
|
44
30
|
result[key] = next
|
|
45
31
|
}
|
|
46
32
|
}
|
|
47
33
|
return result
|
|
48
34
|
}
|
|
49
|
-
return visit(source) as T
|
|
35
|
+
return visit(source, nativePath(source) ?? (rootPath && safeDraftPath(rootPath) ? rootPath : undefined)) as T
|
|
50
36
|
}
|
|
51
37
|
|
|
52
38
|
/** A local index of real native reads. Missing references remain missing until the server answers. */
|
|
@@ -61,11 +47,14 @@ export class NativeDraftReferences {
|
|
|
61
47
|
|
|
62
48
|
add(values: unknown): void {
|
|
63
49
|
const seen = new Set<object>()
|
|
64
|
-
const visit = (value: unknown, parent?: string) => {
|
|
50
|
+
const visit = (value: unknown, parent?: string, inheritedListPaths?: readonly string[]) => {
|
|
65
51
|
if (parent) this.reads.set(parent, value)
|
|
52
|
+
if (parent?.startsWith('single:') && !parent.includes('/')) this.singles.set(parent.slice(7), value)
|
|
66
53
|
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
67
54
|
seen.add(value)
|
|
68
55
|
const at = nativePath(value) ?? parent
|
|
56
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
57
|
+
if (Array.isArray(value)) rememberDraftList(value, paths)
|
|
69
58
|
if (at) this.reads.set(at, value)
|
|
70
59
|
if (object(value)) {
|
|
71
60
|
if (at?.startsWith('single:') && !at.includes('/')) this.singles.set(at.slice(7), value)
|
|
@@ -97,8 +86,8 @@ export class NativeDraftReferences {
|
|
|
97
86
|
}
|
|
98
87
|
for (const [key, child] of Object.entries(value)) {
|
|
99
88
|
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
100
|
-
const
|
|
101
|
-
visit(child,
|
|
89
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
90
|
+
visit(child, childPath, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
102
91
|
}
|
|
103
92
|
}
|
|
104
93
|
visit(values)
|
|
@@ -142,9 +131,9 @@ export class NativeDraftReferences {
|
|
|
142
131
|
}
|
|
143
132
|
|
|
144
133
|
private applyPatch(path: string, value: unknown): void {
|
|
145
|
-
for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value))
|
|
146
|
-
for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value))
|
|
147
|
-
for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value))
|
|
134
|
+
for (const [key, read] of this.reads) this.reads.set(key, key === path ? value : applyNativeDraft(read, path, value, key))
|
|
135
|
+
for (const [key, entry] of this.entries) this.entries.set(key, applyNativeDraft(entry, path, value, `entry:${entry.id}`))
|
|
136
|
+
for (const [key, single] of this.singles) this.singles.set(key, applyNativeDraft(single, path, value, `single:${key}`))
|
|
148
137
|
for (const [key, query] of this.queries) this.queries.set(key, applyNativeDraft(query, path, value))
|
|
149
138
|
}
|
|
150
139
|
|
|
@@ -180,8 +169,11 @@ export class NativeDraftReferences {
|
|
|
180
169
|
return expandField(schema, field, value, ctx(at))
|
|
181
170
|
}
|
|
182
171
|
const children = getContainer(field.container).children(field, field.container === 'u90/group' && !object(value) ? {} : value, schema)
|
|
183
|
-
if (field.container === 'u90/group')
|
|
184
|
-
|
|
172
|
+
if (field.container === 'u90/group') {
|
|
173
|
+
const fields = Object.fromEntries(children.map(child => [child.key, expand(child.decl, child.value, `${at}/${child.key}`)]))
|
|
174
|
+
return { ...draftGroupMetadata(field.fields, at, fields), ...fields }
|
|
175
|
+
}
|
|
176
|
+
const expanded = children.map((child, index) => {
|
|
185
177
|
const path = `${at}/${child.key}`
|
|
186
178
|
const inner = expand(child.decl, child.value, path)
|
|
187
179
|
if (field.container === 'u90/list') return isLeaf(field.item) ? inner : { _id: child.key, $path: path, ...inner as object }
|
|
@@ -189,6 +181,7 @@ export class NativeDraftReferences {
|
|
|
189
181
|
const metadata = { _id: child.key, _block: item._block, ...(typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}) }
|
|
190
182
|
return Object.hasOwn(schema.blocks, String(item._block)) ? { ...metadata, $path: path, ...inner as object } : metadata
|
|
191
183
|
})
|
|
184
|
+
return field.container === 'u90/list' ? rememberDraftList(expanded, children.map(child => `${at}/${child.key}`)) : expanded
|
|
192
185
|
}
|
|
193
186
|
return expand(snapshot.field, snapshot.value, snapshot.path)
|
|
194
187
|
}
|
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { rememberDraftList } from '../elements/ops/draftPaths'
|
|
2
|
+
import { nativeChildPath, nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from './nativePath'
|
|
2
3
|
import type { DraftSnapshot } from './protocol'
|
|
4
|
+
import type { FieldDecl } from '../elements/define'
|
|
3
5
|
|
|
4
6
|
export type DraftOrder = { path: string; ids: string[] }
|
|
5
7
|
export type NativeDraftOrder = DraftOrder
|
|
6
8
|
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
7
9
|
|
|
8
|
-
/** A move's snapshot may start above its zone
|
|
9
|
-
*
|
|
10
|
+
/** A move's snapshot may start above its list/zone. Read only stable IDs:
|
|
11
|
+
* expanding a reorder would replace already-resolved media and presentation values. */
|
|
10
12
|
export function snapshotOrder(snapshot: DraftSnapshot, movedPath: string): DraftOrder | undefined {
|
|
11
13
|
const path = movedPath.slice(0, movedPath.lastIndexOf('/'))
|
|
12
14
|
if (!safeDraftPath(path) || (path !== snapshot.path && !path.startsWith(`${snapshot.path}/`))) return
|
|
13
15
|
let value: unknown = snapshot.value
|
|
16
|
+
let field: FieldDecl | undefined = snapshot.field
|
|
14
17
|
for (const key of path.slice(snapshot.path.length).split('/').filter(Boolean)) {
|
|
15
18
|
if (unsafe.has(key)) return
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
value = value.find(item => item?._id === key)
|
|
21
|
+
if (field && 'container' in field && field.container === 'u90/list') {
|
|
22
|
+
field = field.item
|
|
23
|
+
} else if (field && 'container' in field && field.container === 'u90/zone') {
|
|
24
|
+
const block = value && typeof value === 'object' && '_block' in value ? snapshot.blocks?.[String(value._block)] : undefined
|
|
25
|
+
field = block ? { container: 'u90/group', fields: block.fields } : undefined
|
|
26
|
+
} else field = undefined
|
|
27
|
+
} else {
|
|
28
|
+
value = value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined
|
|
29
|
+
field = field && 'container' in field && field.container === 'u90/group' && Object.hasOwn(field.fields, key) ? field.fields[key] : undefined
|
|
30
|
+
}
|
|
18
31
|
}
|
|
19
|
-
|
|
32
|
+
const list = field && 'container' in field && field.container === 'u90/list'
|
|
33
|
+
if (!Array.isArray(value) || !value.every(item => item && typeof item._id === 'string' && (list || typeof item._block === 'string'))) return
|
|
20
34
|
const ids = value.map(item => item._id as string)
|
|
21
35
|
if (new Set(ids).size !== ids.length || !ids.includes(movedPath.slice(path.length + 1))) return
|
|
22
36
|
return { path, ids }
|
|
@@ -35,25 +49,28 @@ export function ordered<T>(items: T[], ids: string[], id: (item: T) => string |
|
|
|
35
49
|
export function applyNativeOrder<T>(source: T, order: DraftOrder): { value: T; matched: boolean } {
|
|
36
50
|
if (!safeDraftPath(order.path)) return { value: source, matched: false }
|
|
37
51
|
let matched = false
|
|
38
|
-
const visit = (value: unknown, parent?: string, seen = new Set<object>()): unknown => {
|
|
52
|
+
const visit = (value: unknown, parent?: string, seen = new Set<object>(), inheritedListPaths?: readonly string[]): unknown => {
|
|
39
53
|
if (!value || typeof value !== 'object' || seen.has(value) || '$$typeof' in value) return value
|
|
40
54
|
const at = nativePath(value) ?? parent
|
|
55
|
+
const paths = Array.isArray(value) ? nativeListPaths(value) ?? inheritedListPaths : undefined
|
|
41
56
|
if (Array.isArray(value) && at === order.path) {
|
|
42
57
|
matched = true
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const indexed = value.map((item, index) => ({ item, index }))
|
|
59
|
+
const sorted = ordered(indexed, order.ids, ({ item, index }) => {
|
|
60
|
+
const path = paths?.[index] ?? nativePath(item)
|
|
45
61
|
return path?.startsWith(`${order.path}/`) ? path.slice(order.path.length + 1)
|
|
46
62
|
: typeof item?._id === 'string' ? item._id : undefined
|
|
47
63
|
})
|
|
64
|
+
return sorted === indexed ? value : rememberDraftList(sorted.map(({ item }) => item), paths ? sorted.map(({ index }) => paths[index]!) : undefined)
|
|
48
65
|
}
|
|
49
66
|
const nextSeen = new Set(seen).add(value)
|
|
50
67
|
let result: any = value
|
|
51
68
|
for (const [key, child] of Object.entries(value)) {
|
|
52
69
|
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
53
|
-
const
|
|
54
|
-
const next = visit(child,
|
|
70
|
+
const childPath = Array.isArray(value) ? nativeListItemPath(value, Number(key), paths, at) : nativeChildPath(value, key, at)
|
|
71
|
+
const next = visit(child, childPath, nextSeen, Array.isArray(child) ? nativeListPaths(child, value, key) : undefined)
|
|
55
72
|
if (next !== child) {
|
|
56
|
-
if (result === value) result = Array.isArray(value) ? [...value] : { ...value }
|
|
73
|
+
if (result === value) result = Array.isArray(value) ? rememberDraftList([...value], paths) : { ...value }
|
|
57
74
|
result[key] = next
|
|
58
75
|
}
|
|
59
76
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { draftListPaths } from '../elements/ops/draftPaths'
|
|
2
|
+
|
|
3
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype'])
|
|
4
|
+
export const safeDraftPath = (path: string) => /^(page|entry|layout|single):[^/]+(?:\/[^/]+)*$/.test(path) && !path.split('/').some(key => unsafe.has(key))
|
|
5
|
+
|
|
6
|
+
/** Older native groups carry only child paths. Infer their parent only when those paths agree. */
|
|
7
|
+
export function nativePath(value: unknown, seen = new Set<object>()): string | undefined {
|
|
8
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return
|
|
9
|
+
seen.add(value)
|
|
10
|
+
const own = (value as { $path?: unknown }).$path
|
|
11
|
+
if (typeof own === 'string') return safeDraftPath(own) ? own : undefined
|
|
12
|
+
const candidates = new Set<string>()
|
|
13
|
+
for (const [key, child] of Object.entries(value)) {
|
|
14
|
+
if (unsafe.has(key) || key.startsWith('$')) continue
|
|
15
|
+
const at = nativePath(child, seen)
|
|
16
|
+
if (!at) continue
|
|
17
|
+
if (Array.isArray(value) && at.includes('/')) candidates.add(at.slice(0, at.lastIndexOf('/')))
|
|
18
|
+
else if (at.endsWith(`/${key}`)) candidates.add(at.slice(0, -key.length - 1))
|
|
19
|
+
}
|
|
20
|
+
return candidates.size === 1 ? [...candidates][0] : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A declared Single remains canonical even while its scalar read is null. */
|
|
24
|
+
export function nativeChildPath(value: object, key: string, at = nativePath(value)): string | undefined {
|
|
25
|
+
const aliases = (value as { $fieldPaths?: unknown }).$fieldPaths
|
|
26
|
+
const alias = aliases && typeof aliases === 'object' && Object.hasOwn(aliases, key) ? (aliases as Record<string, unknown>)[key] : undefined
|
|
27
|
+
return typeof alias === 'string' && safeDraftPath(alias) ? alias : at ? `${at}/${key}` : undefined
|
|
28
|
+
}
|
|
29
|
+
/** Snapshot metadata supersedes older owner metadata after a list replacement. */
|
|
30
|
+
export function nativeListPaths(value: unknown[], owner?: object, key?: string): readonly string[] | undefined {
|
|
31
|
+
const own = draftListPaths(value)
|
|
32
|
+
const lists = (owner as { $listPaths?: unknown } | undefined)?.$listPaths
|
|
33
|
+
const stored = lists && typeof lists === 'object' && key !== undefined && Object.hasOwn(lists, key) ? (lists as Record<string, unknown>)[key] : undefined
|
|
34
|
+
const paths = own ?? stored
|
|
35
|
+
return Array.isArray(paths) && paths.length === value.length && paths.every(path => typeof path === 'string' && safeDraftPath(path)) ? paths : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function nativeListItemPath(value: unknown[], index: number, paths = nativeListPaths(value), at = nativePath(value)): string | undefined {
|
|
39
|
+
const child = value[index]
|
|
40
|
+
const own = nativePath(child)
|
|
41
|
+
if (own) return own
|
|
42
|
+
if (paths?.[index]) return paths[index]
|
|
43
|
+
const id = child && typeof child === 'object' ? (child as { _id?: unknown })._id : undefined
|
|
44
|
+
return typeof id === 'string' && at && safeDraftPath(`${at}/${id}`) ? `${at}/${id}` : undefined
|
|
45
|
+
}
|
|
@@ -53,7 +53,7 @@ function restoreSheets(snapshots: SheetSnapshot[]) {
|
|
|
53
53
|
* prototype changes. Atomic moves keep iframes/focus alive where supported. Both
|
|
54
54
|
* kinds of move need the CSSOM safeguard for styles in an embedded shadow root.
|
|
55
55
|
*/
|
|
56
|
-
export function preserveBlockMoves(host:
|
|
56
|
+
export function preserveBlockMoves(host: HTMLElement | null): void | (() => void) {
|
|
57
57
|
if (!host) return
|
|
58
58
|
const atomic = host as AtomicHost
|
|
59
59
|
const insert = host.insertBefore
|
package/src/bridge/styles.ts
CHANGED
|
@@ -46,6 +46,18 @@ export const EDITING_CSS = `
|
|
|
46
46
|
outline-offset: 4px;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/* An empty native value stays empty; this draft-only box is never saved as copy. */
|
|
50
|
+
[data-u90-field][data-u90-empty] {
|
|
51
|
+
min-width: 1.5em;
|
|
52
|
+
min-height: 1em;
|
|
53
|
+
}
|
|
54
|
+
[data-u90-field][data-u90-empty]::before {
|
|
55
|
+
display: inline-block;
|
|
56
|
+
min-width: 1.5em;
|
|
57
|
+
min-height: 1em;
|
|
58
|
+
content: '\\00a0';
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
/* a Single inside editable copy: one chip the caret skips, saved back as its {key} (R38) */
|
|
50
62
|
[data-u90-field] [data-single-key] {
|
|
51
63
|
border-radius: 4px;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
4
4
|
import { applyNativeDraft, NativeDraftReferences, safeDraftPath } from './nativeDraft'
|
|
5
|
+
import { nativePath } from './nativePath'
|
|
5
6
|
import type { DraftSnapshot } from './protocol'
|
|
6
7
|
import { registry } from './registry'
|
|
7
8
|
import { applyNativeOrder, reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
@@ -83,6 +84,14 @@ const sameSource = (a: unknown, b: unknown): boolean => {
|
|
|
83
84
|
|
|
84
85
|
export type NativeDraftPatch = { path: string; value: unknown }
|
|
85
86
|
type PreviewPatch = { kind: 'value'; path: string; value: unknown } | { kind: 'order'; path: string; ids: string[] }
|
|
87
|
+
const supersede = (patches: Map<string, PreviewPatch>, patch: PreviewPatch) => {
|
|
88
|
+
if (patch.kind === 'value') for (const [key, previous] of patches) if (previous.path === patch.path || previous.path.startsWith(`${patch.path}/`)) patches.delete(key)
|
|
89
|
+
patches.delete(`${patch.kind}:${patch.path}`)
|
|
90
|
+
}
|
|
91
|
+
const remember = (patches: Map<string, PreviewPatch>, patch: PreviewPatch) => {
|
|
92
|
+
supersede(patches, patch)
|
|
93
|
+
patches.set(`${patch.kind}:${patch.path}`, patch)
|
|
94
|
+
}
|
|
86
95
|
const applyPreviewPatch = <T,>(source: T, patch: PreviewPatch, apply: (source: T, patch: NativeDraftPatch) => T, reorder: (source: T, order: DraftOrder) => T = reorderNativeDraft): T =>
|
|
87
96
|
patch.kind === 'order' ? reorder(source, patch) : apply(source, patch)
|
|
88
97
|
const applyPatch = <T,>(source: T, patch: NativeDraftPatch): T => applyNativeDraft(source, patch.path, patch.value)
|
|
@@ -100,17 +109,20 @@ const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
|
100
109
|
export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch, reorder = apply === applyPatch ? reorderNativeDraft<T> : undefined): T {
|
|
101
110
|
const owner = useContext(DraftContentPath)
|
|
102
111
|
const references = useContext(ReferenceValues)
|
|
112
|
+
const native = apply === applyPatch
|
|
113
|
+
const rootPath = nativePath(source) ?? owner
|
|
114
|
+
const applyValue = useMemo(() => native ? (value: T, patch: NativeDraftPatch) => applyNativeDraft(value, patch.path, patch.value, rootPath) : apply, [native, rootPath, apply])
|
|
103
115
|
const [cache] = useState(() => new NativeDraftReferences())
|
|
104
116
|
const enabled = !!owner || hasNativePath(source)
|
|
105
|
-
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, PreviewPatch>() })
|
|
117
|
+
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string, PreviewPatch>(), settling: new Map<string, PreviewPatch>() })
|
|
106
118
|
let current = shown.value
|
|
107
119
|
if (!sameSource(shown.source, source) || shown.owner !== owner) {
|
|
108
120
|
const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
|
|
109
121
|
current = source
|
|
110
|
-
for (const patch of overrides.values()) current = applyPreviewPatch(current, patch,
|
|
111
|
-
setShown({ source, value: current, owner, overrides })
|
|
122
|
+
for (const patch of overrides.values()) current = applyPreviewPatch(current, patch, applyValue, reorder)
|
|
123
|
+
setShown({ source, value: current, owner, overrides, settling: new Map() })
|
|
112
124
|
}
|
|
113
|
-
const latest = useRef({ current, source, references, apply, reorder })
|
|
125
|
+
const latest = useRef({ current, source, references, apply: applyValue, reorder, native, overrides: shown.overrides, settling: shown.settling })
|
|
114
126
|
useLayoutEffect(() => {
|
|
115
127
|
cache.add(references)
|
|
116
128
|
cache.add(source)
|
|
@@ -118,22 +130,29 @@ export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDr
|
|
|
118
130
|
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
119
131
|
else cache.order(patch)
|
|
120
132
|
}
|
|
121
|
-
latest.current = { current, source, references, apply, reorder }
|
|
122
|
-
}, [cache, current, source, references,
|
|
133
|
+
latest.current = { current, source, references, apply: applyValue, reorder, native, overrides: shown.overrides, settling: shown.settling }
|
|
134
|
+
}, [cache, current, source, references, applyValue, reorder, native, shown.overrides, shown.settling])
|
|
123
135
|
useLayoutEffect(() => {
|
|
124
136
|
if (!enabled) return
|
|
125
137
|
const handler = {
|
|
126
138
|
document,
|
|
127
139
|
beforeRefresh: () => {
|
|
128
140
|
cache.beforeRefresh()
|
|
129
|
-
|
|
141
|
+
// Keep the optimistic baseline visible while this request is pending. It is
|
|
142
|
+
// discarded on a new source/commit, never replayed over the server's answer.
|
|
143
|
+
const settling = new Map(latest.current.settling)
|
|
144
|
+
for (const patch of latest.current.overrides.values()) remember(settling, patch)
|
|
145
|
+
latest.current.overrides = new Map()
|
|
146
|
+
latest.current.settling = settling
|
|
147
|
+
setShown(value => ({ ...value, overrides: new Map(), settling }))
|
|
130
148
|
},
|
|
131
149
|
afterRefresh: () => setShown(previous => {
|
|
132
150
|
cache.settle([latest.current.references, latest.current.source])
|
|
133
151
|
let value = latest.current.source
|
|
134
152
|
for (const patch of previous.overrides.values()) value = applyPreviewPatch(value, patch, latest.current.apply, latest.current.reorder)
|
|
135
153
|
latest.current.current = value
|
|
136
|
-
|
|
154
|
+
latest.current.settling = new Map()
|
|
155
|
+
return { ...previous, source: latest.current.source, value, settling: new Map() }
|
|
137
156
|
}),
|
|
138
157
|
preview: (snapshot: DraftSnapshot, order?: DraftOrder) => {
|
|
139
158
|
if (!safeDraftPath(snapshot.path)) return false
|
|
@@ -141,19 +160,26 @@ export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDr
|
|
|
141
160
|
const patch: PreviewPatch = useOrder ? { kind: 'order', ...order } : { kind: 'value', path: snapshot.path, value: cache.expand(snapshot) }
|
|
142
161
|
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
143
162
|
else cache.order(patch)
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
const overrides = new Map(latest.current.overrides)
|
|
164
|
+
// A replacement supersedes its earlier clear. Replay native values from the real
|
|
165
|
+
// source so a null leaf never has to invent the image/list identity it just lost.
|
|
166
|
+
supersede(overrides, patch)
|
|
167
|
+
const key = `${patch.kind}:${patch.path}`
|
|
168
|
+
let base = latest.current.current
|
|
169
|
+
if (latest.current.native) {
|
|
170
|
+
const history = new Map(latest.current.settling)
|
|
171
|
+
for (const previous of overrides.values()) remember(history, previous)
|
|
172
|
+
supersede(history, patch)
|
|
173
|
+
base = latest.current.source
|
|
174
|
+
for (const previous of history.values()) base = applyPreviewPatch(base, previous, latest.current.apply, latest.current.reorder)
|
|
175
|
+
}
|
|
176
|
+
const result = useOrder === reorderNativeDraft ? applyNativeOrder(base, order!) : undefined
|
|
177
|
+
const next = result ? result.value : applyPreviewPatch(base, patch, latest.current.apply, latest.current.reorder)
|
|
178
|
+
if (result ? !result.matched : next === base) return false
|
|
179
|
+
overrides.set(key, patch)
|
|
147
180
|
latest.current.current = next
|
|
148
|
-
|
|
149
|
-
|
|
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)
|
|
155
|
-
return { ...previous, value: next, overrides }
|
|
156
|
-
})
|
|
181
|
+
latest.current.overrides = overrides
|
|
182
|
+
setShown(previous => ({ ...previous, value: next, overrides }))
|
|
157
183
|
return true
|
|
158
184
|
},
|
|
159
185
|
}
|
package/src/components/Image.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import NextImage, { getImageProps } from 'next/image'
|
|
|
2
2
|
import { createElement } from 'react'
|
|
3
3
|
import type { ImageRead } from '../elements'
|
|
4
4
|
import { draftAttrs } from './draft'
|
|
5
|
+
import { safeDraftPath } from '../bridge/nativePath'
|
|
5
6
|
import { imageProps, isGrantPath, pictureSources, type ImageProps } from './imageProps'
|
|
6
7
|
/**
|
|
7
8
|
* One declared variant's `<source>`, built with `next/image`'s own `getImageProps` so its `srcSet`
|
|
@@ -17,11 +18,14 @@ export const sourceProps = (props: ImageProps, s: ReturnType<typeof pictureSourc
|
|
|
17
18
|
// An unoptimised image has no generated srcSet, so the raw path is the candidate.
|
|
18
19
|
return { key: s.name, media: s.media, srcSet: p.srcSet ?? p.src, sizes: p.sizes }
|
|
19
20
|
}
|
|
20
|
-
export function Image({ value, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
export function Image({ value, draftPath, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; draftPath?: string; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
|
|
22
|
+
const explicit = draftPath && safeDraftPath(draftPath) ? draftPath : undefined
|
|
23
|
+
const path = value?.$path && safeDraftPath(value.$path) ? value.$path : explicit
|
|
24
|
+
const attrs = { ...draftAttrs(value), ...draftAttrs(path ? { $path: path } : null), ...(explicit ? { 'data-u90-empty-target': 'image' } : {}) }
|
|
25
|
+
const missing = () => path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image', style: { minWidth: 48, minHeight: 48, ...(fill ? { position: 'absolute' as const, inset: 0 } : {}) } }, 'Image missing') : null
|
|
26
|
+
if (!value) return missing()
|
|
23
27
|
const props = imageProps(value, { sizes, fill, priority })
|
|
24
|
-
if (!props) return
|
|
28
|
+
if (!props) return missing()
|
|
25
29
|
const sources = pictureSources(value, variants)
|
|
26
30
|
const img = sources.length
|
|
27
31
|
? createElement('picture', null, ...sources.map((s) => createElement('source', sourceProps(props, s))), createElement(NextImage, { ...props, className, ...attrs }))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createElement, Fragment, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
|
+
import { DraftList, type ListTag } from '../bridge/DraftList'
|
|
3
|
+
import { nativeListItemPath, nativeListPaths, nativePath, safeDraftPath } from '../bridge/nativePath'
|
|
4
|
+
|
|
5
|
+
type Props<T, Tag extends ListTag> = { as: Tag; value: T[]; draftPath?: string; itemPaths?: readonly string[]; children: (item: T, index: number) => ReactNode }
|
|
6
|
+
& Omit<ComponentPropsWithoutRef<Tag>, 'as' | 'value' | 'children' | 'dangerouslySetInnerHTML'>
|
|
7
|
+
|
|
8
|
+
/** Server-safe list rendering with immediate draft ordering. Replace the existing
|
|
9
|
+
* container with List as="ul" (or its original tag), preserving its attributes.
|
|
10
|
+
* The item renderer runs here; only rendered slots cross the client boundary. */
|
|
11
|
+
export function List<T, Tag extends ListTag>({ as, value, draftPath, itemPaths, children, ...attributes }: Props<T, Tag>) {
|
|
12
|
+
const paths = itemPaths ?? nativeListPaths(value)
|
|
13
|
+
const first = paths?.[0]
|
|
14
|
+
const path = draftPath ?? (first ? first.slice(0, first.lastIndexOf('/')) : nativePath(value))
|
|
15
|
+
if (!path) return createElement(as, attributes, value.map((item, index) => <Fragment key={(item as { _id?: string } | null)?._id ?? index}>{children(item, index)}</Fragment>))
|
|
16
|
+
if (!safeDraftPath(path)) throw new Error('List requires a valid draft path.')
|
|
17
|
+
const seen = new Set<string>()
|
|
18
|
+
const items = value.map((item, index) => {
|
|
19
|
+
const at = nativeListItemPath(value, index, paths, path)
|
|
20
|
+
const id = at?.startsWith(`${path}/`) ? at.slice(path.length + 1) : undefined
|
|
21
|
+
if (!id || id.includes('/') || seen.has(id)) throw new Error('Draft List items require unique stable paths. Pass itemPaths for nullable leaf lists.')
|
|
22
|
+
seen.add(id)
|
|
23
|
+
return { id, $path: at!, content: children(item, index) }
|
|
24
|
+
})
|
|
25
|
+
return <DraftList key={path} as={as} attributes={attributes} path={path} items={items}/>
|
|
26
|
+
}
|
package/src/components/draft.ts
CHANGED
|
@@ -7,3 +7,22 @@ export const draftAttrs = (value: Marked, edit?: 'text' | 'rich' | 'link-label')
|
|
|
7
7
|
value && value.$path ? { 'data-u90-field': value.$path, ...(edit ? { 'data-u90-edit': edit } : {}), ...(value.$fallback ? { 'data-u90-fallback': '' as const } : {}) } : {}
|
|
8
8
|
export const editable = (value: Marked): DraftAttrs => draftAttrs(value, value && 'text' in value ? 'text' : undefined)
|
|
9
9
|
export const editableRich = (value: Marked): DraftAttrs => draftAttrs(value, 'rich')
|
|
10
|
+
import { nativeChildPath, nativeListItemPath, nativeListPaths, safeDraftPath } from '../bridge/nativePath'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/** An actual draft address for a declared child, including a nullable child with no own path. */
|
|
14
|
+
export function draftFieldPath(parent: unknown, key: string): string | undefined {
|
|
15
|
+
if (!parent || typeof parent !== 'object' || Array.isArray(parent) || !Object.hasOwn(parent, key) || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) || ['__proto__', 'constructor', 'prototype'].includes(key)) return
|
|
16
|
+
const child = (parent as Record<string, unknown>)[key]
|
|
17
|
+
const own = child && typeof child === 'object' ? (child as { $path?: unknown }).$path : undefined
|
|
18
|
+
if (typeof own === 'string' && safeDraftPath(own)) return own
|
|
19
|
+
return nativeChildPath(parent, key)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A nullable leaf-list item's stable address, carried separately from its read value. */
|
|
23
|
+
export function draftListItemPath(parent: unknown, key: string, index: number): string | undefined {
|
|
24
|
+
if (!parent || typeof parent !== 'object' || Array.isArray(parent) || !Object.hasOwn(parent, key) || ['__proto__', 'constructor', 'prototype'].includes(key) || !Number.isInteger(index) || index < 0) return
|
|
25
|
+
const values = (parent as Record<string, unknown>)[key]
|
|
26
|
+
if (!Array.isArray(values) || index >= values.length) return
|
|
27
|
+
return nativeListItemPath(values, index, nativeListPaths(values, parent, key), draftFieldPath(parent, key))
|
|
28
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { Entries } from './Entries'
|
|
2
|
+
export { List } from './List'
|
|
2
3
|
export { Text } from './Text'
|
|
3
4
|
export { RichText, richTextNodes } from './RichText'
|
|
4
5
|
export { Link, isInternal } from './Link'
|
|
@@ -9,5 +10,5 @@ export { Image } from './Image'
|
|
|
9
10
|
export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
|
|
10
11
|
export { Video } from './Video'
|
|
11
12
|
export { videoProps, type VideoHTMLProps } from './videoProps'
|
|
12
|
-
export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
|
|
13
|
+
export { draftAttrs, draftFieldPath, draftListItemPath, editable, editableRich, type DraftAttrs } from './draft'
|
|
13
14
|
export { LanguageSwitcher, useLanguage, rememberLanguage } from '../switcher/LanguageSwitcher'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FieldDecl } from '../define'
|
|
2
|
+
|
|
3
|
+
// Arrays keep their ordinary leaf values, including null. Live snapshots retain IDs
|
|
4
|
+
// separately; enclosing groups serialize the same paths across the RSC boundary.
|
|
5
|
+
const lists = new WeakMap<readonly unknown[], readonly string[]>()
|
|
6
|
+
export const draftListPaths = (value: unknown): readonly string[] | undefined => Array.isArray(value) ? lists.get(value) : undefined
|
|
7
|
+
export function rememberDraftList<T>(value: T[], paths: readonly string[] | undefined): T[] {
|
|
8
|
+
if (paths?.length === value.length) lists.set(value, paths)
|
|
9
|
+
return value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Real declaration addresses for nullable Single aliases, separate from their nullable values. */
|
|
13
|
+
export function draftGroupMetadata(fields: Record<string, FieldDecl>, at: string, values: Record<string, unknown> = {}): { $path: string; $fieldPaths?: Record<string, string>; $listPaths?: Record<string, readonly string[]> } {
|
|
14
|
+
const aliases = Object.entries(fields).flatMap(([key, field]) => {
|
|
15
|
+
const single = 'element' in field && field.element === 'u90/single' ? field.options?.key : undefined
|
|
16
|
+
return typeof single === 'string' && /^[a-z][a-zA-Z0-9_-]*$/.test(single) ? [[key, `single:${single}`]] : []
|
|
17
|
+
})
|
|
18
|
+
const listPaths = Object.keys(fields).flatMap(key => {
|
|
19
|
+
const paths = draftListPaths(values[key])
|
|
20
|
+
return paths?.length ? [[key, paths]] : []
|
|
21
|
+
})
|
|
22
|
+
return { $path: at, ...(aliases.length ? { $fieldPaths: Object.fromEntries(aliases) } : {}), ...(listPaths.length ? { $listPaths: Object.fromEntries(listPaths) } : {}) }
|
|
23
|
+
}
|
|
@@ -4,6 +4,7 @@ import { path } from '../path'
|
|
|
4
4
|
import { getContainer, getElement } from '../registry'
|
|
5
5
|
import { record } from '../containers/group'
|
|
6
6
|
import { prefixedRoute } from '../prefix'
|
|
7
|
+
import { draftGroupMetadata, rememberDraftList } from './draftPaths'
|
|
7
8
|
|
|
8
9
|
export type Resolved = {
|
|
9
10
|
media?: Map<string, MediaRecord>
|
|
@@ -53,9 +54,12 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
|
|
|
53
54
|
if (isLeaf(decl)) return expandLeaf(getElement(decl.element), decl, value, ctx)
|
|
54
55
|
const container = getContainer(decl.container)
|
|
55
56
|
const children = container.children(decl, decl.container === 'u90/group' ? (record(value) ? value : {}) : value, schema)
|
|
56
|
-
if (decl.container === 'u90/group')
|
|
57
|
+
if (decl.container === 'u90/group') {
|
|
58
|
+
const fields = Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
|
|
59
|
+
return { ...(ctx.draft ? draftGroupMetadata(decl.fields, ctx.at, fields) : {}), ...fields }
|
|
60
|
+
}
|
|
57
61
|
const items = Array.isArray(value) ? value : []
|
|
58
|
-
|
|
62
|
+
const expanded = children.map((c, i) => {
|
|
59
63
|
const inner = expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))
|
|
60
64
|
// A leaf item reads as its element's value (which carries its own `$path`); a group item, like a zone item, carries the item's path in draft.
|
|
61
65
|
if (decl.container === 'u90/list') return isLeaf(decl.item) ? inner : { _id: c.key, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
@@ -65,6 +69,7 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
|
|
|
65
69
|
if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
|
|
66
70
|
return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
67
71
|
})
|
|
72
|
+
return ctx.draft && decl.container === 'u90/list' ? rememberDraftList(expanded, children.map(c => path.child(ctx.at, c.key))) : expanded
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.0
|
|
5
|
+
* `npx --yes @unitninety/u90@1.2.0 pair <code>` — the one command an operator hands over. The u90 tool
|
|
6
6
|
* runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
|
|
7
7
|
* repository with no local bin, fetches whatever package npm holds under that name.
|
|
8
8
|
*
|
|
@@ -115,7 +115,7 @@ const run = async () => {
|
|
|
115
115
|
const args = process.argv.slice(2)
|
|
116
116
|
|
|
117
117
|
if (args[0] !== 'pair' || !args[1]) {
|
|
118
|
-
fail('Usage: npx --yes @unitninety/u90@1.0
|
|
118
|
+
fail('Usage: npx --yes @unitninety/u90@1.2.0 pair <code> --site https://example.com [--platform https://cms…]')
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const code = args[1]
|
package/src/legacy/content.ts
CHANGED
|
@@ -360,7 +360,7 @@ export const createContentClient = ({
|
|
|
360
360
|
: !platformUrl
|
|
361
361
|
? 'PLATFORM_URL is unset'
|
|
362
362
|
: 'PAYLOAD_API_KEY is unset'
|
|
363
|
-
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0
|
|
363
|
+
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.2.0 pair\` writes both.`,
|
|
364
364
|
)
|
|
365
365
|
}
|
|
366
366
|
return true
|
package/src/server/render.tsx
CHANGED
|
@@ -21,7 +21,7 @@ export function createRender(site: Site) {
|
|
|
21
21
|
if (!def) return item.$path ? createElement('div', { key: item._id, 'data-u90-block': item.$path, 'data-u90-missing': item._block }, `Block "${item._block}" was removed`) : null
|
|
22
22
|
const { _id, _block, _hidden, $path, ...fields } = item
|
|
23
23
|
// zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
|
|
24
|
-
const props: Record<string, unknown> = { ...fields }
|
|
24
|
+
const props: Record<string, unknown> = { ...fields, ...($path ? { $path } : {}) }
|
|
25
25
|
for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry, anchors })
|
|
26
26
|
// a block declared with `entry:` renders inside that collection's entry pages and receives the entry itself
|
|
27
27
|
if (def.entry) props.entry = entry
|
package/src/site/fields.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BooleanRead, ChoiceReadValue, DateRead, EntriesRead, EntryRead, FileRead, ImageRead, LinkRead, NumberRead, RichTextRead, TextRead, VideoRead } from '../elements'
|
|
2
|
-
import type { BlockDef, Decl, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
|
|
2
|
+
import type { BlockDef, Decl, DraftReadMetadata, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
|
|
3
3
|
|
|
4
4
|
type Common = { label?: string; description?: string; required?: boolean; translatable?: boolean; lock?: string[]; renamedFrom?: string }
|
|
5
5
|
/** Options that are part of the field declaration itself go top-level; the rest is the element's `options`. */
|
|
@@ -35,7 +35,7 @@ export const selection = (to: string, o: Common & { default?: unknown; many?: bo
|
|
|
35
35
|
export const query = (to: string, o: Common & { default?: unknown; previewFields?: string[] } = {}) => leaf<EntriesRead>('u90/query', o, { to, previewFields: o.previewFields })
|
|
36
36
|
export const single = <R>(s: SingleDef<R>, o: Common = {}): Decl<R> => leaf<R>('u90/single', o, { key: s.key })
|
|
37
37
|
|
|
38
|
-
export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<{ [K in keyof F]: Read<F[K]> }
|
|
38
|
+
export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata>> & { readonly __group: true } =>
|
|
39
39
|
({ container: 'u90/group', ...(o.label ? { label: o.label } : {}), fields, order: Object.keys(fields) }) as never
|
|
40
40
|
export const list = <D extends Decl<unknown>>(
|
|
41
41
|
item: D,
|
package/src/site/types.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDec
|
|
|
7
7
|
export type Decl<R> = FieldDecl & { readonly __read?: R }
|
|
8
8
|
export type Read<D> = D extends Decl<infer R> ? R : never
|
|
9
9
|
export type Fields = Record<string, Decl<unknown>>
|
|
10
|
+
/** Present only on native draft reads, never authored content or published props. */
|
|
11
|
+
export type DraftReadMetadata = { $path?: string; $fieldPaths?: Record<string, string>; $listPaths?: Record<string, readonly string[]> }
|
|
10
12
|
/**
|
|
11
13
|
* Flattens an intersection into a single mapped object type. The strict `Equal` check used by
|
|
12
14
|
* `types.test-d.ts` treats an intersection (`{ _id: string } & Read<D>`) as distinct from the
|
|
@@ -72,9 +74,9 @@ export type SiteManifest = {
|
|
|
72
74
|
* collection's declaration. Not `EntryRead`: an entry page's entry has no `collection` or `status`
|
|
73
75
|
* (finding 21).
|
|
74
76
|
*/
|
|
75
|
-
export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } } : never
|
|
77
|
+
export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } & DraftReadMetadata } : never
|
|
76
78
|
export type BlockProps<B> = B extends BlockDef<infer F, infer C>
|
|
77
|
-
? Simplify<{ [K in keyof F]: Read<F[K]> } & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
79
|
+
? Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
78
80
|
: never
|
|
79
81
|
export type { FieldDecl, LeafDecl, GroupDecl, ListDecl, ZoneDecl, BlockDecl, CollectionDecl, SingleDecl, Schema }
|
|
80
82
|
export type { CollectionAuthoring, CollectionAuthoringAnchor, CollectionAuthoringSections } from '../elements'
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.8.0'
|