@unitninety/site-kit 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +104 -1
  2. package/package.json +4 -3
  3. package/src/bridge/DraftBlocks.tsx +10 -0
  4. package/src/bridge/createBridge.ts +11 -10
  5. package/src/bridge/index.ts +1 -0
  6. package/src/bridge/nativeDraft.ts +22 -4
  7. package/src/bridge/nativeOrder.ts +68 -0
  8. package/src/bridge/registry.ts +3 -2
  9. package/src/bridge/selection.ts +59 -10
  10. package/src/bridge/useDraftContent.tsx +35 -22
  11. package/src/components/BlockLink.tsx +28 -0
  12. package/src/components/Link.tsx +8 -1
  13. package/src/components/block-navigation.ts +24 -0
  14. package/src/custom-code/fragments.ts +64 -0
  15. package/src/custom-code/index.ts +30 -0
  16. package/src/elements/blockAnchor.ts +7 -0
  17. package/src/elements/declaration.ts +1 -1
  18. package/src/elements/define.ts +1 -1
  19. package/src/elements/index.ts +1 -0
  20. package/src/elements/ops/validate.ts +9 -2
  21. package/src/features/client/README.md +1 -2
  22. package/src/features/client/SiteFeatures.tsx +15 -25
  23. package/src/features/client/index.ts +0 -6
  24. package/src/features/client/verification.ts +3 -0
  25. package/src/features/contracts/base.ts +1 -5
  26. package/src/features/contracts/index.ts +0 -20
  27. package/src/features/contracts/verificationConfig.ts +7 -0
  28. package/src/features/server/gateway.ts +1 -3
  29. package/src/features/server/index.ts +0 -2
  30. package/src/legacy/cli.mjs +2 -2
  31. package/src/legacy/content.ts +1 -1
  32. package/src/server/BlockAnchors.tsx +92 -0
  33. package/src/server/CustomCode.tsx +29 -0
  34. package/src/server/index.ts +1 -0
  35. package/src/server/read.ts +13 -0
  36. package/src/server/render.tsx +11 -5
  37. package/src/site/defineSite.ts +6 -3
  38. package/src/site/documents.ts +2 -2
  39. package/src/site/forms.ts +32 -0
  40. package/src/site/index.ts +2 -0
  41. package/src/site/types.ts +4 -1
  42. package/src/site/version.ts +1 -1
  43. package/src/site/wire.ts +3 -0
  44. package/src/features/client/ConsentRuntime.tsx +0 -95
  45. package/src/features/client/useConsent.ts +0 -15
  46. package/src/features/consent/README.md +0 -70
  47. package/src/features/consent/cookieScript.ts +0 -53
  48. package/src/features/consent/custom.ts +0 -89
  49. package/src/features/consent/defaults.ts +0 -40
  50. package/src/features/consent/drivers.ts +0 -233
  51. package/src/features/consent/loader.ts +0 -248
  52. package/src/features/consent/registry.ts +0 -398
  53. package/src/features/contracts/consent.ts +0 -150
  54. package/src/features/server/ConsentDefaults.tsx +0 -15
@@ -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
- return $path ? createElement('div', { key: _id, 'data-u90-block': $path, style: { display: 'contents' } }, rendered) : rendered
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
- return language ? createElement(LanguageProvider, { value: language }, tree) : tree
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
  }
@@ -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
  }
@@ -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. */
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.4'
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);`