@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.
Files changed (59) hide show
  1. package/README.md +68 -4
  2. package/package.json +5 -4
  3. package/src/bridge/DraftBlocks.tsx +10 -0
  4. package/src/bridge/U90Bridge.tsx +4 -2
  5. package/src/bridge/caret.ts +7 -8
  6. package/src/bridge/createBridge.ts +30 -10
  7. package/src/bridge/diagnostics.ts +61 -0
  8. package/src/bridge/index.ts +3 -0
  9. package/src/bridge/nativeDraft.ts +47 -36
  10. package/src/bridge/nativeOrder.ts +72 -0
  11. package/src/bridge/nativePath.ts +45 -0
  12. package/src/bridge/registry.ts +3 -2
  13. package/src/bridge/selection.ts +59 -10
  14. package/src/bridge/styles.ts +12 -0
  15. package/src/bridge/useDraftContent.tsx +68 -29
  16. package/src/components/BlockLink.tsx +28 -0
  17. package/src/components/Image.tsx +8 -4
  18. package/src/components/Link.tsx +8 -1
  19. package/src/components/block-navigation.ts +24 -0
  20. package/src/components/draft.ts +19 -0
  21. package/src/components/index.ts +1 -1
  22. package/src/custom-code/fragments.ts +64 -0
  23. package/src/custom-code/index.ts +30 -0
  24. package/src/elements/blockAnchor.ts +7 -0
  25. package/src/elements/index.ts +1 -0
  26. package/src/elements/ops/draftPaths.ts +23 -0
  27. package/src/elements/ops/expand.ts +8 -6
  28. package/src/elements/ops/validate.ts +9 -2
  29. package/src/features/client/README.md +1 -2
  30. package/src/features/client/SiteFeatures.tsx +15 -25
  31. package/src/features/client/index.ts +0 -6
  32. package/src/features/client/verification.ts +3 -0
  33. package/src/features/contracts/base.ts +1 -5
  34. package/src/features/contracts/index.ts +0 -20
  35. package/src/features/contracts/verificationConfig.ts +7 -0
  36. package/src/features/server/gateway.ts +1 -3
  37. package/src/features/server/index.ts +0 -2
  38. package/src/legacy/cli.mjs +2 -2
  39. package/src/legacy/content.ts +1 -1
  40. package/src/server/BlockAnchors.tsx +92 -0
  41. package/src/server/CustomCode.tsx +29 -0
  42. package/src/server/index.ts +1 -0
  43. package/src/server/read.ts +13 -0
  44. package/src/server/render.tsx +12 -6
  45. package/src/site/fields.ts +2 -2
  46. package/src/site/types.ts +4 -2
  47. package/src/site/version.ts +1 -1
  48. package/src/site/wire.ts +3 -0
  49. package/src/features/client/ConsentRuntime.tsx +0 -95
  50. package/src/features/client/useConsent.ts +0 -15
  51. package/src/features/consent/README.md +0 -70
  52. package/src/features/consent/cookieScript.ts +0 -53
  53. package/src/features/consent/custom.ts +0 -89
  54. package/src/features/consent/defaults.ts +0 -40
  55. package/src/features/consent/drivers.ts +0 -233
  56. package/src/features/consent/loader.ts +0 -248
  57. package/src/features/consent/registry.ts +0 -398
  58. package/src/features/contracts/consent.ts +0 -150
  59. 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
+ }
@@ -1,6 +1,7 @@
1
1
  import type { EntryPicks } from './DraftEntries'
2
2
  import type { DraftSnapshot } from './protocol'
3
3
  import type { Bridge } from './createBridge'
4
+ import type { DraftOrder } from './nativeOrder'
4
5
 
5
6
  /**
6
7
  * The one shared, module-level handle onto the live bridge instance and its reveal listeners.
@@ -22,9 +23,9 @@ export const registry: {
22
23
  current: Bridge | null
23
24
  reveals: Set<{ prefix: string; fn: () => void }>
24
25
  entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
25
- draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot): boolean }>
26
+ draftValues: Set<{ document: Document; beforeRefresh(): void; afterRefresh(): void; preview(snapshot: DraftSnapshot, order?: DraftOrder): boolean }>
26
27
  draftContents: Set<{ document: Document; beforeRefresh(): void; preview(path: string, value: unknown[]): boolean }>
27
- blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; move(path: string, before: string | null): boolean }>
28
+ blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; remove?(path: string): boolean; reorder?(order: DraftOrder): boolean; move(path: string, before: string | null): boolean }>
28
29
  } = {
29
30
  current: null,
30
31
  reveals: new Set(),
@@ -1,3 +1,21 @@
1
+ /** Explicit references win; a directly rendered Single otherwise belongs to its enclosing block. */
2
+ function selectionContext(element: Element | null, path: string | null): { via?: string[]; block?: string } {
3
+ const references: string[] = []
4
+ let block: string | undefined
5
+ for (let node = element; node; node = node.parentElement) {
6
+ const reference = node.getAttribute('data-u90-reference')
7
+ if (reference && reference !== path) references.unshift(reference)
8
+ if (!block && path?.startsWith('single:')) {
9
+ const placement = node.getAttribute('data-u90-block')
10
+ if (placement && placement.length <= 512 && /^(page|entry|layout):[^/\s]+\/blocks\/[^/\s]+(?:\/[^/\s]+)*$/.test(placement)) block = placement
11
+ }
12
+ }
13
+ if (references.length) return { via: references.length <= 5 ? references : undefined }
14
+ return block ? { via: [block], block } : {}
15
+ }
16
+
17
+ export const selectionVia = (element: Element | null, path: string | null): string[] | undefined => selectionContext(element, path).via
18
+
1
19
  /** Draft-only selection geometry. Contents wrappers have no CSS box of their own. */
2
20
  export function createSelection(win: Window) {
3
21
  const doc = win.document
@@ -6,16 +24,46 @@ export function createSelection(win: Window) {
6
24
  overlay.setAttribute('aria-hidden', 'true')
7
25
  overlay.hidden = true
8
26
  let path: string | null = null
27
+ let via: readonly string[] = []
9
28
  let shouldReveal = false
10
29
  let frame: number | null = null
11
30
  let observer: MutationObserver | null = null
31
+ let selectedElement: HTMLElement | null = null
12
32
  const escaped = (value: string) => value.replace(/["\\]/g, '\\$&')
13
- const target = () =>
14
- path
15
- ? doc.querySelector<HTMLElement>(
16
- `[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`,
17
- )
18
- : null
33
+ const target = () => {
34
+ if (!path) return null
35
+ const selector = `[data-u90-field="${escaped(path)}"], [data-u90-block="${escaped(path)}"]`
36
+ if (!path.startsWith('single:') || !via.length) return doc.querySelector<HTMLElement>(selector)
37
+ // Re-resolve after React commits, but never silently select a different occurrence if the
38
+ // requested placement has disappeared. Context-free selections retain their old behavior.
39
+ const exact: HTMLElement[] = []
40
+ const fallback: HTMLElement[] = []
41
+ let fallbackBlockLength = 0
42
+ for (const element of doc.querySelectorAll<HTMLElement>(selector)) {
43
+ const context = selectionContext(element, path)
44
+ if (context.via?.length === via.length && context.via.every((ancestor, index) => ancestor === via[index])) exact.push(element)
45
+ // Older markup names only the enclosing block, while the inspector can name its Single
46
+ // field. Explicit references must still match exactly and take precedence over fallback.
47
+ else if (context.block && via.length === 1 && via[0]!.startsWith(`${context.block}/`) && via[0]!.length > context.block.length + 1) {
48
+ // A nested block and its outer block can both render this Single. Use the nearest
49
+ // placement named by the inspector, not another occurrence higher in that subtree.
50
+ if (context.block.length > fallbackBlockLength) {
51
+ fallback.length = 0
52
+ fallbackBlockLength = context.block.length
53
+ }
54
+ if (context.block.length === fallbackBlockLength) fallback.push(element)
55
+ }
56
+ }
57
+ const candidates = exact.length ? exact : fallback
58
+ return candidates.find((element) => bounds(element) !== null) ?? candidates[0] ?? null
59
+ }
60
+ const markTarget = () => {
61
+ const element = target()
62
+ if (selectedElement !== element) selectedElement?.removeAttribute('data-u90-selected')
63
+ selectedElement = element
64
+ element?.setAttribute('data-u90-selected', '')
65
+ return element
66
+ }
19
67
  type Box = {
20
68
  left: number
21
69
  top: number
@@ -42,9 +90,8 @@ export function createSelection(win: Window) {
42
90
  }
43
91
  const paint = () => {
44
92
  frame = null
45
- const element = target()
46
93
  // Server refreshes can replace the selected node without changing its path.
47
- element?.setAttribute('data-u90-selected', '')
94
+ const element = markTarget()
48
95
  let box = element ? bounds(element) : null
49
96
  if (!element || !box) {
50
97
  overlay.hidden = true
@@ -81,13 +128,14 @@ export function createSelection(win: Window) {
81
128
  if (path && frame === null) frame = win.requestAnimationFrame(paint)
82
129
  }
83
130
  return {
84
- select(next: string | null, reveal = false) {
131
+ select(next: string | null, reveal = false, nextVia?: readonly string[]) {
85
132
  doc
86
133
  .querySelectorAll('[data-u90-selected]')
87
134
  .forEach((element) => element.removeAttribute('data-u90-selected'))
88
135
  path = next
136
+ via = nextVia ? [...nextVia] : []
89
137
  shouldReveal = reveal
90
- target()?.setAttribute('data-u90-selected', '')
138
+ markTarget()
91
139
  overlay.hidden = true
92
140
  schedule()
93
141
  },
@@ -125,6 +173,7 @@ export function createSelection(win: Window) {
125
173
  if (frame !== null) win.cancelAnimationFrame(frame)
126
174
  frame = null
127
175
  path = null
176
+ selectedElement = null
128
177
  doc
129
178
  .querySelectorAll('[data-u90-selected]')
130
179
  .forEach((element) => element.removeAttribute('data-u90-selected'))
@@ -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
- export function useDraftPreview<T>(source: T, apply: (source: T, patch: NativeDraftPatch) => T = applyPatch): T {
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, NativeDraftPatch>() })
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, NativeDraftPatch>()
120
+ const overrides = shown.owner === owner ? shown.overrides : new Map<string, PreviewPatch>()
109
121
  current = source
110
- for (const patch of overrides.values()) current = apply(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
- for (const patch of shown.overrides.values()) cache.patch(patch.path, patch.value)
114
- const latest = useRef({ current, source, references, apply })
115
- latest.current = { current, source, references, apply }
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
- setShown(value => ({ ...value, overrides: new Map() }))
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(value, patch)
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
- return { ...previous, source: latest.current.source, value }
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 patch = { path: snapshot.path, value: cache.expand(snapshot) }
134
- cache.patch(patch.path, patch.value)
135
- const next = latest.current.apply(latest.current.current, patch)
136
- if (next === latest.current.current) return false
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
- setShown(previous => {
139
- const overrides = new Map(previous.overrides)
140
- for (const key of overrides.keys()) if (key === patch.path || key.startsWith(`${patch.path}/`)) overrides.delete(key)
141
- overrides.set(patch.path, patch)
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
+ }
@@ -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
- if (!value) return null
22
- const attrs = draftAttrs(value)
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 value.$path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image' }, 'Image missing') : null
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 }))
@@ -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
- return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }