@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
@@ -1,95 +0,0 @@
1
- 'use client'
2
- import {
3
- Suspense,
4
- useCallback,
5
- useLayoutEffect,
6
- useMemo,
7
- useRef,
8
- useState,
9
- type ReactNode,
10
- } from 'react'
11
- import type { PublicFeatureBase } from '../contracts'
12
- import { deniedConsentState } from '../contracts/consent'
13
- import type { CustomConsentRegistration } from '../consent/custom'
14
- import { consentRegistry } from '../consent/registry'
15
- import { ConsentContext } from './useConsent'
16
- import { PageReporter } from './PageReporter'
17
- const ignoreTitle = () => {}
18
- /** Commit-only ownership: rendering never installs listeners, scripts, or authority. */
19
- export function ConsentRuntime({
20
- endpoint,
21
- config,
22
- customConsent,
23
- children,
24
- }: {
25
- endpoint: string
26
- config: PublicFeatureBase | null
27
- customConsent?: CustomConsentRegistration
28
- children: ReactNode
29
- }) {
30
- const owner = useRef({})
31
- const registry = useRef<ReturnType<typeof consentRegistry> | null>(null)
32
- const [snapshot, setSnapshot] = useState(() => ({
33
- state: deniedConsentState(),
34
- ready: false,
35
- error: null as string | null,
36
- reloadRequired: false,
37
- }))
38
- useLayoutEffect(() => {
39
- const current = consentRegistry(window)
40
- registry.current = current
41
- const off = current.subscribe(() => setSnapshot(current.read()))
42
- const currentOwner = owner.current
43
- current.attach(currentOwner)
44
- return () => {
45
- off()
46
- current.detach(currentOwner)
47
- registry.current = null
48
- }
49
- }, [])
50
- useLayoutEffect(() => {
51
- registry.current!.commit(
52
- owner.current,
53
- endpoint,
54
- config?.enabled.consent ?? false,
55
- config?.consent,
56
- customConsent,
57
- )
58
- setSnapshot(registry.current!.read())
59
- }, [endpoint, config, customConsent, customConsent?.adapter, customConsent?.openPreferences])
60
- const openPreferences = useCallback(() => registry.current?.openPreferences(), [])
61
- const page = useCallback((path: string) => registry.current?.page(path), [])
62
- const value = useMemo(
63
- () => ({
64
- state: snapshot.state,
65
- ready: snapshot.ready,
66
- error: snapshot.error,
67
- openPreferences,
68
- }),
69
- [snapshot, openPreferences],
70
- )
71
- return (
72
- <ConsentContext.Provider value={value}>
73
- {children}
74
- <Suspense fallback={null}>
75
- <PageReporter report={page} titleChanged={ignoreTitle} />
76
- </Suspense>
77
- {snapshot.error && (
78
- <div className="u90-features u90-consent-status">
79
- <p role="status" aria-live="polite">
80
- {snapshot.error}
81
- </p>
82
- {snapshot.reloadRequired ? (
83
- <button type="button" data-u90-consent-reload onClick={() => window.location.reload()}>
84
- Reload to apply consent changes
85
- </button>
86
- ) : (
87
- <button type="button" onClick={() => registry.current?.retry()}>
88
- Retry consent setup
89
- </button>
90
- )}
91
- </div>
92
- )}
93
- </ConsentContext.Provider>
94
- )
95
- }
@@ -1,15 +0,0 @@
1
- 'use client'
2
- import { createContext, useContext } from 'react'
3
- import type { ConsentState } from '../contracts/consent'
4
- export type ConsentController = {
5
- state: ConsentState
6
- ready: boolean
7
- error: string | null
8
- openPreferences(): void
9
- }
10
- export const ConsentContext = createContext<ConsentController | null>(null)
11
- export function useConsent(): ConsentController {
12
- const value = useContext(ConsentContext)
13
- if (!value) throw new Error('Consent hooks require a SiteFeatures root.')
14
- return value
15
- }
@@ -1,70 +0,0 @@
1
- # Consent setup and lifecycle
2
-
3
- Install the existing same-origin feature gateway. Mount this standard setup once in the consuming server root layout, independently of whether Consent is currently enabled:
4
-
5
- ```tsx
6
- import { SiteFeatures } from '@unitninety/site-kit/features'
7
- import { ConsentDefaults } from '@unitninety/site-kit/features/server'
8
- import '@unitninety/site-kit/features/styles.css'
9
-
10
- export default function RootLayout({ children }: { children: React.ReactNode }) {
11
- return <html lang="en"><body>
12
- <ConsentDefaults />
13
- <SiteFeatures>{children}</SiteFeatures>
14
- </body></html>
15
- }
16
- ```
17
-
18
- Keep the root around children from their first render, including while choosing custom presentations. Remote accepted configuration controls activation, provider/source, Google mode and integrations. No installation, grant or mode prop is supported. Later administration does not require layout edits. Reuse the native gateway handlers; never add a competing catch-all.
19
-
20
- `ConsentDefaults` is exported only through the server entry. It renders a stable-ID denied-only `next/script` with `beforeInteractive`, without provider IDs, tag sources, configuration fetches or pageviews. Installed Next 16.3.4 queues App Router scripts before hydration; this does not promise native parser-time execution or control unmanaged async scripts. Remove old CMP, GA and other raw optional installers. The browser driver defensively runs the same initializer before its own first managed Google insertion if its marker is absent; conflicting unmanaged Google ownership aborts insertion. The marker is bookkeeping, never consent authority, and defaults never replay on refresh/navigation/regrant.
21
-
22
- `useConsent()` exposes exactly `{ state, ready, error, openPreferences }`. A real preferences button calls `openPreferences`; unavailable or throwing providers produce a handled error. The root supplies an accessible status/retry control and a visitor-controlled **Reload to apply consent changes** button when needed. It never reloads automatically on Close or assumes any event confirms a saved choice. Existing preferences controls remain usable during disable/withdrawal where the provider is still available.
23
-
24
- ## Banner embed URL
25
-
26
- The Cookies workspace accepts an HTTPS script URL from any banner provider. With an active
27
- `custom` installation and no `customConsent` registration, Site Kit inserts that script once per
28
- document. No adapter or consent event is required just to display the provider's banner. The URL
29
- must point to its external JavaScript, not a provider dashboard, iframe page or pasted HTML. Providers
30
- that need inline initialization, extra attributes or synchronous parser execution may still need
31
- website code. The website Content Security Policy must allow the provider’s script origin and
32
- any additional resources it needs; saving a URL does not relax that policy. Custom script changes retain the existing `code.write` permission requirement.
33
-
34
- Embedding does not grant U90 consent: `useConsent().ready` stays false, all U90-managed optional
35
- integrations remain denied, and the provider's own controls handle preferences. Unregistered
36
- `u90:consent` events are ignored. Successful loading alone is not a consent signal. A missing optional
37
- registration is not an installation error; actual network failures/timeouts still surface normally.
38
- Already executed scripts cannot be undone by disabling the feature; reload the document to apply
39
- URL removal/replacement. Remove duplicate legacy installers before enabling the embed.
40
-
41
- Recognized direct CookieScript URLs retain their built-in category connection automatically. Other
42
- URLs use the same provider-independent embed path. Existing saved installations remain compatible.
43
-
44
- ## Optional custom consent connection
45
-
46
- The optional `SiteFeatures` prop `customConsent?: CustomConsentRegistration` selects exactly one code-owned registration, only when the accepted remote provider is `custom`:
47
-
48
- ```ts
49
- // Stable module identity, or useMemo with stable callback/adapter dependencies.
50
- const customConsent = { adapter: myConsentAdapter }
51
- // Alternatively: const customConsent = { openPreferences: reopenMyCMP }
52
- ```
53
-
54
- The adapter supplies `read`, `subscribe` (returning cleanup) and `openPreferences`. The event bridge supplies only the reopen callback and publishes a fresh complete validated `ConsentState` in `window` event `u90:consent` during its active subscription. An event before registration cannot qualify. Missing/conflicting registration, invalid/unready snapshots and missing reopening deny optional code. An absent registration still permits the banner embed described above; an explicitly invalid registration remains an error. There is no arbitrary-window-global fallback. Factories are side-effect free; committed ownership subscribes before provider insertion and immediately reads current state.
55
-
56
- Keep actual adapter identity stable. For a bridge keep both its registration object and reopen callback stable. Replacement after activation requires a document reload and cannot transfer old events/grants. Render-only/suspended updates do not install listeners or replace authority.
57
-
58
- ## Runtime guarantees and limits
59
-
60
- A document-owned registry retains execution records across navigation, React replay, root remount, integration removal and endpoint replacement. Equivalent settings revisions do not reinstall scripts or resubscribe. Last accepted config remains authoritative during failed/rejected refresh; real provider failures, committed disable and endpoint/provider replacement still deny immediately. Session/Feedback errors never decide consent. Disabled Consent does not grant categories.
61
-
62
- The fixed `createConsentLoader(driver)` API exposes `apply(state, integrations): Promise<void>` and `dispose(): void`. `IntegrationDriver` remains `start(integration): Promise<void>` and `stop(id): Promise<'stopped' | 'reload-required'>`. Starts deduplicate; withdrawal invokes stop immediately even during an unresolved start, and late initialization is stopped again. Built-in vetoes are synchronous. Drivers supplied by code must honor that same emission-veto obligation. Ten-second load/stop bounds surface failure and uncertainty. A network load error is retryable; possibly executing timed-out code requires reload.
63
-
64
- Basic Google mode makes no managed Google tag request before consent. Explicit advanced mode permits only classified Google requests while denied and may produce vendor consent-mode traffic; generic rows remain category-gated. Google defaults/update, `ga-disable`, `send_page_view:false` and current-permission checks guard Site Kit-owned SPA pageviews. Denied queued navigation is discarded; regrant emits only the current page.
65
-
66
- The shared closed classifier determines new GA4/ContentSquare drivers and approved executable attributes. An already executed GA source retains its proven teardown on category-policy changes, but its events require **both current row-category permission and analytics consent**. That historical capability never changes classification, code.write authorization or advanced eligibility for the edited row. New recategorized rows are generic.
67
-
68
- ContentSquare uses documented `optout` and requires reload; no web optin or cookie-clearing workaround is supplied. Its persisted opt-out may still affect regrant after reload. Attempted generic/Maps code requires reload regardless of a requested `stop` policy. Removing a script node is not teardown. Any reload requirement blocks every optional restart, including renamed/readded rows, until a real document reload. Previously executed third-party code cannot be rolled back.
69
-
70
- Tests use deterministic local DOM/provider doubles only. Task3 supplies actual production Next browser tag/beacon proof, both public projections, settings authorization/UI, the standard starter mount and exact KAW fixture transforms. No live-vendor account, regional/legal compliance, ContentSquare delivery/regrant or production rollout is certified.
@@ -1,53 +0,0 @@
1
- import { deniedConsentState, type ConsentAdapter, type ConsentState } from '../contracts/consent'
2
-
3
- /** Provider state is authoritative; neither event names nor an action alone grant categories. */
4
- export function normalizeCookieScript(input: unknown): ConsentState {
5
- if (!input || typeof input !== 'object') return deniedConsentState()
6
- const state = input as { action?: unknown; categories?: unknown }
7
- if ((state.action !== 'accept' && state.action !== 'reject') || !Array.isArray(state.categories) ||
8
- !state.categories.every((category) => typeof category === 'string')) return deniedConsentState()
9
- const accepted = state.action === 'accept' ? state.categories : []
10
- return {
11
- version: 1, necessary: true, ready: true,
12
- preferences: accepted.includes('functionality'), analytics: accepted.includes('performance'),
13
- marketing: accepted.includes('targeting'),
14
- }
15
- }
16
-
17
- type CookieScriptWindow = Window & { CookieScript?: { instance?: { currentState?: () => unknown; show?: () => void } } }
18
- const EVENTS = ['CookieScriptLoaded', 'CookieScriptAccept', 'CookieScriptAcceptAll', 'CookieScriptReject', 'CookieScriptClose'] as const
19
-
20
- /** Construct without side effects; the root subscribes before installing its owned CMP. */
21
- export function cookieScriptAdapter(window: Window): ConsentAdapter {
22
- const target = window as CookieScriptWindow
23
- const listeners = new Set<(state: ConsentState) => void>()
24
- let reopenFailed = false
25
- const read = (): ConsentState => {
26
- try {
27
- const instance = target.CookieScript?.instance
28
- if (reopenFailed || typeof instance?.currentState !== 'function' || typeof instance.show !== 'function') return deniedConsentState()
29
- return normalizeCookieScript(instance.currentState())
30
- } catch { return deniedConsentState() }
31
- }
32
- const publish = () => { const state = read(); for (const listener of listeners) listener({ ...state }) }
33
- const change = () => { reopenFailed = false; publish() }
34
- return {
35
- read,
36
- subscribe(listener) {
37
- if (listeners.size === 0) for (const event of EVENTS) window.addEventListener(event, change)
38
- listeners.add(listener)
39
- let subscribed = true
40
- return () => {
41
- if (!subscribed) return
42
- subscribed = false
43
- listeners.delete(listener)
44
- if (listeners.size === 0) for (const event of EVENTS) window.removeEventListener(event, change)
45
- }
46
- },
47
- openPreferences() {
48
- if (!read().ready) { publish(); return }
49
- try { target.CookieScript!.instance!.show!() }
50
- catch { reopenFailed = true; publish() }
51
- },
52
- }
53
- }
@@ -1,89 +0,0 @@
1
- import { ConsentStateSchema, deniedConsentState, type ConsentAdapter, type ConsentState } from '../contracts/consent'
2
-
3
- /** Code-owned registration: choose one stable adapter or one normalized-event bridge. */
4
- export type CustomConsentRegistration =
5
- | { adapter: ConsentAdapter; openPreferences?: never }
6
- | { adapter?: never; openPreferences: () => void }
7
-
8
- export function customConsentAdapter(window: Window, registration?: CustomConsentRegistration): ConsentAdapter {
9
- const listeners = new Set<(state: ConsentState) => void>()
10
- const adapter = registration?.adapter
11
- const registered = adapter && registration.openPreferences === undefined &&
12
- typeof adapter.read === 'function' && typeof adapter.subscribe === 'function' && typeof adapter.openPreferences === 'function'
13
- const reopen = registration?.openPreferences
14
- const bridge = adapter === undefined && typeof registration?.openPreferences === 'function'
15
- let state = deniedConsentState()
16
- let failed = false
17
- let subscription = 0
18
- const hasReopen = () => registered
19
- ? registration?.adapter === adapter && registration.openPreferences === undefined && typeof adapter.openPreferences === 'function'
20
- : bridge && registration?.adapter === undefined && typeof reopen === 'function' && registration?.openPreferences === reopen
21
- let cleanup: (() => void) | undefined
22
- const normalize = (input: unknown): ConsentState => {
23
- try {
24
- const result = ConsentStateSchema.safeParse(input)
25
- return result.success ? result.data : deniedConsentState()
26
- } catch { return deniedConsentState() }
27
- }
28
- const publish = (input: unknown) => {
29
- state = normalize(input)
30
- for (const listener of listeners) listener({ ...state })
31
- }
32
- const change = (event: Event) => { failed = false; publish(hasReopen() ? (event as CustomEvent<unknown>).detail : deniedConsentState()) }
33
- const read = (): ConsentState => {
34
- if (failed || !hasReopen()) return deniedConsentState()
35
- if (registered) {
36
- try { state = normalize(adapter.read()); return { ...state } }
37
- catch { state = deniedConsentState(); return { ...state } }
38
- }
39
- return bridge && listeners.size > 0 ? { ...state } : deniedConsentState()
40
- }
41
- return {
42
- read,
43
- subscribe(listener) {
44
- listeners.add(listener)
45
- if (listeners.size === 1) {
46
- const current = ++subscription
47
- state = deniedConsentState()
48
- failed = false
49
- if (registered) {
50
- try {
51
- let active = false
52
- const off = adapter.subscribe((next) => {
53
- if (!active || current !== subscription || !listeners.size) return
54
- failed = false
55
- publish(hasReopen() ? next : deniedConsentState())
56
- })
57
- if (typeof off !== 'function') throw new Error('Custom subscription must supply cleanup.')
58
- active = true
59
- cleanup = off
60
- publish(read())
61
- } catch { ++subscription; failed = true; publish(deniedConsentState()) }
62
- } else if (bridge) {
63
- window.addEventListener('u90:consent', change)
64
- cleanup = () => window.removeEventListener('u90:consent', change)
65
- }
66
- }
67
- let subscribed = true
68
- return () => {
69
- if (!subscribed) return
70
- subscribed = false
71
- listeners.delete(listener)
72
- if (listeners.size === 0) {
73
- ++subscription
74
- try { cleanup?.() } catch { /* Detached listeners cannot restore this authority. */ }
75
- cleanup = undefined
76
- state = deniedConsentState()
77
- failed = true
78
- }
79
- }
80
- },
81
- openPreferences() {
82
- if (!read().ready) { publish(deniedConsentState()); return }
83
- try {
84
- if (registered) adapter.openPreferences()
85
- else if (bridge) reopen!()
86
- } catch { failed = true; publish(deniedConsentState()) }
87
- },
88
- }
89
- }
@@ -1,40 +0,0 @@
1
- /** Shared server/bootstrap and defensive driver initializer. No tenant/category authority. */
2
- export type GoogleConsentWindow = Window & {
3
- __u90ConsentDefaultsV1?: true
4
- dataLayer?: unknown[]
5
- gtag?: (...args: unknown[]) => void
6
- }
7
- export function initializeConsentDefaults(window: Window): void {
8
- const target = window as GoogleConsentWindow
9
- if (target.__u90ConsentDefaultsV1 === true) {
10
- if (!Array.isArray(target.dataLayer) || typeof target.gtag !== 'function')
11
- throw new Error('Consent defaults are unavailable.')
12
- return
13
- }
14
- if (
15
- target.gtag !== undefined ||
16
- (target.dataLayer !== undefined &&
17
- (!Array.isArray(target.dataLayer) || target.dataLayer.length > 0))
18
- )
19
- throw new Error(
20
- 'Consent defaults ownership conflicts with unmanaged Google code. Reload after correcting setup.',
21
- )
22
- target.dataLayer = target.dataLayer || []
23
- target.gtag = function () {
24
- // Preserve Google's documented gtag queue payload shape.
25
- // eslint-disable-next-line prefer-rest-params
26
- target.dataLayer!.push(arguments)
27
- }
28
- target.gtag('consent', 'default', {
29
- analytics_storage: 'denied',
30
- ad_storage: 'denied',
31
- ad_user_data: 'denied',
32
- ad_personalization: 'denied',
33
- functionality_storage: 'denied',
34
- personalization_storage: 'denied',
35
- security_storage: 'granted',
36
- })
37
- target.__u90ConsentDefaultsV1 = true
38
- }
39
- export const CONSENT_DEFAULTS_ID = 'u90-consent-defaults-v1'
40
- export const CONSENT_DEFAULTS_SCRIPT = `(${initializeConsentDefaults.toString()})(window);`
@@ -1,233 +0,0 @@
1
- import {
2
- classifyManagedIntegration,
3
- deniedConsentState,
4
- ManagedIntegrationSchema,
5
- type ConsentState,
6
- type IntegrationDriver,
7
- type ManagedIntegration,
8
- } from '../contracts/consent'
9
- import { ConsentReloadRequiredError, executableFingerprint } from './loader'
10
- import { initializeConsentDefaults, type GoogleConsentWindow } from './defaults'
11
-
12
- type BrowserWindow = GoogleConsentWindow & {
13
- _uxa?: unknown[][]
14
- [key: `ga-disable-${string}`]: boolean
15
- }
16
- type Execution = {
17
- integration: ManagedIntegration
18
- kind: ReturnType<typeof classifyManagedIntegration>
19
- attempted: boolean
20
- loaded: boolean
21
- running: boolean
22
- allowed: boolean
23
- configured: boolean
24
- uncertain: boolean
25
- element?: HTMLScriptElement
26
- loading?: Promise<void>
27
- lastPage?: string
28
- }
29
- export const CONSENT_LOAD_TIMEOUT_MS = 10000
30
- /** Private built-in driver. Its synchronous veto is independent of async loader completion. */
31
- export function createBrowserConsentDriver(window: Window): IntegrationDriver & {
32
- configure(integrations: ManagedIntegration[]): void
33
- setPermission(state: ConsentState, mode: 'basic' | 'advanced'): void
34
- suspend(value: boolean): void
35
- page(path: string): void
36
- attempted(): boolean
37
- googleAttempted(): boolean
38
- } {
39
- const target = window as BrowserWindow
40
- const records = new Map<string, Execution>()
41
- let state = deniedConsentState()
42
- let mode: 'basic' | 'advanced' = 'basic'
43
- let suspended = false
44
- let path = window.location.pathname
45
- const permitted = (record: Execution) =>
46
- !suspended &&
47
- state.ready &&
48
- state[record.integration.category] &&
49
- record.allowed &&
50
- (record.kind.kind !== 'ga4' || state.analytics)
51
- const signal = () => {
52
- if (target.__u90ConsentDefaultsV1 !== true) return
53
- target.gtag?.('consent', 'update', {
54
- analytics_storage: state.ready && state.analytics ? 'granted' : 'denied',
55
- ad_storage: state.ready && state.marketing ? 'granted' : 'denied',
56
- ad_user_data: state.ready && state.marketing ? 'granted' : 'denied',
57
- ad_personalization: state.ready && state.marketing ? 'granted' : 'denied',
58
- functionality_storage: state.ready && state.preferences ? 'granted' : 'denied',
59
- personalization_storage: state.ready && state.preferences ? 'granted' : 'denied',
60
- security_storage: 'granted',
61
- })
62
- }
63
- const veto = (record: Execution) => {
64
- if (record.kind.kind === 'ga4')
65
- target[`ga-disable-${record.kind.measurementId}`] = !permitted(record)
66
- }
67
- const pageview = (record: Execution) => {
68
- if (
69
- record.kind.kind !== 'ga4' ||
70
- !record.loaded ||
71
- !permitted(record) ||
72
- record.lastPage === path
73
- )
74
- return
75
- record.lastPage = path
76
- target.gtag!('event', 'page_view', {
77
- send_to: record.kind.measurementId,
78
- page_path: path,
79
- page_location: new URL(path, window.location.origin).href,
80
- page_title: window.document.title,
81
- })
82
- }
83
- const optout = () => {
84
- target._uxa = target._uxa || []
85
- target._uxa.push(['optout'])
86
- }
87
- const driver = {
88
- // Preserve proven teardown for executed code; policy edits never promote catalogue authority.
89
- configure(integrations: ManagedIntegration[]) {
90
- for (const record of records.values()) {
91
- const next = integrations.find((row) => row.id === record.integration.id)
92
- if (next && executableFingerprint(next) === executableFingerprint(record.integration))
93
- record.integration = next
94
- else {
95
- record.running = false
96
- record.allowed = false
97
- }
98
- veto(record)
99
- }
100
- },
101
- setPermission(next: ConsentState, nextMode: 'basic' | 'advanced') {
102
- const changed = JSON.stringify(state) !== JSON.stringify(next)
103
- state = { ...next }
104
- mode = nextMode
105
- for (const record of records.values()) {
106
- record.allowed = record.running && state.ready && state[record.integration.category]
107
- if (!permitted(record)) record.lastPage = undefined
108
- veto(record)
109
- }
110
- if (changed) signal()
111
- for (const record of records.values()) pageview(record)
112
- },
113
- suspend(value: boolean) {
114
- suspended = value
115
- for (const record of records.values()) veto(record)
116
- },
117
- page(next: string) {
118
- path = next
119
- for (const record of records.values()) pageview(record)
120
- },
121
- attempted: () => [...records.values()].some((record) => record.attempted),
122
- googleAttempted: () =>
123
- [...records.values()].some((record) => record.attempted && record.kind.kind === 'ga4'),
124
- async start(input: ManagedIntegration) {
125
- const integration = ManagedIntegrationSchema.parse(input)
126
- let record = records.get(integration.id)
127
- if (!record) {
128
- record = {
129
- integration,
130
- kind: classifyManagedIntegration(integration),
131
- attempted: false,
132
- loaded: false,
133
- running: false,
134
- allowed: false,
135
- configured: false,
136
- uncertain: false,
137
- }
138
- records.set(integration.id, record)
139
- }
140
- if (record.uncertain)
141
- throw new ConsentReloadRequiredError(
142
- 'Reload required after uncertain integration initialization.',
143
- )
144
- const current = record
145
- current.running = true
146
- current.integration = integration
147
- current.allowed = !suspended && state.ready && state[integration.category]
148
- if (
149
- !current.allowed &&
150
- !(current.kind.kind === 'ga4' && mode === 'advanced' && state.ready && !suspended)
151
- )
152
- return
153
- if (current.kind.kind === 'ga4') {
154
- initializeConsentDefaults(window)
155
- signal()
156
- veto(current)
157
- if (!current.configured) {
158
- target.gtag!('js', new Date())
159
- target.gtag!('config', current.kind.measurementId, { send_page_view: false })
160
- current.configured = true
161
- }
162
- }
163
- if (current.loaded) {
164
- veto(current)
165
- pageview(current)
166
- return
167
- }
168
- if (!current.loading) {
169
- current.loading = new Promise<void>((resolve, reject) => {
170
- const script = window.document.createElement('script')
171
- script.async = true
172
- for (const [key, value] of Object.entries(integration.attributes ?? {}))
173
- script.setAttribute(key, value)
174
- if (script.id && window.document.getElementById(script.id)) {
175
- reject(Error('Integration element ID is already owned.'))
176
- return
177
- }
178
- script.src = integration.src
179
- script.dataset.u90Integration = integration.id
180
- current.element = script
181
- const finish = () => {
182
- window.clearTimeout(timer)
183
- script.onload = null
184
- script.onerror = null
185
- }
186
- const timer = window.setTimeout(() => {
187
- finish()
188
- current.uncertain = true
189
- reject(new ConsentReloadRequiredError('Integration load timed out; reload required.'))
190
- }, CONSENT_LOAD_TIMEOUT_MS)
191
- script.onload = () => {
192
- finish()
193
- current.loaded = true
194
- resolve()
195
- }
196
- script.onerror = () => {
197
- finish()
198
- script.remove()
199
- current.element = undefined
200
- reject(Error('Integration failed to load. Retry consent setup.'))
201
- }
202
- current.attempted = true
203
- window.document.head.appendChild(script)
204
- }).finally(() => {
205
- current.loading = undefined
206
- })
207
- }
208
- await current.loading
209
- veto(current)
210
- if (current.kind.kind === 'contentsquare' && !permitted(current)) optout()
211
- pageview(current)
212
- },
213
- async stop(id: string): Promise<'stopped' | 'reload-required'> {
214
- const record = records.get(id)
215
- if (!record) return 'stopped'
216
- record.running = false
217
- record.allowed = false
218
- record.lastPage = undefined
219
- veto(record)
220
- if (!record.attempted) return 'stopped'
221
- if (record.kind.kind === 'contentsquare') optout()
222
- if (
223
- record.kind.kind === 'ga4' &&
224
- !record.uncertain &&
225
- record.integration.revocation === 'stop'
226
- )
227
- return 'stopped'
228
- // Removing a node cannot undo downloaded or executing third-party code.
229
- return 'reload-required'
230
- },
231
- }
232
- return driver
233
- }