@unitninety/site-kit 0.0.0-stage → 1.6.2

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 (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,53 @@
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
+ }
@@ -0,0 +1,89 @@
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
+ }
@@ -0,0 +1,40 @@
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);`
@@ -0,0 +1,233 @@
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
+ }
@@ -0,0 +1,248 @@
1
+ import {
2
+ ConsentStateSchema,
3
+ type ConsentState,
4
+ type IntegrationDriver,
5
+ type ManagedIntegration,
6
+ } from '../contracts/consent'
7
+
8
+ export class ConsentReloadRequiredError extends Error {}
9
+ const OPERATION_TIMEOUT_MS = 10000
10
+ function bounded<T>(operation: Promise<T>, timeout: () => void): Promise<T> {
11
+ let timer: ReturnType<typeof setTimeout>
12
+ return Promise.race([
13
+ operation,
14
+ new Promise<never>((_, reject) => {
15
+ timer = setTimeout(() => {
16
+ timeout()
17
+ reject(new ConsentReloadRequiredError('Consent operation timed out; reload required.'))
18
+ }, OPERATION_TIMEOUT_MS)
19
+ }),
20
+ ]).finally(() => clearTimeout(timer))
21
+ }
22
+
23
+ type RecordState = {
24
+ integration: ManagedIntegration
25
+ fingerprint: string
26
+ desired: boolean
27
+ attempted: boolean
28
+ active: boolean
29
+ revoked: boolean
30
+ start?: Promise<void>
31
+ stop?: Promise<void>
32
+ error?: string
33
+ }
34
+ type Store = {
35
+ records: Map<string, RecordState>
36
+ reloadRequired: boolean
37
+ error: string | null
38
+ listeners: Set<() => void>
39
+ }
40
+ const stores = new WeakMap<IntegrationDriver, Store>()
41
+ function storeFor(driver: IntegrationDriver): Store {
42
+ let store = stores.get(driver)
43
+ if (!store) {
44
+ store = { records: new Map(), reloadRequired: false, error: null, listeners: new Set() }
45
+ stores.set(driver, store)
46
+ }
47
+ return store
48
+ }
49
+ export function executableFingerprint(integration: ManagedIntegration): string {
50
+ return JSON.stringify([
51
+ integration.src,
52
+ Object.entries(integration.attributes ?? {}).sort(([a], [b]) => a.localeCompare(b)),
53
+ ])
54
+ }
55
+ /** Internal diagnostics; only the fixed loader contract is exposed by the package. */
56
+ export function consentLoaderStatus(driver: IntegrationDriver) {
57
+ const { reloadRequired, error } = storeFor(driver)
58
+ return { reloadRequired, error }
59
+ }
60
+ export function observeConsentLoader(driver: IntegrationDriver, listener: () => void) {
61
+ const listeners = storeFor(driver).listeners
62
+ listeners.add(listener)
63
+ return () => {
64
+ listeners.delete(listener)
65
+ }
66
+ }
67
+ function publish(store: Store) {
68
+ for (const listener of store.listeners) listener()
69
+ }
70
+ export function requireConsentReload(driver: IntegrationDriver, reason: string) {
71
+ const store = storeFor(driver)
72
+ store.reloadRequired = true
73
+ store.error = reason
74
+ for (const record of store.records.values()) {
75
+ record.desired = false
76
+ void stopRecord(driver, store, record).catch(() => {})
77
+ }
78
+ publish(store)
79
+ }
80
+ function stopRecord(
81
+ driver: IntegrationDriver,
82
+ store: Store,
83
+ record: RecordState,
84
+ finalize = false,
85
+ ): Promise<void> {
86
+ if (!record.attempted || (record.revoked && !finalize)) return record.stop ?? Promise.resolve()
87
+ record.revoked = true
88
+ record.active = false
89
+ // Invoke before constructing/awaiting any queue: drivers synchronously latch their veto.
90
+ let operation: Promise<'stopped' | 'reload-required'>
91
+ try {
92
+ operation = driver.stop(record.integration.id)
93
+ } catch (cause) {
94
+ operation = Promise.reject(cause)
95
+ }
96
+ const stop = bounded(operation, () =>
97
+ requireConsentReload(driver, 'Reload to apply consent changes. Integration stop timed out.'),
98
+ )
99
+ .then(
100
+ (result) => {
101
+ if (result === 'reload-required' || record.integration.revocation === 'reload')
102
+ requireConsentReload(
103
+ driver,
104
+ 'Reload to apply consent changes. Previously started code requires a fresh document.',
105
+ )
106
+ },
107
+ (cause) => {
108
+ requireConsentReload(
109
+ driver,
110
+ `Reload to apply consent changes. Integration stop failed: ${cause instanceof Error ? cause.message : 'unavailable'}`,
111
+ )
112
+ throw cause
113
+ },
114
+ )
115
+ .finally(() => {
116
+ if (record.stop === stop) record.stop = undefined
117
+ publish(store)
118
+ })
119
+ record.stop = stop
120
+ return stop
121
+ }
122
+ function startRecord(driver: IntegrationDriver, store: Store, record: RecordState): Promise<void> {
123
+ if (record.start) return record.start
124
+ if (!record.desired || store.reloadRequired || record.active)
125
+ return record.stop ?? Promise.resolve()
126
+ const begin = async () => {
127
+ if (record.stop) await record.stop
128
+ if (!record.desired || store.reloadRequired || record.active) return
129
+ record.attempted = true
130
+ record.revoked = false
131
+ record.error = undefined
132
+ try {
133
+ let expired = false
134
+ const operation = driver.start(record.integration)
135
+ void operation.then(
136
+ () => {
137
+ if (expired) void stopRecord(driver, store, record, true).catch(() => {})
138
+ },
139
+ () => {},
140
+ )
141
+ await bounded(operation, () => {
142
+ expired = true
143
+ requireConsentReload(
144
+ driver,
145
+ 'Reload to apply consent changes. Integration initialization timed out.',
146
+ )
147
+ })
148
+ if (!record.desired || record.revoked || store.reloadRequired) {
149
+ await stopRecord(driver, store, record, true)
150
+ } else record.active = true
151
+ } catch (cause) {
152
+ if (cause instanceof ConsentReloadRequiredError) requireConsentReload(driver, cause.message)
153
+ record.error = cause instanceof Error ? cause.message : 'Integration initialization failed'
154
+ store.error = record.error
155
+ publish(store)
156
+ throw cause
157
+ }
158
+ }
159
+ // begin invokes start synchronously until its first await.
160
+ const starting = begin().finally(() => {
161
+ if (record.start === starting) record.start = undefined
162
+ publish(store)
163
+ })
164
+ record.start = starting
165
+ return starting
166
+ }
167
+ export function createConsentLoader(driver: IntegrationDriver): {
168
+ apply(state: ConsentState, integrations: ManagedIntegration[]): Promise<void>
169
+ dispose(): void
170
+ } {
171
+ const store = storeFor(driver)
172
+ let disposed = false
173
+ return {
174
+ async apply(input, integrations) {
175
+ if (disposed) return
176
+ const parsed = ConsentStateSchema.safeParse(input)
177
+ const state = parsed.success ? parsed.data : null
178
+ const incoming = new Map(integrations.map((row) => [row.id, row]))
179
+ const operations: Promise<void>[] = []
180
+ // Record *all* restrictions first, independently of asynchronous failures.
181
+ for (const [id, record] of store.records) {
182
+ const next = incoming.get(id)
183
+ if (next && executableFingerprint(next) !== record.fingerprint && record.attempted)
184
+ requireConsentReload(
185
+ driver,
186
+ 'Reload to apply consent changes. An executed integration was replaced.',
187
+ )
188
+ record.desired = !store.reloadRequired && !!next && !!state?.ready && state[next.category]
189
+ if (next && executableFingerprint(next) === record.fingerprint) record.integration = next
190
+ if (!record.desired) operations.push(stopRecord(driver, store, record))
191
+ }
192
+ // A different ID must not slip past a stop that may latch document reload.
193
+ const pendingStops = [...store.records.values()].flatMap((record) =>
194
+ record.stop ? [record.stop] : [],
195
+ )
196
+ const stopped = pendingStops.length ? Promise.allSettled(pendingStops) : undefined
197
+ for (const integration of integrations) {
198
+ let record = store.records.get(integration.id)
199
+ if (
200
+ !record ||
201
+ (!record.attempted && executableFingerprint(integration) !== record.fingerprint)
202
+ ) {
203
+ record = {
204
+ integration,
205
+ fingerprint: executableFingerprint(integration),
206
+ desired: false,
207
+ attempted: false,
208
+ active: false,
209
+ revoked: false,
210
+ }
211
+ store.records.set(integration.id, record)
212
+ }
213
+ record.desired = !store.reloadRequired && !!state?.ready && state[integration.category]
214
+ if (record.desired) {
215
+ const current = record
216
+ operations.push(
217
+ (stopped
218
+ ? stopped.then(() => {
219
+ if (!disposed) return startRecord(driver, store, current)
220
+ })
221
+ : startRecord(driver, store, current)
222
+ ).then(async () => {
223
+ // A regrant during an unresolved start/stop resumes only once both settled.
224
+ if (!disposed && current.desired && !current.active && !store.reloadRequired)
225
+ await startRecord(driver, store, current)
226
+ }),
227
+ )
228
+ }
229
+ }
230
+ const results = await Promise.allSettled(operations)
231
+ const failure = results.find(
232
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
233
+ )
234
+ if (failure) throw failure.reason
235
+ if (!store.reloadRequired && ![...store.records.values()].some((record) => record.error))
236
+ store.error = null
237
+ publish(store)
238
+ },
239
+ dispose() {
240
+ if (disposed) return
241
+ disposed = true
242
+ for (const record of store.records.values()) {
243
+ record.desired = false
244
+ void stopRecord(driver, store, record).catch(() => {})
245
+ }
246
+ },
247
+ }
248
+ }