@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,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,12 @@
1
+ export { Text } from './Text'
2
+ export { RichText, richTextNodes } from './RichText'
3
+ export { Link, isInternal } from './Link'
4
+ export { File } from './File'
5
+ export { DateText } from './DateText'
6
+ export { NumberText } from './NumberText'
7
+ export { Image } from './Image'
8
+ export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
9
+ export { Video } from './Video'
10
+ export { videoProps, type VideoHTMLProps } from './videoProps'
11
+ export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
12
+ 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
+ }
@@ -0,0 +1,195 @@
1
+ import { z, type ZodType } from 'zod'
2
+ import type { ContainerId, ElementId } from './ids'
3
+
4
+ export type Mode = 'draft' | 'publish'
5
+ export type Severity = 'error' | 'warning'
6
+ export type Id = string | number
7
+ /** A media, page or entry id as stored: a positive integer or a non-empty string. */
8
+ export const Id: ZodType<Id> = z.union([z.int().positive(), z.string().trim().min(1)])
9
+
10
+ /**
11
+ * An absolute http(s) URL, or a root-relative path. The character after the leading `/` may be
12
+ * neither `/` nor `\`: a browser reads `//host` and `/\host` alike as another host. Text and Link
13
+ * share it.
14
+ */
15
+ export const URL_RE = /^(https?:\/\/\S+|\/(?:[^\/\\\s]\S*)?)$/
16
+ /** A phone number: an optional leading `+`, a digit, then at least five more digits, spaces, parentheses or hyphens. */
17
+ export const TEL_RE = /^\+?[0-9][0-9 ()\-]{5,}$/
18
+
19
+ /** An issue relative to one leaf value; the walker prefixes the leaf's path. */
20
+ export type LeafIssue = { path: (string | number)[]; code: string; message: string; severity?: Severity }
21
+ /** An issue at a full path such as `page:4f2/blocks/b1/photo/alt`. */
22
+ export type Issue = { path: string; code: string; message: string; severity: Severity }
23
+
24
+ export type Ref =
25
+ | { kind: 'media'; id: Id }
26
+ | { kind: 'page'; id: Id }
27
+ | { kind: 'entry'; collection: string; id: Id; depth: number }
28
+ | { kind: 'single'; key: string }
29
+ | { kind: 'query'; collection: string; query: unknown }
30
+ | { kind: 'inverse'; collection: string; field: string; id: Id }
31
+
32
+ export type ImageCrop = { x: number; y: number; width: number; height: number }
33
+ export type Rendition = {
34
+ crop: ImageCrop | null
35
+ url: string
36
+ width: number | null
37
+ height: number | null
38
+ blurDataURL?: string
39
+ status: 'ready' | 'processing' | 'error'
40
+ }
41
+ export type MediaRecord = {
42
+ id: Id
43
+ url: string
44
+ mimeType: string
45
+ width: number | null
46
+ height: number | null
47
+ filename: string
48
+ blurDataURL?: string
49
+ /** Renditions the platform has produced for this asset, one per crop. Absent means none yet. */
50
+ renditions?: Rendition[]
51
+ }
52
+ /** A media record as a read value carries it: everything but the platform's renditions. Image, Video and File share it. */
53
+ export type MediaAsset = Omit<MediaRecord, 'renditions'>
54
+ export const MediaAssetSchema = z.object({ id: Id, url: z.string(), mimeType: z.string(), width: z.number().nullable(), height: z.number().nullable(), filename: z.string(), blurDataURL: z.string().optional() })
55
+ export type PageRecord = { id: Id; route: string; title: string }
56
+ /** A stored entry as the platform hands it to `expand`. */
57
+ export type EntryRecord = { id: Id; slug: string; route?: string; fields: Record<string, unknown>; blocks?: unknown[] }
58
+ export type EntryRead = {
59
+ id: Id
60
+ collection: string
61
+ status: 'ready' | 'missing'
62
+ slug?: string
63
+ route?: string
64
+ /** Present when expanded to depth ≥ 1. */
65
+ fields?: Record<string, unknown>
66
+ }
67
+ /** Read schema of an entry reached through a relation, selection or query. */
68
+ export const EntryReadSchema = z.object({ id: Id, collection: z.string(), status: z.enum(['ready', 'missing']), slug: z.string().optional(), route: z.string().optional(), fields: z.record(z.string(), z.unknown()).optional() })
69
+
70
+ /** Everything `expand` may ask for. Built by `ops/expand` from pre-fetched data; the leaf never fetches. */
71
+ export type ExpandCtx = {
72
+ at: string
73
+ draft: boolean
74
+ /** Draft only: the overlay keys (`overlayKey(at)`) of this document's leaves that read in another language than the one asked for. */
75
+ fallbacks?: ReadonlySet<string>
76
+ /** The language a translated frame reads in, read from a draft request's `DRAFT_LOCALE_PARAM`, which internal link hrefs keep as that parameter. */
77
+ draftLocale?: string
78
+ media(id: Id): MediaRecord | undefined
79
+ page(id: Id): PageRecord | undefined
80
+ /** The Single's read value, already expanded by the caller. */
81
+ single(key: string): unknown
82
+ entry(collection: string, id: Id, depth: number): EntryRead
83
+ query(collection: string, query: unknown): { entries: EntryRead[]; total: number }
84
+ inverse(collection: string, field: string, id: Id): EntryRead[]
85
+ }
86
+ export type SeedCtx = {
87
+ importAsset(source: string): Promise<Id>
88
+ resolvePage(key: string): Id
89
+ resolveEntry(collection: string, key: string): Id
90
+ newId(): string
91
+ }
92
+ /** `hasDefault` is true only when the value is absent and a declared default reads in its place. */
93
+ export type FieldMeta = { required: boolean; hasDefault: boolean }
94
+
95
+ export type LeafDecl = {
96
+ element: ElementId
97
+ /** When `false`, the field is never translated. */
98
+ translatable?: boolean
99
+ label?: string
100
+ description?: string
101
+ required?: boolean
102
+ /** Seed shape. */
103
+ default?: unknown
104
+ /** Sub-paths a client may not change, e.g. `['to']` on a link. */
105
+ lock?: string[]
106
+ renamedFrom?: string
107
+ options?: Record<string, unknown>
108
+ }
109
+ export type GroupDecl = { container: 'u90/group'; label?: string; fields: Record<string, FieldDecl>; order?: string[] }
110
+ export type ListDecl = { container: 'u90/list'; label?: string; item: FieldDecl; min?: number; max?: number; default?: unknown[] }
111
+ export type ZoneDecl = { container: 'u90/zone'; label?: string; allow: string[]; default?: string[]; fixed?: boolean }
112
+ export type FieldDecl = LeafDecl | GroupDecl | ListDecl | ZoneDecl
113
+ export const isLeaf = (d: FieldDecl): d is LeafDecl => 'element' in d
114
+
115
+ export type BlockDecl = { name: string; label: string; fields: Record<string, FieldDecl>; entry?: string; order?: string[] }
116
+ export type CollectionDecl = {
117
+ name: string
118
+ label: string
119
+ fields: Record<string, FieldDecl>
120
+ template?: ZoneDecl
121
+ defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
122
+ order?: string[]
123
+ }
124
+ export type SingleDecl = { key: string; field: FieldDecl }
125
+ export type Schema = {
126
+ blocks: Record<string, BlockDecl>
127
+ collections: Record<string, CollectionDecl>
128
+ singles: Record<string, SingleDecl>
129
+ }
130
+ export type DocumentDecl =
131
+ | { kind: 'page'; blocks: ZoneDecl }
132
+ | { kind: 'layout'; blocks: ZoneDecl }
133
+ | { kind: 'entry'; collection: string }
134
+ | { kind: 'single'; key: string }
135
+
136
+ export const SLOT = 'SLOT'
137
+
138
+ /**
139
+ * What a language overlay holds for this leaf, and how it lands on the stored value. Destinations,
140
+ * media and structure never travel in an overlay: a link's overlay is its label, an image's its alt
141
+ * text and caption. `apply` answers undefined when there is no base value to translate.
142
+ */
143
+ export type TranslationCodec<O, V, T> = {
144
+ applies(o: O): boolean
145
+ value(o: O): ZodType<T>
146
+ pick(v: V, o: O): T | null
147
+ apply(base: V | undefined, t: T, o: O): V | undefined
148
+ /** Whether a stored value has a part an overlay could land on, such as a video's poster. Absent, every stored value has. */
149
+ present?(v: V, o: O): boolean
150
+ }
151
+
152
+ export type ElementEntry<O = any, S = unknown, V = unknown, R = unknown> = {
153
+ id: ElementId
154
+ version: number
155
+ label: string
156
+ placement: 'any' | 'collection' | 'content'
157
+ options: ZodType<O>
158
+ seed(o: O): ZodType<S>
159
+ stored(o: O): ZodType<V>
160
+ /**
161
+ * The lenient shape a stored value must have to be read; defaults to `stored(o)`. An element whose
162
+ * options narrow `stored` into an enum, a union or an integer declares one without those rules, so
163
+ * content written under wider options keeps reading while `validate` reports it.
164
+ */
165
+ shape?(o: O): ZodType
166
+ read(o: O): ZodType<R>
167
+ /** Publish-only completeness. `v` is undefined when the field is absent from storage. */
168
+ complete(v: V | undefined, o: O, field: FieldMeta): LeafIssue[]
169
+ refs(v: V, o: O, at: string): Ref[]
170
+ toStored(seed: S, o: O, ctx: SeedCtx): Promise<V>
171
+ expand(v: V, o: O, ctx: ExpandCtx): R
172
+ /** Read shape of a seed value, for defaults of fields absent from storage. */
173
+ expandSeed(seed: S, o: O, ctx: ExpandCtx): R
174
+ toText(v: V, o: O): string
175
+ translatable(v: V, o: O): { path: (string | number)[]; value: string }[]
176
+ /** What a language overlay holds for this element; null where nothing translates. */
177
+ translation: TranslationCodec<O, V, any> | null
178
+ empty(o: O): V
179
+ upgrade: Record<number, (old: unknown) => unknown>
180
+ }
181
+ export const defineElement = <O, S, V, R>(e: ElementEntry<O, S, V, R>) => e
182
+
183
+ export type Child = { key: string; decl: FieldDecl; value: unknown; set(v: unknown): void }
184
+ export type ContainerEntry<D extends FieldDecl = FieldDecl> = {
185
+ id: ContainerId
186
+ children(decl: D, value: unknown, schema: Schema): Child[]
187
+ /** Structural checks on the container's own value; the walker prefixes the path. */
188
+ enter(decl: D, value: unknown, schema: Schema): LeafIssue[]
189
+ }
190
+ export const defineContainer = <D extends FieldDecl>(c: ContainerEntry<D>) => c
191
+
192
+ export const requiredIssue = (present: boolean, field: FieldMeta): LeafIssue[] =>
193
+ !present && field.required && !field.hasDefault
194
+ ? [{ path: [], code: 'required', message: 'A value is required to publish.' }]
195
+ : []