@unitninety/site-kit 0.0.0-stage → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { EditorBlock, EditorField } from './contract'
|
|
2
|
+
|
|
3
|
+
export type MediaId = number | string
|
|
4
|
+
export type ImageFocalPoint = { x: number; y: number }
|
|
5
|
+
export type ImageCrop = ImageFocalPoint & { width: number; height: number }
|
|
6
|
+
export type ImageConstraints = {
|
|
7
|
+
mimeTypes?: string[]
|
|
8
|
+
maxBytes?: number
|
|
9
|
+
minWidth?: number
|
|
10
|
+
minHeight?: number
|
|
11
|
+
cropAspectRatios?: number[]
|
|
12
|
+
}
|
|
13
|
+
export type ImageVariantDeclaration = { media: string; constraints?: ImageConstraints }
|
|
14
|
+
export type ImageVariant = {
|
|
15
|
+
asset: MediaId
|
|
16
|
+
crop: ImageCrop | null
|
|
17
|
+
focalPoint: ImageFocalPoint | null
|
|
18
|
+
}
|
|
19
|
+
export type ImageValue =
|
|
20
|
+
| null
|
|
21
|
+
| (ImageVariant & {
|
|
22
|
+
alt: string
|
|
23
|
+
decorative: boolean
|
|
24
|
+
caption: string | null
|
|
25
|
+
variants: Record<string, ImageVariant>
|
|
26
|
+
})
|
|
27
|
+
export type ImageSeedVariant = {
|
|
28
|
+
asset: MediaId | { source: string }
|
|
29
|
+
crop?: ImageCrop | null
|
|
30
|
+
focalPoint?: ImageFocalPoint | null
|
|
31
|
+
}
|
|
32
|
+
export type ImageSeedValue =
|
|
33
|
+
| null
|
|
34
|
+
| (ImageSeedVariant & {
|
|
35
|
+
alt?: string
|
|
36
|
+
decorative?: boolean
|
|
37
|
+
caption?: string | null
|
|
38
|
+
variants?: Record<string, ImageSeedVariant>
|
|
39
|
+
})
|
|
40
|
+
export type ImageAsset = {
|
|
41
|
+
id: MediaId
|
|
42
|
+
url: string
|
|
43
|
+
mimeType: string
|
|
44
|
+
width: number | null
|
|
45
|
+
height: number | null
|
|
46
|
+
filename: string
|
|
47
|
+
blurDataURL?: string
|
|
48
|
+
}
|
|
49
|
+
export type ImageRendition = {
|
|
50
|
+
url: string
|
|
51
|
+
width: number | null
|
|
52
|
+
height: number | null
|
|
53
|
+
blurDataURL?: string
|
|
54
|
+
}
|
|
55
|
+
export type ImageReadVariant = {
|
|
56
|
+
asset: ImageAsset | null
|
|
57
|
+
crop: ImageCrop | null
|
|
58
|
+
focalPoint: ImageFocalPoint | null
|
|
59
|
+
rendition: ImageRendition | null
|
|
60
|
+
status: 'ready' | 'missing' | 'processing' | 'error'
|
|
61
|
+
}
|
|
62
|
+
export type ImageReadValue =
|
|
63
|
+
| null
|
|
64
|
+
| (ImageReadVariant & {
|
|
65
|
+
alt: string
|
|
66
|
+
decorative: boolean
|
|
67
|
+
caption: string | null
|
|
68
|
+
variants: Record<string, ImageReadVariant>
|
|
69
|
+
})
|
|
70
|
+
export type ImageValidationIssue = { path: string; message: string }
|
|
71
|
+
export type ImageValidationOptions = {
|
|
72
|
+
path?: string
|
|
73
|
+
mode: 'draft' | 'publish'
|
|
74
|
+
required?: boolean
|
|
75
|
+
variants?: Record<string, ImageVariantDeclaration>
|
|
76
|
+
}
|
|
77
|
+
const record = (v: unknown): v is Record<string, unknown> =>
|
|
78
|
+
!!v && typeof v === 'object' && !Array.isArray(v)
|
|
79
|
+
const at = (path: string, key: string) => (path ? `${path}.${key}` : key)
|
|
80
|
+
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
|
|
81
|
+
const id = (v: unknown) =>
|
|
82
|
+
(finite(v) && v > 0 && Number.isInteger(v)) || (typeof v === 'string' && v.trim().length > 0)
|
|
83
|
+
const cropValid = (c: unknown): c is ImageCrop =>
|
|
84
|
+
record(c) &&
|
|
85
|
+
[c.x, c.y, c.width, c.height].every(finite) &&
|
|
86
|
+
(c.x as number) >= 0 &&
|
|
87
|
+
(c.y as number) >= 0 &&
|
|
88
|
+
(c.width as number) > 0 &&
|
|
89
|
+
(c.height as number) > 0 &&
|
|
90
|
+
(c.x as number) + (c.width as number) <= 100 &&
|
|
91
|
+
(c.y as number) + (c.height as number) <= 100
|
|
92
|
+
|
|
93
|
+
/** Pure shape validation. Asset authorization and rendition readiness are enforced by the server. */
|
|
94
|
+
export function validateImageValue(
|
|
95
|
+
value: unknown,
|
|
96
|
+
options: ImageValidationOptions,
|
|
97
|
+
): ImageValidationIssue[] {
|
|
98
|
+
const issues: ImageValidationIssue[] = []
|
|
99
|
+
const path = options.path ?? ''
|
|
100
|
+
const issue = (p: string, message: string) => issues.push({ path: p, message })
|
|
101
|
+
if (value === null || value === undefined) {
|
|
102
|
+
if (options.required && options.mode === 'publish')
|
|
103
|
+
issue(path, 'An image is required for publication.')
|
|
104
|
+
return issues
|
|
105
|
+
}
|
|
106
|
+
if (!record(value)) return [{ path, message: 'Expected an Image object or null.' }]
|
|
107
|
+
function structural(v: Record<string, unknown>, p: string) {
|
|
108
|
+
for (const [key, allowed] of [
|
|
109
|
+
['crop', ['x', 'y', 'width', 'height']],
|
|
110
|
+
['focalPoint', ['x', 'y']],
|
|
111
|
+
] as const) {
|
|
112
|
+
const geometry = v[key]
|
|
113
|
+
if (record(geometry))
|
|
114
|
+
for (const property of Object.keys(geometry)) {
|
|
115
|
+
if (!(allowed as readonly string[]).includes(property))
|
|
116
|
+
issue(at(at(p, key), property), 'Unknown image geometry property.')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!id(v.asset))
|
|
121
|
+
issue(
|
|
122
|
+
at(p, 'asset'),
|
|
123
|
+
'Expected a media ID; expanded assets and seed sources are not write values.',
|
|
124
|
+
)
|
|
125
|
+
if (v.crop !== null && !cropValid(v.crop))
|
|
126
|
+
issue(at(p, 'crop'), 'Crop must be a finite positive rectangle inside the original image.')
|
|
127
|
+
const point = v.focalPoint
|
|
128
|
+
if (point !== null) {
|
|
129
|
+
if (
|
|
130
|
+
!record(point) ||
|
|
131
|
+
!finite(point.x) ||
|
|
132
|
+
!finite(point.y) ||
|
|
133
|
+
point.x < 0 ||
|
|
134
|
+
point.x > 100 ||
|
|
135
|
+
point.y < 0 ||
|
|
136
|
+
point.y > 100
|
|
137
|
+
)
|
|
138
|
+
issue(at(p, 'focalPoint'), 'Focal point needs x and y between 0 and 100.')
|
|
139
|
+
else if (
|
|
140
|
+
cropValid(v.crop) &&
|
|
141
|
+
(point.x < v.crop.x ||
|
|
142
|
+
point.x > v.crop.x + v.crop.width ||
|
|
143
|
+
point.y < v.crop.y ||
|
|
144
|
+
point.y > v.crop.y + v.crop.height)
|
|
145
|
+
)
|
|
146
|
+
issue(at(p, 'focalPoint'), 'Focal point must lie inside the crop.')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
structural(value, path)
|
|
150
|
+
if (typeof value.alt !== 'string') issue(at(path, 'alt'), 'Alternative text must be a string.')
|
|
151
|
+
if (typeof value.decorative !== 'boolean')
|
|
152
|
+
issue(at(path, 'decorative'), 'Decorative must be a boolean.')
|
|
153
|
+
if (value.caption !== null && typeof value.caption !== 'string')
|
|
154
|
+
issue(at(path, 'caption'), 'Caption must be text or null.')
|
|
155
|
+
if (value.decorative === true && value.alt !== '')
|
|
156
|
+
issue(at(path, 'alt'), 'Decorative images must have empty alternative text.')
|
|
157
|
+
if (
|
|
158
|
+
options.mode === 'publish' &&
|
|
159
|
+
value.decorative === false &&
|
|
160
|
+
typeof value.alt === 'string' &&
|
|
161
|
+
!value.alt.trim()
|
|
162
|
+
)
|
|
163
|
+
issue(at(path, 'alt'), 'Meaningful images need alternative text for publication.')
|
|
164
|
+
for (const key of Object.keys(value))
|
|
165
|
+
if (!['asset', 'alt', 'decorative', 'caption', 'focalPoint', 'crop', 'variants'].includes(key))
|
|
166
|
+
issue(at(path, key), 'Unknown Image property.')
|
|
167
|
+
if (value.variants !== undefined) {
|
|
168
|
+
if (!record(value.variants)) issue(at(path, 'variants'), 'Variants must be an object.')
|
|
169
|
+
else
|
|
170
|
+
for (const [key, v] of Object.entries(value.variants)) {
|
|
171
|
+
const p = at(at(path, 'variants'), key)
|
|
172
|
+
if (
|
|
173
|
+
!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key) ||
|
|
174
|
+
['constructor', 'prototype', '__proto__'].includes(key) ||
|
|
175
|
+
!Object.hasOwn(options.variants ?? {}, key)
|
|
176
|
+
)
|
|
177
|
+
issue(p, 'Variant is not declared by this field.')
|
|
178
|
+
if (!record(v)) {
|
|
179
|
+
issue(p, 'An override must be a complete structural image value.')
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
structural(v, p)
|
|
183
|
+
for (const prop of Object.keys(v))
|
|
184
|
+
if (!['asset', 'crop', 'focalPoint'].includes(prop))
|
|
185
|
+
issue(at(p, prop), 'Variants only override asset, crop and focal point.')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return issues
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class ImageValidationError extends Error {
|
|
192
|
+
constructor(public issues: ImageValidationIssue[]) {
|
|
193
|
+
super(issues.map((i) => `${i.path}: ${i.message}`).join('; '))
|
|
194
|
+
this.name = 'ImageValidationError'
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Defaults are applied to absent properties only. Explicit invalid values never become defaults. */
|
|
198
|
+
export function normalizeImageSeed(value: unknown): ImageSeedValue {
|
|
199
|
+
if (value === null || value === undefined) return null
|
|
200
|
+
if (!record(value))
|
|
201
|
+
throw new ImageValidationError([
|
|
202
|
+
{ path: '', message: 'Expected an Image seed object or null.' },
|
|
203
|
+
])
|
|
204
|
+
const structural = (v: Record<string, unknown>) => ({
|
|
205
|
+
...v,
|
|
206
|
+
focalPoint: v.focalPoint === undefined ? null : v.focalPoint,
|
|
207
|
+
crop: v.crop === undefined ? null : v.crop,
|
|
208
|
+
})
|
|
209
|
+
const normalized = {
|
|
210
|
+
...structural(value),
|
|
211
|
+
alt: value.alt === undefined ? '' : value.alt,
|
|
212
|
+
decorative: value.decorative === undefined ? false : value.decorative,
|
|
213
|
+
caption: value.caption === undefined || value.caption === '' ? null : value.caption,
|
|
214
|
+
variants: value.variants === undefined ? {} : value.variants,
|
|
215
|
+
}
|
|
216
|
+
if (record(normalized.variants))
|
|
217
|
+
normalized.variants = Object.fromEntries(
|
|
218
|
+
Object.entries(normalized.variants).map(([k, v]) => [k, record(v) ? structural(v) : v]),
|
|
219
|
+
)
|
|
220
|
+
const check = structuredClone(normalized) as Record<string, unknown>
|
|
221
|
+
const seedIssues: ImageValidationIssue[] = []
|
|
222
|
+
const translate = (v: Record<string, unknown>, path: string) => {
|
|
223
|
+
if (!record(v.asset)) return
|
|
224
|
+
if (
|
|
225
|
+
typeof v.asset.source === 'string' &&
|
|
226
|
+
v.asset.source.trim() &&
|
|
227
|
+
Object.keys(v.asset).length === 1
|
|
228
|
+
)
|
|
229
|
+
v.asset = v.asset.source
|
|
230
|
+
else
|
|
231
|
+
seedIssues.push({
|
|
232
|
+
path: at(path, 'asset'),
|
|
233
|
+
message: 'Seed asset must be a media ID or { source: nonempty string }.',
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
translate(check, '')
|
|
237
|
+
if (record(check.variants))
|
|
238
|
+
for (const [k, v] of Object.entries(check.variants))
|
|
239
|
+
if (record(v)) translate(v, `variants.${k}`)
|
|
240
|
+
const variants = record(check.variants)
|
|
241
|
+
? Object.fromEntries(Object.keys(check.variants).map((k) => [k, { media: 'seed' }]))
|
|
242
|
+
: {}
|
|
243
|
+
const issues = [...seedIssues, ...validateImageValue(check, { mode: 'draft', variants })]
|
|
244
|
+
if (issues.length) throw new ImageValidationError(issues)
|
|
245
|
+
return normalized as ImageSeedValue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The existing upload importer uses source strings at upload positions. */
|
|
249
|
+
export function imageSeedForImport(value: unknown): ImageSeedValue {
|
|
250
|
+
const normalized = normalizeImageSeed(value)
|
|
251
|
+
if (!normalized) return null
|
|
252
|
+
const lower = (v: ImageSeedVariant) => ({
|
|
253
|
+
...v,
|
|
254
|
+
asset: typeof v.asset === 'object' ? v.asset.source : v.asset,
|
|
255
|
+
})
|
|
256
|
+
return {
|
|
257
|
+
...lower(normalized),
|
|
258
|
+
variants: Object.fromEntries(
|
|
259
|
+
Object.entries(normalized.variants ?? {}).map(([k, v]) => [k, lower(v)]),
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function validateImageDeclaration(
|
|
265
|
+
value: { constraints?: unknown; variants?: unknown },
|
|
266
|
+
path = '',
|
|
267
|
+
): ImageValidationIssue[] {
|
|
268
|
+
const issues: ImageValidationIssue[] = []
|
|
269
|
+
function constraints(v: unknown, p: string) {
|
|
270
|
+
if (v === undefined) return
|
|
271
|
+
if (!record(v)) {
|
|
272
|
+
issues.push({ path: p, message: 'Constraints must be an object.' })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
for (const [key, n] of Object.entries(v)) {
|
|
276
|
+
const issue = () => issues.push({ path: at(p, key), message: 'Invalid image constraint.' })
|
|
277
|
+
if (['maxBytes', 'minWidth', 'minHeight'].includes(key)) {
|
|
278
|
+
if (!finite(n) || n <= 0 || !Number.isInteger(n)) issue()
|
|
279
|
+
} else if (key === 'mimeTypes') {
|
|
280
|
+
if (
|
|
281
|
+
!Array.isArray(n) ||
|
|
282
|
+
!n.length ||
|
|
283
|
+
!n.every((s) => typeof s === 'string' && /^image\/[a-z0-9.+-]+$/i.test(s))
|
|
284
|
+
)
|
|
285
|
+
issue()
|
|
286
|
+
} else if (key === 'cropAspectRatios') {
|
|
287
|
+
if (!Array.isArray(n) || !n.length || !n.every((r) => finite(r) && r > 0)) issue()
|
|
288
|
+
} else issue()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
constraints(value.constraints, at(path, 'constraints'))
|
|
292
|
+
if (value.variants !== undefined) {
|
|
293
|
+
if (!record(value.variants))
|
|
294
|
+
issues.push({ path: at(path, 'variants'), message: 'Variants must be a named object.' })
|
|
295
|
+
else
|
|
296
|
+
for (const [key, v] of Object.entries(value.variants)) {
|
|
297
|
+
const p = at(at(path, 'variants'), key)
|
|
298
|
+
if (
|
|
299
|
+
!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key) ||
|
|
300
|
+
['constructor', 'prototype', '__proto__'].includes(key) ||
|
|
301
|
+
!record(v) ||
|
|
302
|
+
typeof v.media !== 'string' ||
|
|
303
|
+
!v.media.trim()
|
|
304
|
+
) {
|
|
305
|
+
issues.push({ path: p, message: 'Variant needs a safe name and media condition.' })
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
for (const prop of Object.keys(v))
|
|
309
|
+
if (!['media', 'constraints'].includes(prop))
|
|
310
|
+
issues.push({ path: at(p, prop), message: 'Unknown variant declaration property.' })
|
|
311
|
+
constraints(v.constraints, at(p, 'constraints'))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return issues
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export type ImageAssetMetadata = {
|
|
318
|
+
mimeType?: string | null
|
|
319
|
+
filesize?: number | null
|
|
320
|
+
byteSize?: number | null
|
|
321
|
+
width?: number | null
|
|
322
|
+
height?: number | null
|
|
323
|
+
}
|
|
324
|
+
export function validateImageConstraints(
|
|
325
|
+
asset: ImageAssetMetadata,
|
|
326
|
+
crop: ImageCrop | null,
|
|
327
|
+
constraints: ImageConstraints = {},
|
|
328
|
+
path = '',
|
|
329
|
+
): ImageValidationIssue[] {
|
|
330
|
+
const issues: ImageValidationIssue[] = []
|
|
331
|
+
const issue = (key: string, message: string) =>
|
|
332
|
+
issues.push({ path: at(at(path, 'constraints'), key), message })
|
|
333
|
+
if (constraints.mimeTypes && !constraints.mimeTypes.includes(asset.mimeType ?? ''))
|
|
334
|
+
issue('mimeTypes', 'Source image format is not allowed.')
|
|
335
|
+
const bytes = asset.byteSize ?? asset.filesize
|
|
336
|
+
if (constraints.maxBytes && (!finite(bytes) || bytes > constraints.maxBytes))
|
|
337
|
+
issue('maxBytes', 'Original image exceeds the byte limit or its size is unknown.')
|
|
338
|
+
if (crop !== null && !cropValid(crop))
|
|
339
|
+
return [...issues, { path: at(path, 'crop'), message: 'Invalid crop.' }]
|
|
340
|
+
const width = finite(asset.width) && asset.width > 0 ? asset.width : null
|
|
341
|
+
const height = finite(asset.height) && asset.height > 0 ? asset.height : null
|
|
342
|
+
const w =
|
|
343
|
+
width === null
|
|
344
|
+
? null
|
|
345
|
+
: crop
|
|
346
|
+
? Math.ceil(((crop.x + crop.width) * width) / 100) - Math.floor((crop.x * width) / 100)
|
|
347
|
+
: width
|
|
348
|
+
const h =
|
|
349
|
+
height === null
|
|
350
|
+
? null
|
|
351
|
+
: crop
|
|
352
|
+
? Math.ceil(((crop.y + crop.height) * height) / 100) - Math.floor((crop.y * height) / 100)
|
|
353
|
+
: height
|
|
354
|
+
if (constraints.minWidth && (w === null || w < constraints.minWidth))
|
|
355
|
+
issue('minWidth', 'Cropped image has too few usable horizontal pixels.')
|
|
356
|
+
if (constraints.minHeight && (h === null || h < constraints.minHeight))
|
|
357
|
+
issue('minHeight', 'Cropped image has too few usable vertical pixels.')
|
|
358
|
+
if (
|
|
359
|
+
constraints.cropAspectRatios &&
|
|
360
|
+
(w === null ||
|
|
361
|
+
h === null ||
|
|
362
|
+
!constraints.cropAspectRatios.some((r) => Math.abs(w - h * r) <= Math.max(1, r)))
|
|
363
|
+
)
|
|
364
|
+
issue('cropAspectRatios', 'Image aspect ratio does not match an allowed ratio.')
|
|
365
|
+
return issues
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function imagePosition(
|
|
369
|
+
image: { focalPoint: ImageFocalPoint | null; crop: ImageCrop | null } | null | undefined,
|
|
370
|
+
fallback = '50% 50%',
|
|
371
|
+
): string {
|
|
372
|
+
const point = image?.focalPoint
|
|
373
|
+
if (!point || !finite(point.x) || !finite(point.y)) return fallback
|
|
374
|
+
const clamp = (v: number) => Math.min(100, Math.max(0, v))
|
|
375
|
+
const crop = image?.crop
|
|
376
|
+
if (crop)
|
|
377
|
+
return cropValid(crop)
|
|
378
|
+
? `${clamp(((point.x - crop.x) / crop.width) * 100)}% ${clamp(((point.y - crop.y) / crop.height) * 100)}%`
|
|
379
|
+
: fallback
|
|
380
|
+
return `${clamp(point.x)}% ${clamp(point.y)}%`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export type ImageSlot = {
|
|
384
|
+
field: EditorField
|
|
385
|
+
path: string
|
|
386
|
+
get: () => unknown
|
|
387
|
+
set: (value: unknown) => void
|
|
388
|
+
}
|
|
389
|
+
/** Semantic boundaries for hydration and atomic writes, with the same block resolution as uploadSlots. */
|
|
390
|
+
export function imageSlots(
|
|
391
|
+
content: unknown,
|
|
392
|
+
blocks: EditorBlock[],
|
|
393
|
+
allBlocks: EditorBlock[] = blocks,
|
|
394
|
+
): ImageSlot[] {
|
|
395
|
+
const slots: ImageSlot[] = []
|
|
396
|
+
function fields(schema: EditorField[], container: unknown, path: string) {
|
|
397
|
+
if (!record(container)) return
|
|
398
|
+
for (const field of schema) {
|
|
399
|
+
const p = at(path, field.name),
|
|
400
|
+
value = container[field.name]
|
|
401
|
+
if (field.type === 'group' && field.element === 'image') {
|
|
402
|
+
slots.push({
|
|
403
|
+
field,
|
|
404
|
+
path: p,
|
|
405
|
+
get: () => container[field.name],
|
|
406
|
+
set: (v) => {
|
|
407
|
+
container[field.name] = v
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
if (field.type === 'group') fields(field.fields ?? [], value, p)
|
|
413
|
+
if (field.type === 'array' && Array.isArray(value))
|
|
414
|
+
value.forEach((v, i) => fields(field.fields ?? [], v, at(p, String(i))))
|
|
415
|
+
if (field.type === 'blocks')
|
|
416
|
+
rows(
|
|
417
|
+
value,
|
|
418
|
+
field.blocks ??
|
|
419
|
+
(field.childBlocks ?? []).flatMap((slug) => allBlocks.filter((b) => b.slug === slug)),
|
|
420
|
+
p,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function rows(value: unknown, schema: EditorBlock[], path: string) {
|
|
425
|
+
if (!Array.isArray(value)) return
|
|
426
|
+
value.forEach((v, i) => {
|
|
427
|
+
const block = record(v) ? schema.find((b) => b.slug === v.blockType) : undefined
|
|
428
|
+
if (block) fields(block.fields, v, at(path, String(i)))
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
rows(content, blocks, '')
|
|
432
|
+
return slots
|
|
433
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unitninety/site-kit — the client-site half of the platform contract.
|
|
3
|
+
*
|
|
4
|
+
* A `site-*` repo installs this instead of copy-pasting ~1,000 lines of plumbing. The
|
|
5
|
+
* split of entry points is deliberate:
|
|
6
|
+
*
|
|
7
|
+
* @unitninety/site-kit types, schema authoring, the fetcher, route factories
|
|
8
|
+
* @unitninety/site-kit/bridge the editing bridge — a client component
|
|
9
|
+
* @unitninety/site-kit/draft the "you are viewing a draft" bar — a client component
|
|
10
|
+
* @unitninety/site-kit/shape render helpers, safe in client components
|
|
11
|
+
*
|
|
12
|
+
* `bridge` and `shape` are separate because the fetcher holds the API key and must stay
|
|
13
|
+
* server-side. Importing them from one barrel is how a secret ends up in a browser
|
|
14
|
+
* bundle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
BRIDGE_VERSION,
|
|
19
|
+
type CollectionRoutes,
|
|
20
|
+
type EditorBlock,
|
|
21
|
+
type EditorField,
|
|
22
|
+
type EditorFieldType,
|
|
23
|
+
type ManifestCollection,
|
|
24
|
+
type PageDeclaration,
|
|
25
|
+
type PairedCredentials,
|
|
26
|
+
type SiteManifest,
|
|
27
|
+
} from './contract'
|
|
28
|
+
|
|
29
|
+
export { type NavEntry, pagesFromNav } from './nav'
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
array,
|
|
33
|
+
/*
|
|
34
|
+
* Exported under a different name for the reason `text` is: a site's own block library is
|
|
35
|
+
* conventionally `export const blocks = defineBlocks({…})`, and every site that declared a
|
|
36
|
+
* zone would otherwise shadow this import in the one file it needs it.
|
|
37
|
+
*/
|
|
38
|
+
blocks as blocksField,
|
|
39
|
+
type BlockSpec,
|
|
40
|
+
checkbox,
|
|
41
|
+
code,
|
|
42
|
+
type CollectionSpec,
|
|
43
|
+
createManifest,
|
|
44
|
+
date,
|
|
45
|
+
defineBlocks,
|
|
46
|
+
defineCollections,
|
|
47
|
+
email,
|
|
48
|
+
group,
|
|
49
|
+
image,
|
|
50
|
+
video,
|
|
51
|
+
file,
|
|
52
|
+
type VideoOptions,
|
|
53
|
+
type FileOptions,
|
|
54
|
+
type ImageOptions,
|
|
55
|
+
layout,
|
|
56
|
+
type LayoutEntry,
|
|
57
|
+
type LayoutMeta,
|
|
58
|
+
link,
|
|
59
|
+
number,
|
|
60
|
+
reference,
|
|
61
|
+
relationship,
|
|
62
|
+
richText,
|
|
63
|
+
select,
|
|
64
|
+
sentence,
|
|
65
|
+
type SentenceValue,
|
|
66
|
+
SENTENCE_LINK,
|
|
67
|
+
singles,
|
|
68
|
+
type SingleSpec,
|
|
69
|
+
SLOT,
|
|
70
|
+
SLOT_BLOCK,
|
|
71
|
+
text as textField,
|
|
72
|
+
textarea,
|
|
73
|
+
upload,
|
|
74
|
+
} from './defineBlocks'
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
block,
|
|
78
|
+
type BlockEntry,
|
|
79
|
+
collection,
|
|
80
|
+
type CollectionEntry,
|
|
81
|
+
type CollectionOptions,
|
|
82
|
+
defineSite,
|
|
83
|
+
form,
|
|
84
|
+
type FormDeclaration,
|
|
85
|
+
type FormEntry,
|
|
86
|
+
type FormFieldDeclaration,
|
|
87
|
+
type LayoutDeclaration,
|
|
88
|
+
manifestFrom,
|
|
89
|
+
type SingleDeclaration,
|
|
90
|
+
type Site,
|
|
91
|
+
type SiteSpec,
|
|
92
|
+
} from './defineSite'
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
createContentClient,
|
|
96
|
+
type CollectionRow,
|
|
97
|
+
type ContentResult,
|
|
98
|
+
type OpeningHoursDay,
|
|
99
|
+
type OpeningHoursRow,
|
|
100
|
+
type PageSeo,
|
|
101
|
+
type Section,
|
|
102
|
+
type SeoMedia,
|
|
103
|
+
type SeoOrganisation,
|
|
104
|
+
type SiteSeo,
|
|
105
|
+
type SitemapEntry,
|
|
106
|
+
} from './content'
|
|
107
|
+
|
|
108
|
+
export {
|
|
109
|
+
createExitPreviewRoute,
|
|
110
|
+
createManifestRoute,
|
|
111
|
+
createPreviewRoute,
|
|
112
|
+
createRevalidateRoute,
|
|
113
|
+
} from './routes'
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
applyTitleTemplate,
|
|
117
|
+
metadataFor,
|
|
118
|
+
organisationJsonLd,
|
|
119
|
+
robotsFrom,
|
|
120
|
+
sitemapFrom,
|
|
121
|
+
type SeoMetadata,
|
|
122
|
+
} from './seo'
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
asMediaId,
|
|
126
|
+
collectMediaIds,
|
|
127
|
+
normaliseUploadsIn,
|
|
128
|
+
resolveUploadsIn,
|
|
129
|
+
uploadSlots,
|
|
130
|
+
type UploadSlot,
|
|
131
|
+
} from './uploads'
|
|
132
|
+
|
|
133
|
+
export {
|
|
134
|
+
doc,
|
|
135
|
+
FORMAT,
|
|
136
|
+
heading,
|
|
137
|
+
lineBreak,
|
|
138
|
+
link as richLink,
|
|
139
|
+
list,
|
|
140
|
+
listItem,
|
|
141
|
+
paragraph,
|
|
142
|
+
type RichBlock,
|
|
143
|
+
type RichInline,
|
|
144
|
+
type RichTextNode,
|
|
145
|
+
type RichTextValue,
|
|
146
|
+
text as richTextRun,
|
|
147
|
+
} from './lexical'
|
|
148
|
+
|
|
149
|
+
export { RichText } from './richText'
|
|
150
|
+
|
|
151
|
+
export { type DomLike, toRichText } from './lexicalDom'
|
|
152
|
+
|
|
153
|
+
export {
|
|
154
|
+
applyTranslationPatch,
|
|
155
|
+
ISO_COUNTRY_CODES,
|
|
156
|
+
languageFieldCandidate,
|
|
157
|
+
canonicalLocale,
|
|
158
|
+
deactivateLanguage,
|
|
159
|
+
duplicateWithFreshIdentities,
|
|
160
|
+
ensureStableIdentities,
|
|
161
|
+
evaluateLanguageHealth,
|
|
162
|
+
fallbackLocales,
|
|
163
|
+
isSemanticallyEmpty,
|
|
164
|
+
localizableFields,
|
|
165
|
+
localePath,
|
|
166
|
+
localePrefix,
|
|
167
|
+
reconcileStableIdentities,
|
|
168
|
+
RESERVED_LOCALE_PREFIXES,
|
|
169
|
+
resolveLocaleRequest,
|
|
170
|
+
resolveField,
|
|
171
|
+
sourceFingerprint,
|
|
172
|
+
stableAddressFor,
|
|
173
|
+
STABLE_ID_KEY,
|
|
174
|
+
selectLocale,
|
|
175
|
+
validateLanguageSettings,
|
|
176
|
+
type IdentityFactory,
|
|
177
|
+
type IdentityResult,
|
|
178
|
+
type LanguageDefinition,
|
|
179
|
+
type LanguageFieldHealth,
|
|
180
|
+
type LanguageHealth,
|
|
181
|
+
type LanguageLifecycleResult,
|
|
182
|
+
type LanguageSettings,
|
|
183
|
+
type LanguageSettingsValidation,
|
|
184
|
+
type LocalizableField,
|
|
185
|
+
type LocaleRequestResult,
|
|
186
|
+
type TranslationDocument,
|
|
187
|
+
type TranslationField,
|
|
188
|
+
type TranslationOwner,
|
|
189
|
+
type TranslationPatch,
|
|
190
|
+
type TranslationPatchResult,
|
|
191
|
+
} from './languages'
|
|
192
|
+
|
|
193
|
+
export {
|
|
194
|
+
createLanguageRuntime,
|
|
195
|
+
overlayLanguageOwner,
|
|
196
|
+
languagePreferenceCookie,
|
|
197
|
+
LANGUAGE_PREFERENCE_COOKIE,
|
|
198
|
+
LANGUAGE_VERSION,
|
|
199
|
+
type LanguageSnapshot,
|
|
200
|
+
type LanguageRuntimeOwner,
|
|
201
|
+
} from './languages/runtime'
|
|
202
|
+
export { languageLinks, languageHref, languageDirection } from './languages/links'
|
|
203
|
+
export { languageMetadataFor, languageSitemapFrom } from './languages/seo'
|
|
204
|
+
|
|
205
|
+
export { acceptsLanguageFrame, type LanguageFrameContext } from './preview'
|
|
206
|
+
|
|
207
|
+
export * from './image'
|
|
208
|
+
|
|
209
|
+
export * from './media'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React, { useEffect, useState } from 'react'
|
|
3
|
+
import { languageLinks } from './languages/links'
|
|
4
|
+
import { languagePreferenceCookie } from './languages/runtime'
|
|
5
|
+
import type { LanguageDefinition } from './languages/types'
|
|
6
|
+
// Called only by deliberate click/auxclick handlers; normal native navigation is preserved.
|
|
7
|
+
const remember = (language: string) => {
|
|
8
|
+
document.cookie = languagePreferenceCookie(language, window.location.protocol === 'https:')
|
|
9
|
+
}
|
|
10
|
+
/** Unstyled links for the site's shared header or footer. */
|
|
11
|
+
export function LanguageSwitcher({
|
|
12
|
+
languages,
|
|
13
|
+
locale,
|
|
14
|
+
logicalPath,
|
|
15
|
+
label = 'Language',
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
languages: readonly LanguageDefinition[]
|
|
19
|
+
locale: string
|
|
20
|
+
logicalPath: string
|
|
21
|
+
label?: string
|
|
22
|
+
className?: string
|
|
23
|
+
}) {
|
|
24
|
+
const [suffix, setSuffix] = useState({ search: '', hash: '' })
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const update = () => setSuffix({ search: window.location.search, hash: window.location.hash })
|
|
27
|
+
update()
|
|
28
|
+
window.addEventListener('popstate', update)
|
|
29
|
+
window.addEventListener('hashchange', update)
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener('popstate', update)
|
|
32
|
+
window.removeEventListener('hashchange', update)
|
|
33
|
+
}
|
|
34
|
+
}, [logicalPath])
|
|
35
|
+
const target = new URL(logicalPath, 'https://language.invalid')
|
|
36
|
+
if (!target.search) target.search = suffix.search
|
|
37
|
+
if (!target.hash) target.hash = suffix.hash
|
|
38
|
+
const currentPath = target.pathname + target.search + target.hash
|
|
39
|
+
return (
|
|
40
|
+
<nav aria-label={label} className={className}>
|
|
41
|
+
{languageLinks({ languages, locale, logicalPath: currentPath }).map((link) => (
|
|
42
|
+
<a
|
|
43
|
+
key={link.locale}
|
|
44
|
+
href={link.href}
|
|
45
|
+
hrefLang={link.locale}
|
|
46
|
+
lang={link.locale}
|
|
47
|
+
aria-current={link.current ? 'page' : undefined}
|
|
48
|
+
onClick={() => remember(link.locale)}
|
|
49
|
+
onAuxClick={(event) => {
|
|
50
|
+
if (event.button === 1) remember(link.locale)
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{link.label}
|
|
54
|
+
</a>
|
|
55
|
+
))}
|
|
56
|
+
</nav>
|
|
57
|
+
)
|
|
58
|
+
}
|