@unitninety/site-kit 0.0.0-stage → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. 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
+ }