@unitninety/site-kit 1.6.5 → 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 +42 -7
- package/package.json +5 -4
- 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/index.ts +1 -0
- package/src/elements/ops/expand.ts +1 -4
- 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/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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
|
|
2
|
+
import type { CustomCodePlacement } from './index'
|
|
3
|
+
|
|
4
|
+
type Node = DefaultTreeAdapterTypes.ChildNode
|
|
5
|
+
export type CustomCodeElement = DefaultTreeAdapterTypes.Element
|
|
6
|
+
const headTags = new Set(['script', 'style', 'meta', 'link'])
|
|
7
|
+
const bodyTags = new Set(['script', 'style', 'link', 'noscript'])
|
|
8
|
+
const fallbackTags = new Set(['iframe', 'img', 'div', 'span', 'p'])
|
|
9
|
+
const voidTags = new Set(['meta', 'link', 'img'])
|
|
10
|
+
const reservedAttributes = new Set(['children', 'dangerouslysetinnerhtml', 'suppresshydrationwarning', 'suppresscontenteditablewarning', 'key', 'ref'])
|
|
11
|
+
const urlAttributes = new Set(['src', 'href', 'action', 'formaction', 'poster', 'background', 'cite', 'data', 'longdesc', 'manifest', 'xlink:href'])
|
|
12
|
+
|
|
13
|
+
/** parse5 supplies HTML tokenization, decoded attribute values, and source spans. In addition
|
|
14
|
+
* to parser errors we require complete contiguous spans: HTML's normal error recovery can
|
|
15
|
+
* silently drop document wrappers/end tags or insert missing closing tags without an error. */
|
|
16
|
+
export function parseCustomCodeFragment(source: string, placement: CustomCodePlacement): CustomCodeElement[] {
|
|
17
|
+
const fail = (reason: string): never => { throw new Error(`${placement}: ${reason}`) }
|
|
18
|
+
const fragment = parseFragment(source, {
|
|
19
|
+
scriptingEnabled: false, sourceCodeLocationInfo: true,
|
|
20
|
+
onParseError: (error) => fail(`malformed HTML (${error.code})`),
|
|
21
|
+
})
|
|
22
|
+
const visit = (nodes: Node[], start: number, end: number, fallback: boolean, rawText = false) => {
|
|
23
|
+
let cursor = start
|
|
24
|
+
for (const node of nodes) {
|
|
25
|
+
const loc = node.sourceCodeLocation
|
|
26
|
+
if (!loc || loc.startOffset !== cursor) fail('unsupported or malformed HTML was discarded by the parser')
|
|
27
|
+
cursor = loc!.endOffset
|
|
28
|
+
if (node.nodeName === '#comment') continue
|
|
29
|
+
if ('value' in node) {
|
|
30
|
+
if (!fallback && !rawText && node.value.trim()) fail('ordinary text is only supported inside noscript fallbacks')
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (!('tagName' in node)) fail('document declarations are not supported')
|
|
34
|
+
const element = node as CustomCodeElement
|
|
35
|
+
const tag = element.tagName
|
|
36
|
+
const allowed = fallback ? fallbackTags : placement === 'head' ? headTags : bodyTags
|
|
37
|
+
if (!allowed.has(tag)) fail(`<${tag}> is not supported in this placement${fallback ? ' inside noscript' : ''}`)
|
|
38
|
+
const location = element.sourceCodeLocation!
|
|
39
|
+
if (!location.startTag || (!voidTags.has(tag) && !location.endTag)) fail(`<${tag}> requires explicit opening and closing tags`)
|
|
40
|
+
for (const { name, value } of element.attrs) {
|
|
41
|
+
if (name.startsWith('on')) fail(`event attribute ${name} is not supported`)
|
|
42
|
+
if (reservedAttributes.has(name)) fail(`reserved attribute ${name} is not supported`)
|
|
43
|
+
if (name === 'srcdoc' || name === 'srcset' || name === 'imagesrcset') fail(`attribute ${name} is not supported`)
|
|
44
|
+
if (name === 'style' && !fallback) fail('inline style attributes on resource tags are not supported; use a style element')
|
|
45
|
+
if (tag === 'meta' && name === 'http-equiv' && value.trim().toLowerCase() === 'refresh') fail('meta refresh is not supported')
|
|
46
|
+
if (urlAttributes.has(name)) {
|
|
47
|
+
// Reject whitespace/control characters and backslashes before URL parsing can repair them.
|
|
48
|
+
if (/[\u0000-\u0020\u007f\\]/u.test(value) || !(value.startsWith('/') && !value.startsWith('//') || /^https:\/\//i.test(value))) fail(`${name} must be an HTTPS or root-relative resource URL`)
|
|
49
|
+
try { new URL(value, 'https://custom-code.invalid') } catch { fail(`${name} is not a valid resource URL`) }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!voidTags.has(tag)) visit(element.childNodes, location.startTag!.endOffset, location.endTag!.startOffset, fallback || tag === 'noscript', tag === 'script' || tag === 'style')
|
|
53
|
+
}
|
|
54
|
+
if (cursor !== end) fail('unsupported or malformed HTML was discarded by the parser')
|
|
55
|
+
}
|
|
56
|
+
visit(fragment.childNodes, 0, source.length, false)
|
|
57
|
+
return fragment.childNodes.filter((node): node is CustomCodeElement => 'tagName' in node)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Retain raw text and fallback markup exactly, including HTML entities and JS comments. */
|
|
61
|
+
export function fragmentInnerHtml(source: string, element: CustomCodeElement): string {
|
|
62
|
+
const location = element.sourceCodeLocation!
|
|
63
|
+
return location.endTag ? source.slice(location.startTag!.endOffset, location.endTag.startOffset) : ''
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseCustomCodeFragment } from './fragments'
|
|
2
|
+
|
|
3
|
+
export type CustomCodePlacement = 'head' | 'bodyStart' | 'bodyEnd'
|
|
4
|
+
export type CustomCode = Record<CustomCodePlacement, string>
|
|
5
|
+
export type PublishedCustomCode = { version: 1; revision: number } & CustomCode
|
|
6
|
+
export const EMPTY_CUSTOM_CODE: CustomCode = Object.freeze({ head: '', bodyStart: '', bodyEnd: '' })
|
|
7
|
+
|
|
8
|
+
/** Server-safe validation only: never executes scripts or retrieves submitted URLs.
|
|
9
|
+
* Client forms should import types only, keeping the HTML parser on the server. */
|
|
10
|
+
export function validateCustomCode(input: unknown): CustomCode {
|
|
11
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('custom code: expected head, bodyStart and bodyEnd strings')
|
|
12
|
+
const fields = Object.keys(EMPTY_CUSTOM_CODE) as CustomCodePlacement[]
|
|
13
|
+
for (const key of Object.keys(input)) {
|
|
14
|
+
if (!fields.includes(key as CustomCodePlacement)) throw new Error(`${key}: unsupported custom code field`)
|
|
15
|
+
}
|
|
16
|
+
const result = { ...EMPTY_CUSTOM_CODE }
|
|
17
|
+
let total = 0
|
|
18
|
+
for (const field of fields) {
|
|
19
|
+
const value = (input as Record<string, unknown>)[field]
|
|
20
|
+
if (!Object.hasOwn(input, field) || typeof value !== 'string') throw new Error(`${field}: expected a string`)
|
|
21
|
+
const normalized = value.replace(/\r\n?/g, '\n')
|
|
22
|
+
const size = new TextEncoder().encode(normalized).length
|
|
23
|
+
if (size > 64 * 1024) throw new Error(`${field}: exceeds the 64 KiB UTF-8 limit`)
|
|
24
|
+
total += size
|
|
25
|
+
if (total > 128 * 1024) throw new Error(`${field}: exceeds the 128 KiB total UTF-8 limit`)
|
|
26
|
+
parseCustomCodeFragment(normalized, field)
|
|
27
|
+
result[field] = normalized
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ID_RE } from './ids'
|
|
2
|
+
|
|
3
|
+
/** The public fragment for a stored block identity; names and ordering never enter its address. */
|
|
4
|
+
export function blockAnchor(id: string): string {
|
|
5
|
+
if (!ID_RE.test(id)) throw new Error('Invalid stored block identity')
|
|
6
|
+
return `u90-block-${id}`
|
|
7
|
+
}
|
package/src/elements/index.ts
CHANGED
|
@@ -33,3 +33,4 @@ export { translatableParts } from './ops/translatable'
|
|
|
33
33
|
export { overlayKey, translatableLeaves, applyOverlay, type OverlayValues } from './ops/overlay'
|
|
34
34
|
export { upgrade, UpgradeError, type UpgradeOptions } from './ops/upgrade'
|
|
35
35
|
export { toJSONSchema } from './jsonSchema'
|
|
36
|
+
export { blockAnchor } from './blockAnchor'
|
|
@@ -62,10 +62,7 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
|
|
|
62
62
|
const item = items[i]
|
|
63
63
|
const block = record(item) ? String(item._block) : ''
|
|
64
64
|
const metadata = record(item) && typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}
|
|
65
|
-
|
|
66
|
-
// its address both to move the page content and to place other blocks before it.
|
|
67
|
-
if (block === SLOT) return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}) }
|
|
68
|
-
if (!Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
|
|
65
|
+
if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
|
|
69
66
|
return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
70
67
|
})
|
|
71
68
|
}
|
|
@@ -68,9 +68,16 @@ export function validate(schema: Schema, docDecl: DocumentDecl, doc: unknown, at
|
|
|
68
68
|
const pointer = path.toPointer(opts.previous, here)
|
|
69
69
|
const before = pointer === null ? undefined : valueAtPointer(opts.previous, pointer)
|
|
70
70
|
if (before !== undefined)
|
|
71
|
-
for (const sub of decl.lock)
|
|
72
|
-
|
|
71
|
+
for (const sub of decl.lock) {
|
|
72
|
+
// A blank fixed link label from an older editor may be restored only to the
|
|
73
|
+
// site's exact default. Other locked parts and deliberately cleared links stay fixed.
|
|
74
|
+
const restoresLabel = entry.id === 'u90/link' && sub === 'label' &&
|
|
75
|
+
record(before) && typeof before.label === 'string' && !before.label.trim() &&
|
|
76
|
+
record(decl.default) && typeof decl.default.label === 'string' && !!decl.default.label.trim() &&
|
|
77
|
+
dig(value, sub) === decl.default.label
|
|
78
|
+
if (!restoresLabel && JSON.stringify(dig(before, sub)) !== JSON.stringify(dig(value, sub)))
|
|
73
79
|
push(here, { path: sub.split('.'), code: 'locked', message: 'This part is set by the site and cannot be changed here.' })
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
82
|
if (value !== undefined && value !== null) {
|
|
76
83
|
const o = entry.options.safeParse(decl.options ?? {})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The `@unitninety/site-kit/features` entry point targets Next 16.3.8–16.x and React 19.
|
|
4
4
|
It uses Next's committed pathname updates; wrap `SiteFeatures` around the root layout's
|
|
5
|
-
children from the first render
|
|
5
|
+
children from the first render and import `@unitninety/site-kit/features/styles.css`. The internal pathname
|
|
6
6
|
reporter has its own Suspense boundary. Pure `features/contracts` has no Next dependency.
|
|
7
7
|
No non-Next runtime support is claimed. The package's Next peer is optional for consumers
|
|
8
8
|
using only contracts or server APIs, but required for the feature client entry point.
|
|
@@ -75,7 +75,6 @@ omitted at first capture; later settings changes cannot alter a retry.
|
|
|
75
75
|
The scoped theme variables are `--u90-feature-background`, `--u90-feature-foreground`,
|
|
76
76
|
`--u90-feature-accent`, `--u90-feature-radius`, and `--u90-feature-shadow`.
|
|
77
77
|
|
|
78
|
-
See [consent setup](../consent/README.md) for the server root bootstrap, optional custom registration, remote authority, lifecycle limits and preferences hook. Consent does not reset the Feedback presentation, session or focus lifetime.
|
|
79
78
|
|
|
80
79
|
## Ask AI stock setup and remix
|
|
81
80
|
|
|
@@ -15,8 +15,6 @@ import type { FeedbackInput, PublicFeatureBase } from '../contracts'
|
|
|
15
15
|
import { FeatureContext, type OverlayName } from './context'
|
|
16
16
|
import { AskAiProvider } from './AskAiProvider'
|
|
17
17
|
import { FeedbackProvider } from './FeedbackProvider'
|
|
18
|
-
import { ConsentRuntime } from './ConsentRuntime'
|
|
19
|
-
import type { CustomConsentRegistration } from '../consent/custom'
|
|
20
18
|
import { acquireVerificationProof } from './verification'
|
|
21
19
|
import type { VerificationAction } from '../contracts'
|
|
22
20
|
import { createFeatureTransport, waitForSignal, type VisitorVerification } from './transport'
|
|
@@ -32,7 +30,6 @@ export type SiteFeaturesProps = {
|
|
|
32
30
|
endpoint?: string
|
|
33
31
|
components?: { AskAI?: ComponentType; Feedback?: ComponentType }
|
|
34
32
|
verification?: VisitorVerification
|
|
35
|
-
customConsent?: CustomConsentRegistration
|
|
36
33
|
children?: ReactNode
|
|
37
34
|
}
|
|
38
35
|
export function SiteFeatures(props: SiteFeaturesProps) {
|
|
@@ -43,7 +40,6 @@ function Runtime({
|
|
|
43
40
|
endpoint,
|
|
44
41
|
components,
|
|
45
42
|
verification,
|
|
46
|
-
customConsent,
|
|
47
43
|
children,
|
|
48
44
|
}: SiteFeaturesProps) {
|
|
49
45
|
const adapter = useRef(verification)
|
|
@@ -223,28 +219,22 @@ function Runtime({
|
|
|
223
219
|
const Feedback = components?.Feedback ?? builtins.feedback
|
|
224
220
|
return (
|
|
225
221
|
<FeatureContext.Provider value={value}>
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
role="status"
|
|
239
|
-
aria-live="polite"
|
|
240
|
-
aria-atomic="true"
|
|
241
|
-
>
|
|
242
|
-
{error ?? announcement}
|
|
243
|
-
</div>
|
|
222
|
+
<AskAiProvider endpoint={endpoint ?? '/api/u90/features/v1'} getTransport={getTransport}>
|
|
223
|
+
<FeedbackProvider presentation={Feedback}>
|
|
224
|
+
{children}
|
|
225
|
+
<div className="u90-features">
|
|
226
|
+
<Suspense fallback={null}>{config?.enabled.askAi && AskAI && <AskAI />}</Suspense>
|
|
227
|
+
<div
|
|
228
|
+
className="u90-feature-announcement"
|
|
229
|
+
role="status"
|
|
230
|
+
aria-live="polite"
|
|
231
|
+
aria-atomic="true"
|
|
232
|
+
>
|
|
233
|
+
{error ?? announcement}
|
|
244
234
|
</div>
|
|
245
|
-
</
|
|
246
|
-
</
|
|
247
|
-
</
|
|
235
|
+
</div>
|
|
236
|
+
</FeedbackProvider>
|
|
237
|
+
</AskAiProvider>
|
|
248
238
|
</FeatureContext.Provider>
|
|
249
239
|
)
|
|
250
240
|
}
|
|
@@ -12,12 +12,6 @@ export { FeatureClientError, type VisitorVerification } from './transport'
|
|
|
12
12
|
|
|
13
13
|
export { useFeedback, type FeedbackController, type FeedbackAnswer } from './useFeedback'
|
|
14
14
|
|
|
15
|
-
export { cookieScriptAdapter, normalizeCookieScript } from '../consent/cookieScript'
|
|
16
|
-
export { customConsentAdapter, type CustomConsentRegistration } from '../consent/custom'
|
|
17
|
-
|
|
18
|
-
export { useConsent, type ConsentController } from './useConsent'
|
|
19
|
-
export { createConsentLoader } from '../consent/loader'
|
|
20
|
-
|
|
21
15
|
export { useSiteSearch } from './useSiteSearch'
|
|
22
16
|
|
|
23
17
|
export { useAskAI, type AskAiController } from './useAskAI'
|
|
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
|
|
|
3
3
|
import {
|
|
4
4
|
ProofSchema,
|
|
5
5
|
VerificationConfigSchema,
|
|
6
|
+
isUnconfiguredFormVerification,
|
|
6
7
|
type VerificationAction,
|
|
7
8
|
type VerificationConfig,
|
|
8
9
|
} from '../contracts'
|
|
@@ -61,6 +62,8 @@ export async function acquireVerificationProof(
|
|
|
61
62
|
options: { signal?: AbortSignal } = {},
|
|
62
63
|
): Promise<string | null> {
|
|
63
64
|
const parsed = VerificationConfigSchema.parse(config)
|
|
65
|
+
options.signal?.throwIfAborted()
|
|
66
|
+
if (isUnconfiguredFormVerification(parsed, action)) return null
|
|
64
67
|
if (parsed.readiness !== 'configured' || (parsed.mode !== 'disabled' && !parsed.siteKey))
|
|
65
68
|
throw Error('Verification unavailable. Please retry or contact the site owner.')
|
|
66
69
|
const signal = options.signal
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { VerificationConfigSchema, type VerificationConfig } from './verificationConfig'
|
|
2
2
|
import { PublicAskAiWidgetSettingsSchema, type AskAiWidgetSettings } from './askAiSettings'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
-
import { PublicConsentInstallationSchema, type ConsentInstallation } from './consent'
|
|
5
4
|
import { PublicFeedbackWidgetSettingsSchema, type FeedbackWidgetSettings } from './feedbackSettings'
|
|
6
5
|
|
|
7
6
|
/** Visitor-feature wire version; independent of bridge and Site Kit package versions. */
|
|
8
7
|
export const FEATURE_CONTRACT_VERSION = 1
|
|
9
8
|
|
|
10
|
-
export type FeatureKey = 'search' | 'askAi' | 'feedback'
|
|
9
|
+
export type FeatureKey = 'search' | 'askAi' | 'feedback'
|
|
11
10
|
export type PageContext = { path: string; title?: string }
|
|
12
11
|
export type FeatureFlags = Record<FeatureKey, boolean>
|
|
13
12
|
export type PublicFeatureBase = {
|
|
@@ -17,7 +16,6 @@ export type PublicFeatureBase = {
|
|
|
17
16
|
enabled: FeatureFlags
|
|
18
17
|
askAi?: AskAiWidgetSettings
|
|
19
18
|
feedback?: FeedbackWidgetSettings
|
|
20
|
-
consent?: ConsentInstallation
|
|
21
19
|
}
|
|
22
20
|
export type FeatureErrorCode =
|
|
23
21
|
| 'invalid_request'
|
|
@@ -40,7 +38,6 @@ export const FeatureFlagsSchema = z.strictObject({
|
|
|
40
38
|
search: z.boolean().default(false),
|
|
41
39
|
askAi: z.boolean().default(false),
|
|
42
40
|
feedback: z.boolean().default(false),
|
|
43
|
-
consent: z.boolean().default(false),
|
|
44
41
|
})
|
|
45
42
|
|
|
46
43
|
const PAGE_ORIGIN = 'https://page-context.invalid'
|
|
@@ -71,7 +68,6 @@ export const PublicFeatureBaseSchema = z.looseObject({
|
|
|
71
68
|
enabled: FeatureFlagsSchema.loose(),
|
|
72
69
|
askAi: PublicAskAiWidgetSettingsSchema.optional(),
|
|
73
70
|
feedback: PublicFeedbackWidgetSettingsSchema.optional(),
|
|
74
|
-
consent: PublicConsentInstallationSchema.optional(),
|
|
75
71
|
readiness: z
|
|
76
72
|
.object({
|
|
77
73
|
search: z.object({
|
|
@@ -25,26 +25,6 @@ export {
|
|
|
25
25
|
} from './feedbackSettings'
|
|
26
26
|
export type { FeedbackWidgetSettings } from './feedbackSettings'
|
|
27
27
|
|
|
28
|
-
export {
|
|
29
|
-
ConsentStateSchema,
|
|
30
|
-
ConsentInstallationSchema,
|
|
31
|
-
PublicConsentInstallationSchema,
|
|
32
|
-
ManagedIntegrationSchema,
|
|
33
|
-
deniedConsentState,
|
|
34
|
-
isCookieScriptURL,
|
|
35
|
-
classifyManagedIntegration,
|
|
36
|
-
consentChangeRequiresCodeWrite,
|
|
37
|
-
} from './consent'
|
|
38
|
-
export type {
|
|
39
|
-
ConsentState,
|
|
40
|
-
ConsentAdapter,
|
|
41
|
-
ConsentCategory,
|
|
42
|
-
ConsentInstallation,
|
|
43
|
-
ManagedIntegration,
|
|
44
|
-
IntegrationDriver,
|
|
45
|
-
ManagedIntegrationClassification,
|
|
46
|
-
ConsentConfiguration,
|
|
47
|
-
} from './consent'
|
|
48
28
|
|
|
49
29
|
export * from './knowledge'
|
|
50
30
|
export * from './search'
|
|
@@ -7,3 +7,10 @@ export const VerificationConfigSchema = z.strictObject({
|
|
|
7
7
|
revision: z.int().nonnegative(),
|
|
8
8
|
})
|
|
9
9
|
export type VerificationConfig = z.infer<typeof VerificationConfigSchema>
|
|
10
|
+
|
|
11
|
+
/** An absent form CAPTCHA connection is optional; broken or configured connections are not. */
|
|
12
|
+
export const isUnconfiguredFormVerification = (config: VerificationConfig, action?: string) =>
|
|
13
|
+
action === 'form_submit' &&
|
|
14
|
+
config.mode === 'managed' &&
|
|
15
|
+
config.readiness === 'setup_required' &&
|
|
16
|
+
config.siteKey === null
|
|
@@ -185,20 +185,18 @@ export function createSiteFeatures(options: SiteFeaturesOptions) {
|
|
|
185
185
|
if (operation === 'config') {
|
|
186
186
|
const parsed = PublicFeatureBaseSchema.safeParse(await exchange())
|
|
187
187
|
if (!parsed.success) throw new FeatureFailure('unavailable')
|
|
188
|
-
const { version, revision, enabled, feedback,
|
|
188
|
+
const { version, revision, enabled, feedback, askAi, verification } = parsed.data
|
|
189
189
|
return Response.json(
|
|
190
190
|
{
|
|
191
191
|
version,
|
|
192
192
|
revision,
|
|
193
193
|
...(verification ? { verification } : {}),
|
|
194
|
-
...(consent ? { consent } : {}),
|
|
195
194
|
feedback: FeedbackWidgetSettingsSchema.parse(feedback ?? {}),
|
|
196
195
|
askAi: AskAiWidgetSettingsSchema.parse(askAi ?? {}),
|
|
197
196
|
enabled: {
|
|
198
197
|
search: enabled.search,
|
|
199
198
|
askAi: enabled.askAi,
|
|
200
199
|
feedback: enabled.feedback,
|
|
201
|
-
consent: enabled.consent,
|
|
202
200
|
},
|
|
203
201
|
},
|
|
204
202
|
{ headers: { 'cache-control': 'no-store' } },
|
|
@@ -2,5 +2,3 @@ import 'server-only'
|
|
|
2
2
|
export { createSiteFeatures } from './gateway'
|
|
3
3
|
export type { FeatureEnvironment, VerifyVisitor, SiteFeaturesOptions } from './gateway'
|
|
4
4
|
export { createRecaptchaVerifier } from './verifyVisitor'
|
|
5
|
-
|
|
6
|
-
export { ConsentDefaults } from './ConsentDefaults'
|
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.0.
|
|
5
|
+
* `npx --yes @unitninety/u90@1.0.6 pair <code>` — the one command an operator hands over. The u90 tool
|
|
6
6
|
* runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
|
|
7
7
|
* repository with no local bin, fetches whatever package npm holds under that name.
|
|
8
8
|
*
|
|
@@ -115,7 +115,7 @@ const run = async () => {
|
|
|
115
115
|
const args = process.argv.slice(2)
|
|
116
116
|
|
|
117
117
|
if (args[0] !== 'pair' || !args[1]) {
|
|
118
|
-
fail('Usage: npx --yes @unitninety/u90@1.0.
|
|
118
|
+
fail('Usage: npx --yes @unitninety/u90@1.0.6 pair <code> --site https://example.com [--platform https://cms…]')
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const code = args[1]
|
package/src/legacy/content.ts
CHANGED
|
@@ -360,7 +360,7 @@ export const createContentClient = ({
|
|
|
360
360
|
: !platformUrl
|
|
361
361
|
? 'PLATFORM_URL is unset'
|
|
362
362
|
: 'PAYLOAD_API_KEY is unset'
|
|
363
|
-
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.
|
|
363
|
+
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.6 pair\` writes both.`,
|
|
364
364
|
)
|
|
365
365
|
}
|
|
366
366
|
return true
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
4
|
+
import { ID_RE } from '../elements/ids'
|
|
5
|
+
import { arrivingBlockNavigation, clearBlockNavigation } from '../components/block-navigation'
|
|
6
|
+
|
|
7
|
+
/** Install navigation only for SDK-marked destinations within this render tree. */
|
|
8
|
+
export function installBlockAnchors(root: HTMLElement): () => void {
|
|
9
|
+
const doc = root.ownerDocument
|
|
10
|
+
const win = doc.defaultView!
|
|
11
|
+
let observer: MutationObserver | undefined
|
|
12
|
+
let timeout: number | undefined
|
|
13
|
+
const stopWaiting = () => {
|
|
14
|
+
observer?.disconnect()
|
|
15
|
+
observer = undefined
|
|
16
|
+
win.clearTimeout(timeout)
|
|
17
|
+
}
|
|
18
|
+
const identity = (hash: string) => {
|
|
19
|
+
try {
|
|
20
|
+
const id = decodeURIComponent(hash.slice(1))
|
|
21
|
+
return id.startsWith('u90-block-') && ID_RE.test(id.slice(10)) ? id : null
|
|
22
|
+
} catch { return null }
|
|
23
|
+
}
|
|
24
|
+
const find = (id: string) => {
|
|
25
|
+
const target = doc.getElementById(id)
|
|
26
|
+
return target && root.contains(target) && target.hasAttribute('data-u90-anchor') ? target : null
|
|
27
|
+
}
|
|
28
|
+
const scroll = (id: string) => {
|
|
29
|
+
const target = find(id)
|
|
30
|
+
if (!target) return false
|
|
31
|
+
const request = arrivingBlockNavigation(win)
|
|
32
|
+
if (request) {
|
|
33
|
+
clearBlockNavigation(win)
|
|
34
|
+
if (win.location.href !== request.url.href) win.history.replaceState(null, '', request.url.href)
|
|
35
|
+
if (!request.scroll) return true
|
|
36
|
+
}
|
|
37
|
+
// getClientRects skips display:contents and non-rendered descendants, retaining
|
|
38
|
+
// native scrollIntoView handling of the actual child's scroll-margin.
|
|
39
|
+
const child = Array.from(target.querySelectorAll<HTMLElement>('*')).find(node => node.getClientRects().length > 0)
|
|
40
|
+
if (!child) return false
|
|
41
|
+
const event = new win.CustomEvent('u90:block-anchor', { bubbles: true, cancelable: true, detail: { target: child } })
|
|
42
|
+
if (child.dispatchEvent(event)) child.scrollIntoView({ block: 'start' })
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
const requestedIdentity = () => identity(arrivingBlockNavigation(win)?.url.hash ?? win.location.hash)
|
|
46
|
+
const navigate = () => {
|
|
47
|
+
stopWaiting()
|
|
48
|
+
const id = requestedIdentity()
|
|
49
|
+
if (!id || scroll(id)) return
|
|
50
|
+
// Streaming/client navigation can deliver the destination after the hash.
|
|
51
|
+
// Observe only this tree, only for a requested destination, for at most 10s.
|
|
52
|
+
observer = new win.MutationObserver(() => { if (requestedIdentity() !== id || scroll(id)) stopWaiting() })
|
|
53
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'style', 'class', 'hidden'] })
|
|
54
|
+
timeout = win.setTimeout(stopWaiting, 10_000)
|
|
55
|
+
}
|
|
56
|
+
const click = (event: MouseEvent) => {
|
|
57
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
58
|
+
const node = event.target
|
|
59
|
+
if (!(node instanceof win.Element)) return
|
|
60
|
+
const link = node.closest('a[href]')
|
|
61
|
+
if (!link || link.hasAttribute('download') || (link.getAttribute('target') && link.getAttribute('target') !== '_self')) return
|
|
62
|
+
let url: URL
|
|
63
|
+
try { url = new URL(link.getAttribute('href')!, doc.baseURI) } catch { return }
|
|
64
|
+
if (url.origin !== win.location.origin || url.pathname !== win.location.pathname || url.search !== win.location.search) return
|
|
65
|
+
const id = identity(url.hash)
|
|
66
|
+
if (!id) return
|
|
67
|
+
const existing = doc.getElementById(id)
|
|
68
|
+
if (existing && !find(id)) return
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
if (url.hash !== win.location.hash) win.history.pushState(null, '', url.href)
|
|
71
|
+
navigate()
|
|
72
|
+
}
|
|
73
|
+
doc.addEventListener('click', click)
|
|
74
|
+
doc.addEventListener('u90:block-navigate', navigate)
|
|
75
|
+
win.addEventListener('hashchange', navigate)
|
|
76
|
+
win.addEventListener('popstate', navigate)
|
|
77
|
+
navigate()
|
|
78
|
+
return () => {
|
|
79
|
+
stopWaiting()
|
|
80
|
+
doc.removeEventListener('click', click)
|
|
81
|
+
doc.removeEventListener('u90:block-navigate', navigate)
|
|
82
|
+
win.removeEventListener('hashchange', navigate)
|
|
83
|
+
win.removeEventListener('popstate', navigate)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Server-rendered children remain server components across this client boundary. */
|
|
88
|
+
export function BlockAnchors({ children }: { children: ReactNode }) {
|
|
89
|
+
const root = useRef<HTMLDivElement>(null)
|
|
90
|
+
useEffect(() => root.current ? installBlockAnchors(root.current) : undefined, [children])
|
|
91
|
+
return <div ref={root} style={{ display: 'contents' }}>{children}</div>
|
|
92
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from 'react'
|
|
2
|
+
import type { CustomCodePlacement, PublishedCustomCode } from '../custom-code'
|
|
3
|
+
import { fragmentInnerHtml, parseCustomCodeFragment } from '../custom-code/fragments'
|
|
4
|
+
|
|
5
|
+
const names: Record<string, string> = {
|
|
6
|
+
class: 'className', charset: 'charSet', 'http-equiv': 'httpEquiv', crossorigin: 'crossOrigin',
|
|
7
|
+
referrerpolicy: 'referrerPolicy', fetchpriority: 'fetchPriority', nomodule: 'noModule',
|
|
8
|
+
itemprop: 'itemProp', itemscope: 'itemScope', itemtype: 'itemType', itemid: 'itemID', itemref: 'itemRef',
|
|
9
|
+
hreflang: 'hrefLang', tabindex: 'tabIndex', accesskey: 'accessKey', contenteditable: 'contentEditable',
|
|
10
|
+
}
|
|
11
|
+
const booleans = new Set(['async', 'defer', 'nomodule', 'disabled', 'hidden', 'itemscope', 'inert', 'autofocus'])
|
|
12
|
+
|
|
13
|
+
/** Initial-document native elements, never next/script or a hydration-time injector.
|
|
14
|
+
* React hoists/deduplicates async scripts and links unless itemProp is present. An empty
|
|
15
|
+
* microdata token opts these resources out without adding a property; preserve supplied values.
|
|
16
|
+
* Metadata intentionally retains React's head handling (itemprop would conflict with name/etc.).
|
|
17
|
+
* Inline script/style bytes and validated noscript fallback HTML bypass React text escaping. */
|
|
18
|
+
export function U90CustomCode({ code, placement }: { code: PublishedCustomCode | null; placement: CustomCodePlacement }): ReactNode {
|
|
19
|
+
if (!code || !code[placement]) return null
|
|
20
|
+
const source = code[placement]
|
|
21
|
+
return parseCustomCodeFragment(source, placement).map((element, index) => {
|
|
22
|
+
const props: Record<string, unknown> = { key: index }
|
|
23
|
+
for (const attr of element.attrs) props[names[attr.name] ?? attr.name] = booleans.has(attr.name) ? true : attr.value
|
|
24
|
+
const tag = element.tagName
|
|
25
|
+
if ((tag === 'link' || tag === 'script' && props.src && props.async || tag === 'style' && props.href && props.precedence) && props.itemProp === undefined) props.itemProp = ''
|
|
26
|
+
if (tag !== 'meta' && tag !== 'link') props.dangerouslySetInnerHTML = { __html: fragmentInnerHtml(source, element) }
|
|
27
|
+
return createElement(tag, props)
|
|
28
|
+
})
|
|
29
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -3,4 +3,5 @@ export { PREVIEW_COOKIE, type NextAdapters } from './adapters'
|
|
|
3
3
|
export { DRAFT_LOCALE_PARAM } from '../elements'
|
|
4
4
|
export type { RouteRead } from '../site'
|
|
5
5
|
export { U90Bridge } from '../bridge/U90Bridge'
|
|
6
|
+
export { U90CustomCode } from './CustomCode'
|
|
6
7
|
export type { TestPreviewContext } from '../test-preview/runtime'
|
package/src/server/read.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SiteLanguages } from '../languages/routing'
|
|
|
4
4
|
import { sourceMediaPaths } from '../test-preview/media'
|
|
5
5
|
import type { TestPreviewContext, TestPreviewRuntime } from '../test-preview/runtime'
|
|
6
6
|
import type { NextAdapters } from './adapters'
|
|
7
|
+
import type { PublishedCustomCode } from '../custom-code'
|
|
7
8
|
|
|
8
9
|
export type Env = Partial<Record<'PLATFORM_URL' | 'PAYLOAD_API_KEY' | 'REVALIDATE_SECRET' | 'SITE_URL' | 'VERCEL_DEPLOYMENT_ID' | 'VERCEL_URL' | 'U90_TEST_PREVIEW_BUILD_ID', string>>
|
|
9
10
|
/** `running` is the running code's `manifestHash`, compared with each reply's to tell whether the platform is behind a deploy. */
|
|
@@ -173,6 +174,18 @@ export function createRead(d: Deps) {
|
|
|
173
174
|
if (!d.adapters.draftEnabled || await draftEnabled(instance)) await currentDraft()
|
|
174
175
|
return siteOnce()
|
|
175
176
|
},
|
|
177
|
+
/** Published initial-document code uses the same site-tagged request as metadata/languages.
|
|
178
|
+
* Authenticate preview context before suppressing output, including custom draft adapters. */
|
|
179
|
+
customCode: async (): Promise<PublishedCustomCode | null> => {
|
|
180
|
+
if (!d.adapters.draftEnabled) {
|
|
181
|
+
if (await currentDraft()) return null
|
|
182
|
+
} else if (await draftEnabled(instance)) {
|
|
183
|
+
await currentDraft()
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
if (!d.env.PLATFORM_URL) return null
|
|
187
|
+
return (await siteOnce()).customCode ?? null
|
|
188
|
+
},
|
|
176
189
|
/**
|
|
177
190
|
* The site's languages, from the same per-request `/site` read as `site()`: cached until `site` is revalidated outside
|
|
178
191
|
* draft, read once per request in draft. Null for a site with no platform, one with no languages, or a `/site` that
|
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/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
|