@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.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- 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
|
+
}
|