@unitninety/site-kit 1.6.5 → 1.7.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 +68 -4
- package/package.json +5 -4
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/U90Bridge.tsx +4 -2
- package/src/bridge/caret.ts +7 -8
- package/src/bridge/createBridge.ts +30 -10
- package/src/bridge/diagnostics.ts +61 -0
- package/src/bridge/index.ts +3 -0
- package/src/bridge/nativeDraft.ts +47 -36
- package/src/bridge/nativeOrder.ts +72 -0
- package/src/bridge/nativePath.ts +45 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/styles.ts +12 -0
- package/src/bridge/useDraftContent.tsx +68 -29
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Image.tsx +8 -4
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/components/draft.ts +19 -0
- package/src/components/index.ts +1 -1
- package/src/custom-code/fragments.ts +64 -0
- package/src/custom-code/index.ts +30 -0
- package/src/elements/blockAnchor.ts +7 -0
- package/src/elements/index.ts +1 -0
- package/src/elements/ops/draftPaths.ts +23 -0
- package/src/elements/ops/expand.ts +8 -6
- package/src/elements/ops/validate.ts +9 -2
- package/src/features/client/README.md +1 -2
- package/src/features/client/SiteFeatures.tsx +15 -25
- package/src/features/client/index.ts +0 -6
- package/src/features/client/verification.ts +3 -0
- package/src/features/contracts/base.ts +1 -5
- package/src/features/contracts/index.ts +0 -20
- package/src/features/contracts/verificationConfig.ts +7 -0
- package/src/features/server/gateway.ts +1 -3
- package/src/features/server/index.ts +0 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/BlockAnchors.tsx +92 -0
- package/src/server/CustomCode.tsx +29 -0
- package/src/server/index.ts +1 -0
- package/src/server/read.ts +13 -0
- package/src/server/render.tsx +12 -6
- package/src/site/fields.ts +2 -2
- package/src/site/types.ts +4 -2
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +3 -0
- package/src/features/client/ConsentRuntime.tsx +0 -95
- package/src/features/client/useConsent.ts +0 -15
- package/src/features/consent/README.md +0 -70
- package/src/features/consent/cookieScript.ts +0 -53
- package/src/features/consent/custom.ts +0 -89
- package/src/features/consent/defaults.ts +0 -40
- package/src/features/consent/drivers.ts +0 -233
- package/src/features/consent/loader.ts +0 -248
- package/src/features/consent/registry.ts +0 -398
- package/src/features/contracts/consent.ts +0 -150
- package/src/features/server/ConsentDefaults.tsx +0 -15
|
@@ -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
|
+
}
|
package/src/bridge/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntryPicks } from './DraftEntries'
|
|
2
2
|
import type { DraftSnapshot } from './protocol'
|
|
3
3
|
import type { Bridge } from './createBridge'
|
|
4
|
+
import type { DraftOrder } from './nativeOrder'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The one shared, module-level handle onto the live bridge instance and its reveal listeners.
|
|
@@ -22,9 +23,9 @@ export const registry: {
|
|
|
22
23
|
current: Bridge | null
|
|
23
24
|
reveals: Set<{ prefix: string; fn: () => void }>
|
|
24
25
|
entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
|
|
25
|
-
draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot): boolean }>
|
|
26
|
+
draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot, order?: DraftOrder): boolean }>
|
|
26
27
|
draftContents: Set<{ document: Document; beforeRefresh(): void; preview(path: string, value: unknown[]): boolean }>
|
|
27
|
-
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; move(path: string, before: string | null): boolean }>
|
|
28
|
+
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; reorder?(order: DraftOrder): boolean; move(path: string, before: string | null): boolean }>
|
|
28
29
|
} = {
|
|
29
30
|
current: null,
|
|
30
31
|
reveals: new Set(),
|
package/src/bridge/selection.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/** Explicit references win; a directly rendered Single otherwise belongs to its enclosing block. */
|
|
2
|
+
function selectionContext(element: Element | null, path: string | null): { via?: string[]; block?: string } {
|
|
3
|
+
const references: string[] = []
|
|
4
|
+
let block: string | undefined
|
|
5
|
+
for (let node = element; node; node = node.parentElement) {
|
|
6
|
+
const reference = node.getAttribute('data-u90-reference')
|
|
7
|
+
if (reference && reference !== path) references.unshift(reference)
|
|
8
|
+
if (!block && path?.startsWith('single:')) {
|
|
9
|
+
const placement = node.getAttribute('data-u90-block')
|
|
10
|
+
if (placement && placement.length <= 512 && /^(page|entry|layout):[^/\s]+\/blocks\/[^/\s]+(?:\/[^/\s]+)*$/.test(placement)) block = placement
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (references.length) return { via: references.length <= 5 ? references : undefined }
|
|
14
|
+
return block ? { via: [block], block } : {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const selectionVia = (element: Element | null, path: string | null): string[] | undefined => selectionContext(element, path).via
|
|
18
|
+
|
|
1
19
|
/** Draft-only selection geometry. Contents wrappers have no CSS box of their own. */
|
|
2
20
|
export function createSelection(win: Window) {
|
|
3
21
|
const doc = win.document
|
|
@@ -6,16 +24,46 @@ export function createSelection(win: Window) {
|
|
|
6
24
|
overlay.setAttribute('aria-hidden', 'true')
|
|
7
25
|
overlay.hidden = true
|
|
8
26
|
let path: string | null = null
|
|
27
|
+
let via: readonly string[] = []
|
|
9
28
|
let shouldReveal = false
|
|
10
29
|
let frame: number | null = null
|
|
11
30
|
let observer: MutationObserver | null = null
|
|
31
|
+
let selectedElement: HTMLElement | null = null
|
|
12
32
|
const escaped = (value: string) => value.replace(/["\\]/g, '\\$&')
|
|
13
|
-
const target = () =>
|
|
14
|
-
path
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
const target = () => {
|
|
34
|
+
if (!path) return null
|
|
35
|
+
const selector = `[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`
|
|
36
|
+
if (!path.startsWith('single:') || !via.length) return doc.querySelector<HTMLElement>(selector)
|
|
37
|
+
// Re-resolve after React commits, but never silently select a different occurrence if the
|
|
38
|
+
// requested placement has disappeared. Context-free selections retain their old behavior.
|
|
39
|
+
const exact: HTMLElement[] = []
|
|
40
|
+
const fallback: HTMLElement[] = []
|
|
41
|
+
let fallbackBlockLength = 0
|
|
42
|
+
for (const element of doc.querySelectorAll<HTMLElement>(selector)) {
|
|
43
|
+
const context = selectionContext(element, path)
|
|
44
|
+
if (context.via?.length === via.length && context.via.every((ancestor, index) => ancestor === via[index])) exact.push(element)
|
|
45
|
+
// Older markup names only the enclosing block, while the inspector can name its Single
|
|
46
|
+
// field. Explicit references must still match exactly and take precedence over fallback.
|
|
47
|
+
else if (context.block && via.length === 1 && via[0]!.startsWith(`${context.block}/`) && via[0]!.length > context.block.length + 1) {
|
|
48
|
+
// A nested block and its outer block can both render this Single. Use the nearest
|
|
49
|
+
// placement named by the inspector, not another occurrence higher in that subtree.
|
|
50
|
+
if (context.block.length > fallbackBlockLength) {
|
|
51
|
+
fallback.length = 0
|
|
52
|
+
fallbackBlockLength = context.block.length
|
|
53
|
+
}
|
|
54
|
+
if (context.block.length === fallbackBlockLength) fallback.push(element)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const candidates = exact.length ? exact : fallback
|
|
58
|
+
return candidates.find((element) => bounds(element) !== null) ?? candidates[0] ?? null
|
|
59
|
+
}
|
|
60
|
+
const markTarget = () => {
|
|
61
|
+
const element = target()
|
|
62
|
+
if (selectedElement !== element) selectedElement?.removeAttribute('data-u90-selected')
|
|
63
|
+
selectedElement = element
|
|
64
|
+
element?.setAttribute('data-u90-selected', '')
|
|
65
|
+
return element
|
|
66
|
+
}
|
|
19
67
|
type Box = {
|
|
20
68
|
left: number
|
|
21
69
|
top: number
|
|
@@ -42,9 +90,8 @@ export function createSelection(win: Window) {
|
|
|
42
90
|
}
|
|
43
91
|
const paint = () => {
|
|
44
92
|
frame = null
|
|
45
|
-
const element = target()
|
|
46
93
|
// Server refreshes can replace the selected node without changing its path.
|
|
47
|
-
element
|
|
94
|
+
const element = markTarget()
|
|
48
95
|
let box = element ? bounds(element) : null
|
|
49
96
|
if (!element || !box) {
|
|
50
97
|
overlay.hidden = true
|
|
@@ -81,13 +128,14 @@ export function createSelection(win: Window) {
|
|
|
81
128
|
if (path && frame === null) frame = win.requestAnimationFrame(paint)
|
|
82
129
|
}
|
|
83
130
|
return {
|
|
84
|
-
select(next: string | null, reveal = false) {
|
|
131
|
+
select(next: string | null, reveal = false, nextVia?: readonly string[]) {
|
|
85
132
|
doc
|
|
86
133
|
.querySelectorAll('[data-u90-selected]')
|
|
87
134
|
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
|
88
135
|
path = next
|
|
136
|
+
via = nextVia ? [...nextVia] : []
|
|
89
137
|
shouldReveal = reveal
|
|
90
|
-
|
|
138
|
+
markTarget()
|
|
91
139
|
overlay.hidden = true
|
|
92
140
|
schedule()
|
|
93
141
|
},
|
|
@@ -125,6 +173,7 @@ export function createSelection(win: Window) {
|
|
|
125
173
|
if (frame !== null) win.cancelAnimationFrame(frame)
|
|
126
174
|
frame = null
|
|
127
175
|
path = null
|
|
176
|
+
selectedElement = null
|
|
128
177
|
doc
|
|
129
178
|
.querySelectorAll('[data-u90-selected]')
|
|
130
179
|
.forEach((element) => element.removeAttribute('data-u90-selected'))
|
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,8 +2,10 @@
|
|
|
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'
|
|
8
|
+
import { applyNativeOrder, reorderNativeDraft, type DraftOrder } from './nativeOrder'
|
|
7
9
|
|
|
8
10
|
/** Set by draft block rendering; published components have no editing owner. */
|
|
9
11
|
export const DraftContentPath = createContext<string | undefined>(undefined)
|
|
@@ -81,6 +83,17 @@ const sameSource = (a: unknown, b: unknown): boolean => {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export type NativeDraftPatch = { path: string; value: unknown }
|
|
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
|
+
}
|
|
95
|
+
const applyPreviewPatch = <T,>(source: T, patch: PreviewPatch, apply: (source: T, patch: NativeDraftPatch) => T, reorder: (source: T, order: DraftOrder) => T = reorderNativeDraft): T =>
|
|
96
|
+
patch.kind === 'order' ? reorder(source, patch) : apply(source, patch)
|
|
84
97
|
const applyPatch = <T,>(source: T, patch: NativeDraftPatch): T => applyNativeDraft(source, patch.path, patch.value)
|
|
85
98
|
const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
86
99
|
if (!value || typeof value !== 'object' || seen.has(value)) return false
|
|
@@ -90,57 +103,83 @@ const hasNativePath = (value: unknown, seen = new Set<object>()): boolean => {
|
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
/** React owns native previews throughout saving; a later refresh settles the server's result.
|
|
93
|
-
* The optional adapter maps a native patch into a site's existing plain presentation props.
|
|
94
|
-
|
|
106
|
+
* The optional adapter maps a native patch into a site's existing plain presentation props.
|
|
107
|
+
* Custom adapters keep receiving complete snapshots unless they supply a third, order-only
|
|
108
|
+
* adapter. Use reorderNativeDraft for collections retaining native block $path values. */
|
|
109
|
+
export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch, reorder = apply === applyPatch ? reorderNativeDraft<T> : undefined): T {
|
|
95
110
|
const owner = useContext(DraftContentPath)
|
|
96
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])
|
|
97
115
|
const [cache] = useState(() => new NativeDraftReferences())
|
|
98
|
-
const indexed = useRef<{ source: T; references: readonly unknown[] } | null>(null)
|
|
99
|
-
if (!indexed.current || !sameSource(indexed.current.source, source) || indexed.current.references !== references) {
|
|
100
|
-
cache.add(references)
|
|
101
|
-
cache.add(source)
|
|
102
|
-
indexed.current = { source, references }
|
|
103
|
-
}
|
|
104
116
|
const enabled = !!owner || hasNativePath(source)
|
|
105
|
-
const [shown, setShown] = useState({ source, value: source, owner, overrides: new Map<string,
|
|
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
|
-
const overrides = shown.owner === owner ? shown.overrides : new Map<string,
|
|
120
|
+
const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
|
|
109
121
|
current = source
|
|
110
|
-
for (const patch of overrides.values()) current =
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
const latest = useRef({ current, source, references, apply: applyValue, reorder, native, overrides: shown.overrides, settling: shown.settling })
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
cache.add(references)
|
|
128
|
+
cache.add(source)
|
|
129
|
+
for (const patch of shown.overrides.values()) {
|
|
130
|
+
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
131
|
+
else cache.order(patch)
|
|
132
|
+
}
|
|
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])
|
|
116
135
|
useLayoutEffect(() => {
|
|
117
136
|
if (!enabled) return
|
|
118
137
|
const handler = {
|
|
119
138
|
document,
|
|
120
139
|
beforeRefresh: () => {
|
|
121
140
|
cache.beforeRefresh()
|
|
122
|
-
|
|
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 }))
|
|
123
148
|
},
|
|
124
149
|
afterRefresh: () => setShown(previous => {
|
|
125
150
|
cache.settle([latest.current.references, latest.current.source])
|
|
126
151
|
let value = latest.current.source
|
|
127
|
-
for (const patch of previous.overrides.values()) value = latest.current.apply
|
|
152
|
+
for (const patch of previous.overrides.values()) value = applyPreviewPatch(value, patch, latest.current.apply, latest.current.reorder)
|
|
128
153
|
latest.current.current = value
|
|
129
|
-
|
|
154
|
+
latest.current.settling = new Map()
|
|
155
|
+
return { ...previous, source: latest.current.source, value, settling: new Map() }
|
|
130
156
|
}),
|
|
131
|
-
preview: (snapshot: DraftSnapshot) => {
|
|
157
|
+
preview: (snapshot: DraftSnapshot, order?: DraftOrder) => {
|
|
132
158
|
if (!safeDraftPath(snapshot.path)) return false
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
const useOrder = order && latest.current.reorder
|
|
160
|
+
const patch: PreviewPatch = useOrder ? { kind: 'order', ...order } : { kind: 'value', path: snapshot.path, value: cache.expand(snapshot) }
|
|
161
|
+
if (patch.kind === 'value') cache.patch(patch.path, patch.value)
|
|
162
|
+
else cache.order(patch)
|
|
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)
|
|
137
180
|
latest.current.current = next
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
for (const key of overrides.keys()) if (key === patch.path || key.startsWith(`${patch.path}/`)) overrides.delete(key)
|
|
141
|
-
overrides.set(patch.path, patch)
|
|
142
|
-
return { ...previous, value: next, overrides }
|
|
143
|
-
})
|
|
181
|
+
latest.current.overrides = overrides
|
|
182
|
+
setShown(previous => ({ ...previous, value: next, overrides }))
|
|
144
183
|
return true
|
|
145
184
|
},
|
|
146
185
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import NextLink from 'next/link'
|
|
4
|
+
import { type ComponentProps } from 'react'
|
|
5
|
+
import { clearBlockNavigation, requestBlockNavigation } from './block-navigation'
|
|
6
|
+
|
|
7
|
+
/** Only generated SDK destinations need cooperation with NextLink's navigation lifecycle. */
|
|
8
|
+
export function BlockLink({ onNavigate, href, replace, scroll, ...props }: ComponentProps<typeof NextLink>) {
|
|
9
|
+
return <NextLink {...props} href={href} replace={replace} scroll={scroll} onNavigate={event => {
|
|
10
|
+
let cancelled = false
|
|
11
|
+
onNavigate?.({ preventDefault: () => { cancelled = true; event.preventDefault() } })
|
|
12
|
+
if (cancelled || typeof href !== 'string') return
|
|
13
|
+
const url = new URL(href, window.location.href)
|
|
14
|
+
if (url.origin !== window.location.origin) return
|
|
15
|
+
if (url.pathname !== window.location.pathname || url.search !== window.location.search) {
|
|
16
|
+
requestBlockNavigation(window, url, scroll !== false)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
clearBlockNavigation(window)
|
|
20
|
+
// NextLink has already honored onClick, modifiers, download and target; the
|
|
21
|
+
// caller's onNavigate also ran before committing generated same-page navigation.
|
|
22
|
+
event.preventDefault()
|
|
23
|
+
// Next's native history integration copies its own router state. Passing that
|
|
24
|
+
// private state back would mark this as an internal update and skip URL sync.
|
|
25
|
+
if (replace || url.href !== window.location.href) window.history[replace ? 'replaceState' : 'pushState'](null, '', url.href)
|
|
26
|
+
if (scroll !== false) window.document.dispatchEvent(new window.CustomEvent('u90:block-navigate'))
|
|
27
|
+
}} />
|
|
28
|
+
}
|
package/src/components/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 }))
|
package/src/components/Link.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import NextLink from 'next/link'
|
|
|
2
2
|
import { Children, createElement, type ReactNode } from 'react'
|
|
3
3
|
import type { LinkRead } from '../elements'
|
|
4
4
|
import { draftAttrs } from './draft'
|
|
5
|
+
import { BlockLink } from './BlockLink'
|
|
6
|
+
import { ID_RE } from '../elements/ids'
|
|
5
7
|
export const isInternal = (href: string) => href.startsWith('/') && !href.startsWith('//')
|
|
6
8
|
export function Link({ value, children, ...rest }: { value: LinkRead | null | undefined; children?: ReactNode } & Record<string, unknown>) {
|
|
7
9
|
if (!value) return null
|
|
@@ -20,5 +22,10 @@ export function Link({ value, children, ...rest }: { value: LinkRead | null | un
|
|
|
20
22
|
// element's props pass through). The href is always the value's.
|
|
21
23
|
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
24
|
for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
|
|
23
|
-
|
|
25
|
+
let generated = false
|
|
26
|
+
try {
|
|
27
|
+
const hash = decodeURIComponent(new URL(value.href, 'https://u90.invalid').hash.slice(1))
|
|
28
|
+
generated = hash.startsWith('u90-block-') && ID_RE.test(hash.slice(10))
|
|
29
|
+
} catch { /* malformed legacy fragments remain ordinary links */ }
|
|
30
|
+
return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(generated ? BlockLink : NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
31
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A generated link's accepted navigation, shared with the destination renderer. */
|
|
2
|
+
type BlockNavigation = { url: URL; scroll: boolean; expires: number }
|
|
3
|
+
const pending = new WeakMap<Window, BlockNavigation>()
|
|
4
|
+
|
|
5
|
+
export function requestBlockNavigation(win: Window, url: URL, scroll: boolean) {
|
|
6
|
+
pending.set(win, { url, scroll, expires: Date.now() + 10_000 })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function clearBlockNavigation(win: Window) {
|
|
10
|
+
pending.delete(win)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function arrivingBlockNavigation(win: Window): BlockNavigation | undefined {
|
|
14
|
+
const request = pending.get(win)
|
|
15
|
+
if (!request) return
|
|
16
|
+
if (Date.now() >= request.expires) { pending.delete(win); return }
|
|
17
|
+
const actual = new URL(win.location.href)
|
|
18
|
+
const expected = request.url
|
|
19
|
+
if (actual.origin !== expected.origin || actual.pathname !== expected.pathname || actual.search !== expected.search) return
|
|
20
|
+
// Next 16.4 caches the initial document fragment in its canonical route URL,
|
|
21
|
+
// then appends the requested fragment on a return navigation. Correct only a
|
|
22
|
+
// still-pending explicit request, never arbitrary fragment-containing URLs.
|
|
23
|
+
if (actual.hash === expected.hash || actual.hash.endsWith(expected.hash)) return request
|
|
24
|
+
}
|
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
|
@@ -9,5 +9,5 @@ export { Image } from './Image'
|
|
|
9
9
|
export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
|
|
10
10
|
export { Video } from './Video'
|
|
11
11
|
export { videoProps, type VideoHTMLProps } from './videoProps'
|
|
12
|
-
export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
|
|
12
|
+
export { draftAttrs, draftFieldPath, draftListItemPath, editable, editableRich, type DraftAttrs } from './draft'
|
|
13
13
|
export { LanguageSwitcher, useLanguage, rememberLanguage } from '../switcher/LanguageSwitcher'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
|
|
2
|
+
import type { CustomCodePlacement } from './index'
|
|
3
|
+
|
|
4
|
+
type Node = DefaultTreeAdapterTypes.ChildNode
|
|
5
|
+
export type CustomCodeElement = DefaultTreeAdapterTypes.Element
|
|
6
|
+
const headTags = new Set(['script', 'style', 'meta', 'link'])
|
|
7
|
+
const bodyTags = new Set(['script', 'style', 'link', 'noscript'])
|
|
8
|
+
const fallbackTags = new Set(['iframe', 'img', 'div', 'span', 'p'])
|
|
9
|
+
const voidTags = new Set(['meta', 'link', 'img'])
|
|
10
|
+
const reservedAttributes = new Set(['children', 'dangerouslysetinnerhtml', 'suppresshydrationwarning', 'suppresscontenteditablewarning', 'key', 'ref'])
|
|
11
|
+
const urlAttributes = new Set(['src', 'href', 'action', 'formaction', 'poster', 'background', 'cite', 'data', 'longdesc', 'manifest', 'xlink:href'])
|
|
12
|
+
|
|
13
|
+
/** parse5 supplies HTML tokenization, decoded attribute values, and source spans. In addition
|
|
14
|
+
* to parser errors we require complete contiguous spans: HTML's normal error recovery can
|
|
15
|
+
* silently drop document wrappers/end tags or insert missing closing tags without an error. */
|
|
16
|
+
export function parseCustomCodeFragment(source: string, placement: CustomCodePlacement): CustomCodeElement[] {
|
|
17
|
+
const fail = (reason: string): never => { throw new Error(`${placement}: ${reason}`) }
|
|
18
|
+
const fragment = parseFragment(source, {
|
|
19
|
+
scriptingEnabled: false, sourceCodeLocationInfo: true,
|
|
20
|
+
onParseError: (error) => fail(`malformed HTML (${error.code})`),
|
|
21
|
+
})
|
|
22
|
+
const visit = (nodes: Node[], start: number, end: number, fallback: boolean, rawText = false) => {
|
|
23
|
+
let cursor = start
|
|
24
|
+
for (const node of nodes) {
|
|
25
|
+
const loc = node.sourceCodeLocation
|
|
26
|
+
if (!loc || loc.startOffset !== cursor) fail('unsupported or malformed HTML was discarded by the parser')
|
|
27
|
+
cursor = loc!.endOffset
|
|
28
|
+
if (node.nodeName === '#comment') continue
|
|
29
|
+
if ('value' in node) {
|
|
30
|
+
if (!fallback && !rawText && node.value.trim()) fail('ordinary text is only supported inside noscript fallbacks')
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (!('tagName' in node)) fail('document declarations are not supported')
|
|
34
|
+
const element = node as CustomCodeElement
|
|
35
|
+
const tag = element.tagName
|
|
36
|
+
const allowed = fallback ? fallbackTags : placement === 'head' ? headTags : bodyTags
|
|
37
|
+
if (!allowed.has(tag)) fail(`<${tag}> is not supported in this placement${fallback ? ' inside noscript' : ''}`)
|
|
38
|
+
const location = element.sourceCodeLocation!
|
|
39
|
+
if (!location.startTag || (!voidTags.has(tag) && !location.endTag)) fail(`<${tag}> requires explicit opening and closing tags`)
|
|
40
|
+
for (const { name, value } of element.attrs) {
|
|
41
|
+
if (name.startsWith('on')) fail(`event attribute ${name} is not supported`)
|
|
42
|
+
if (reservedAttributes.has(name)) fail(`reserved attribute ${name} is not supported`)
|
|
43
|
+
if (name === 'srcdoc' || name === 'srcset' || name === 'imagesrcset') fail(`attribute ${name} is not supported`)
|
|
44
|
+
if (name === 'style' && !fallback) fail('inline style attributes on resource tags are not supported; use a style element')
|
|
45
|
+
if (tag === 'meta' && name === 'http-equiv' && value.trim().toLowerCase() === 'refresh') fail('meta refresh is not supported')
|
|
46
|
+
if (urlAttributes.has(name)) {
|
|
47
|
+
// Reject whitespace/control characters and backslashes before URL parsing can repair them.
|
|
48
|
+
if (/[\u0000-\u0020\u007f\\]/u.test(value) || !(value.startsWith('/') && !value.startsWith('//') || /^https:\/\//i.test(value))) fail(`${name} must be an HTTPS or root-relative resource URL`)
|
|
49
|
+
try { new URL(value, 'https://custom-code.invalid') } catch { fail(`${name} is not a valid resource URL`) }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!voidTags.has(tag)) visit(element.childNodes, location.startTag!.endOffset, location.endTag!.startOffset, fallback || tag === 'noscript', tag === 'script' || tag === 'style')
|
|
53
|
+
}
|
|
54
|
+
if (cursor !== end) fail('unsupported or malformed HTML was discarded by the parser')
|
|
55
|
+
}
|
|
56
|
+
visit(fragment.childNodes, 0, source.length, false)
|
|
57
|
+
return fragment.childNodes.filter((node): node is CustomCodeElement => 'tagName' in node)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Retain raw text and fallback markup exactly, including HTML entities and JS comments. */
|
|
61
|
+
export function fragmentInnerHtml(source: string, element: CustomCodeElement): string {
|
|
62
|
+
const location = element.sourceCodeLocation!
|
|
63
|
+
return location.endTag ? source.slice(location.startTag!.endOffset, location.endTag.startOffset) : ''
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseCustomCodeFragment } from './fragments'
|
|
2
|
+
|
|
3
|
+
export type CustomCodePlacement = 'head' | 'bodyStart' | 'bodyEnd'
|
|
4
|
+
export type CustomCode = Record<CustomCodePlacement, string>
|
|
5
|
+
export type PublishedCustomCode = { version: 1; revision: number } & CustomCode
|
|
6
|
+
export const EMPTY_CUSTOM_CODE: CustomCode = Object.freeze({ head: '', bodyStart: '', bodyEnd: '' })
|
|
7
|
+
|
|
8
|
+
/** Server-safe validation only: never executes scripts or retrieves submitted URLs.
|
|
9
|
+
* Client forms should import types only, keeping the HTML parser on the server. */
|
|
10
|
+
export function validateCustomCode(input: unknown): CustomCode {
|
|
11
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('custom code: expected head, bodyStart and bodyEnd strings')
|
|
12
|
+
const fields = Object.keys(EMPTY_CUSTOM_CODE) as CustomCodePlacement[]
|
|
13
|
+
for (const key of Object.keys(input)) {
|
|
14
|
+
if (!fields.includes(key as CustomCodePlacement)) throw new Error(`${key}: unsupported custom code field`)
|
|
15
|
+
}
|
|
16
|
+
const result = { ...EMPTY_CUSTOM_CODE }
|
|
17
|
+
let total = 0
|
|
18
|
+
for (const field of fields) {
|
|
19
|
+
const value = (input as Record<string, unknown>)[field]
|
|
20
|
+
if (!Object.hasOwn(input, field) || typeof value !== 'string') throw new Error(`${field}: expected a string`)
|
|
21
|
+
const normalized = value.replace(/\r\n?/g, '\n')
|
|
22
|
+
const size = new TextEncoder().encode(normalized).length
|
|
23
|
+
if (size > 64 * 1024) throw new Error(`${field}: exceeds the 64 KiB UTF-8 limit`)
|
|
24
|
+
total += size
|
|
25
|
+
if (total > 128 * 1024) throw new Error(`${field}: exceeds the 128 KiB total UTF-8 limit`)
|
|
26
|
+
parseCustomCodeFragment(normalized, field)
|
|
27
|
+
result[field] = normalized
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ID_RE } from './ids'
|
|
2
|
+
|
|
3
|
+
/** The public fragment for a stored block identity; names and ordering never enter its address. */
|
|
4
|
+
export function blockAnchor(id: string): string {
|
|
5
|
+
if (!ID_RE.test(id)) throw new Error('Invalid stored block identity')
|
|
6
|
+
return `u90-block-${id}`
|
|
7
|
+
}
|
package/src/elements/index.ts
CHANGED
|
@@ -33,3 +33,4 @@ export { translatableParts } from './ops/translatable'
|
|
|
33
33
|
export { overlayKey, translatableLeaves, applyOverlay, type OverlayValues } from './ops/overlay'
|
|
34
34
|
export { upgrade, UpgradeError, type UpgradeOptions } from './ops/upgrade'
|
|
35
35
|
export { toJSONSchema } from './jsonSchema'
|
|
36
|
+
export { blockAnchor } from './blockAnchor'
|
|
@@ -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
|
+
}
|