@unitninety/site-kit 1.6.4 → 1.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -1
- package/package.json +4 -3
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/createBridge.ts +11 -10
- package/src/bridge/index.ts +1 -0
- package/src/bridge/nativeDraft.ts +22 -4
- package/src/bridge/nativeOrder.ts +68 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/useDraftContent.tsx +35 -22
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/custom-code/fragments.ts +64 -0
- package/src/custom-code/index.ts +30 -0
- package/src/elements/blockAnchor.ts +7 -0
- package/src/elements/declaration.ts +1 -1
- package/src/elements/define.ts +1 -1
- package/src/elements/index.ts +1 -0
- package/src/elements/ops/validate.ts +9 -2
- package/src/features/client/README.md +1 -2
- package/src/features/client/SiteFeatures.tsx +15 -25
- package/src/features/client/index.ts +0 -6
- package/src/features/client/verification.ts +3 -0
- package/src/features/contracts/base.ts +1 -5
- package/src/features/contracts/index.ts +0 -20
- package/src/features/contracts/verificationConfig.ts +7 -0
- package/src/features/server/gateway.ts +1 -3
- package/src/features/server/index.ts +0 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/BlockAnchors.tsx +92 -0
- package/src/server/CustomCode.tsx +29 -0
- package/src/server/index.ts +1 -0
- package/src/server/read.ts +13 -0
- package/src/server/render.tsx +11 -5
- package/src/site/defineSite.ts +6 -3
- package/src/site/documents.ts +2 -2
- package/src/site/forms.ts +32 -0
- package/src/site/index.ts +2 -0
- package/src/site/types.ts +4 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +3 -0
- package/src/features/client/ConsentRuntime.tsx +0 -95
- package/src/features/client/useConsent.ts +0 -15
- package/src/features/consent/README.md +0 -70
- package/src/features/consent/cookieScript.ts +0 -53
- package/src/features/consent/custom.ts +0 -89
- package/src/features/consent/defaults.ts +0 -40
- package/src/features/consent/drivers.ts +0 -233
- package/src/features/consent/loader.ts +0 -248
- package/src/features/consent/registry.ts +0 -398
- package/src/features/contracts/consent.ts +0 -150
- package/src/features/server/ConsentDefaults.tsx +0 -15
package/src/server/render.tsx
CHANGED
|
@@ -4,13 +4,16 @@ import type { RouteRead } from '../site'
|
|
|
4
4
|
import type { LanguageLinks } from '../languages/routing'
|
|
5
5
|
import { LanguageProvider } from '../switcher/LanguageSwitcher'
|
|
6
6
|
import { DraftBlocks } from '../bridge/DraftBlocks'
|
|
7
|
+
import { blockAnchor } from '../elements/blockAnchor'
|
|
8
|
+
import { ID_RE } from '../elements/ids'
|
|
9
|
+
import { BlockAnchors } from './BlockAnchors'
|
|
7
10
|
|
|
8
11
|
type BlockItem = { _id: string; _block: string; _hidden?: boolean; $path?: string } & Record<string, unknown>
|
|
9
12
|
/** What a block declared with `entry:` receives, when the document being rendered is an entry page. */
|
|
10
13
|
type EntryProp = { id: string; slug?: string; route?: string; fields?: Record<string, unknown> }
|
|
11
14
|
|
|
12
15
|
export function createRender(site: Site) {
|
|
13
|
-
const Blocks = ({ value, slot, entry }: { value: unknown; slot?: ReactNode; entry?: EntryProp }): ReactElement => {
|
|
16
|
+
const Blocks = ({ value, slot, entry, anchors = true }: { value: unknown; slot?: ReactNode; entry?: EntryProp; anchors?: boolean }): ReactElement => {
|
|
14
17
|
const items = ((Array.isArray(value) ? value : []) as BlockItem[]).filter(item => item._hidden !== true)
|
|
15
18
|
const renderedItems = items.map((item) => {
|
|
16
19
|
if (item._block === 'SLOT') return createElement(Fragment, { key: item._id }, slot)
|
|
@@ -19,18 +22,20 @@ export function createRender(site: Site) {
|
|
|
19
22
|
const { _id, _block, _hidden, $path, ...fields } = item
|
|
20
23
|
// zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
|
|
21
24
|
const props: Record<string, unknown> = { ...fields }
|
|
22
|
-
for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry })
|
|
25
|
+
for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry, anchors })
|
|
23
26
|
// a block declared with `entry:` renders inside that collection's entry pages and receives the entry itself
|
|
24
27
|
if (def.entry) props.entry = entry
|
|
25
28
|
const rendered = createElement(def.component as never, { key: _id, ...props } as never)
|
|
26
|
-
|
|
29
|
+
if (!anchors && !$path) return rendered
|
|
30
|
+
const anchor = anchors && ID_RE.test(_id) ? blockAnchor(_id) : undefined
|
|
31
|
+
return createElement('div', { key: _id, id: anchor, 'data-u90-anchor': anchor ? '' : undefined, 'data-u90-block': $path, style: { display: 'contents' } }, rendered)
|
|
27
32
|
})
|
|
28
33
|
return items.some((item) => item.$path)
|
|
29
34
|
? createElement(DraftBlocks, { items: items.map((item, index) => ({ id: item._id, path: item.$path, content: renderedItems[index] })) })
|
|
30
35
|
: createElement(Fragment, null, ...renderedItems)
|
|
31
36
|
}
|
|
32
37
|
const Layout = ({ chain, layouts, children }: { chain: string[]; layouts: Record<string, unknown>; children: ReactNode }): ReactElement =>
|
|
33
|
-
chain.reduceRight<ReactElement>((inner, key) => createElement(Blocks, { value: (layouts[key] as { blocks: unknown })?.blocks, slot: inner }), createElement(Fragment, null, children))
|
|
38
|
+
chain.reduceRight<ReactElement>((inner, key) => createElement(Blocks, { value: (layouts[key] as { blocks: unknown })?.blocks, slot: inner, anchors: false }), createElement(Fragment, null, children))
|
|
34
39
|
/** `language`, on a language site's page only, puts the page's language and its addresses within reach of `LanguageSwitcher`. Without it the tree is the one it always was. */
|
|
35
40
|
const render = (read: RouteRead, language?: LanguageLinks): ReactElement => {
|
|
36
41
|
const doc = read.read as { blocks?: unknown; fields?: Record<string, unknown> } | undefined
|
|
@@ -40,7 +45,8 @@ export function createRender(site: Site) {
|
|
|
40
45
|
? { id: document.id, slug: document.slug, route: document.collection ? site.collections[document.collection]?.route?.replace(':slug', document.slug) : undefined, fields: doc?.fields }
|
|
41
46
|
: undefined
|
|
42
47
|
const tree = <Layout chain={document?.layoutChain ?? []} layouts={read.layouts ?? {}}><Blocks value={doc?.blocks} entry={entry} /></Layout>
|
|
43
|
-
|
|
48
|
+
const anchored = <BlockAnchors key={document?.id}>{tree}</BlockAnchors>
|
|
49
|
+
return language ? createElement(LanguageProvider, { value: language }, anchored) : anchored
|
|
44
50
|
}
|
|
45
51
|
return { Blocks, Layout, render }
|
|
46
52
|
}
|
package/src/site/defineSite.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormsSchema, type FormDeclaration } from './forms'
|
|
1
2
|
import { seedIssues, validateSchema, type BlockDecl, type CollectionDecl, type FieldDecl, type Issue, type Schema, type SeedKnown, type ZoneDecl } from '../elements'
|
|
2
3
|
import { SLOT } from './documents'
|
|
3
4
|
import type { BlockDef, CollectionDef, LayoutDef, PageDef, PageTypeDef, Seo, Singles, Site, SiteManifest } from './types'
|
|
@@ -35,7 +36,8 @@ const misplacedZones = (decl: FieldDecl, at: string, direct: boolean, into: Issu
|
|
|
35
36
|
const PAGE_KEY = /^[a-z][a-zA-Z0-9_-]*$/
|
|
36
37
|
const strip = (fields: Record<string, unknown>) => Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, JSON.parse(JSON.stringify(v))])) as Record<string, FieldDecl>
|
|
37
38
|
|
|
38
|
-
export function defineSite(input: { singles: Singles; collections: Record<string, CollectionDef>; layouts: Record<string, LayoutDef>; pages: Record<string, PageDef>; pageTypes: Record<string, PageTypeDef>; seo?: Seo; knowledge?: KnowledgePolicyV1 }): Site {
|
|
39
|
+
export function defineSite(input: { singles: Singles; collections: Record<string, CollectionDef>; layouts: Record<string, LayoutDef>; pages: Record<string, PageDef>; pageTypes: Record<string, PageTypeDef>; seo?: Seo; knowledge?: KnowledgePolicyV1; forms?: FormDeclaration[] }): Site {
|
|
40
|
+
const forms = input.forms === undefined ? undefined : FormsSchema.parse(input.forms)
|
|
39
41
|
const defaults = Object.entries(input.layouts).filter(([, l]) => l.default)
|
|
40
42
|
if (defaults.length !== 1) throw new Error(`A site needs exactly one default layout; found ${defaults.length}.`)
|
|
41
43
|
const defaultLayout = defaults[0][0]
|
|
@@ -96,7 +98,7 @@ export function defineSite(input: { singles: Singles; collections: Record<string
|
|
|
96
98
|
const schema: Schema = {
|
|
97
99
|
blocks: Object.fromEntries([...blocks.values()].map((b) => [b.name, { name: b.name, label: b.label, fields: strip(b.fields), ...(b.shared ? { shared: b.shared } : {}), ...(b.entry ? { entry: b.entry.name } : {}), order: Object.keys(b.fields) } satisfies BlockDecl])),
|
|
98
100
|
collections: Object.fromEntries(Object.values(input.collections).map((c) => [c.name, { name: c.name, label: c.label, fields: strip(c.fields), ...(c.authoring ? { authoring: JSON.parse(JSON.stringify(c.authoring)) } : {}), ...(c.template ? { template: JSON.parse(JSON.stringify(c.template.zone)) } : {}), ...(c.defaultSort ? { defaultSort: c.defaultSort } : {}), order: Object.keys(c.fields) } satisfies CollectionDecl])),
|
|
99
|
-
singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, field: JSON.parse(JSON.stringify(s.decl)) }])),
|
|
101
|
+
singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, ...(s.editor ? { editor: s.editor } : {}), field: JSON.parse(JSON.stringify(s.decl)) }])),
|
|
100
102
|
}
|
|
101
103
|
// Finding 1: seed entries were checked only when seeded, at the platform or in the defaults store.
|
|
102
104
|
const known: SeedKnown = {
|
|
@@ -106,6 +108,7 @@ export function defineSite(input: { singles: Singles; collections: Record<string
|
|
|
106
108
|
const badSeeds = Object.values(input.collections).flatMap((c) => (c.entries ?? []).flatMap((e) => seedIssues(schema, { kind: 'entry', collection: c.name }, { fields: e.fields, blocks: e.blocks }, `collections.${c.name}.entries.${e.slug}`, known)))
|
|
107
109
|
if (badSeeds.length) throw new SiteDeclarationError(badSeeds)
|
|
108
110
|
return {
|
|
111
|
+
...(forms !== undefined ? { forms } : {}),
|
|
109
112
|
...(input.knowledge !== undefined ? { knowledge: input.knowledge } : {}),
|
|
110
113
|
singles: input.singles, collections: input.collections, blocks: Object.fromEntries(blocks), schema, seo: input.seo ?? {}, defaultLayout,
|
|
111
114
|
layouts: Object.fromEntries(Object.entries(input.layouts).map(([k, l]) => [k, { default: !!l.default, parent: l.parent, blocks: l.entries.map((e) => (e === SLOT ? 'SLOT' : e.name)) }])),
|
|
@@ -128,5 +131,5 @@ export function manifestFrom(site: Site): SiteManifest {
|
|
|
128
131
|
pageTypes: Object.keys(site.pageTypes),
|
|
129
132
|
layouts: Object.keys(site.layouts),
|
|
130
133
|
}
|
|
131
|
-
return { ...(site.knowledge !== undefined ? { knowledge: site.knowledge } : {}), schema: r.schema, layouts: site.layouts, pages: site.pages, pageTypes: site.pageTypes, entries, collections, routes, seo: site.seo, bridgeVersion: 4, siteKitVersion: SITE_KIT_VERSION, order }
|
|
134
|
+
return { ...(site.forms !== undefined ? { forms: FormsSchema.parse(site.forms) } : {}), ...(site.knowledge !== undefined ? { knowledge: site.knowledge } : {}), schema: r.schema, layouts: site.layouts, pages: site.pages, pageTypes: site.pageTypes, entries, collections, routes, seo: site.seo, bridgeVersion: 4, siteKitVersion: SITE_KIT_VERSION, order }
|
|
132
135
|
}
|
package/src/site/documents.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { BlockDef, CollectionDef, Decl, EntrySeed, Fields, LayoutDef, PageD
|
|
|
4
4
|
export const SLOT = 'SLOT' as const
|
|
5
5
|
export const humanise = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
|
6
6
|
|
|
7
|
-
export const defineSingles = <M extends Record<string, Decl<unknown>>>(map: M): { [K in keyof M]: SingleDef<M[K] extends Decl<infer R> ? R : never> } =>
|
|
8
|
-
Object.fromEntries(Object.entries(map).map(([key, decl]) => [key, { key, decl }])) as never
|
|
7
|
+
export const defineSingles = <M extends Record<string, Decl<unknown>>>(map: M, authoring: Partial<Record<keyof M, { editor: 'cms' | 'canvas' }>> = {}): { [K in keyof M]: SingleDef<M[K] extends Decl<infer R> ? R : never> } =>
|
|
8
|
+
Object.fromEntries(Object.entries(map).map(([key, decl]) => [key, { key, decl, ...authoring[key] }])) as never
|
|
9
9
|
export const collection = <F extends Fields>(name: string, o: { label?: string; fields: F; route?: string; defaultSort?: CollectionDef['defaultSort']; entries?: EntrySeed[]; authoring?: CollectionDef['authoring'] }): CollectionDef<F> =>
|
|
10
10
|
({ kind: 'collection', name, label: o.label ?? humanise(name), fields: o.fields, route: o.route, defaultSort: o.defaultSort, ...(o.authoring ? { authoring: o.authoring } : {}), ...(o.entries ? { entries: o.entries } : {}) })
|
|
11
11
|
export const withTemplate = <C extends CollectionDef>(c: C, zone: Decl<unknown>, o: { layout?: string } = {}): C =>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const identifier = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/).max(100)
|
|
4
|
+
const label = z.string().trim().min(1).max(200)
|
|
5
|
+
export const FormFieldSchema = z.strictObject({
|
|
6
|
+
name: identifier, label,
|
|
7
|
+
type: z.enum(['checkbox', 'date', 'email', 'number', 'select', 'tel', 'text', 'textarea']),
|
|
8
|
+
required: z.boolean().optional(), role: z.enum(['email', 'name', 'phone']).optional(),
|
|
9
|
+
options: z.array(label).max(200).optional(),
|
|
10
|
+
}).superRefine((field, ctx) => {
|
|
11
|
+
if (field.type === 'select' && !field.options?.length) ctx.addIssue({ code: 'custom', path: ['options'], message: 'Select fields need options.' })
|
|
12
|
+
if (field.options && new Set(field.options).size !== field.options.length) ctx.addIssue({ code: 'custom', path: ['options'], message: 'Options must be unique.' })
|
|
13
|
+
})
|
|
14
|
+
export const FormDeclarationSchema = z.strictObject({
|
|
15
|
+
slug: identifier, label, fields: z.array(FormFieldSchema).min(1).max(100),
|
|
16
|
+
recipients: z.array(z.email()).max(50).optional(),
|
|
17
|
+
}).superRefine((form, ctx) => {
|
|
18
|
+
const names = new Set<string>()
|
|
19
|
+
form.fields.forEach((field, i) => {
|
|
20
|
+
if (names.has(field.name)) ctx.addIssue({ code: 'custom', path: ['fields', i, 'name'], message: 'Field names must be unique.' })
|
|
21
|
+
names.add(field.name)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
export const FormsSchema = z.array(FormDeclarationSchema).max(100).superRefine((forms, ctx) => {
|
|
25
|
+
const slugs = new Set<string>()
|
|
26
|
+
forms.forEach((form, i) => {
|
|
27
|
+
if (slugs.has(form.slug)) ctx.addIssue({ code: 'custom', path: [i, 'slug'], message: 'Form slugs must be unique.' })
|
|
28
|
+
slugs.add(form.slug)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
export type FormDeclaration = z.infer<typeof FormDeclarationSchema>
|
|
32
|
+
export type FormFieldDeclaration = z.infer<typeof FormFieldSchema>
|
package/src/site/index.ts
CHANGED
|
@@ -15,3 +15,5 @@ export { sitemapFor } from '../seo/sitemap'
|
|
|
15
15
|
|
|
16
16
|
export { SeoSeedsSchema, DocumentSeoSeedSchema, SiteSeoSeedSchema, seoSeedIssues, seoSeedAssets, type SeoSeeds, type DocumentSeoSeed, type SiteSeoSeed } from '../seo/seeds'
|
|
17
17
|
export { CollectionViewSchema, CollectionViewsSchema, collectionViewFields, defaultCollectionView, reconcileCollectionView, validateCollectionView, collectionViewsIssues, type CollectionView, type CollectionViews, type CollectionViewField } from './formView'
|
|
18
|
+
|
|
19
|
+
export { FormsSchema, FormDeclarationSchema, FormFieldSchema, type FormDeclaration, type FormFieldDeclaration } from './forms'
|
package/src/site/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { KnowledgePolicyV1 } from '../features/contracts/knowledgePolicy'
|
|
2
|
+
import type { FormDeclaration } from './forms'
|
|
2
3
|
import type { ReactNode } from 'react'
|
|
3
4
|
import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDecl, Schema, SingleDecl, ZoneDecl } from '../elements'
|
|
4
5
|
|
|
@@ -20,7 +21,7 @@ export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
|
20
21
|
*/
|
|
21
22
|
export type ZoneNode = ReactNode
|
|
22
23
|
|
|
23
|
-
export type SingleDef<R = unknown> = { key: string; decl: Decl<R
|
|
24
|
+
export type SingleDef<R = unknown> = { key: string; decl: Decl<R>; editor?: 'cms' | 'canvas' }
|
|
24
25
|
export type Singles = Record<string, SingleDef>
|
|
25
26
|
/**
|
|
26
27
|
* An entry a collection starts with: its slug (also the key a seed's `{ $ref: { key } }` names it
|
|
@@ -45,6 +46,7 @@ export type Seo = { titleTemplate?: string; defaultImage?: string; organisation?
|
|
|
45
46
|
|
|
46
47
|
export type Site = {
|
|
47
48
|
knowledge?: KnowledgePolicyV1
|
|
49
|
+
forms?: FormDeclaration[]
|
|
48
50
|
singles: Singles; collections: Record<string, CollectionDef>
|
|
49
51
|
layouts: Record<string, { default: boolean; parent?: string; blocks: string[] }>
|
|
50
52
|
pages: Record<string, { route: string; layout: string; blocks: ZoneDecl }>
|
|
@@ -53,6 +55,7 @@ export type Site = {
|
|
|
53
55
|
}
|
|
54
56
|
export type SiteManifest = {
|
|
55
57
|
knowledge?: KnowledgePolicyV1
|
|
58
|
+
forms?: FormDeclaration[]
|
|
56
59
|
schema: Schema
|
|
57
60
|
layouts: Site['layouts']; pages: Site['pages']; pageTypes: Site['pageTypes']
|
|
58
61
|
/** Each collection's declared seed entries, by collection name; collections that declare none are absent. */
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.6.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.6'
|
package/src/site/wire.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntryRecord, FieldDecl, Id, MediaRecord, PageRecord } from '../elements'
|
|
2
2
|
import type { SiteManifest } from './types'
|
|
3
|
+
import type { PublishedCustomCode } from '../custom-code'
|
|
3
4
|
|
|
4
5
|
/** A page's or an entry's SEO settings as the platform stores them. `ogImage` is a media id whose record is in the reply's `media`. */
|
|
5
6
|
export type ReplySeo = { title: string | null; description: string | null; ogTitle: string | null; ogDescription: string | null; ogImage: Id | null; canonical: string | null; noindex: boolean }
|
|
@@ -84,6 +85,8 @@ export type SeoOrganisation = {
|
|
|
84
85
|
|
|
85
86
|
/** The site-wide SEO settings, as `/site` answers them. */
|
|
86
87
|
export type SiteReply = {
|
|
88
|
+
/** Public published snapshot only; drafts and attribution never cross this boundary. */
|
|
89
|
+
customCode?: PublishedCustomCode | null
|
|
87
90
|
allowIndexing: boolean
|
|
88
91
|
bingVerification: null | string
|
|
89
92
|
description: null | string
|
|
@@ -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);`
|