@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
@@ -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,21 +54,22 @@ 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') return Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
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
- return children.map((c, i) => {
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) }
62
66
  const item = items[i]
63
67
  const block = record(item) ? String(item._block) : ''
64
68
  const metadata = record(item) && typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}
65
- // The slot has no fields, but it is still a movable layout item. DraftBlocks needs
66
- // its address both to move the page content and to place other blocks before it.
67
- if (block === SLOT) return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}) }
68
- if (!Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
69
+ if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
69
70
  return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
70
71
  })
72
+ return ctx.draft && decl.container === 'u90/list' ? rememberDraftList(expanded, children.map(c => path.child(ctx.at, c.key))) : expanded
71
73
  }
72
74
 
73
75
  /**
@@ -68,9 +68,16 @@ export function validate(schema: Schema, docDecl: DocumentDecl, doc: unknown, at
68
68
  const pointer = path.toPointer(opts.previous, here)
69
69
  const before = pointer === null ? undefined : valueAtPointer(opts.previous, pointer)
70
70
  if (before !== undefined)
71
- for (const sub of decl.lock)
72
- if (JSON.stringify(dig(before, sub)) !== JSON.stringify(dig(value, sub)))
71
+ for (const sub of decl.lock) {
72
+ // A blank fixed link label from an older editor may be restored only to the
73
+ // site's exact default. Other locked parts and deliberately cleared links stay fixed.
74
+ const restoresLabel = entry.id === 'u90/link' && sub === 'label' &&
75
+ record(before) && typeof before.label === 'string' && !before.label.trim() &&
76
+ record(decl.default) && typeof decl.default.label === 'string' && !!decl.default.label.trim() &&
77
+ dig(value, sub) === decl.default.label
78
+ if (!restoresLabel && JSON.stringify(dig(before, sub)) !== JSON.stringify(dig(value, sub)))
73
79
  push(here, { path: sub.split('.'), code: 'locked', message: 'This part is set by the site and cannot be changed here.' })
80
+ }
74
81
  }
75
82
  if (value !== undefined && value !== null) {
76
83
  const o = entry.options.safeParse(decl.options ?? {})
@@ -2,7 +2,7 @@
2
2
 
3
3
  The `@unitninety/site-kit/features` entry point targets Next 16.3.8–16.x and React 19.
4
4
  It uses Next's committed pathname updates; wrap `SiteFeatures` around the root layout's
5
- children from the first render, mount the server `ConsentDefaults` once before it, and import `@unitninety/site-kit/features/styles.css`. The internal pathname
5
+ children from the first render and import `@unitninety/site-kit/features/styles.css`. The internal pathname
6
6
  reporter has its own Suspense boundary. Pure `features/contracts` has no Next dependency.
7
7
  No non-Next runtime support is claimed. The package's Next peer is optional for consumers
8
8
  using only contracts or server APIs, but required for the feature client entry point.
@@ -75,7 +75,6 @@ omitted at first capture; later settings changes cannot alter a retry.
75
75
  The scoped theme variables are `--u90-feature-background`, `--u90-feature-foreground`,
76
76
  `--u90-feature-accent`, `--u90-feature-radius`, and `--u90-feature-shadow`.
77
77
 
78
- See [consent setup](../consent/README.md) for the server root bootstrap, optional custom registration, remote authority, lifecycle limits and preferences hook. Consent does not reset the Feedback presentation, session or focus lifetime.
79
78
 
80
79
  ## Ask AI stock setup and remix
81
80
 
@@ -15,8 +15,6 @@ import type { FeedbackInput, PublicFeatureBase } from '../contracts'
15
15
  import { FeatureContext, type OverlayName } from './context'
16
16
  import { AskAiProvider } from './AskAiProvider'
17
17
  import { FeedbackProvider } from './FeedbackProvider'
18
- import { ConsentRuntime } from './ConsentRuntime'
19
- import type { CustomConsentRegistration } from '../consent/custom'
20
18
  import { acquireVerificationProof } from './verification'
21
19
  import type { VerificationAction } from '../contracts'
22
20
  import { createFeatureTransport, waitForSignal, type VisitorVerification } from './transport'
@@ -32,7 +30,6 @@ export type SiteFeaturesProps = {
32
30
  endpoint?: string
33
31
  components?: { AskAI?: ComponentType; Feedback?: ComponentType }
34
32
  verification?: VisitorVerification
35
- customConsent?: CustomConsentRegistration
36
33
  children?: ReactNode
37
34
  }
38
35
  export function SiteFeatures(props: SiteFeaturesProps) {
@@ -43,7 +40,6 @@ function Runtime({
43
40
  endpoint,
44
41
  components,
45
42
  verification,
46
- customConsent,
47
43
  children,
48
44
  }: SiteFeaturesProps) {
49
45
  const adapter = useRef(verification)
@@ -223,28 +219,22 @@ function Runtime({
223
219
  const Feedback = components?.Feedback ?? builtins.feedback
224
220
  return (
225
221
  <FeatureContext.Provider value={value}>
226
- <ConsentRuntime
227
- endpoint={endpoint ?? '/api/u90/features/v1'}
228
- config={config}
229
- customConsent={customConsent}
230
- >
231
- <AskAiProvider endpoint={endpoint ?? '/api/u90/features/v1'} getTransport={getTransport}>
232
- <FeedbackProvider presentation={Feedback}>
233
- {children}
234
- <div className="u90-features">
235
- <Suspense fallback={null}>{config?.enabled.askAi && AskAI && <AskAI />}</Suspense>
236
- <div
237
- className="u90-feature-announcement"
238
- role="status"
239
- aria-live="polite"
240
- aria-atomic="true"
241
- >
242
- {error ?? announcement}
243
- </div>
222
+ <AskAiProvider endpoint={endpoint ?? '/api/u90/features/v1'} getTransport={getTransport}>
223
+ <FeedbackProvider presentation={Feedback}>
224
+ {children}
225
+ <div className="u90-features">
226
+ <Suspense fallback={null}>{config?.enabled.askAi && AskAI && <AskAI />}</Suspense>
227
+ <div
228
+ className="u90-feature-announcement"
229
+ role="status"
230
+ aria-live="polite"
231
+ aria-atomic="true"
232
+ >
233
+ {error ?? announcement}
244
234
  </div>
245
- </FeedbackProvider>
246
- </AskAiProvider>
247
- </ConsentRuntime>
235
+ </div>
236
+ </FeedbackProvider>
237
+ </AskAiProvider>
248
238
  </FeatureContext.Provider>
249
239
  )
250
240
  }
@@ -12,12 +12,6 @@ export { FeatureClientError, type VisitorVerification } from './transport'
12
12
 
13
13
  export { useFeedback, type FeedbackController, type FeedbackAnswer } from './useFeedback'
14
14
 
15
- export { cookieScriptAdapter, normalizeCookieScript } from '../consent/cookieScript'
16
- export { customConsentAdapter, type CustomConsentRegistration } from '../consent/custom'
17
-
18
- export { useConsent, type ConsentController } from './useConsent'
19
- export { createConsentLoader } from '../consent/loader'
20
-
21
15
  export { useSiteSearch } from './useSiteSearch'
22
16
 
23
17
  export { useAskAI, type AskAiController } from './useAskAI'
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
3
3
  import {
4
4
  ProofSchema,
5
5
  VerificationConfigSchema,
6
+ isUnconfiguredFormVerification,
6
7
  type VerificationAction,
7
8
  type VerificationConfig,
8
9
  } from '../contracts'
@@ -61,6 +62,8 @@ export async function acquireVerificationProof(
61
62
  options: { signal?: AbortSignal } = {},
62
63
  ): Promise<string | null> {
63
64
  const parsed = VerificationConfigSchema.parse(config)
65
+ options.signal?.throwIfAborted()
66
+ if (isUnconfiguredFormVerification(parsed, action)) return null
64
67
  if (parsed.readiness !== 'configured' || (parsed.mode !== 'disabled' && !parsed.siteKey))
65
68
  throw Error('Verification unavailable. Please retry or contact the site owner.')
66
69
  const signal = options.signal
@@ -1,13 +1,12 @@
1
1
  import { VerificationConfigSchema, type VerificationConfig } from './verificationConfig'
2
2
  import { PublicAskAiWidgetSettingsSchema, type AskAiWidgetSettings } from './askAiSettings'
3
3
  import { z } from 'zod'
4
- import { PublicConsentInstallationSchema, type ConsentInstallation } from './consent'
5
4
  import { PublicFeedbackWidgetSettingsSchema, type FeedbackWidgetSettings } from './feedbackSettings'
6
5
 
7
6
  /** Visitor-feature wire version; independent of bridge and Site Kit package versions. */
8
7
  export const FEATURE_CONTRACT_VERSION = 1
9
8
 
10
- export type FeatureKey = 'search' | 'askAi' | 'feedback' | 'consent'
9
+ export type FeatureKey = 'search' | 'askAi' | 'feedback'
11
10
  export type PageContext = { path: string; title?: string }
12
11
  export type FeatureFlags = Record<FeatureKey, boolean>
13
12
  export type PublicFeatureBase = {
@@ -17,7 +16,6 @@ export type PublicFeatureBase = {
17
16
  enabled: FeatureFlags
18
17
  askAi?: AskAiWidgetSettings
19
18
  feedback?: FeedbackWidgetSettings
20
- consent?: ConsentInstallation
21
19
  }
22
20
  export type FeatureErrorCode =
23
21
  | 'invalid_request'
@@ -40,7 +38,6 @@ export const FeatureFlagsSchema = z.strictObject({
40
38
  search: z.boolean().default(false),
41
39
  askAi: z.boolean().default(false),
42
40
  feedback: z.boolean().default(false),
43
- consent: z.boolean().default(false),
44
41
  })
45
42
 
46
43
  const PAGE_ORIGIN = 'https://page-context.invalid'
@@ -71,7 +68,6 @@ export const PublicFeatureBaseSchema = z.looseObject({
71
68
  enabled: FeatureFlagsSchema.loose(),
72
69
  askAi: PublicAskAiWidgetSettingsSchema.optional(),
73
70
  feedback: PublicFeedbackWidgetSettingsSchema.optional(),
74
- consent: PublicConsentInstallationSchema.optional(),
75
71
  readiness: z
76
72
  .object({
77
73
  search: z.object({
@@ -25,26 +25,6 @@ export {
25
25
  } from './feedbackSettings'
26
26
  export type { FeedbackWidgetSettings } from './feedbackSettings'
27
27
 
28
- export {
29
- ConsentStateSchema,
30
- ConsentInstallationSchema,
31
- PublicConsentInstallationSchema,
32
- ManagedIntegrationSchema,
33
- deniedConsentState,
34
- isCookieScriptURL,
35
- classifyManagedIntegration,
36
- consentChangeRequiresCodeWrite,
37
- } from './consent'
38
- export type {
39
- ConsentState,
40
- ConsentAdapter,
41
- ConsentCategory,
42
- ConsentInstallation,
43
- ManagedIntegration,
44
- IntegrationDriver,
45
- ManagedIntegrationClassification,
46
- ConsentConfiguration,
47
- } from './consent'
48
28
 
49
29
  export * from './knowledge'
50
30
  export * from './search'
@@ -7,3 +7,10 @@ export const VerificationConfigSchema = z.strictObject({
7
7
  revision: z.int().nonnegative(),
8
8
  })
9
9
  export type VerificationConfig = z.infer<typeof VerificationConfigSchema>
10
+
11
+ /** An absent form CAPTCHA connection is optional; broken or configured connections are not. */
12
+ export const isUnconfiguredFormVerification = (config: VerificationConfig, action?: string) =>
13
+ action === 'form_submit' &&
14
+ config.mode === 'managed' &&
15
+ config.readiness === 'setup_required' &&
16
+ config.siteKey === null
@@ -185,20 +185,18 @@ export function createSiteFeatures(options: SiteFeaturesOptions) {
185
185
  if (operation === 'config') {
186
186
  const parsed = PublicFeatureBaseSchema.safeParse(await exchange())
187
187
  if (!parsed.success) throw new FeatureFailure('unavailable')
188
- const { version, revision, enabled, feedback, consent, askAi, verification } = parsed.data
188
+ const { version, revision, enabled, feedback, askAi, verification } = parsed.data
189
189
  return Response.json(
190
190
  {
191
191
  version,
192
192
  revision,
193
193
  ...(verification ? { verification } : {}),
194
- ...(consent ? { consent } : {}),
195
194
  feedback: FeedbackWidgetSettingsSchema.parse(feedback ?? {}),
196
195
  askAi: AskAiWidgetSettingsSchema.parse(askAi ?? {}),
197
196
  enabled: {
198
197
  search: enabled.search,
199
198
  askAi: enabled.askAi,
200
199
  feedback: enabled.feedback,
201
- consent: enabled.consent,
202
200
  },
203
201
  },
204
202
  { headers: { 'cache-control': 'no-store' } },
@@ -2,5 +2,3 @@ import 'server-only'
2
2
  export { createSiteFeatures } from './gateway'
3
3
  export type { FeatureEnvironment, VerifyVisitor, SiteFeaturesOptions } from './gateway'
4
4
  export { createRecaptchaVerifier } from './verifyVisitor'
5
-
6
- export { ConsentDefaults } from './ConsentDefaults'
@@ -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 pair <code>` — the one command an operator hands over. The u90 tool
5
+ * `npx --yes @unitninety/u90@1.1.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.5 pair <code> --site https://example.com [--platform https://cms…]')
118
+ fail('Usage: npx --yes @unitninety/u90@1.1.0 pair <code> --site https://example.com [--platform https://cms…]')
119
119
  }
120
120
 
121
121
  const code = args[1]
@@ -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.5 pair\` writes both.`,
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.1.0 pair\` writes both.`,
364
364
  )
365
365
  }
366
366
  return true
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react'
4
+ import { ID_RE } from '../elements/ids'
5
+ import { arrivingBlockNavigation, clearBlockNavigation } from '../components/block-navigation'
6
+
7
+ /** Install navigation only for SDK-marked destinations within this render tree. */
8
+ export function installBlockAnchors(root: HTMLElement): () => void {
9
+ const doc = root.ownerDocument
10
+ const win = doc.defaultView!
11
+ let observer: MutationObserver | undefined
12
+ let timeout: number | undefined
13
+ const stopWaiting = () => {
14
+ observer?.disconnect()
15
+ observer = undefined
16
+ win.clearTimeout(timeout)
17
+ }
18
+ const identity = (hash: string) => {
19
+ try {
20
+ const id = decodeURIComponent(hash.slice(1))
21
+ return id.startsWith('u90-block-') && ID_RE.test(id.slice(10)) ? id : null
22
+ } catch { return null }
23
+ }
24
+ const find = (id: string) => {
25
+ const target = doc.getElementById(id)
26
+ return target && root.contains(target) && target.hasAttribute('data-u90-anchor') ? target : null
27
+ }
28
+ const scroll = (id: string) => {
29
+ const target = find(id)
30
+ if (!target) return false
31
+ const request = arrivingBlockNavigation(win)
32
+ if (request) {
33
+ clearBlockNavigation(win)
34
+ if (win.location.href !== request.url.href) win.history.replaceState(null, '', request.url.href)
35
+ if (!request.scroll) return true
36
+ }
37
+ // getClientRects skips display:contents and non-rendered descendants, retaining
38
+ // native scrollIntoView handling of the actual child's scroll-margin.
39
+ const child = Array.from(target.querySelectorAll<HTMLElement>('*')).find(node => node.getClientRects().length > 0)
40
+ if (!child) return false
41
+ const event = new win.CustomEvent('u90:block-anchor', { bubbles: true, cancelable: true, detail: { target: child } })
42
+ if (child.dispatchEvent(event)) child.scrollIntoView({ block: 'start' })
43
+ return true
44
+ }
45
+ const requestedIdentity = () => identity(arrivingBlockNavigation(win)?.url.hash ?? win.location.hash)
46
+ const navigate = () => {
47
+ stopWaiting()
48
+ const id = requestedIdentity()
49
+ if (!id || scroll(id)) return
50
+ // Streaming/client navigation can deliver the destination after the hash.
51
+ // Observe only this tree, only for a requested destination, for at most 10s.
52
+ observer = new win.MutationObserver(() => { if (requestedIdentity() !== id || scroll(id)) stopWaiting() })
53
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'style', 'class', 'hidden'] })
54
+ timeout = win.setTimeout(stopWaiting, 10_000)
55
+ }
56
+ const click = (event: MouseEvent) => {
57
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
58
+ const node = event.target
59
+ if (!(node instanceof win.Element)) return
60
+ const link = node.closest('a[href]')
61
+ if (!link || link.hasAttribute('download') || (link.getAttribute('target') && link.getAttribute('target') !== '_self')) return
62
+ let url: URL
63
+ try { url = new URL(link.getAttribute('href')!, doc.baseURI) } catch { return }
64
+ if (url.origin !== win.location.origin || url.pathname !== win.location.pathname || url.search !== win.location.search) return
65
+ const id = identity(url.hash)
66
+ if (!id) return
67
+ const existing = doc.getElementById(id)
68
+ if (existing && !find(id)) return
69
+ event.preventDefault()
70
+ if (url.hash !== win.location.hash) win.history.pushState(null, '', url.href)
71
+ navigate()
72
+ }
73
+ doc.addEventListener('click', click)
74
+ doc.addEventListener('u90:block-navigate', navigate)
75
+ win.addEventListener('hashchange', navigate)
76
+ win.addEventListener('popstate', navigate)
77
+ navigate()
78
+ return () => {
79
+ stopWaiting()
80
+ doc.removeEventListener('click', click)
81
+ doc.removeEventListener('u90:block-navigate', navigate)
82
+ win.removeEventListener('hashchange', navigate)
83
+ win.removeEventListener('popstate', navigate)
84
+ }
85
+ }
86
+
87
+ /** Server-rendered children remain server components across this client boundary. */
88
+ export function BlockAnchors({ children }: { children: ReactNode }) {
89
+ const root = useRef<HTMLDivElement>(null)
90
+ useEffect(() => root.current ? installBlockAnchors(root.current) : undefined, [children])
91
+ return <div ref={root} style={{ display: 'contents' }}>{children}</div>
92
+ }
@@ -0,0 +1,29 @@
1
+ import { createElement, type ReactNode } from 'react'
2
+ import type { CustomCodePlacement, PublishedCustomCode } from '../custom-code'
3
+ import { fragmentInnerHtml, parseCustomCodeFragment } from '../custom-code/fragments'
4
+
5
+ const names: Record<string, string> = {
6
+ class: 'className', charset: 'charSet', 'http-equiv': 'httpEquiv', crossorigin: 'crossOrigin',
7
+ referrerpolicy: 'referrerPolicy', fetchpriority: 'fetchPriority', nomodule: 'noModule',
8
+ itemprop: 'itemProp', itemscope: 'itemScope', itemtype: 'itemType', itemid: 'itemID', itemref: 'itemRef',
9
+ hreflang: 'hrefLang', tabindex: 'tabIndex', accesskey: 'accessKey', contenteditable: 'contentEditable',
10
+ }
11
+ const booleans = new Set(['async', 'defer', 'nomodule', 'disabled', 'hidden', 'itemscope', 'inert', 'autofocus'])
12
+
13
+ /** Initial-document native elements, never next/script or a hydration-time injector.
14
+ * React hoists/deduplicates async scripts and links unless itemProp is present. An empty
15
+ * microdata token opts these resources out without adding a property; preserve supplied values.
16
+ * Metadata intentionally retains React's head handling (itemprop would conflict with name/etc.).
17
+ * Inline script/style bytes and validated noscript fallback HTML bypass React text escaping. */
18
+ export function U90CustomCode({ code, placement }: { code: PublishedCustomCode | null; placement: CustomCodePlacement }): ReactNode {
19
+ if (!code || !code[placement]) return null
20
+ const source = code[placement]
21
+ return parseCustomCodeFragment(source, placement).map((element, index) => {
22
+ const props: Record<string, unknown> = { key: index }
23
+ for (const attr of element.attrs) props[names[attr.name] ?? attr.name] = booleans.has(attr.name) ? true : attr.value
24
+ const tag = element.tagName
25
+ if ((tag === 'link' || tag === 'script' && props.src && props.async || tag === 'style' && props.href && props.precedence) && props.itemProp === undefined) props.itemProp = ''
26
+ if (tag !== 'meta' && tag !== 'link') props.dangerouslySetInnerHTML = { __html: fragmentInnerHtml(source, element) }
27
+ return createElement(tag, props)
28
+ })
29
+ }
@@ -3,4 +3,5 @@ export { PREVIEW_COOKIE, type NextAdapters } from './adapters'
3
3
  export { DRAFT_LOCALE_PARAM } from '../elements'
4
4
  export type { RouteRead } from '../site'
5
5
  export { U90Bridge } from '../bridge/U90Bridge'
6
+ export { U90CustomCode } from './CustomCode'
6
7
  export type { TestPreviewContext } from '../test-preview/runtime'
@@ -4,6 +4,7 @@ import type { SiteLanguages } from '../languages/routing'
4
4
  import { sourceMediaPaths } from '../test-preview/media'
5
5
  import type { TestPreviewContext, TestPreviewRuntime } from '../test-preview/runtime'
6
6
  import type { NextAdapters } from './adapters'
7
+ import type { PublishedCustomCode } from '../custom-code'
7
8
 
8
9
  export type Env = Partial<Record<'PLATFORM_URL' | 'PAYLOAD_API_KEY' | 'REVALIDATE_SECRET' | 'SITE_URL' | 'VERCEL_DEPLOYMENT_ID' | 'VERCEL_URL' | 'U90_TEST_PREVIEW_BUILD_ID', string>>
9
10
  /** `running` is the running code's `manifestHash`, compared with each reply's to tell whether the platform is behind a deploy. */
@@ -173,6 +174,18 @@ export function createRead(d: Deps) {
173
174
  if (!d.adapters.draftEnabled || await draftEnabled(instance)) await currentDraft()
174
175
  return siteOnce()
175
176
  },
177
+ /** Published initial-document code uses the same site-tagged request as metadata/languages.
178
+ * Authenticate preview context before suppressing output, including custom draft adapters. */
179
+ customCode: async (): Promise<PublishedCustomCode | null> => {
180
+ if (!d.adapters.draftEnabled) {
181
+ if (await currentDraft()) return null
182
+ } else if (await draftEnabled(instance)) {
183
+ await currentDraft()
184
+ return null
185
+ }
186
+ if (!d.env.PLATFORM_URL) return null
187
+ return (await siteOnce()).customCode ?? null
188
+ },
176
189
  /**
177
190
  * The site's languages, from the same per-request `/site` read as `site()`: cached until `site` is revalidated outside
178
191
  * draft, read once per request in draft. Null for a site with no platform, one with no languages, or a `/site` that
@@ -4,13 +4,16 @@ import type { RouteRead } from '../site'
4
4
  import type { LanguageLinks } from '../languages/routing'
5
5
  import { LanguageProvider } from '../switcher/LanguageSwitcher'
6
6
  import { DraftBlocks } from '../bridge/DraftBlocks'
7
+ import { blockAnchor } from '../elements/blockAnchor'
8
+ import { ID_RE } from '../elements/ids'
9
+ import { BlockAnchors } from './BlockAnchors'
7
10
 
8
11
  type BlockItem = { _id: string; _block: string; _hidden?: boolean; $path?: string } & Record<string, unknown>
9
12
  /** What a block declared with `entry:` receives, when the document being rendered is an entry page. */
10
13
  type EntryProp = { id: string; slug?: string; route?: string; fields?: Record<string, unknown> }
11
14
 
12
15
  export function createRender(site: Site) {
13
- const Blocks = ({ value, slot, entry }: { value: unknown; slot?: ReactNode; entry?: EntryProp }): ReactElement => {
16
+ const Blocks = ({ value, slot, entry, anchors = true }: { value: unknown; slot?: ReactNode; entry?: EntryProp; anchors?: boolean }): ReactElement => {
14
17
  const items = ((Array.isArray(value) ? value : []) as BlockItem[]).filter(item => item._hidden !== true)
15
18
  const renderedItems = items.map((item) => {
16
19
  if (item._block === 'SLOT') return createElement(Fragment, { key: item._id }, slot)
@@ -18,19 +21,21 @@ export function createRender(site: Site) {
18
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
19
22
  const { _id, _block, _hidden, $path, ...fields } = item
20
23
  // zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
21
- const props: Record<string, unknown> = { ...fields }
22
- 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 })
24
+ const props: Record<string, unknown> = { ...fields, ...($path ? { $path } : {}) }
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 })
23
26
  // a block declared with `entry:` renders inside that collection's entry pages and receives the entry itself
24
27
  if (def.entry) props.entry = entry
25
28
  const rendered = createElement(def.component as never, { key: _id, ...props } as never)
26
- return $path ? createElement('div', { key: _id, 'data-u90-block': $path, style: { display: 'contents' } }, rendered) : rendered
29
+ if (!anchors && !$path) return rendered
30
+ const anchor = anchors && ID_RE.test(_id) ? blockAnchor(_id) : undefined
31
+ return createElement('div', { key: _id, id: anchor, 'data-u90-anchor': anchor ? '' : undefined, 'data-u90-block': $path, style: { display: 'contents' } }, rendered)
27
32
  })
28
33
  return items.some((item) => item.$path)
29
34
  ? createElement(DraftBlocks, { items: items.map((item, index) => ({ id: item._id, path: item.$path, content: renderedItems[index] })) })
30
35
  : createElement(Fragment, null, ...renderedItems)
31
36
  }
32
37
  const Layout = ({ chain, layouts, children }: { chain: string[]; layouts: Record<string, unknown>; children: ReactNode }): ReactElement =>
33
- chain.reduceRight<ReactElement>((inner, key) => createElement(Blocks, { value: (layouts[key] as { blocks: unknown })?.blocks, slot: inner }), createElement(Fragment, null, children))
38
+ chain.reduceRight<ReactElement>((inner, key) => createElement(Blocks, { value: (layouts[key] as { blocks: unknown })?.blocks, slot: inner, anchors: false }), createElement(Fragment, null, children))
34
39
  /** `language`, on a language site's page only, puts the page's language and its addresses within reach of `LanguageSwitcher`. Without it the tree is the one it always was. */
35
40
  const render = (read: RouteRead, language?: LanguageLinks): ReactElement => {
36
41
  const doc = read.read as { blocks?: unknown; fields?: Record<string, unknown> } | undefined
@@ -40,7 +45,8 @@ export function createRender(site: Site) {
40
45
  ? { id: document.id, slug: document.slug, route: document.collection ? site.collections[document.collection]?.route?.replace(':slug', document.slug) : undefined, fields: doc?.fields }
41
46
  : undefined
42
47
  const tree = <Layout chain={document?.layoutChain ?? []} layouts={read.layouts ?? {}}><Blocks value={doc?.blocks} entry={entry} /></Layout>
43
- return language ? createElement(LanguageProvider, { value: language }, tree) : tree
48
+ const anchored = <BlockAnchors key={document?.id}>{tree}</BlockAnchors>
49
+ return language ? createElement(LanguageProvider, { value: language }, anchored) : anchored
44
50
  }
45
51
  return { Blocks, Layout, render }
46
52
  }
@@ -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]> }> & { readonly __group: true } =>
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'
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.5'
1
+ export const SITE_KIT_VERSION = '1.7.0'
package/src/site/wire.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EntryRecord, FieldDecl, Id, MediaRecord, PageRecord } from '../elements'
2
2
  import type { SiteManifest } from './types'
3
+ import type { PublishedCustomCode } from '../custom-code'
3
4
 
4
5
  /** A page's or an entry's SEO settings as the platform stores them. `ogImage` is a media id whose record is in the reply's `media`. */
5
6
  export type ReplySeo = { title: string | null; description: string | null; ogTitle: string | null; ogDescription: string | null; ogImage: Id | null; canonical: string | null; noindex: boolean }
@@ -84,6 +85,8 @@ export type SeoOrganisation = {
84
85
 
85
86
  /** The site-wide SEO settings, as `/site` answers them. */
86
87
  export type SiteReply = {
88
+ /** Public published snapshot only; drafts and attribution never cross this boundary. */
89
+ customCode?: PublishedCustomCode | null
87
90
  allowIndexing: boolean
88
91
  bingVerification: null | string
89
92
  description: null | string