@unitninety/site-kit 1.6.4 → 1.6.6

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 (54) hide show
  1. package/README.md +104 -1
  2. package/package.json +4 -3
  3. package/src/bridge/DraftBlocks.tsx +10 -0
  4. package/src/bridge/createBridge.ts +11 -10
  5. package/src/bridge/index.ts +1 -0
  6. package/src/bridge/nativeDraft.ts +22 -4
  7. package/src/bridge/nativeOrder.ts +68 -0
  8. package/src/bridge/registry.ts +3 -2
  9. package/src/bridge/selection.ts +59 -10
  10. package/src/bridge/useDraftContent.tsx +35 -22
  11. package/src/components/BlockLink.tsx +28 -0
  12. package/src/components/Link.tsx +8 -1
  13. package/src/components/block-navigation.ts +24 -0
  14. package/src/custom-code/fragments.ts +64 -0
  15. package/src/custom-code/index.ts +30 -0
  16. package/src/elements/blockAnchor.ts +7 -0
  17. package/src/elements/declaration.ts +1 -1
  18. package/src/elements/define.ts +1 -1
  19. package/src/elements/index.ts +1 -0
  20. package/src/elements/ops/validate.ts +9 -2
  21. package/src/features/client/README.md +1 -2
  22. package/src/features/client/SiteFeatures.tsx +15 -25
  23. package/src/features/client/index.ts +0 -6
  24. package/src/features/client/verification.ts +3 -0
  25. package/src/features/contracts/base.ts +1 -5
  26. package/src/features/contracts/index.ts +0 -20
  27. package/src/features/contracts/verificationConfig.ts +7 -0
  28. package/src/features/server/gateway.ts +1 -3
  29. package/src/features/server/index.ts +0 -2
  30. package/src/legacy/cli.mjs +2 -2
  31. package/src/legacy/content.ts +1 -1
  32. package/src/server/BlockAnchors.tsx +92 -0
  33. package/src/server/CustomCode.tsx +29 -0
  34. package/src/server/index.ts +1 -0
  35. package/src/server/read.ts +13 -0
  36. package/src/server/render.tsx +11 -5
  37. package/src/site/defineSite.ts +6 -3
  38. package/src/site/documents.ts +2 -2
  39. package/src/site/forms.ts +32 -0
  40. package/src/site/index.ts +2 -0
  41. package/src/site/types.ts +4 -1
  42. package/src/site/version.ts +1 -1
  43. package/src/site/wire.ts +3 -0
  44. package/src/features/client/ConsentRuntime.tsx +0 -95
  45. package/src/features/client/useConsent.ts +0 -15
  46. package/src/features/consent/README.md +0 -70
  47. package/src/features/consent/cookieScript.ts +0 -53
  48. package/src/features/consent/custom.ts +0 -89
  49. package/src/features/consent/defaults.ts +0 -40
  50. package/src/features/consent/drivers.ts +0 -233
  51. package/src/features/consent/loader.ts +0 -248
  52. package/src/features/consent/registry.ts +0 -398
  53. package/src/features/contracts/consent.ts +0 -150
  54. package/src/features/server/ConsentDefaults.tsx +0 -15
@@ -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
+ }
@@ -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
+ }
@@ -84,7 +84,7 @@ export const CollectionDeclSchema = z.strictObject({
84
84
  seen.add(name)
85
85
  })
86
86
  })
87
- export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema })
87
+ export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema, editor: z.enum(['cms', 'canvas']).optional() })
88
88
  export const SchemaSchema = z.strictObject({ blocks: z.record(z.string(), BlockDeclSchema), collections: z.record(z.string(), CollectionDeclSchema), singles: z.record(z.string(), SingleDeclSchema) })
89
89
 
90
90
  type Where = 'block' | 'collection' | 'single'
@@ -140,7 +140,7 @@ export type CollectionDecl = {
140
140
  defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
141
141
  order?: string[]
142
142
  }
143
- export type SingleDecl = { key: string; field: FieldDecl }
143
+ export type SingleDecl = { key: string; field: FieldDecl; editor?: 'cms' | 'canvas' }
144
144
  export type Schema = {
145
145
  blocks: Record<string, BlockDecl>
146
146
  collections: Record<string, CollectionDecl>
@@ -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'
@@ -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.4 pair <code>` — the one command an operator hands over. The u90 tool
5
+ * `npx --yes @unitninety/u90@1.0.6 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.4 pair <code> --site https://example.com [--platform https://cms…]')
118
+ fail('Usage: npx --yes @unitninety/u90@1.0.6 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.4 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.0.6 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