@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.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,30 @@
1
+ import NextImage, { getImageProps } from 'next/image'
2
+ import { createElement } from 'react'
3
+ import type { ImageRead } from '../elements'
4
+ import { draftAttrs } from './draft'
5
+ import { imageProps, isGrantPath, pictureSources, type ImageProps } from './imageProps'
6
+ /**
7
+ * One declared variant's `<source>`, built with `next/image`'s own `getImageProps` so its `srcSet`
8
+ * carries the candidates a plain `<Image>` would. `getImageProps` refuses `fill` together with a
9
+ * `width` or `height` — it throws outside production, so under `next dev`, which the Canvas preview
10
+ * runs — so a fill image's sources take no size of their own.
11
+ */
12
+ export const sourceProps = (props: ImageProps, s: ReturnType<typeof pictureSources>[number]) => {
13
+ const size = props.fill ? {} : { width: s.width ?? props.width, height: s.height ?? props.height }
14
+ // The variant's own URL decides, in both directions: a grant path is never optimised, whatever the main image is.
15
+ const unoptimized = isGrantPath(s.srcSet)
16
+ const { props: p } = getImageProps({ ...props, src: s.srcSet, ...size, unoptimized, alt: props.alt })
17
+ // An unoptimised image has no generated srcSet, so the raw path is the candidate.
18
+ return { key: s.name, media: s.media, srcSet: p.srcSet ?? p.src, sizes: p.sizes }
19
+ }
20
+ export function Image({ value, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
21
+ if (!value) return null
22
+ const attrs = draftAttrs(value)
23
+ const props = imageProps(value, { sizes, fill, priority })
24
+ if (!props) return value.$path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image' }, 'Image missing') : null
25
+ const sources = pictureSources(value, variants)
26
+ const img = sources.length
27
+ ? createElement('picture', null, ...sources.map((s) => createElement('source', sourceProps(props, s))), createElement(NextImage, { ...props, className, ...attrs }))
28
+ : createElement(NextImage, { ...props, className, ...attrs })
29
+ return value.caption ? createElement('figure', { className: figureClassName }, img, createElement('figcaption', null, value.caption)) : img
30
+ }
@@ -0,0 +1,24 @@
1
+ import NextLink from 'next/link'
2
+ import { Children, createElement, type ReactNode } from 'react'
3
+ import type { LinkRead } from '../elements'
4
+ import { draftAttrs } from './draft'
5
+ export const isInternal = (href: string) => href.startsWith('/') && !href.startsWith('//')
6
+ export function Link({ value, children, ...rest }: { value: LinkRead | null | undefined; children?: ReactNode } & Record<string, unknown>) {
7
+ if (!value) return null
8
+ const attrs = { ...rest, ...draftAttrs(value) }
9
+ // A label has its own editable surface: replacing the anchor's text would remove icons and
10
+ // other children. Only wrap the actual label, never arbitrary custom or icon-only content.
11
+ // Published markup is unchanged. Both markers use the link path, so selection stays on the link.
12
+ const content = children ?? value.label
13
+ const label = value.$path ? Children.map(content, child =>
14
+ typeof child === 'string' && child === value.label
15
+ ? createElement('span', draftAttrs(value, 'link-label'), child)
16
+ : child,
17
+ ) : content
18
+ if (!value.href) return createElement('span', attrs, label)
19
+ // A new-tab link gets target and rel; a caller's own target or rel wins over them (§5: the
20
+ // element's props pass through). The href is always the value's.
21
+ const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
22
+ for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
23
+ return isInternal(value.href) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
24
+ }
@@ -0,0 +1,9 @@
1
+ import { createElement, type ElementType } from 'react'
2
+ import type { NumberRead } from '../elements'
3
+ import { draftAttrs } from './draft'
4
+ export function NumberText<T extends ElementType = 'span'>({ value, as, locale, format, ...rest }: { value: NumberRead | null | undefined; as?: T; locale?: string; format?: Intl.NumberFormatOptions } & Record<string, unknown>) {
5
+ if (!value) return null
6
+ const text = new Intl.NumberFormat(locale, format).format(value.value)
7
+ const attrs = draftAttrs(value)
8
+ return as ? createElement(as, { ...rest, ...attrs }, text) : value.$path ? createElement('span', attrs, text) : text
9
+ }
@@ -0,0 +1,47 @@
1
+ import { createElement, Fragment, type ElementType, type ReactNode } from 'react'
2
+ import type { RichTextRead } from '../elements'
3
+ import { draftAttrs } from './draft'
4
+ type Node = { type: string; text?: string; format?: number; tag?: string; children?: Node[]; fields?: { href?: string | null; newTab?: boolean; to?: unknown }; listType?: string; key?: string }
5
+ const MARKS: Array<[number, string]> = [[1, 'strong'], [2, 'em'], [8, 'u'], [4, 's'], [16, 'code']]
6
+ const inline = (n: Node, key: number, draft: boolean): ReactNode => {
7
+ if (n.type === 'linebreak') return createElement('br', { key })
8
+ if (n.type === 'link') {
9
+ const anchor = Boolean(n.fields?.href)
10
+ // In draft, a link carries the field's own `{ to, newTab }` — v1's `LinkTo` — so
11
+ // `lexicalDom.ts` reads an untouched link back exactly rather than reclassifying it from
12
+ // `href` alone. The dead-link `<span>` (a deleted page, an unrouted entry) carries it too:
13
+ // without it the reader sees plain text, and the next keystroke in the field drops the link.
14
+ const linkAttrs = draft ? { 'data-u90-link': JSON.stringify({ to: n.fields?.to, newTab: Boolean(n.fields?.newTab) }) } : {}
15
+ return createElement(
16
+ anchor ? 'a' : 'span',
17
+ { key, ...linkAttrs, ...(anchor ? { href: n.fields!.href, ...(n.fields!.newTab ? { target: '_blank', rel: 'noopener' } : {}) } : {}) },
18
+ ...(n.children ?? []).map((c, i) => inline(c, i, draft)),
19
+ )
20
+ }
21
+ // In draft a Single is a chip, inside its marks, so `lexicalDom.ts` reads the marks as the token's format (R38).
22
+ let out: ReactNode = draft && n.type === 'single' && typeof n.key === 'string'
23
+ ? createElement('span', { 'data-single-key': n.key, contentEditable: false, suppressContentEditableWarning: true }, n.text ?? '')
24
+ : n.text ?? ''
25
+ for (const [bit, tag] of MARKS) if ((n.format ?? 0) & bit) out = createElement(tag, null, out)
26
+ return createElement(Fragment, { key }, out)
27
+ }
28
+ /**
29
+ * A stored `tag` is data, not markup: only a real heading or list tag becomes an element. Anything
30
+ * else would throw at render (`img` is void, `h1 onclick=x` is no tag at all — a 500) or, as
31
+ * `plaintext`, swallow the rest of the document. A heading falls back to `h2`, a list to its
32
+ * `listType`.
33
+ */
34
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
35
+ const LISTS = new Set(['ul', 'ol'])
36
+ const blockOf = (n: Node, key: number, draft: boolean): ReactNode => {
37
+ const kids = (n.children ?? []).map((c, i) => inline(c, i, draft))
38
+ if (n.type === 'heading') return createElement(n.tag && HEADINGS.has(n.tag) ? n.tag : 'h2', { key }, ...kids)
39
+ if (n.type === 'quote') return createElement('blockquote', { key }, ...kids)
40
+ if (n.type === 'list') return createElement(n.tag && LISTS.has(n.tag) ? n.tag : n.listType === 'number' ? 'ol' : 'ul', { key }, ...(n.children ?? []).map((li, i) => createElement('li', { key: i }, ...(li.children ?? []).map((c, j) => inline(c, j, draft)))))
41
+ return createElement('p', { key }, ...kids)
42
+ }
43
+ export const richTextNodes = (root: unknown, draft = false): ReactNode => ((root as { children?: Node[] })?.children ?? []).map((b, i) => blockOf(b, i, draft))
44
+ export function RichText<T extends ElementType = 'div'>({ value, as, ...rest }: { value: RichTextRead | null | undefined; as?: T } & Record<string, unknown>) {
45
+ if (!value) return null
46
+ return createElement(as ?? 'div', { ...rest, ...draftAttrs(value, 'rich') }, richTextNodes((value as { root: unknown }).root, Boolean((value as { $path?: string }).$path)))
47
+ }
@@ -0,0 +1,15 @@
1
+ import { createElement, type ComponentProps, type ElementType, type ReactNode } from 'react'
2
+ import type { TextRead } from '../elements'
3
+ import { draftAttrs } from './draft'
4
+ type Props<T extends ElementType> = { value: TextRead | null | undefined; as?: T } & Omit<ComponentProps<T>, 'value' | 'as' | 'children'>
5
+ /** In draft, each resolved Single is a chip the caret cannot enter, which the bridge saves back as its `{key}` (R38). */
6
+ const chips = (value: TextRead): ReactNode =>
7
+ value.$path && value.segments
8
+ ? value.segments.map((s, i) => (typeof s === 'string' ? s : createElement('span', { key: i, 'data-single-key': s.key, contentEditable: false, suppressContentEditableWarning: true }, s.text)))
9
+ : value.text
10
+ export function Text<T extends ElementType = 'span'>({ value, as, ...rest }: Props<T>) {
11
+ if (!value) return null
12
+ const attrs = draftAttrs(value, 'text')
13
+ if (!as) return value.$path ? createElement('span', attrs, chips(value)) : value.text
14
+ return createElement(as, { ...rest, ...attrs }, chips(value))
15
+ }
@@ -0,0 +1,12 @@
1
+ // Video.tsx (server-safe)
2
+ import { createElement } from 'react'
3
+ import type { VideoRead } from '../elements'
4
+ import { draftAttrs } from './draft'
5
+ import { videoProps } from './videoProps'
6
+ import { VideoPlayback } from './VideoPlayback'
7
+ export function Video({ value, ...rest }: { value: VideoRead | null | undefined } & Record<string, unknown>) {
8
+ if (!value || !value.asset) return null
9
+ const bounded = value.startTime > 0 || value.endTime !== null
10
+ const el = createElement('video', { ...rest, ...draftAttrs(value), ...videoProps(value), ...(bounded ? { 'data-u90-start': value.startTime, 'data-u90-end': value.endTime ?? '' } : {}) })
11
+ return bounded ? createElement(VideoPlayback, null, el) : el
12
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+ import { useEffect, useRef, type ReactNode } from 'react'
3
+ /** Seeks to data-u90-start on load and stops (or loops back) at data-u90-end. Renders only its child. */
4
+ export function VideoPlayback({ children }: { children: ReactNode }) {
5
+ const ref = useRef<HTMLSpanElement>(null)
6
+ useEffect(() => {
7
+ const v = ref.current?.querySelector('video'); if (!v) return
8
+ const start = Number(v.dataset.u90Start ?? 0), end = v.dataset.u90End ? Number(v.dataset.u90End) : null
9
+ const onMeta = () => { v.currentTime = start }
10
+ const onTime = () => { if (end !== null && v.currentTime >= end) { if (v.loop) { v.currentTime = start; void v.play().catch(() => {}) } else v.pause() } }
11
+ const onEnded = () => { if (end === null && v.loop) v.currentTime = start }
12
+ v.addEventListener('loadedmetadata', onMeta); v.addEventListener('timeupdate', onTime); v.addEventListener('ended', onEnded)
13
+ // The server-rendered <video> starts loading before this effect runs on hydration, so its
14
+ // metadata may already be in (HAVE_METADATA) — `loadedmetadata` has fired and will not again.
15
+ if (v.readyState >= 1) onMeta()
16
+ return () => { v.removeEventListener('loadedmetadata', onMeta); v.removeEventListener('timeupdate', onTime); v.removeEventListener('ended', onEnded) }
17
+ }, [])
18
+ return <span ref={ref} style={{ display: 'contents' }}>{children}</span>
19
+ }
@@ -0,0 +1,9 @@
1
+ /** `Record<string, unknown>` (rather than a bare `{ $path?: string }`) so TS's weak-type check does not reject a
2
+ * literal like `{ text: 'x' }` at call sites — every real read value has more fields than just `$path`. */
3
+ export type Marked = ({ $path?: string; $fallback?: boolean } & Record<string, unknown>) | null | undefined
4
+ export type DraftAttrs = { 'data-u90-field'?: string; 'data-u90-edit'?: 'text' | 'rich' | 'link-label'; 'data-u90-fallback'?: '' }
5
+ /** The one attribute (two for editable text) a draft render adds to the element it renders, and a third on a leaf that reads in a fallback language. Nothing in production. */
6
+ export const draftAttrs = (value: Marked, edit?: 'text' | 'rich' | 'link-label'): DraftAttrs =>
7
+ value && value.$path ? { 'data-u90-field': value.$path, ...(edit ? { 'data-u90-edit': edit } : {}), ...(value.$fallback ? { 'data-u90-fallback': '' as const } : {}) } : {}
8
+ export const editable = (value: Marked): DraftAttrs => draftAttrs(value, value && 'text' in value ? 'text' : undefined)
9
+ export const editableRich = (value: Marked): DraftAttrs => draftAttrs(value, 'rich')
@@ -0,0 +1,26 @@
1
+ import type { ImageRead, ImageReadVariant } from '../elements'
2
+ type Geometry = { focalPoint: { x: number; y: number } | null; crop: { x: number; y: number; width: number; height: number } | null }
3
+ const clamp = (v: number) => Math.min(100, Math.max(0, v))
4
+ /** Ported from legacy image.ts `imagePosition`: the focal point as CSS object-position, relative to the crop when there is one. */
5
+ export const objectPosition = (g: Geometry, fallback = '50% 50%'): string => {
6
+ const p = g.focalPoint
7
+ if (!p) return fallback
8
+ if (g.crop) return `${clamp(((p.x - g.crop.x) / g.crop.width) * 100)}% ${clamp(((p.y - g.crop.y) / g.crop.height) * 100)}%`
9
+ return `${clamp(p.x)}% ${clamp(p.y)}%`
10
+ }
11
+ export type ImageProps = { src: string; alt: string; width?: number; height?: number; fill?: boolean; sizes?: string; priority?: boolean; unoptimized?: boolean; style: { objectPosition: string } }
12
+ const source = (v: ImageReadVariant) => (v.status === 'ready' && v.rendition ? v.rendition : v.asset ? { url: v.asset.url, width: v.asset.width, height: v.asset.height } : null)
13
+ /** A draft grant path (a private item on a draft page): short-lived, so the site's image cache must never keep it. */
14
+ const GRANT = /^\/api\/u90\/media\/.+[/-]g\d{10}-[0-9a-f]{16}(?:\.webp)?$/
15
+ export const isGrantPath = (url: string): boolean => GRANT.test(url)
16
+ export function imageProps(value: ImageRead, opts: { sizes?: string; fill?: boolean; priority?: boolean }): ImageProps | null {
17
+ if (!value) return null
18
+ const s = source(value)
19
+ if (!s) return null
20
+ const base = { src: s.url, alt: value.decorative ? '' : value.alt, ...(opts.sizes ? { sizes: opts.sizes } : {}), ...(opts.priority ? { priority: true } : {}), ...(isGrantPath(s.url) ? { unoptimized: true } : {}), style: { objectPosition: objectPosition(value) } }
21
+ if (opts.fill) return { ...base, fill: true }
22
+ if (s.width && s.height) return { ...base, width: s.width, height: s.height }
23
+ throw new Error(`u90 <Image>: "${value.alt || s.url}" has no intrinsic size; pass fill inside a sized, positioned container.`)
24
+ }
25
+ export const pictureSources = (value: NonNullable<ImageRead>, declared: Record<string, { media: string }> = {}) =>
26
+ Object.entries(value.variants).flatMap(([name, v]) => { const s = source(v); const media = declared[name]?.media; return s && media ? [{ name, media, srcSet: s.url, width: s.width, height: s.height }] : [] })
@@ -0,0 +1,13 @@
1
+ export { Entries } from './Entries'
2
+ export { Text } from './Text'
3
+ export { RichText, richTextNodes } from './RichText'
4
+ export { Link, isInternal } from './Link'
5
+ export { File } from './File'
6
+ export { DateText } from './DateText'
7
+ export { NumberText } from './NumberText'
8
+ export { Image } from './Image'
9
+ export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
10
+ export { Video } from './Video'
11
+ export { videoProps, type VideoHTMLProps } from './videoProps'
12
+ export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
13
+ export { LanguageSwitcher, useLanguage, rememberLanguage } from '../switcher/LanguageSwitcher'
@@ -0,0 +1,6 @@
1
+ // videoProps.ts — port of legacy media.ts videoProps over the read shape
2
+ import type { VideoRead } from '../elements'
3
+ export type VideoHTMLProps = { src?: string; poster?: string; autoPlay: boolean; muted: boolean; loop: boolean; controls: boolean; playsInline: boolean; preload: 'none' | 'metadata' | 'auto' }
4
+ export function videoProps(v: NonNullable<VideoRead>): VideoHTMLProps {
5
+ return { src: v.asset?.url, poster: v.poster?.status === 'ready' ? v.poster.rendition?.url : undefined, autoPlay: v.autoplay, muted: v.muted, loop: v.loop, controls: v.controls, playsInline: v.playsInline, preload: v.preload }
6
+ }
@@ -0,0 +1,33 @@
1
+ import { defineContainer, isLeaf, type GroupDecl, type LeafIssue } from '../define'
2
+ import { orderedEntries } from '../order'
3
+
4
+ export const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
5
+
6
+ export const group = defineContainer<GroupDecl>({
7
+ id: 'u90/group',
8
+ children: (decl, value) => {
9
+ const v = record(value) ? value : {}
10
+ return orderedEntries(decl.fields, decl.order).map(([name, field]) => {
11
+ const renamed = isLeaf(field) ? field.renamedFrom : undefined
12
+ const current = v[name] !== undefined ? v[name] : renamed !== undefined ? v[renamed] : undefined
13
+ return {
14
+ key: name,
15
+ decl: field,
16
+ value: current,
17
+ set: (next: unknown) => {
18
+ v[name] = next
19
+ if (renamed !== undefined && renamed in v) delete v[renamed]
20
+ },
21
+ }
22
+ })
23
+ },
24
+ enter: (decl, value) => {
25
+ if (value === undefined || value === null) return []
26
+ if (!record(value)) return [{ path: [], code: 'invalid_type', message: 'Expected an object.' }]
27
+ const known = new Set(['_id', '_block', ...Object.keys(decl.fields), ...Object.values(decl.fields).flatMap((f) => (isLeaf(f) && f.renamedFrom ? [f.renamedFrom] : []))])
28
+ const issues: LeafIssue[] = []
29
+ for (const key of Object.keys(value))
30
+ if (!known.has(key)) issues.push({ path: [key], code: 'unknown-field', message: `No field "${key}" is declared here.`, severity: 'warning' })
31
+ return issues
32
+ },
33
+ })
@@ -0,0 +1,31 @@
1
+ import { defineContainer, isLeaf, type ListDecl, type LeafIssue } from '../define'
2
+ import { ID_RE } from '../path'
3
+ import { record } from './group'
4
+
5
+ export const list = defineContainer<ListDecl>({
6
+ id: 'u90/list',
7
+ children: (decl, value) => {
8
+ if (!Array.isArray(value)) return []
9
+ const leaf = isLeaf(decl.item)
10
+ return value.map((item, i) => ({
11
+ key: record(item) && typeof item._id === 'string' ? item._id : `#${i}`,
12
+ decl: decl.item,
13
+ value: leaf ? (record(item) ? item._value : undefined) : item,
14
+ set: (next: unknown) => {
15
+ if (!leaf) value[i] = next
16
+ else if (record(item)) item._value = next
17
+ },
18
+ }))
19
+ },
20
+ enter: (decl, value) => {
21
+ if (value === undefined || value === null) return []
22
+ if (!Array.isArray(value)) return [{ path: [], code: 'invalid_type', message: 'Expected a list.' }]
23
+ const issues: LeafIssue[] = []
24
+ if (decl.min !== undefined && value.length < decl.min) issues.push({ path: [], code: 'too_small', message: `At least ${decl.min} item${decl.min === 1 ? '' : 's'}.` })
25
+ if (decl.max !== undefined && value.length > decl.max) issues.push({ path: [], code: 'too_big', message: `At most ${decl.max} item${decl.max === 1 ? '' : 's'}.` })
26
+ value.forEach((item, i) => {
27
+ if (!record(item) || typeof item._id !== 'string' || !ID_RE.test(item._id)) issues.push({ path: [i], code: 'missing-id', message: 'Every item needs an _id.' })
28
+ })
29
+ return issues
30
+ },
31
+ })
@@ -0,0 +1,41 @@
1
+ import { SLOT, defineContainer, type GroupDecl, type LeafIssue, type ZoneDecl } from '../define'
2
+ import { ID_RE } from '../path'
3
+ import { record } from './group'
4
+
5
+ /** Every item becomes a group child — a known block's fields, or no fields for SLOT and unknown blocks — so order is kept and nothing under SLOT is visited. */
6
+ export const zone = defineContainer<ZoneDecl>({
7
+ id: 'u90/zone',
8
+ children: (decl, value, schema) => {
9
+ if (!Array.isArray(value)) return []
10
+ return value.map((item, i) => {
11
+ const block = record(item) && typeof item._block === 'string' && item._block !== SLOT && Object.hasOwn(schema.blocks, item._block) ? schema.blocks[item._block] : undefined
12
+ return {
13
+ key: record(item) && typeof item._id === 'string' ? item._id : `#${i}`,
14
+ decl: { container: 'u90/group', fields: block?.fields ?? {}, order: block?.order } as GroupDecl,
15
+ value: item,
16
+ set: (next: unknown) => { value[i] = next },
17
+ }
18
+ })
19
+ },
20
+ enter: (decl, value, schema) => {
21
+ if (value === undefined || value === null) return []
22
+ if (!Array.isArray(value)) return [{ path: [], code: 'invalid_type', message: 'Expected a list of blocks.' }]
23
+ const issues: LeafIssue[] = []
24
+ value.forEach((item, i) => {
25
+ const id = record(item) && typeof item._id === 'string' ? item._id : i
26
+ if (!record(item) || typeof item._id !== 'string' || !ID_RE.test(item._id)) issues.push({ path: [id], code: 'missing-id', message: 'Every block needs an _id.' })
27
+ if (!record(item) || typeof item._block !== 'string') return issues.push({ path: [id], code: 'missing-block', message: 'Every item needs a _block.' })
28
+ if (item._block === SLOT) return
29
+ if (!Object.hasOwn(schema.blocks, item._block)) return issues.push({ path: [id, '_block'], code: 'unknown-block', message: `No block named "${item._block}".`, severity: 'warning' })
30
+ if (!decl.allow.includes(item._block)) issues.push({ path: [id, '_block'], code: 'not-allowed', message: `"${item._block}" is not allowed here.` })
31
+ })
32
+ if (decl.fixed && decl.default) {
33
+ // A fixed zone fixes which blocks it holds, each once; editors may still reorder them (decision 6).
34
+ const sorted = (names: unknown[]) => names.map(String).sort()
35
+ const actual = value.filter(record).map((x) => x._block).filter((b) => b !== SLOT)
36
+ if (JSON.stringify(sorted(actual)) !== JSON.stringify(sorted(decl.default)))
37
+ issues.push({ path: [], code: 'fixed', message: `This zone holds exactly these blocks, in any order: ${decl.default.join(', ')}.` })
38
+ }
39
+ return issues
40
+ },
41
+ })
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod'
2
+ import type { BlockDecl, CollectionDecl, FieldDecl, Issue, LeafDecl, Schema, SingleDecl, ZoneDecl } from './define'
3
+ import { isLeaf } from './define'
4
+ import { ELEMENT_IDS, type ElementId } from './ids'
5
+ import { zodIssues } from './leaf'
6
+ import { getElement } from './registry'
7
+
8
+ const NAME = /^[a-z][a-zA-Z0-9]*$/
9
+ /**
10
+ * zod reports a bad record key through its own `invalid_key` issue rather than surfacing a `params.code`
11
+ * set on the key schema (verified against zod 4.4.3), so field names are checked in a `superRefine` instead,
12
+ * adding a `custom` issue with `params.code: 'field-name'` per bad key.
13
+ */
14
+ const nameRecord = <T extends z.ZodTypeAny>(v: T) =>
15
+ z.record(z.string(), v).superRefine((obj, ctx) => {
16
+ for (const key of Object.keys(obj))
17
+ if (!NAME.test(key)) ctx.addIssue({ code: 'custom', message: 'Field names are camelCase identifiers.', params: { code: 'field-name' }, path: [key] })
18
+ })
19
+ /** `order` sits beside `fields`, never inside it: it lists a permutation of `fields`' own keys, each once. */
20
+ const checkOrder = (fields: Record<string, unknown>, order: string[] | undefined, ctx: z.RefinementCtx) => {
21
+ if (!order) return
22
+ const seen = new Set<string>()
23
+ for (const name of order) {
24
+ if (!Object.hasOwn(fields, name)) ctx.addIssue({ code: 'custom', message: `"${name}" is not a field here.`, path: ['order'] })
25
+ else if (seen.has(name)) ctx.addIssue({ code: 'custom', message: `"${name}" is listed twice.`, path: ['order'] })
26
+ seen.add(name)
27
+ }
28
+ }
29
+
30
+ const LeafDeclSchema = z.strictObject({
31
+ element: z.enum(ELEMENT_IDS),
32
+ label: z.string().optional(),
33
+ description: z.string().optional(),
34
+ required: z.boolean().optional(),
35
+ translatable: z.boolean().optional(),
36
+ default: z.unknown().optional(),
37
+ lock: z.array(z.string().min(1)).optional(),
38
+ renamedFrom: z.string().regex(NAME).optional(),
39
+ options: z.record(z.string(), z.unknown()).optional(),
40
+ })
41
+ export const FieldDeclSchema: z.ZodType<FieldDecl> = z.lazy(() =>
42
+ z.union([
43
+ LeafDeclSchema,
44
+ z.strictObject({ container: z.literal('u90/group'), label: z.string().optional(), fields: nameRecord(FieldDeclSchema), order: z.array(z.string()).optional() })
45
+ .superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx)),
46
+ z.strictObject({ container: z.literal('u90/list'), label: z.string().optional(), item: FieldDeclSchema, min: z.int().nonnegative().optional(), max: z.int().positive().optional(), default: z.array(z.unknown()).optional() }),
47
+ z.strictObject({ container: z.literal('u90/zone'), label: z.string().optional(), allow: z.array(z.string().min(1)).min(1), default: z.array(z.string().min(1)).optional(), fixed: z.boolean().optional() }),
48
+ ]),
49
+ ) as z.ZodType<FieldDecl>
50
+ const ZoneDeclSchema = z.strictObject({ container: z.literal('u90/zone'), label: z.string().optional(), allow: z.array(z.string().min(1)).min(1), default: z.array(z.string().min(1)).optional(), fixed: z.boolean().optional() })
51
+ export const BlockDeclSchema = z.strictObject({ name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), entry: z.string().optional(), order: z.array(z.string()).optional() })
52
+ .superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
53
+ export const CollectionDeclSchema = z.strictObject({
54
+ name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), template: ZoneDeclSchema.optional(),
55
+ defaultSort: z.union([z.strictObject({ field: z.string(), dir: z.enum(['asc', 'desc']) }), z.literal('manual')]).optional(),
56
+ order: z.array(z.string()).optional(),
57
+ }).superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
58
+ export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema })
59
+ export const SchemaSchema = z.strictObject({ blocks: z.record(z.string(), BlockDeclSchema), collections: z.record(z.string(), CollectionDeclSchema), singles: z.record(z.string(), SingleDeclSchema) })
60
+
61
+ type Where = 'block' | 'collection' | 'single'
62
+ const issue = (path: string[], code: string, message: string): Issue => ({ path: path.join('.'), code, message, severity: 'error' })
63
+
64
+ /** Parses, then checks everything the zod shape cannot: placement, cross-references, options, defaults, and fills query targetFields. */
65
+ export function validateSchema(input: unknown): { ok: true; schema: Schema } | { ok: false; issues: Issue[] } {
66
+ const parsed = SchemaSchema.safeParse(input)
67
+ if (!parsed.success) return { ok: false, issues: zodIssues(parsed.error).map((i) => issue(i.path.map(String), i.code, i.message)) }
68
+ const schema = parsed.data as Schema
69
+ const issues: Issue[] = []
70
+ const targetFields = (collection: string) => Object.fromEntries(Object.entries(schema.collections[collection]?.fields ?? {}).filter(([, d]) => isLeaf(d)).map(([n, d]) => [n, (d as LeafDecl).element]))
71
+
72
+ const leaf = (d: LeafDecl, at: string[], where: Where) => {
73
+ const entry = getElement(d.element)
74
+ if ((entry.placement === 'collection' && where !== 'collection') || (entry.placement === 'content' && where !== 'block'))
75
+ issues.push(issue(at, 'placement', `${d.element} may only be declared in ${entry.placement === 'collection' ? 'a collection' : 'a block'}.`))
76
+ if (d.element === 'u90/query' && d.options?.to && schema.collections[String(d.options.to)]) d.options = { ...d.options, targetFields: targetFields(String(d.options.to)) }
77
+ const o = entry.options.safeParse(d.options ?? {})
78
+ if (!o.success) { zodIssues(o.error).forEach((i) => issues.push(issue([...at, 'options', ...i.path.map(String)], i.code, i.message))); return }
79
+ const opts = o.data as Record<string, unknown>
80
+ if ((d.element === 'u90/relation' || d.element === 'u90/selection' || d.element === 'u90/query') && !schema.collections[String(opts.to)])
81
+ issues.push(issue([...at, 'options', 'to'], 'unknown-collection', `No collection named "${opts.to}".`))
82
+ if (d.element === 'u90/relation' && opts.inverseOf) {
83
+ const target = schema.collections[String(opts.to)]?.fields[String(opts.inverseOf)]
84
+ if (!target || !isLeaf(target) || target.element !== 'u90/relation') issues.push(issue([...at, 'options', 'inverseOf'], 'unknown-inverse', `"${opts.to}" has no relation field "${opts.inverseOf}".`))
85
+ }
86
+ if (d.element === 'u90/single' && !schema.singles[String(opts.key)]) issues.push(issue([...at, 'options', 'key'], 'unknown-single', `No Single named "${opts.key}".`))
87
+ if (d.default !== undefined) {
88
+ const s = entry.seed(o.data).safeParse(d.default)
89
+ if (!s.success) zodIssues(s.error).forEach((i) => issues.push(issue([...at, 'default', ...i.path.map(String)], i.code, i.message)))
90
+ else d.default = s.data
91
+ }
92
+ }
93
+ const zone = (d: ZoneDecl, at: string[]) => {
94
+ d.allow.forEach((b, i) => { if (!schema.blocks[b]) issues.push(issue([...at, 'allow', String(i)], 'unknown-block', `No block named "${b}".`)) })
95
+ d.default?.forEach((b, i) => { if (!d.allow.includes(b)) issues.push(issue([...at, 'default', String(i)], 'not-allowed', `"${b}" is not in allow.`)) })
96
+ if (d.fixed && !d.default) issues.push(issue(at, 'fixed-needs-default', 'A fixed zone must list its blocks in default.'))
97
+ }
98
+ const field = (d: FieldDecl, at: string[], where: Where) => {
99
+ if (isLeaf(d)) return leaf(d, at, where)
100
+ if (d.container === 'u90/group') return Object.entries(d.fields).forEach(([n, f]) => field(f, [...at, 'fields', n], where))
101
+ if (d.container === 'u90/list') {
102
+ if (!isLeaf(d.item) && d.item.container !== 'u90/group')
103
+ issues.push(issue(at, 'list-item-kind', 'A list item must be an element or a group; wrap a list or a zone in a group.'))
104
+ return field(d.item, [...at, 'item'], where)
105
+ }
106
+ zone(d, at)
107
+ }
108
+ for (const [n, b] of Object.entries(schema.blocks)) {
109
+ if (b.entry && !schema.collections[b.entry]) issues.push(issue(['blocks', n, 'entry'], 'unknown-collection', `No collection named "${b.entry}".`))
110
+ Object.entries(b.fields).forEach(([f, d]) => field(d, ['blocks', n, 'fields', f], 'block'))
111
+ }
112
+ for (const [n, c] of Object.entries(schema.collections)) {
113
+ Object.entries(c.fields).forEach(([f, d]) => field(d, ['collections', n, 'fields', f], 'collection'))
114
+ if (c.template) zone(c.template, ['collections', n, 'template'])
115
+ }
116
+ for (const [n, s] of Object.entries(schema.singles)) field(s.field, ['singles', n, 'field'], 'single')
117
+ return issues.length ? { ok: false, issues } : { ok: true, schema }
118
+ }