@unitninety/site-kit 1.6.5 → 1.7.0
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 +68 -4
- package/package.json +5 -4
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/U90Bridge.tsx +4 -2
- package/src/bridge/caret.ts +7 -8
- package/src/bridge/createBridge.ts +30 -10
- package/src/bridge/diagnostics.ts +61 -0
- package/src/bridge/index.ts +3 -0
- package/src/bridge/nativeDraft.ts +47 -36
- package/src/bridge/nativeOrder.ts +72 -0
- package/src/bridge/nativePath.ts +45 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/styles.ts +12 -0
- package/src/bridge/useDraftContent.tsx +68 -29
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Image.tsx +8 -4
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/components/draft.ts +19 -0
- package/src/components/index.ts +1 -1
- 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/draftPaths.ts +23 -0
- package/src/elements/ops/expand.ts +8 -6
- 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 +12 -6
- package/src/site/fields.ts +2 -2
- package/src/site/types.ts +4 -2
- 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
|
@@ -4,6 +4,7 @@ import { path } from '../path'
|
|
|
4
4
|
import { getContainer, getElement } from '../registry'
|
|
5
5
|
import { record } from '../containers/group'
|
|
6
6
|
import { prefixedRoute } from '../prefix'
|
|
7
|
+
import { draftGroupMetadata, rememberDraftList } from './draftPaths'
|
|
7
8
|
|
|
8
9
|
export type Resolved = {
|
|
9
10
|
media?: Map<string, MediaRecord>
|
|
@@ -53,21 +54,22 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
|
|
|
53
54
|
if (isLeaf(decl)) return expandLeaf(getElement(decl.element), decl, value, ctx)
|
|
54
55
|
const container = getContainer(decl.container)
|
|
55
56
|
const children = container.children(decl, decl.container === 'u90/group' ? (record(value) ? value : {}) : value, schema)
|
|
56
|
-
if (decl.container === 'u90/group')
|
|
57
|
+
if (decl.container === 'u90/group') {
|
|
58
|
+
const fields = Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
|
|
59
|
+
return { ...(ctx.draft ? draftGroupMetadata(decl.fields, ctx.at, fields) : {}), ...fields }
|
|
60
|
+
}
|
|
57
61
|
const items = Array.isArray(value) ? value : []
|
|
58
|
-
|
|
62
|
+
const expanded = children.map((c, i) => {
|
|
59
63
|
const inner = expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))
|
|
60
64
|
// A leaf item reads as its element's value (which carries its own `$path`); a group item, like a zone item, carries the item's path in draft.
|
|
61
65
|
if (decl.container === 'u90/list') return isLeaf(decl.item) ? inner : { _id: c.key, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
62
66
|
const item = items[i]
|
|
63
67
|
const block = record(item) ? String(item._block) : ''
|
|
64
68
|
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 }
|
|
69
|
+
if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
|
|
69
70
|
return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
70
71
|
})
|
|
72
|
+
return ctx.draft && decl.container === 'u90/list' ? rememberDraftList(expanded, children.map(c => path.child(ctx.at, c.key))) : expanded
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -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.1.0 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.1.0 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.1.0 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)
|
|
@@ -18,19 +21,21 @@ export function createRender(site: Site) {
|
|
|
18
21
|
if (!def) return item.$path ? createElement('div', { key: item._id, 'data-u90-block': item.$path, 'data-u90-missing': item._block }, `Block "${item._block}" was removed`) : null
|
|
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
|
-
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 })
|
|
24
|
+
const props: Record<string, unknown> = { ...fields, ...($path ? { $path } : {}) }
|
|
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/fields.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BooleanRead, ChoiceReadValue, DateRead, EntriesRead, EntryRead, FileRead, ImageRead, LinkRead, NumberRead, RichTextRead, TextRead, VideoRead } from '../elements'
|
|
2
|
-
import type { BlockDef, Decl, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
|
|
2
|
+
import type { BlockDef, Decl, DraftReadMetadata, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
|
|
3
3
|
|
|
4
4
|
type Common = { label?: string; description?: string; required?: boolean; translatable?: boolean; lock?: string[]; renamedFrom?: string }
|
|
5
5
|
/** Options that are part of the field declaration itself go top-level; the rest is the element's `options`. */
|
|
@@ -35,7 +35,7 @@ export const selection = (to: string, o: Common & { default?: unknown; many?: bo
|
|
|
35
35
|
export const query = (to: string, o: Common & { default?: unknown; previewFields?: string[] } = {}) => leaf<EntriesRead>('u90/query', o, { to, previewFields: o.previewFields })
|
|
36
36
|
export const single = <R>(s: SingleDef<R>, o: Common = {}): Decl<R> => leaf<R>('u90/single', o, { key: s.key })
|
|
37
37
|
|
|
38
|
-
export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<{ [K in keyof F]: Read<F[K]> }
|
|
38
|
+
export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata>> & { readonly __group: true } =>
|
|
39
39
|
({ container: 'u90/group', ...(o.label ? { label: o.label } : {}), fields, order: Object.keys(fields) }) as never
|
|
40
40
|
export const list = <D extends Decl<unknown>>(
|
|
41
41
|
item: D,
|
package/src/site/types.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDec
|
|
|
7
7
|
export type Decl<R> = FieldDecl & { readonly __read?: R }
|
|
8
8
|
export type Read<D> = D extends Decl<infer R> ? R : never
|
|
9
9
|
export type Fields = Record<string, Decl<unknown>>
|
|
10
|
+
/** Present only on native draft reads, never authored content or published props. */
|
|
11
|
+
export type DraftReadMetadata = { $path?: string; $fieldPaths?: Record<string, string>; $listPaths?: Record<string, readonly string[]> }
|
|
10
12
|
/**
|
|
11
13
|
* Flattens an intersection into a single mapped object type. The strict `Equal` check used by
|
|
12
14
|
* `types.test-d.ts` treats an intersection (`{ _id: string } & Read<D>`) as distinct from the
|
|
@@ -72,9 +74,9 @@ export type SiteManifest = {
|
|
|
72
74
|
* collection's declaration. Not `EntryRead`: an entry page's entry has no `collection` or `status`
|
|
73
75
|
* (finding 21).
|
|
74
76
|
*/
|
|
75
|
-
export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } } : never
|
|
77
|
+
export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } & DraftReadMetadata } : never
|
|
76
78
|
export type BlockProps<B> = B extends BlockDef<infer F, infer C>
|
|
77
|
-
? Simplify<{ [K in keyof F]: Read<F[K]> } & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
79
|
+
? Simplify<{ [K in keyof F]: Read<F[K]> } & DraftReadMetadata & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
78
80
|
: never
|
|
79
81
|
export type { FieldDecl, LeafDecl, GroupDecl, ListDecl, ZoneDecl, BlockDecl, CollectionDecl, SingleDecl, Schema }
|
|
80
82
|
export type { CollectionAuthoring, CollectionAuthoringAnchor, CollectionAuthoringSections } from '../elements'
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.7.0'
|
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
|