@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,1602 @@
1
+ import {
2
+ normalizeVideoSeed,
3
+ normalizeFileSeed,
4
+ videoSeedForImport,
5
+ fileSeedForImport,
6
+ type VideoSeedValue,
7
+ type FileSeedValue,
8
+ } from './media'
9
+ import {
10
+ imageSeedForImport,
11
+ normalizeImageSeed,
12
+ validateImageDeclaration,
13
+ ImageValidationError,
14
+ type ImageSeedValue,
15
+ type ImageConstraints,
16
+ type ImageVariantDeclaration,
17
+ } from './image'
18
+ import {
19
+ BLOCK_ENTRY,
20
+ BRIDGE_VERSION,
21
+ type BlockEntry,
22
+ type CollectionRoutes,
23
+ type EditorBlock,
24
+ type EditorField,
25
+ type ManifestCollection,
26
+ type PageDeclaration,
27
+ type SeedSection,
28
+ type SiteManifest,
29
+ type Spec,
30
+ type SpecKey,
31
+ } from './contract'
32
+
33
+ /**
34
+ * How a site declares its own content schema.
35
+ *
36
+ * This is the piece that makes onboarding a client free. The site author writes the
37
+ * blocks their design actually has; the platform renders an editor from them. No
38
+ * migration, no deploy, no code in the platform repo.
39
+ *
40
+ * export const blocks = defineBlocks({
41
+ * hero: {
42
+ * label: 'Hero',
43
+ * fields: {
44
+ * headline: text({ required: true }),
45
+ * slides: array({ fields: { title: text(), photo: upload() } }),
46
+ * },
47
+ * },
48
+ * })
49
+ *
50
+ * Blocks and fields are keyed by object rather than listed in arrays, which is not
51
+ * cosmetic: object keys are unique by construction, so two blocks cannot share a slug
52
+ * and two fields cannot share a name. Both of those are rejected by the platform's
53
+ * validator, and this way they are rejected by the type system first — a duplicate
54
+ * slug makes stored content unresolvable, and a duplicate name makes a dotted path
55
+ * ambiguous.
56
+ *
57
+ * Labels default to a humanised version of the key, because the common case is a label
58
+ * that just reads the key back.
59
+ */
60
+
61
+ /** Everything a field helper accepts. Each helper narrows it. */
62
+ type Common = {
63
+ /**
64
+ * What this field currently says on the site, as the *platform* will store it.
65
+ *
66
+ * Carried into the manifest's `seed` so a tenant's first pair populates its CMS with the
67
+ * site's real copy instead of blanks. Never reaches the editor as schema — the platform
68
+ * stores it once, at pair time, and from then on the stored content is the only truth.
69
+ */
70
+ default?: unknown
71
+ description?: string
72
+ label?: string
73
+ readOnly?: boolean
74
+ required?: boolean
75
+ /**
76
+ * What this field currently says on the site, as the *site* renders it.
77
+ *
78
+ * Identical to `default` for almost every field. The two diverge exactly where the stored
79
+ * form and the rendered form differ: `link()` renders `{ href, label }` and stores
80
+ * `{ kind: 'url', url, … }`. `upload()` carries the same repo path on both, but they mean
81
+ * different things about it — rendered, it is the asset; seeded, it is the key the
82
+ * platform's media ingestion relinks to an id.
83
+ *
84
+ * This is what `defineSite` collects into `site.values` — the object every component
85
+ * imports as `brand`.
86
+ */
87
+ value?: unknown
88
+ }
89
+
90
+ /** Available only on copy-bearing scalar helpers. */
91
+ type LocalizableCommon = Common & {
92
+ /** Eligible copy is localized by default. Set false for identifiers and textual facts. */
93
+ localized?: boolean
94
+ }
95
+
96
+ /**
97
+ * A field before its name is known — the name comes from its object key.
98
+ *
99
+ * `V` is the type of the value this field commits, carried phantom-wise through `Spec` so
100
+ * that `defineSite` can read `site.values` off the helpers a site wrote. It is a property of
101
+ * the *helper*, not of its argument: widening the argument to `Common | V` is what would let
102
+ * an options object be mistaken for a bare value, which `opts` would then classify as options
103
+ * and `base` would spread to `{}` — the value gone, with no error anywhere.
104
+ */
105
+ type Unnamed<V = unknown> = AnyField & Spec<V>
106
+
107
+ /**
108
+ * A field of unstated value type — the shape `FieldSpec` is keyed by.
109
+ *
110
+ * Deliberately *without* `Spec`, and that is load-bearing rather than tidy. A helper's value
111
+ * type is inferred, and a contextual return type is an inference site like any other: were
112
+ * `FieldSpec` a map of `Unnamed<unknown>`, then `block({ address: group({ … }) })` would
113
+ * contextually type the `group` call as returning `Spec<unknown>`, `V` would infer as
114
+ * `unknown` from that alone, and the aggregate default would never be reached — every nested
115
+ * group in every site would come out `unknown`. Carrying no `VALUE` at all leaves nothing to
116
+ * infer from, so each helper's own reasoning stands.
117
+ */
118
+ type AnyField = Omit<EditorField, 'name'> & { name?: never }
119
+
120
+ /**
121
+ * The value a field spec commits, or `never` for one that carries nothing.
122
+ *
123
+ * `Spec`'s phantom property is optional, so every object matches `Spec<infer V>` — the
124
+ * `SpecKey extends keyof T` test is what tells a real carrier apart from an object that
125
+ * simply has no opinion. See `SpecKey`.
126
+ */
127
+ type Committed<T> = SpecKey extends keyof T ? (T extends Spec<infer V> ? V : never) : never
128
+
129
+ /**
130
+ * A non-schema marker `link()` stamps onto the field spec it returns, so that later code
131
+ * can recognise a link field by how it was *built* rather than guessing from its shape.
132
+ *
133
+ * `seedItem` uses this to decide whether to translate an array item's field into the
134
+ * platform's stored link shape. Guessing instead — e.g. "does this object have an `href`"
135
+ * — would misfire on a plain `group` field whose committed value happens to have an `href`
136
+ * alongside other keys, a realistic shape for a card with a title and a link.
137
+ *
138
+ * **A symbol, and the one non-schema key `namedFields` does not strip.** `seedItem` recurses,
139
+ * so it has to recognise a link at *any* depth inside an array item — and a link nested inside
140
+ * a group has already been through `namedFields` when that group was constructed. A string
141
+ * marker would have been stripped there, before anything could read it, and the item's
142
+ * committed `{ href }` would then be pruned as a key the schema does not declare: the button's
143
+ * URL gone, silently, which is worse than not pruning at all. A symbol key survives an object
144
+ * rest spread, and is invisible to `JSON.stringify` and `Object.keys` — so it reaches
145
+ * `seedItem` without reaching the platform, which receives this schema as JSON.
146
+ *
147
+ * `Symbol.for`, not `Symbol()`: the registry is keyed by string, so two *instances* of this
148
+ * package in one bundle — a vendored tarball beside a workspace link, which is exactly how the
149
+ * first site to use this consumes it — still agree on the marker. With `Symbol()` they would
150
+ * not, and the failure is silent and is the one this marker exists to prevent: a link built by
151
+ * one instance is unrecognisable to the other, its committed `href` is pruned as a key the
152
+ * schema does not declare, and the button is stored with no address.
153
+ */
154
+ const LINK_FIELD_MARKER: unique symbol = Symbol.for('u90.linkField')
155
+
156
+ /** A plain object — not null, not an array. The shape a group's committed value has. */
157
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
158
+ value !== null && typeof value === 'object' && !Array.isArray(value)
159
+
160
+ const humanise = (key: string): string =>
161
+ key
162
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
163
+ .replace(/[-_]+/g, ' ')
164
+ .replace(/^./, (c) => c.toUpperCase())
165
+
166
+ /*
167
+ * `default` is *kept* on the spec object and stripped later, in `namedFields`.
168
+ *
169
+ * It has to survive here: `seedFrom` reads the same spec the author wrote, so a value
170
+ * dropped at this point is gone before the seed is collected — which is exactly the
171
+ * bug the round-trip check caught, seed sections arriving with a `blockType` and no
172
+ * values at all. It must equally never reach the schema, where a field carrying its
173
+ * default would let a stale site config overwrite what a client typed. So: kept on the
174
+ * spec, removed on the way into `EditorField[]`.
175
+ */
176
+
177
+ /**
178
+ * Let a scalar helper take its copy directly: `text('Building WA')`.
179
+ *
180
+ * The rule is one sentence: **a non-object argument is the value; an object is options.**
181
+ * It applies only to the scalar helpers, whose values are never objects. `array`, `group`,
182
+ * `link`, `relationship` and `blocks` keep the object-options form, because theirs are.
183
+ */
184
+ const opts = (arg: unknown): Common =>
185
+ isRecord(arg) ? (arg as Common) : { default: arg, value: arg }
186
+
187
+ const base = <V = unknown>(
188
+ type: EditorField['type'],
189
+ raw: Common & { localized?: boolean },
190
+ ): Unnamed<V> => {
191
+ const options: Common & { localized?: boolean } = {
192
+ ...raw,
193
+ value: 'value' in raw ? raw.value : raw.default,
194
+ }
195
+ return {
196
+ ...(options.default === undefined ? {} : { default: options.default }),
197
+ ...(options.value === undefined ? {} : { value: options.value }),
198
+ ...(options.description === undefined ? {} : { description: options.description }),
199
+ ...(options.localized === undefined ? {} : { localized: options.localized }),
200
+ ...(options.readOnly === undefined ? {} : { readOnly: options.readOnly }),
201
+ ...(options.required === undefined ? {} : { required: options.required }),
202
+ label: options.label ?? '',
203
+ type,
204
+ } as Unnamed<V>
205
+ }
206
+
207
+ /**
208
+ * What a scalar helper commits, over `B` — the type its bare form takes.
209
+ *
210
+ * Four signatures, resolved in the order `base` itself resolves the carriers: an explicit
211
+ * `value`, else an explicit `default`, else a bare argument, else **nothing**.
212
+ *
213
+ * That last one is the whole point of writing these as overloads. A helper handed no carrier
214
+ * commits no carrier: `blockValues` skips it, and `brand.pageBody.heading` is `undefined` at
215
+ * runtime. A signature that answered `string` there would be a type that compiles
216
+ * `brand.pageBody.heading.trim()` into a `TypeError` — and it would do it at exactly the
217
+ * fallback-render guarantee this document exists to hold up, in the direction that crashes
218
+ * rather than renders blank. A hand-written `Brand` said `heading?: string`; this says
219
+ * `undefined`, which is stricter and equally honest.
220
+ *
221
+ * The carrier signatures read the committed type back off what was written rather than
222
+ * pinning it, because `default` and `value` legitimately hold different shapes — richText
223
+ * stores a Lexical document and renders copy, an upload's committed value may be an explicit
224
+ * `null`. What is *not* being done is widening the argument to `Common | V`: each object
225
+ * signature demands a **named** carrier, so an options object stays options and
226
+ * `text({ label: 'Headline' })` matches the last signature rather than being read as a value
227
+ * that `opts` would then classify as options and drop on the floor.
228
+ */
229
+ type Scalar<B, O extends Common = Common> = {
230
+ <V>(arg: O & { value: V }): Unnamed<V>
231
+ <V>(arg: O & { default: V }): Unnamed<V>
232
+ (arg: B): Unnamed<B>
233
+ (arg?: O): Unnamed<undefined>
234
+ }
235
+
236
+ export const text: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
237
+ base<never>('text', opts(arg) as LocalizableCommon)
238
+ export const textarea: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
239
+ base<never>('textarea', opts(arg))
240
+ export const number: Scalar<number> = (arg: Common | number = {}) =>
241
+ base<never>('number', opts(arg))
242
+ export const checkbox: Scalar<boolean> = (arg: Common | boolean = {}) =>
243
+ base<never>('checkbox', opts(arg))
244
+ export const email: Scalar<string> = (arg: Common | string = {}) => base<never>('email', opts(arg))
245
+
246
+ /**
247
+ * A script URL, an embed snippet, or anything else that is code rather than prose.
248
+ *
249
+ * Identical to `textarea` in what it stores and what it commits; the type is what tells the
250
+ * editor to draw a monospace control with spellcheck off and to badge the row `E`. Nothing
251
+ * about rendering changes — the site does whatever it already did with the string.
252
+ *
253
+ * The distinction earns itself the moment a client opens the rail: `pbir`'s reviews block
254
+ * holds a heading, a lead, two button captions *and* the elfsight script URL, and every one
255
+ * of them read as plain text. A field a client should almost never touch looked exactly like
256
+ * the four they edit constantly.
257
+ */
258
+ export const code: Scalar<string> = (arg: Common | string = {}) => base<never>('code', opts(arg))
259
+
260
+ /**
261
+ * Long-form copy, in whichever of its forms the *site* renders.
262
+ *
263
+ * The field that made `Scalar` read its answer off the carrier rather than pin it: the
264
+ * platform stores a Lexical document while a site may render either plain copy or a document
265
+ * of its own, so `string` would be wrong for every site that renders a document and `unknown`
266
+ * would put a cast at every reader of a field that is a string almost every time.
267
+ */
268
+ export const richText: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
269
+ base<never>('richText', opts(arg))
270
+
271
+ /**
272
+ * A calendar day — a post's published date, an event, a completion date.
273
+ *
274
+ * Not localizable, unlike `text` and `richText`: a date is the same day in every language,
275
+ * and `validate.ts` restricts `localized` to the three copy types for that reason.
276
+ */
277
+ export const date: Scalar<string> = (arg: Common | string = {}) => base<never>('date', opts(arg))
278
+
279
+ /** The choices a `select` offers: a `{ label, value }` pair, or a value to humanise. */
280
+ type SelectOptions = Common & { options: ({ label: string; value: string } | string)[] }
281
+
282
+ /**
283
+ * One of a fixed set of choices.
284
+ *
285
+ * The same four cases as `Scalar`, except that the bare form needs the choices alongside it —
286
+ * a select with no `options` is a control with nothing in it. `select({ options })` on its
287
+ * own declares the choices and commits none of them, which is `undefined`, not the first
288
+ * choice: the platform holds no defaults, and a field the site never committed renders as
289
+ * whatever the component does with nothing.
290
+ */
291
+ export const select: {
292
+ <V>(arg: SelectOptions & { value: V }): Unnamed<V>
293
+ <V>(arg: SelectOptions & { default: V }): Unnamed<V>
294
+ (arg: string, extra: SelectOptions): Unnamed<string>
295
+ (arg: SelectOptions): Unnamed<undefined>
296
+ } = (arg: SelectOptions | string, extra?: SelectOptions) => {
297
+ const options = (extra ? { ...extra, default: arg, value: arg } : arg) as SelectOptions
298
+ return {
299
+ ...base<never>('select', options),
300
+ options: options.options.map((option) =>
301
+ typeof option === 'string' ? { label: humanise(option), value: option } : option,
302
+ ),
303
+ }
304
+ }
305
+
306
+ /**
307
+ * An image, video or document from the client's own media library.
308
+ *
309
+ * The argument is the **committed** asset the component falls back to — a path in the site
310
+ * repo. It is carried on both carriers: `value` is what the component renders when the
311
+ * platform is unreachable, and `default` is the path the platform's media ingestion relinks
312
+ * to the id of the media document now holding those bytes.
313
+ *
314
+ * **This used to seed nothing**, on the reasoning that a tenant's media library is empty
315
+ * until someone uploads to it, so a repo path stored as a media id would resolve to nothing
316
+ * and show a broken thumbnail on the day the client paired. That reasoning was right and its
317
+ * premise has expired: `importArchiveAssets` now uploads every committed path a site names,
318
+ * and `relinkSeedAssets` swaps the path for the id it was uploaded as. With no path in the
319
+ * seed there was nothing to swap, so a client paired onto a library full of their own
320
+ * photographs and an editor whose every image slot read "Use the site's own image".
321
+ *
322
+ * The platform is what keeps this honest, and it has to be, because a path is not an id: a
323
+ * seeded upload value that ingestion did not relink is **dropped** before it is stored — see
324
+ * `dropUnlinkedUploads` in the platform's `manifest/seed.ts`. A pair with no ingestion behind
325
+ * it therefore lands exactly where it landed before: an empty slot, and the committed asset
326
+ * still rendering underneath it.
327
+ *
328
+ * `upload()` with no argument commits nothing and is typed `undefined` — a slot the client
329
+ * fills, with no repo asset underneath it. Reading the carrier back rather than pinning
330
+ * `string` is also what keeps `upload({ value: null })` honest: an explicit null is a
331
+ * deliberate "no asset", and it is not a path.
332
+ */
333
+ export type ImageOptions = Omit<Common, 'default' | 'value' | 'label'> & {
334
+ label: string
335
+ default?: ImageSeedValue
336
+ constraints?: ImageConstraints
337
+ variants?: Record<string, ImageVariantDeclaration>
338
+ }
339
+
340
+ /** One atomic Image placement, with upload children retained for media traversal. */
341
+ export const image = (options: ImageOptions): Unnamed<ImageSeedValue> => {
342
+ const issues = validateImageDeclaration(options)
343
+ if (issues.length) throw new ImageValidationError(issues)
344
+ const normalized = normalizeImageSeed(options.default)
345
+ for (const key of Object.keys(normalized?.variants ?? {})) {
346
+ if (!Object.hasOwn(options.variants ?? {}, key))
347
+ throw new ImageValidationError([
348
+ { path: `variants.${key}`, message: 'Variant is not declared by this field.' },
349
+ ])
350
+ }
351
+ const structural = (): EditorField[] => [
352
+ { name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
353
+ {
354
+ name: 'focalPoint',
355
+ label: 'Focal point',
356
+ type: 'group',
357
+ localized: false,
358
+ fields: ['x', 'y'].map((name) => ({ name, label: name, type: 'number' })),
359
+ },
360
+ {
361
+ name: 'crop',
362
+ label: 'Crop',
363
+ type: 'group',
364
+ localized: false,
365
+ fields: ['x', 'y', 'width', 'height'].map((name) => ({ name, label: name, type: 'number' })),
366
+ },
367
+ ]
368
+ return {
369
+ ...base<ImageSeedValue>('group', {
370
+ ...options,
371
+ default: imageSeedForImport(normalized),
372
+ value: normalized,
373
+ }),
374
+ element: 'image',
375
+ contractVersion: 1,
376
+ ...(options.constraints ? { constraints: options.constraints } : {}),
377
+ ...(options.variants ? { variants: options.variants } : {}),
378
+ fields: [
379
+ ...structural(),
380
+ { name: 'alt', label: 'Alternative text', type: 'text' },
381
+ { name: 'decorative', label: 'Decorative', type: 'checkbox', localized: false },
382
+ { name: 'caption', label: 'Caption', type: 'text' },
383
+ {
384
+ name: 'variants',
385
+ label: 'Variants',
386
+ type: 'group',
387
+ localized: false,
388
+ fields: Object.keys(options.variants ?? {}).map((name) => ({
389
+ name,
390
+ label: name,
391
+ type: 'group',
392
+ fields: structural(),
393
+ })),
394
+ },
395
+ ],
396
+ }
397
+ }
398
+
399
+ export type VideoOptions = Omit<Common, 'default' | 'value' | 'label'> & {
400
+ label: string
401
+ default?: VideoSeedValue
402
+ poster?: Pick<ImageOptions, 'label' | 'constraints' | 'variants'>
403
+ }
404
+ export type FileOptions = Omit<Common, 'default' | 'value' | 'label'> & {
405
+ label: string
406
+ default?: FileSeedValue
407
+ }
408
+ export const video = (options: VideoOptions): Unnamed<VideoSeedValue> => {
409
+ const poster = options.poster ?? { label: 'Poster' }
410
+ const posterField = image(poster)
411
+ const normalized = normalizeVideoSeed(options.default, { posterVariants: poster.variants ?? {} })
412
+ return {
413
+ ...base<VideoSeedValue>('group', {
414
+ ...options,
415
+ default: videoSeedForImport(normalized),
416
+ value: normalized,
417
+ }),
418
+ element: 'video',
419
+ contractVersion: 1,
420
+ poster: {
421
+ label: poster.label,
422
+ ...(poster.constraints ? { constraints: poster.constraints } : {}),
423
+ ...(poster.variants ? { variants: poster.variants } : {}),
424
+ },
425
+ fields: [
426
+ { name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
427
+ { ...posterField, name: 'poster' },
428
+ ...['posterTime', 'startTime', 'endTime'].map((name) => ({
429
+ name,
430
+ label: name,
431
+ type: 'number' as const,
432
+ localized: false,
433
+ })),
434
+ ...['autoplay', 'muted', 'loop', 'controls', 'playsInline'].map((name) => ({
435
+ name,
436
+ label: name,
437
+ type: 'checkbox' as const,
438
+ localized: false,
439
+ })),
440
+ {
441
+ name: 'preload',
442
+ label: 'Preload',
443
+ type: 'select',
444
+ localized: false,
445
+ options: ['none', 'metadata', 'auto'].map((value) => ({ label: value, value })),
446
+ },
447
+ ],
448
+ }
449
+ }
450
+ export const file = (options: FileOptions): Unnamed<FileSeedValue> => {
451
+ const normalized = normalizeFileSeed(options.default)
452
+ return {
453
+ ...base<FileSeedValue>('group', {
454
+ ...options,
455
+ default: fileSeedForImport(normalized),
456
+ value: normalized,
457
+ }),
458
+ element: 'file',
459
+ contractVersion: 1,
460
+ fields: [
461
+ { name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
462
+ ...['label', 'description', 'downloadName'].map((name) => ({
463
+ name,
464
+ label: name,
465
+ type: 'text' as const,
466
+ })),
467
+ ],
468
+ }
469
+ }
470
+
471
+ export const upload: Scalar<string> = (arg: Common | string = {}) => {
472
+ const options = opts(arg)
473
+ const committed = 'value' in options ? options.value : options.default
474
+ return {
475
+ ...base<never>('upload', { ...options, default: committed, value: committed }),
476
+ relationTo: 'media',
477
+ }
478
+ }
479
+
480
+ /** Everything `relationship()` accepts beyond the common carriers. */
481
+ type RelationshipOptions = Common & {
482
+ hasMany?: boolean
483
+ inverse?: { label: string; name: string }
484
+ relationTo: string
485
+ }
486
+
487
+ /**
488
+ * A reference to another record — one of this site's pages, or rows of one of its own
489
+ * collections.
490
+ *
491
+ * `relationTo: 'pages'` is the page link `link()` uses and is reserved. Anything else must
492
+ * name a collection this site declares in `defineCollections`, or the platform rejects the
493
+ * whole manifest rather than render a picker with nothing in it.
494
+ *
495
+ * `hasMany` makes it an ordered list of references; the order is the client's and is stored.
496
+ * `inverse` asks the target collection to show this edge from its own side — see `EditorField`.
497
+ *
498
+ * Answers `undefined` when nothing was committed, the same as every other helper. It used to
499
+ * answer `unknown`, on the reasoning that `unknown` refuses every dereference and so cannot
500
+ * crash. It can: `unknown` is not `undefined`, so a *group* holding one uncommitted reference
501
+ * read as present — `Aggregate` saw a member that was not `undefined` and typed the whole
502
+ * group as an object. The crash then lands one level up, at the group, where the members
503
+ * never get the chance to refuse anything. A page reference beside a caption the client fills
504
+ * in is an ordinary block, so this has to be exact rather than merely cautious.
505
+ */
506
+ export const relationship: {
507
+ <V>(options: RelationshipOptions & { value: V }): Unnamed<V>
508
+ <V>(options: RelationshipOptions & { default: V }): Unnamed<V>
509
+ (options: RelationshipOptions): Unnamed<undefined>
510
+ } = (options: RelationshipOptions) => ({
511
+ ...base<never>('relationship', options),
512
+ ...(options.hasMany === undefined ? {} : { hasMany: options.hasMany }),
513
+ ...(options.inverse === undefined ? {} : { inverse: options.inverse }),
514
+ relationTo: options.relationTo,
515
+ })
516
+
517
+ /**
518
+ * A field whose value lives once, in the client's Singles, and is pointed at from here.
519
+ *
520
+ * `reference('phone')` names the **default** key. The block stores nothing until a client
521
+ * overrides it, so a field that has never been touched still resolves — see the design note
522
+ * on why an unwired state is not allowed to exist.
523
+ *
524
+ * The platform resolves this on egress, so a site reads it exactly as it reads a text field
525
+ * and needs no concept of a Single at all.
526
+ *
527
+ * Not `relationTo`, deliberately: see `EditorFieldType`'s `'reference'` case in
528
+ * `contract.ts` for why sharing it with `relationship()` would put `_singles` where a
529
+ * client's own collections belong.
530
+ */
531
+ export const reference = (
532
+ defaultKey: string,
533
+ options: { description?: string; label?: string } = {},
534
+ ) => ({ ...base<never>('reference', options), referenceKey: defaultKey })
535
+
536
+ /**
537
+ * The committed value of every child that carries one, keyed by name.
538
+ *
539
+ * Read at construction time, before `namedFields` strips `value` from each child on its way
540
+ * into the group's own schema `fields`. By the time a group is walked from the outside —
541
+ * `defineSite`'s `blockValues`, say — its children are schema-only and carry nothing;
542
+ * aggregating here, once, is what lets a bare `group()` still contribute its children's
543
+ * committed copy. A nested group's own aggregate was already computed when *it* was built,
544
+ * so reading its top-level `value` here is enough — no recursion required.
545
+ */
546
+ const aggregateValues = (fields: FieldSpec): Record<string, unknown> | undefined => {
547
+ const out: Record<string, unknown> = {}
548
+ for (const [name, field] of Object.entries(fields)) {
549
+ const value = (field as { value?: unknown }).value
550
+ if (value !== undefined) out[name] = value
551
+ }
552
+ return Object.keys(out).length > 0 ? out : undefined
553
+ }
554
+
555
+ /**
556
+ * What a group's children commit between them — or `undefined` when that is nothing at all.
557
+ *
558
+ * Mirrors `aggregateValues`, which returns `undefined` rather than an empty object when no
559
+ * child carries a value. A group of fields a client fills in and the site commits none of is
560
+ * absent from `site.values` entirely, so the type has to be absent too; saying `{ open:
561
+ * undefined; close: undefined }` would let `brand.contact.hours.open` compile against a
562
+ * `hours` that is itself `undefined`.
563
+ *
564
+ * **This rests on every helper answering `undefined` — not `unknown` — when it was handed no
565
+ * carrier**, and it is why `relationship` and `blocks` had to stop answering `unknown`. One
566
+ * member that is merely *unreadable* rather than *absent* fails the test below, and the whole
567
+ * group then types as present. Any new helper has to keep that bargain or this silently
568
+ * over-promises again, one level above where the wrong type was written.
569
+ */
570
+ type Aggregate<F> =
571
+ { [K in keyof F]: Committed<F[K]> } extends Record<string, undefined>
572
+ ? undefined
573
+ : { [K in keyof F]: Committed<F[K]> }
574
+
575
+ /** The seed default of every child that carries one, keyed by name. See `aggregateValues`. */
576
+ const aggregateDefaults = (fields: FieldSpec): Record<string, unknown> | undefined => {
577
+ const out: Record<string, unknown> = {}
578
+ for (const [name, field] of Object.entries(fields)) {
579
+ const value = (field as { default?: unknown }).default
580
+ if (value !== undefined) out[name] = value
581
+ }
582
+ return Object.keys(out).length > 0 ? out : undefined
583
+ }
584
+
585
+ /**
586
+ * A named group of fields, edited together as one panel.
587
+ *
588
+ * Carries its own `default`/`value` the way every other helper does — aggregated from
589
+ * whichever children carry one, at construction time, before `namedFields` strips those
590
+ * carriers from the children on their way into this group's own schema `fields`. An
591
+ * explicit `default`/`value` passed to `group()` itself wins over the aggregate: `link()`
592
+ * hands `group()` its own translated `default`/`value`, and those must not be overwritten
593
+ * by an aggregate over its five schema-only children.
594
+ *
595
+ * The type follows the runtime's precedence exactly, which is why `default` and `value` are
596
+ * lifted out of `Common` and given parameters of their own: an explicit `value` wins, else
597
+ * the children's aggregate, else an explicit `default`. **A group none of whose children
598
+ * committed anything aggregates to nothing, and `aggregateValues` returns `undefined` rather
599
+ * than `{}`** — so `Aggregate` collapses to `undefined` in that case, and a reader is made to
600
+ * check before dereferencing something that is genuinely not there.
601
+ *
602
+ * The aggregate is computed from the *spec map* the caller wrote, never from `fields` on the
603
+ * returned descriptor — `namedFields` has already turned that into an `EditorField[]` with
604
+ * the carriers stripped, so a reader walking it from the outside would find nothing at all.
605
+ */
606
+ export const group = <F extends FieldSpec, V = never, D = undefined>(
607
+ options: Omit<Common, 'default' | 'value'> & { default?: D; fields: F; value?: V },
608
+ ): Unnamed<[V] extends [never] ? (Aggregate<F> extends undefined ? D : Aggregate<F>) : V> => {
609
+ const aggregatedDefault = aggregateDefaults(options.fields)
610
+ const aggregatedValue = aggregateValues(options.fields)
611
+ const groupDefault = options.default !== undefined ? options.default : aggregatedDefault
612
+ const groupValue =
613
+ options.value !== undefined
614
+ ? options.value
615
+ : aggregatedValue !== undefined
616
+ ? aggregatedValue
617
+ : groupDefault
618
+
619
+ return {
620
+ ...base<never>('group', {
621
+ ...options,
622
+ ...(groupDefault === undefined ? {} : { default: groupDefault }),
623
+ ...(groupValue === undefined ? {} : { value: groupValue }),
624
+ }),
625
+ fields: namedFields(options.fields),
626
+ }
627
+ }
628
+
629
+ /**
630
+ * A committed button in the shape the platform stores.
631
+ *
632
+ * The site writes what it renders — `{ href, label }`, what `resolveLink(cms, fallback)`
633
+ * takes — while the inspector edits a group of `{ kind, url, newTab, … }`. One translation,
634
+ * read by `link()` for its own seed and by `seedItem` for a link inside an array item; they
635
+ * were two copies of the same six lines, which is one copy too many for a shape the platform
636
+ * stores permanently.
637
+ */
638
+ const storedLink = (committed: LinkValue & { text?: string }): Record<string, unknown> => ({
639
+ /*
640
+ * A sentence's copy rides through the same translation as the link inside it, because a
641
+ * sentence *is* a link group with one field more — see `sentence()`. Conditional rather
642
+ * than always present, so an ordinary button stores exactly the four keys it always did.
643
+ */
644
+ ...(committed.text === undefined ? {} : { text: committed.text }),
645
+ ...(committed.label === undefined ? {} : { label: committed.label }),
646
+ kind: 'url',
647
+ newTab: committed.newTab ?? false,
648
+ url: committed.href,
649
+ })
650
+
651
+ /**
652
+ * A link `default` already in the shape the platform stores, rather than the shape a
653
+ * component renders. `link()` must accept either: before this file translated `{ href }`
654
+ * into `{ kind, url }`, a `default` passed straight through, so a site whose own helper
655
+ * already builds the stored shape — as jdsi's `seedLink()` does — got it stored correctly.
656
+ * Making `link()` translate unconditionally silently broke that site on upgrade, with no
657
+ * error: `committed.href` read `undefined` off an object that never had an `href`, and the
658
+ * button lost its address.
659
+ *
660
+ * Discriminated by a key only the stored shape carries — `kind` or `url` — never by the
661
+ * *absence* of `href`. A committed link legitimately written as `{ href: undefined }` for
662
+ * some placeholder case would otherwise be misread as already-stored.
663
+ */
664
+ const isStoredLinkValue = (value: unknown): value is Record<string, unknown> =>
665
+ isRecord(value) && ('kind' in value || 'url' in value)
666
+
667
+ /**
668
+ * The rendered equivalent of an already-stored link — the inverse of `storedLink`, read only
669
+ * to populate `value`.
670
+ *
671
+ * `value` is what `site.values` carries into the component that actually renders this link,
672
+ * and a component reads `{ href, label }` — see `LinkValue`. Leaving `value` empty when
673
+ * `default` arrives pre-stored would mean `brand.*.cta.href` silently disappears at the one
674
+ * carrier a component reads, even though the field's stored `url` is intact. `href` falls
675
+ * back to `''` rather than `undefined` for a `kind: 'page'` default with no `url` of its own
676
+ * — there is no resolved page address available at spec-building time, and `''` keeps
677
+ * `LinkValue.href` the required string a component's `resolveLink` expects rather than
678
+ * widening it to optional for a case no site actually writes today.
679
+ */
680
+ const valueFromStored = (stored: Record<string, unknown>): LinkValue & { text?: string } => ({
681
+ ...(stored.text === undefined ? {} : { text: stored.text as string }),
682
+ ...(stored.label === undefined ? {} : { label: stored.label as string }),
683
+ href: (stored.url as string | undefined) ?? '',
684
+ ...(stored.newTab === undefined ? {} : { newTab: stored.newTab as boolean }),
685
+ })
686
+
687
+ /**
688
+ * The blocks a zone field may hold, from whichever form it used.
689
+ *
690
+ * **This is the third copy of this exact resolution rule**, not the second. `childBlockFor` /
691
+ * `childBlocksFor` in `src/dashboard/canvas/nestedBlocks.ts` is the platform's own authority;
692
+ * `childrenOf` in `uploads.ts` (this package) duplicates it because uploads.ts is vendored into
693
+ * every client's site and cannot import from the platform's `src/`. This is a third copy for
694
+ * the same reason — `defineBlocks.ts` cannot import from `uploads.ts` either without pulling
695
+ * upload-collection code into a seeding path that has nothing to do with it, and the two
696
+ * modules within this package are otherwise independent. Three copies, named by file in all
697
+ * three doc comments — say the number and every path, not "the pair" or "the only duplicate",
698
+ * or the count is the next thing to go stale. `verify:nested-blocks` checks all three agree,
699
+ * not only `nestedBlocks.ts` and `uploads.ts`; this function is not exported, so that check
700
+ * exercises it through observable behaviour rather than calling it directly — see the check
701
+ * itself for why.
702
+ *
703
+ * `field.blocks` (inline definitions) is returned as-is when present; otherwise each entry of
704
+ * `field.childBlocks` (slugs) is looked up in `allBlocks` — the site's full declared block
705
+ * library — skipping any slug that resolves to nothing.
706
+ */
707
+ const childrenOf = (field: EditorField, allBlocks: EditorBlock[]): EditorBlock[] => {
708
+ if (field.blocks !== undefined) return field.blocks
709
+
710
+ const resolved: EditorBlock[] = []
711
+ for (const slug of field.childBlocks ?? []) {
712
+ const block = allBlocks.find((entry) => entry.slug === slug)
713
+ if (block !== undefined) resolved.push(block)
714
+ }
715
+ return resolved
716
+ }
717
+
718
+ /**
719
+ * One zone's committed items, each translated against the block it names.
720
+ *
721
+ * A zone's children are heterogeneous — every entry names its own shape by `blockType` — so
722
+ * unlike `group`/`array` there is no single `fields` to walk them against. Each entry is
723
+ * looked up in `allBlocks` by that name and walked with `seedItem` against *its own* declared
724
+ * fields; an entry naming a block `allBlocks` does not carry (a slug that resolves to nothing,
725
+ * or simply not a record) is passed through untouched, the same "prune, do not correct" rule
726
+ * `seedItem` applies to a value of the wrong shape.
727
+ *
728
+ * `blockType` itself is not a declared field of the child block, so `seedItem`'s own loop —
729
+ * which only ever copies keys present in the `fields` map it is given — would drop it. It is
730
+ * added back explicitly, from the resolved block's own slug rather than the entry's, for the
731
+ * reason `publishedBlock` refuses a slugless zone block at declaration time: the stored name
732
+ * and the declared name must be the same string or a later read would not find the block this
733
+ * write resolved against.
734
+ */
735
+ const seedZone = (field: EditorField, raw: unknown[], allBlocks: EditorBlock[]): unknown[] => {
736
+ const nested = new Map(childrenOf(field, allBlocks).map((block) => [block.slug, block]))
737
+ return raw.map((entry) => {
738
+ if (!isRecord(entry)) return entry
739
+ const blockType = (entry as { blockType?: unknown }).blockType
740
+ const child = typeof blockType === 'string' ? nested.get(blockType) : undefined
741
+ if (!child) return entry
742
+ return { ...seedItem(namedFieldSpec(child.fields), entry, allBlocks), blockType: child.slug }
743
+ })
744
+ }
745
+
746
+ /**
747
+ * One array item, reduced to what the platform should store.
748
+ *
749
+ * Undeclared keys are dropped and a nested `link` is translated — the same rules the field
750
+ * helpers apply one level up, applied per item because an array's values arrive as whole
751
+ * objects rather than through the helpers.
752
+ *
753
+ * **Uploads used to be dropped here too**, and now ride along as the committed repo path.
754
+ * See `upload()` for why that flipped: the path is the only thing that tells the platform's
755
+ * media ingestion which uploaded file belongs in which slot, and a row's portrait is written
756
+ * per row — so pruning it here left `relinkSeedAssets` with nothing to relink and every
757
+ * seeded row imageless. The platform drops any path ingestion did not relink, which is what
758
+ * keeps a repo path from ever being stored as a media id.
759
+ *
760
+ * A field is translated as a link only when it carries `LINK_FIELD_MARKER` — i.e. it was
761
+ * built by `link()` — never by guessing from shape. A plain `group` whose committed value
762
+ * happens to have an `href` alongside other keys (a card with a title and a link, say) is
763
+ * not a link and must survive with every one of its keys intact.
764
+ *
765
+ * **`group`, `array` and `blocks` all recurse, because the rule is the field type and not the
766
+ * nesting level** — this used to say only `group` and `array` did, which was true right up
767
+ * until a site declared a zone with committed children and the missing case went unnoticed
768
+ * until PBIR's nav did exactly that. A `group` or `array` *inside* an item is walked against
769
+ * its own declared fields, which is what carries a nested `upload()`'s path through to the
770
+ * platform and what prunes an undeclared key a one-level walk would let ride along. A `blocks`
771
+ * zone recurses differently — see `seedZone` — because its children do not share one shape to
772
+ * walk them against; each is resolved against the block its own `blockType` names, out of
773
+ * `allBlocks`, the full library this call was handed.
774
+ *
775
+ * **`allBlocks` is only ever non-empty when this walk starts from `startersFrom`.** The other
776
+ * two callers — `array()`'s own `default` translation and `defineCollections`' row seeding —
777
+ * run while a site's module is still being evaluated, before `defineSite` has finished
778
+ * collecting every declared block into one list, so a zone nested inside an array item or a
779
+ * collection row cannot resolve a slug-named child (`of: [...]`) at that point and is passed
780
+ * through unresolved — not a regression, the same as before this case existed at all. Only a
781
+ * zone reached through a block's own top-level field, or nested inside another such zone's own
782
+ * items, resolves — which is every zone this plan's PBIR case needed.
783
+ *
784
+ * Those nested fields are an `EditorField[]` rather than the map the author wrote —
785
+ * `group()` and `array()` call `namedFields` on their children when they are *constructed*,
786
+ * long before anything seeds — so `namedFieldSpec` puts the names back on before the
787
+ * recursion, which is the same normalisation `defineSite`'s `validate` needs for the same
788
+ * reason. A committed value of the wrong shape for its field (a string where a group is
789
+ * declared, `null` where an array is) is passed through untouched rather than forced: this
790
+ * prunes, it does not correct.
791
+ */
792
+ const seedItem = (
793
+ fields: FieldSpec,
794
+ item: Record<string, unknown>,
795
+ allBlocks: EditorBlock[],
796
+ ): Record<string, unknown> => {
797
+ const out: Record<string, unknown> = {}
798
+ for (const [name, field] of Object.entries(fields)) {
799
+ if (!(name in item)) continue
800
+ const descriptor = field as unknown as EditorField & {
801
+ fields?: unknown
802
+ [LINK_FIELD_MARKER]?: true
803
+ }
804
+ const raw = item[name]
805
+ if (descriptor.element === 'video' || descriptor.element === 'file') {
806
+ out[name] =
807
+ descriptor.element === 'video'
808
+ ? videoSeedForImport(raw, { posterVariants: descriptor.poster?.variants ?? {} })
809
+ : fileSeedForImport(raw)
810
+ continue
811
+ }
812
+ if (descriptor.element === 'image') {
813
+ const normalized = normalizeImageSeed(raw)
814
+ for (const key of Object.keys(normalized?.variants ?? {}))
815
+ if (!Object.hasOwn(descriptor.variants ?? {}, key))
816
+ throw new ImageValidationError([
817
+ { path: `${name}.variants.${key}`, message: 'Variant is not declared by this field.' },
818
+ ])
819
+ out[name] = imageSeedForImport(normalized)
820
+ continue
821
+ }
822
+ if (descriptor[LINK_FIELD_MARKER]) {
823
+ // Either shape, for the reason `link()` accepts either — see `isStoredLinkValue`. A
824
+ // row or item whose helper already built the stored form must not be translated twice,
825
+ // which reads `href` off an object that never had one and drops the address.
826
+ out[name] = isStoredLinkValue(raw) ? raw : storedLink(raw as LinkValue)
827
+ continue
828
+ }
829
+ if (descriptor.type === 'group' && isRecord(raw)) {
830
+ out[name] = seedItem(namedFieldSpec(descriptor.fields), raw, allBlocks)
831
+ continue
832
+ }
833
+ if (descriptor.type === 'array' && Array.isArray(raw)) {
834
+ const nested = namedFieldSpec(descriptor.fields)
835
+ out[name] = (raw as unknown[]).map((entry) =>
836
+ isRecord(entry) ? seedItem(nested, entry, allBlocks) : entry,
837
+ )
838
+ continue
839
+ }
840
+ if (descriptor.type === 'blocks' && Array.isArray(raw)) {
841
+ out[name] = seedZone(descriptor, raw as unknown[], allBlocks)
842
+ continue
843
+ }
844
+ out[name] = raw
845
+ }
846
+ return out
847
+ }
848
+
849
+ /**
850
+ * A field descriptor built by one of the helpers above, as opposed to a `FieldSpec` map.
851
+ *
852
+ * `base` always stamps a string `type` and a string `label` onto whatever it returns, so a
853
+ * descriptor is recognisable by that pair and a fields-map is not — a map's own `.type`
854
+ * property only exists when one of its fields happens to be named `type`, and that field's
855
+ * value is itself a descriptor object, not a string. This is what lets `array` tell its
856
+ * legacy form apart from a new-form field-spec that happens to declare a field named `fields`.
857
+ */
858
+ const isFieldDescriptor = (value: unknown): boolean =>
859
+ value !== null &&
860
+ typeof value === 'object' &&
861
+ typeof (value as Record<string, unknown>).type === 'string' &&
862
+ typeof (value as Record<string, unknown>).label === 'string'
863
+
864
+ /**
865
+ * A repeating set of items that all share one shape.
866
+ *
867
+ * `array(fields, items)` — the fields first, the committed items second. The items are the
868
+ * value the component falls back to and may carry keys the fields do not declare: a slide's
869
+ * `video`, a poster's `posterFallback`. Those ride along in `site.values` and are pruned out
870
+ * of the seed, because a key with no field is a key no client can edit and the platform has
871
+ * no business storing it.
872
+ *
873
+ * The legacy `array({ fields, … })` form is still accepted, so specs written for
874
+ * `defineBlocks` keep compiling. It is distinguished by carrying a `fields` key whose value
875
+ * is *not itself a field descriptor* — see `isFieldDescriptor`. The legacy branch is passed
876
+ * straight to `base` untouched: no `seedItem` pruning runs over it, because its `default` was
877
+ * already written as whole stored rows by the site author, not derived from committed items.
878
+ *
879
+ * The committed items are also where the *type* comes from: an item is a whole object, so
880
+ * there is nothing to read it off except the items themselves. Reading it off `fields`
881
+ * instead would give the wrong answer even in principle — `namedFields` runs on those at
882
+ * construction time, so by the time anything can look, the shape is an `EditorField[]` with
883
+ * neither the carriers nor the author's key names left on it.
884
+ *
885
+ * **`array(fields)` with no items commits nothing, and is typed `undefined` rather than an
886
+ * empty list.** This is the sharpest case of the four `Scalar` covers, because the reader is
887
+ * a `.map()` rather than a property access: a type saying `Record<string, unknown>[]` would
888
+ * compile `brand.hero.gallery.map(…)` straight into a `TypeError`. It is *not* fixed by
889
+ * writing `[]` at runtime — a declared-but-uncommitted array genuinely has nothing in it, and
890
+ * a site that means an empty list writes `array(fields, [])` and gets one. (A collection
891
+ * differs: `defineSite` writes `rows ?? []` there, so it reads as `[]` in both the type and
892
+ * at runtime.)
893
+ */
894
+ export const array: {
895
+ <I extends Record<string, unknown>>(first: FieldSpec, items: I[], extra?: Common): Unnamed<I[]>
896
+ <V>(first: Common & { fields: FieldSpec; value: V }): Unnamed<V>
897
+ <V>(first: Common & { fields: FieldSpec; default: V }): Unnamed<V>
898
+ (first: Common & { fields: FieldSpec }): Unnamed<undefined>
899
+ (first: FieldSpec): Unnamed<undefined>
900
+ } = (
901
+ first: (Common & { fields: FieldSpec }) | FieldSpec,
902
+ items?: Record<string, unknown>[],
903
+ extra: Common = {},
904
+ ) => {
905
+ const legacy = 'fields' in first && !isFieldDescriptor((first as { fields: unknown }).fields)
906
+
907
+ if (legacy) {
908
+ const options = first as Common & { fields: FieldSpec }
909
+ return {
910
+ ...base<never>('array', options),
911
+ fields: namedFields(options.fields),
912
+ }
913
+ }
914
+
915
+ const fields = first as FieldSpec
916
+ const options: Common = { ...extra, ...(items === undefined ? {} : { value: items }) }
917
+ const value = ('value' in options ? options.value : options.default) as
918
+ Record<string, unknown>[] | undefined
919
+
920
+ return {
921
+ ...base<never>('array', {
922
+ ...options,
923
+ /*
924
+ * `allBlocks: []`, not the site's full library — this runs while the site's module is
925
+ * still being evaluated, before `defineSite` has finished collecting every declared
926
+ * block into one list. A zone nested inside one of these items and named by slug
927
+ * (`of: [...]`) cannot resolve here for exactly that reason; see the note on
928
+ * `seedItem`. An inline zone (`blocks: [...]`) is unaffected — `childrenOf` reads that
929
+ * form straight off the field, no registry needed.
930
+ */
931
+ ...(value === undefined ? {} : { default: value.map((item) => seedItem(fields, item, [])) }),
932
+ ...(value === undefined ? {} : { value }),
933
+ }),
934
+ fields: namedFields(fields),
935
+ }
936
+ }
937
+
938
+ /**
939
+ * A dynamic zone: an ordered list of sections whose shapes differ from one another.
940
+ *
941
+ * The distinction from `array` is *shape*, not ownership. An array's items all have the
942
+ * same fields, so one field list describes every one of them. A zone's sections each name
943
+ * their own block in `blockType`, and are read against that block alone — which is what
944
+ * lets one project lead with a gallery and the next with a quote.
945
+ *
946
+ * The client adds, removes and reorders these in Canvas, and that is the point: on a row,
947
+ * arrangement *is* the content. Give it the blocks the section may be — usually a subset
948
+ * of the same `defineBlocks` call the rest of the site uses, since a block is worth
949
+ * writing once:
950
+ *
951
+ * body: blocks({ label: 'Body', blocks: [gallery, quote] })
952
+ *
953
+ * Two things worth knowing before you declare one. A section is created empty — the
954
+ * platform holds no defaults, deliberately — so a block with a `required` field cannot be
955
+ * added by a client at all until they fill it, and one with no editable fields at all
956
+ * renders in the rail with nothing under it. Both are legal; neither is much use.
957
+ *
958
+ * A zone is created empty, so the uncommitted form is the *normal* one here rather than the
959
+ * exception — all the more reason for it to answer `undefined`. See `relationship` for why
960
+ * `unknown` was not safe.
961
+ *
962
+ * **Either form of block is accepted**: a `defineBlocks` result, or the `block()` entries a
963
+ * brand document writes. Without the second, a site using `defineSite` would have to keep a
964
+ * `defineBlocks` call beside it purely to describe its zones, which is the duplication the
965
+ * one-document rule exists to remove. A zone block seeds nothing whichever way it is written
966
+ * — only a top-level entry seeds — so `block()`'s `seed` is not read here.
967
+ *
968
+ * **A zone's children can be named two ways.** `blocks: [gallery, quote]` above embeds their
969
+ * definitions, which is what every zone has done until now. `of: ['navItem']` instead names
970
+ * slugs, resolved against the site's declared blocks when read — the only form that can
971
+ * express a block containing itself, since an embedded definition would have to serialise its
972
+ * own expansion forever. Exactly one of the two is meaningful on any call, and this constructor
973
+ * throws if a caller supplies neither or both — a typed call can't reach either case, but an
974
+ * agent-authored declaration built from a spread object or an `any`-typed value can, and a loud
975
+ * failure at declaration time beats an empty zone silently rendering as a section with nothing
976
+ * in it. The platform's manifest validator (`src/manifest/validate.ts`) enforces the same rule
977
+ * again on the way in, for a declaration that arrives some other way.
978
+ *
979
+ * **A zone may commit its children whichever way it names them.** `value`/`default` resolve the
980
+ * same for `of: [...]` as they do for `blocks: [...]` — a nav item with sub-items needs the
981
+ * slug form to express itself at all, and needs a starting menu just as much as any other zone
982
+ * does, so the carrier overloads exist for both forms rather than only the older one.
983
+ */
984
+ export const blocks: {
985
+ <V>(options: Common & { blocks: ZoneBlock[]; value: V }): Unnamed<V>
986
+ <V>(options: Common & { blocks: ZoneBlock[]; default: V }): Unnamed<V>
987
+ (options: Common & { blocks: ZoneBlock[] }): Unnamed<undefined>
988
+ <V>(options: Common & { of: string[]; value: V }): Unnamed<V>
989
+ <V>(options: Common & { of: string[]; default: V }): Unnamed<V>
990
+ (options: Common & { of: string[] }): Unnamed<undefined>
991
+ } = (options: Common & { blocks?: ZoneBlock[]; of?: string[] }) => {
992
+ if (options.blocks === undefined && options.of === undefined) {
993
+ throw new Error(
994
+ `A zone names no children. Give it blocks: [...] to embed their definitions, or of: [...] to name their slugs — a zone with neither is a field with nothing to render.`,
995
+ )
996
+ }
997
+ if (options.blocks !== undefined && options.of !== undefined) {
998
+ throw new Error(
999
+ `A zone names its children two ways at once. blocks: [...] embeds definitions and of: [...] names slugs; the two would disagree the moment a block was renamed, so exactly one is required, not both.`,
1000
+ )
1001
+ }
1002
+
1003
+ return {
1004
+ ...base<never>('blocks', options),
1005
+ /*
1006
+ * One form or the other, never both — enforced above, so `options.blocks` is defined in
1007
+ * exactly the branch that reads it.
1008
+ */
1009
+ ...(options.of === undefined
1010
+ ? { blocks: options.blocks!.map(publishedBlock) }
1011
+ : { childBlocks: options.of }),
1012
+ }
1013
+ }
1014
+
1015
+ /** A section a zone may hold: already published, or still as the site wrote it. */
1016
+ export type ZoneBlock = BlockEntry<FieldSpec> | EditorBlock
1017
+
1018
+ /** Guarded like `isBlock`/`isCollection`: `in` on a `null` from a JS caller is a TypeError. */
1019
+ const isBlockEntry = (value: ZoneBlock): value is BlockEntry<FieldSpec> =>
1020
+ value !== null && typeof value === 'object' && BLOCK_ENTRY in value
1021
+
1022
+ /**
1023
+ * A zone's block in the published form, from whichever form the site wrote.
1024
+ *
1025
+ * The conversion is `defineBlocks`'s, key for key, because the same block has to arrive at
1026
+ * the platform the same way no matter which call built it — the schema is what stored content
1027
+ * is read back against.
1028
+ *
1029
+ * A zone block has no document key to take a slug from, so it states its own. Refusing here
1030
+ * is the whole reason this throws rather than inventing one: a slug keys stored content
1031
+ * permanently, and a guess made at publish time would be a different guess after any edit.
1032
+ */
1033
+ const publishedBlock = (entry: ZoneBlock): EditorBlock => {
1034
+ if (!isBlockEntry(entry)) return entry
1035
+ const { slug } = entry
1036
+ if (!slug) {
1037
+ throw new Error(
1038
+ `A block in this zone declares no slug. A zone's blocks are not keyed by a document key, so each states its own: block({ … }, { slug: 'gallery' }). Stored sections name their block by slug, so one without a slug could never be read back.`,
1039
+ )
1040
+ }
1041
+ return { fields: namedFields(entry.fields), label: entry.label ?? humanise(slug), slug }
1042
+ }
1043
+
1044
+ /**
1045
+ * A button as the *site* renders it — what `resolveLink(cms, fallback)` takes.
1046
+ *
1047
+ * Deliberately not the shape the platform stores; see `link()` for the translation between
1048
+ * the two, and `Common.value` for why a helper may commit one shape and seed another.
1049
+ */
1050
+ export type LinkValue = { href: string; label?: string; newTab?: boolean }
1051
+
1052
+ /**
1053
+ * A button: its words, where it goes, and whether it opens a new tab.
1054
+ *
1055
+ * The group it returns carries `button: true` into the published schema, and that flag —
1056
+ * not the key the site happened to file it under — is what makes the inspector edit the
1057
+ * whole thing as one panel instead of five separate fields. It used to be the name: a
1058
+ * group called exactly `link` got the panel and anything else got five loose controls,
1059
+ * which spent a block's one good name on its first button and left a second one with
1060
+ * nowhere to go. Writing `cta: link({ label: 'Submit Button' })` beside `link: link({ … })`
1061
+ * now gives two buttons, each named for what it does.
1062
+ *
1063
+ * `withLabel: false` is for a tile or row whose entire surface is the link, so no button
1064
+ * text is rendered and modelling one would put a dead field in the client's editor.
1065
+ *
1066
+ * `link()` with no committed button commits nothing: all five of its children are declared
1067
+ * without carriers, so the group aggregates to `undefined`. The committed type is read back
1068
+ * off what was written, so `brand.hero.cta.label` is a `string` when a label was written and
1069
+ * a compile error when the site never wrote one — rather than a `TypeError` in a component
1070
+ * rendering a button that does not exist.
1071
+ */
1072
+ /**
1073
+ * The five fields a destination is made of, built once because two helpers need them.
1074
+ *
1075
+ * `link()` and `sentence()` publish the same button underneath — the inspector's panel edits
1076
+ * `label`, `kind`, `page`, `url` and `newTab` whichever of the two declared them, and a
1077
+ * second copy of the list here would be a second copy of the shape the platform stores.
1078
+ */
1079
+ const linkFields = ({
1080
+ pageCollection,
1081
+ withLabel,
1082
+ }: {
1083
+ pageCollection: string
1084
+ withLabel: boolean
1085
+ }): FieldSpec => ({
1086
+ ...(withLabel ? { label: text({ description: 'The words on the button.' }) } : {}),
1087
+ kind: select({
1088
+ description: 'Where the button goes.',
1089
+ options: [
1090
+ { label: 'A page on this site', value: 'page' },
1091
+ { label: 'A web address', value: 'url' },
1092
+ ],
1093
+ }),
1094
+ page: relationship({ description: 'Pick one of your pages.', relationTo: pageCollection }),
1095
+ url: text({
1096
+ description: 'A full address, a path on this site, or a mailto:/tel: link.',
1097
+ localized: false,
1098
+ }),
1099
+ newTab: checkbox({ label: 'Open in a new tab' }),
1100
+ })
1101
+
1102
+ export const link: {
1103
+ <V extends LinkValue>(
1104
+ options: Common & { pageCollection?: string; value: V; withLabel?: boolean },
1105
+ ): Unnamed<V>
1106
+ <V extends LinkValue>(
1107
+ options: Common & { default: V; pageCollection?: string; withLabel?: boolean },
1108
+ ): Unnamed<V>
1109
+ (options?: Common & { pageCollection?: string; withLabel?: boolean }): Unnamed<undefined>
1110
+ } = (options: Common & { pageCollection?: string; withLabel?: boolean } = {}) => {
1111
+ const { pageCollection = 'pages', withLabel = true } = options
1112
+ const raw = options.default as undefined | LinkValue | Record<string, unknown>
1113
+
1114
+ /*
1115
+ * A committed button is written the way the site renders it — `{ href, label }`, what
1116
+ * `resolveLink(cms, fallback)` takes. The platform stores the group the inspector edits,
1117
+ * which is a different shape. Translating here rather than asking each site to write
1118
+ * `{ kind: 'url', url }` by hand is what removes jdsi's `seedLink` helper — but a site
1119
+ * that already writes the stored shape (jdsi's own `seedLink()` did, until it was
1120
+ * removed) must still be accepted: see `isStoredLinkValue`.
1121
+ */
1122
+ const alreadyStored = raw !== undefined && isStoredLinkValue(raw)
1123
+ const stored = raw === undefined ? undefined : alreadyStored ? raw : storedLink(raw as LinkValue)
1124
+ const committed =
1125
+ raw === undefined ? undefined : alreadyStored ? valueFromStored(raw) : (raw as LinkValue)
1126
+
1127
+ return {
1128
+ /*
1129
+ * Schema, unlike `LINK_FIELD_MARKER` beneath it: this one is meant to be published.
1130
+ * `namedFields` spreads whatever the descriptor carries into the field it emits, so
1131
+ * it reaches the platform as JSON and the inspector reads it there.
1132
+ */
1133
+ element: 'button' as const,
1134
+ ...group({
1135
+ ...options,
1136
+ ...(stored === undefined ? {} : { default: stored }),
1137
+ ...(committed === undefined ? {} : { value: committed }),
1138
+ fields: linkFields({ pageCollection, withLabel }),
1139
+ }),
1140
+ [LINK_FIELD_MARKER]: true,
1141
+ } as Unnamed<never>
1142
+ }
1143
+
1144
+ /** The marker that says where the link falls inside a sentence. See `sentence()`. */
1145
+ export const SENTENCE_LINK = '{link}'
1146
+
1147
+ /**
1148
+ * A sentence as the *site* renders it: the copy, and the link inside it.
1149
+ *
1150
+ * `text` holds the whole line with `{link}` standing where the link goes. One string, not
1151
+ * two, and that is the entire point — see `sentence()`.
1152
+ */
1153
+ export type SentenceValue = LinkValue & { text: string }
1154
+
1155
+ /**
1156
+ * A line of copy with one link inside it, edited as one thing.
1157
+ *
1158
+ * privacyNote: sentence({
1159
+ * label: 'Privacy Note',
1160
+ * default: {
1161
+ * text: 'See our {link} for how we handle your details.',
1162
+ * href: '/privacy',
1163
+ * label: 'Privacy Policy',
1164
+ * },
1165
+ * })
1166
+ *
1167
+ * **Why this exists rather than three fields.** pbir's privacy line was `before`, `link`,
1168
+ * `after` — a text field, a button and another text field, three rows in the rail for one
1169
+ * sentence. It read badly and it rendered wrongly: `text()` in `shape.ts` trims what it is
1170
+ * given, so `'See our '` came back without the space that separated it from the link, and
1171
+ * the page said `See ourPrivacy Policyfor how we handle your details.` **Whitespace between
1172
+ * two fields belongs to neither of them**, and no amount of care in either field can hold
1173
+ * it. One string can, because the spaces are interior to it.
1174
+ *
1175
+ * The link keeps everything a button has: its words, a page on this site or a web address,
1176
+ * and whether it opens a new tab. What it does not keep is a row of its own — the inspector
1177
+ * draws the sentence and its destination in one panel, which `element: 'sentence'` is what
1178
+ * asks for.
1179
+ *
1180
+ * `{link}` is a marker in the copy, not markup: a client types a normal sentence and the
1181
+ * marker says where the link falls. A sentence that has lost its marker still renders its
1182
+ * link, appended — see `resolveSentence` in `shape.ts`. A legal link that vanishes because
1183
+ * somebody rewrote the sentence around it is a worse failure than one in an awkward place.
1184
+ */
1185
+ export const sentence: {
1186
+ <V extends SentenceValue>(options: Common & { default: V; pageCollection?: string }): Unnamed<V>
1187
+ <V extends SentenceValue>(options: Common & { pageCollection?: string; value: V }): Unnamed<V>
1188
+ (options?: Common & { pageCollection?: string }): Unnamed<undefined>
1189
+ } = (options: Common & { pageCollection?: string } = {}) => {
1190
+ const { pageCollection = 'pages' } = options
1191
+ const raw = options.default as undefined | Record<string, unknown> | SentenceValue
1192
+
1193
+ /*
1194
+ * Translated exactly as `link()` translates its own default, and accepting either shape
1195
+ * for the same reason: a sentence stores `{ text, label, kind, url, newTab }` and renders
1196
+ * `{ text, href, label }`, and `storedLink`/`valueFromStored` are the one pair that knows
1197
+ * how those two correspond. `text` rides through both.
1198
+ */
1199
+ const alreadyStored = raw !== undefined && isStoredLinkValue(raw)
1200
+ const stored =
1201
+ raw === undefined ? undefined : alreadyStored ? raw : storedLink(raw as SentenceValue)
1202
+ const committed =
1203
+ raw === undefined ? undefined : alreadyStored ? valueFromStored(raw) : (raw as SentenceValue)
1204
+
1205
+ return {
1206
+ element: 'sentence' as const,
1207
+ ...group({
1208
+ ...options,
1209
+ ...(stored === undefined ? {} : { default: stored }),
1210
+ ...(committed === undefined ? {} : { value: committed }),
1211
+ fields: {
1212
+ /*
1213
+ * `textarea`, not `text`: a sentence wraps, and a client editing one wants to see all
1214
+ * of it. It is declared first so the inspector's panel reads in the order the client
1215
+ * thinks in — the words, then which words are the link, then where it goes.
1216
+ */
1217
+ text: textarea({
1218
+ description: `The line of copy. Put ${SENTENCE_LINK} where the link should fall.`,
1219
+ }),
1220
+ ...linkFields({ pageCollection, withLabel: true }),
1221
+ },
1222
+ }),
1223
+ /*
1224
+ * The same marker a button carries, because `seedItem` has the same job to do with a
1225
+ * sentence inside an array item: translate the committed shape into the stored one. A
1226
+ * sentence is a `group`, so without this it would take `seedItem`'s group branch, which
1227
+ * copies only the keys the schema declares — `href` is not one of them, and the link
1228
+ * would be seeded with no address at all.
1229
+ */
1230
+ [LINK_FIELD_MARKER]: true,
1231
+ } as Unnamed<never>
1232
+ }
1233
+
1234
+ export type FieldSpec = Record<string, AnyField>
1235
+
1236
+ /**
1237
+ * The tag that tells `defineSite` this entry is a layout.
1238
+ *
1239
+ * A symbol for the same reason `BLOCK_ENTRY` is one: a site spec is a flat object of the
1240
+ * author's own keys, and a carrier has to be recognisable without reserving a key name that
1241
+ * a site might legitimately want for something else.
1242
+ */
1243
+ export const LAYOUT = Symbol('u90.layout')
1244
+
1245
+ /** The reserved blockType marking where a page's own sections go. See `defineSite`. */
1246
+ export const SLOT_BLOCK = '_slot'
1247
+
1248
+ /**
1249
+ * The one entry of a layout that is not a block: the hole the page renders into.
1250
+ *
1251
+ * A frozen literal rather than a factory, because there is nothing to configure and two
1252
+ * calls must be indistinguishable — the one-slot rule below counts them.
1253
+ */
1254
+ export const SLOT = Object.freeze({ blockType: SLOT_BLOCK }) as { blockType: string }
1255
+
1256
+ export type LayoutMeta = { default?: boolean; label: string }
1257
+
1258
+ export type LayoutEntry = {
1259
+ [LAYOUT]: true
1260
+ entries: (Record<string, unknown> & { blockType: string })[]
1261
+ meta: LayoutMeta
1262
+ }
1263
+
1264
+ /**
1265
+ * A named arrangement of blocks with one hole in it.
1266
+ *
1267
+ * The entries are the same shape a page's sections are and a block's seed is — one object
1268
+ * per entry, keyed by `blockType` — so the manifest validator, the media walker and the
1269
+ * seed-upload relinker all work on them unchanged. That reuse is the whole reason a layout
1270
+ * is expressible at all without a second content pipeline.
1271
+ *
1272
+ * No slug: the declaration key names it, and that name is what re-seeding is keyed on.
1273
+ */
1274
+ export const layout = (
1275
+ meta: LayoutMeta,
1276
+ entries: (Record<string, unknown> & { blockType: string })[],
1277
+ ): LayoutEntry => ({ [LAYOUT]: true, entries, meta })
1278
+
1279
+ /**
1280
+ * The tag that tells `defineSite` this entry declares Singles.
1281
+ *
1282
+ * A symbol beside `LAYOUT`, for the reason that one is a symbol: a site spec is a flat
1283
+ * object of the author's own keys, and a carrier has to be recognisable without reserving a
1284
+ * key name a site might legitimately want.
1285
+ */
1286
+ export const SINGLES = Symbol('u90.singles')
1287
+
1288
+ /** What a site writes for one entry of `singles()`: the type, the client's name for it, and
1289
+ * the value the site ships with. The key comes from the map. */
1290
+ export type SingleSpec = { label: string; type: 'link' | 'text'; value: string }
1291
+
1292
+ export type SinglesEntry = {
1293
+ [SINGLES]: true
1294
+ declared: Record<string, SingleSpec>
1295
+ }
1296
+
1297
+ /**
1298
+ * The shared values this site starts with, keyed by the identifier blocks point at.
1299
+ *
1300
+ * A Single is one named scalar the client keeps once — a phone number, an ABN — and every
1301
+ * block that prints it declares `reference('phone')` and reads it as an ordinary text field.
1302
+ * A site could already *point at* one and had no way to *declare* one, so a freshly paired
1303
+ * client opened the Singles tab to nothing: the site rendered its committed fallbacks
1304
+ * perfectly and the values behind them were undiscoverable and uneditable, which is a worse
1305
+ * state than a visible mistake because nothing signals it.
1306
+ *
1307
+ * **This is not `business()` returning.** That was a category the platform invented, with a
1308
+ * fixed identity — a logo, a phone, an ABN, socials — so it only ever fitted the sites it
1309
+ * had been written for, and the platform had opinions about what each field meant. This says
1310
+ * only "the shared values this site starts with". The keys are the site's, the labels are
1311
+ * the client's, and what is seeded becomes rows the client wholly owns: theirs to edit,
1312
+ * rename, empty or delete, with no second opinion from here afterwards.
1313
+ *
1314
+ * **The declared value seeds the row; it does not replace the component's fallback.** A
1315
+ * component still renders `text(content.phone, COMMITTED.phone)`, because a site has to draw
1316
+ * itself when the platform is unreachable. Both read the same constant, so there is still one
1317
+ * literal per fact:
1318
+ *
1319
+ * singles({ phone: { label: 'Phone', type: 'text', value: COMMITTED.phone } })
1320
+ * text(content.phone, COMMITTED.phone)
1321
+ *
1322
+ * No slug and no meta: the map keys name the values, and that name is what re-seeding is
1323
+ * keyed on — the same bargain `layout()` strikes with its declaration key.
1324
+ */
1325
+ export const singles = (declared: Record<string, SingleSpec>): SinglesEntry => ({
1326
+ [SINGLES]: true,
1327
+ declared,
1328
+ })
1329
+
1330
+ export type BlockSpec = Record<
1331
+ string,
1332
+ { fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
1333
+ >
1334
+
1335
+ /**
1336
+ * Attach each field's name from its key, fill in any missing label, and drop `default`.
1337
+ *
1338
+ * This is the boundary: everything from here on is schema, published to the platform
1339
+ * and rendered into a client's editor. A default is seed data and stops here.
1340
+ *
1341
+ * `LINK_FIELD_MARKER` is the one thing that rides through, and only because it is a symbol:
1342
+ * the rest spread copies it, `JSON.stringify` drops it, and `seedItem` needs it to recognise
1343
+ * a link nested inside a group. See the marker's own note.
1344
+ */
1345
+ export const namedFields = (spec: FieldSpec): EditorField[] =>
1346
+ Object.entries(spec).map(([name, field]) => {
1347
+ const {
1348
+ default: _seed,
1349
+ value: _committed,
1350
+ ...schema
1351
+ } = field as Unnamed & {
1352
+ default?: unknown
1353
+ value?: unknown
1354
+ }
1355
+ return {
1356
+ ...schema,
1357
+ label: field.label || humanise(name),
1358
+ name,
1359
+ }
1360
+ }) as EditorField[]
1361
+
1362
+ /**
1363
+ * A nested `fields` value, reduced to the `FieldSpec` shape a recursive walk needs.
1364
+ *
1365
+ * A top-level entry's `fields` is the author-written map — untouched, keyed by name. A
1366
+ * `group()` or `array()` field's own `fields` is not: those helpers call `namedFields` on
1367
+ * their children synchronously, at construction time, before any walk begins — so by the time
1368
+ * `seedItem` or `defineSite`'s `validate` reaches them they have already become an
1369
+ * `EditorField[]`, each element carrying its name as a `.name` property rather than as an
1370
+ * object key. Walking that array with `Object.entries` would read the array's *indices*
1371
+ * ("0", "1", …) as if they were field names, which rejects every nested group and array a
1372
+ * site declares and prunes every nested value out of the seed. Rebuilding the map from each
1373
+ * element's own `.name` is what lets one recursive walk pass through either shape.
1374
+ */
1375
+ export const namedFieldSpec = (fields: unknown): FieldSpec =>
1376
+ Array.isArray(fields)
1377
+ ? (Object.fromEntries(
1378
+ (fields as { name: string }[]).map((field) => [field.name, field]),
1379
+ ) as unknown as FieldSpec)
1380
+ : (fields as FieldSpec)
1381
+
1382
+ export const defineBlocks = (spec: BlockSpec): EditorBlock[] =>
1383
+ Object.entries(spec).map(([slug, block]) => ({
1384
+ fields: namedFields(block.fields),
1385
+ label: block.label ?? humanise(slug),
1386
+ ...(block.layoutOnly === undefined ? {} : { layoutOnly: block.layoutOnly }),
1387
+ ...(block.renders === undefined ? {} : { renders: block.renders }),
1388
+ slug,
1389
+ }))
1390
+
1391
+ /**
1392
+ * What a fresh instance of each block contains, keyed by slug. See `SiteManifest.starters`.
1393
+ *
1394
+ * Reads both carriers, and it has to. `default` is defined as what the platform stores, so it
1395
+ * is preferred; but a site may say the same thing positionally — `text('Building WA.')` sets
1396
+ * `value`, what the component renders — and a client adding that block should get those words
1397
+ * rather than an empty box. `seedFrom` reads only `default` because a *seed* is written once
1398
+ * at pair time against content the site already serves; a starter has no such history.
1399
+ *
1400
+ * Blocks contributing nothing are omitted rather than published empty, so the dashboard can
1401
+ * tell "arrives ready to edit" from "arrives blank" and say so before the client commits.
1402
+ *
1403
+ * **`allBlocks` is why this takes a second argument `array()` and `group()` never needed.**
1404
+ * Every other field helper translates its own `default`/`value` into the stored button shape
1405
+ * the moment the site calls it — `link()` does this itself, and `array()` does it by calling
1406
+ * `seedItem` on each item during its own construction. `blocksField` cannot: a zone named by
1407
+ * slug (`of: [...]`) only says which block its children *may* be, not what that block's fields
1408
+ * are, and the block it names may not even have been declared yet in the site's own module —
1409
+ * see `navItem` in PBIR's `site.ts`, which names itself. The block library only exists whole
1410
+ * once `defineSite` finishes collecting every entry, which is this function's caller and the
1411
+ * one place that can resolve a slug zone's own committed children. A `blocks`-typed field's
1412
+ * chosen value is therefore routed through `seedZone` here, on the way into the starter — the
1413
+ * one translation `array`'s own construction-time pass could never have done for it.
1414
+ */
1415
+ export const startersFrom = (
1416
+ spec: BlockSpec,
1417
+ allBlocks: EditorBlock[],
1418
+ ): Record<string, Record<string, unknown>> => {
1419
+ const out: Record<string, Record<string, unknown>> = {}
1420
+
1421
+ for (const [slug, block] of Object.entries(spec)) {
1422
+ const fields = namedFieldSpec(block.fields)
1423
+ const starter: Record<string, unknown> = {}
1424
+
1425
+ for (const [name, field] of Object.entries(fields)) {
1426
+ const descriptor = field as unknown as EditorField
1427
+ const carrier = field as { default?: unknown; value?: unknown }
1428
+ const chosen = carrier.default !== undefined ? carrier.default : carrier.value
1429
+ if (chosen === undefined) continue
1430
+ starter[name] =
1431
+ descriptor.type === 'blocks' && Array.isArray(chosen)
1432
+ ? seedZone(descriptor, chosen, allBlocks)
1433
+ : chosen
1434
+ }
1435
+
1436
+ /*
1437
+ * A zone block states its own slug and a keyed one takes the document key. Read through
1438
+ * an optional so both forms land under the slug stored content actually names.
1439
+ */
1440
+ const key = (block as { slug?: string }).slug ?? slug
1441
+ if (Object.keys(starter).length > 0) out[key] = starter
1442
+ }
1443
+
1444
+ return out
1445
+ }
1446
+
1447
+ export type CollectionSpec = Record<
1448
+ string,
1449
+ {
1450
+ /** Where these rows render on the site. See `CollectionRoutes`. */
1451
+ appearsOn?: CollectionRoutes
1452
+ description?: string
1453
+ detailField?: string
1454
+ fields: FieldSpec
1455
+ itemNoun?: string
1456
+ label?: string
1457
+ /** One object per committed row, in the order the site renders them. */
1458
+ seed?: Record<string, unknown>[]
1459
+ titleField: string
1460
+ }
1461
+ >
1462
+
1463
+ /**
1464
+ * How a site declares its repeatable record types.
1465
+ *
1466
+ * export const collections = defineCollections({
1467
+ * projects: {
1468
+ * titleField: 'name',
1469
+ * detailField: 'sector',
1470
+ * fields: { name: text(), sector: text(), photo: upload() },
1471
+ * seed: brand.projects.map(({ name, sector }) => ({ name, sector })),
1472
+ * },
1473
+ * })
1474
+ *
1475
+ * Keyed by object for the same reason blocks are: the key *is* the slug, so two
1476
+ * collections cannot collide, and a collision here would make stored rows ambiguous
1477
+ * about which record type they belong to.
1478
+ *
1479
+ * Unlike a block, the seed is written as whole rows rather than collected from
1480
+ * per-field `default`s — a collection's seed is a *list*, and there is no sensible
1481
+ * way to spell "six rows" as a default on each field.
1482
+ *
1483
+ * **A row is pruned exactly as an array item is**, through the same `seedItem`: undeclared
1484
+ * keys dropped, marked links translated, uploads carried through as their committed path, at
1485
+ * every depth `seedItem` can resolve. The rule is the field's type, never the container it
1486
+ * sits in — a row and an item are the same problem one level apart. Left unpruned, a committed
1487
+ * `{ href }` reached the platform as a button with no address; jdsi's migration hit that and
1488
+ * it is what this exists to stop. A zone nested inside a row and named by slug does not
1489
+ * resolve here — see the note on the `seedItem` call below and on `seedItem` itself.
1490
+ */
1491
+ export const defineCollections = (spec: CollectionSpec): ManifestCollection[] =>
1492
+ Object.entries(spec).map(([slug, entry]) => ({
1493
+ ...(entry.appearsOn === undefined ? {} : { appearsOn: entry.appearsOn }),
1494
+ ...(entry.description === undefined ? {} : { description: entry.description }),
1495
+ ...(entry.detailField === undefined ? {} : { detailField: entry.detailField }),
1496
+ ...(entry.itemNoun === undefined ? {} : { itemNoun: entry.itemNoun }),
1497
+ /*
1498
+ * `allBlocks: []` for the same reason `array()`'s own default carries it: a collection's
1499
+ * rows are pruned while the site's module is still being evaluated, and `defineCollections`
1500
+ * is a public export a site may call standalone, without ever going through `defineSite` at
1501
+ * all — there is no site-wide block list to hand it even when one exists. A zone nested
1502
+ * inside a row and named by slug (`of: [...]`) does not resolve here; see `seedItem`.
1503
+ */
1504
+ ...(entry.seed === undefined
1505
+ ? {}
1506
+ : { seed: entry.seed.map((row) => seedItem(entry.fields, row, [])) }),
1507
+ fields: namedFields(entry.fields),
1508
+ label: entry.label ?? humanise(slug),
1509
+ slug,
1510
+ titleField: entry.titleField,
1511
+ }))
1512
+
1513
+ /**
1514
+ * Build the manifest this site publishes.
1515
+ *
1516
+ * `generatedAt` is stamped at call time rather than at build time, so an operator
1517
+ * looking at a stale schema in the dashboard sees when the *running* site last
1518
+ * reported it, not when the bundle was compiled.
1519
+ */
1520
+ export const createManifest = ({
1521
+ blocks,
1522
+ collections,
1523
+ newPage,
1524
+ pages = [],
1525
+ routesAllPages,
1526
+ seed,
1527
+ }: {
1528
+ blocks: EditorBlock[]
1529
+ /** Repeatable record types, from `defineCollections`. Their seeds ride along. */
1530
+ collections?: ManifestCollection[]
1531
+ /**
1532
+ * The sections a client-added page starts with. See `SiteManifest.newPage`.
1533
+ *
1534
+ * A literal `SeedSection[]`, **not** a `BlockSpec` like `seed` — there is no `seedFrom`
1535
+ * pass over it. `seedFrom` emits one section per declared block in spec order, which is a
1536
+ * layout for one particular page (the home page) and wrong for every other. A starting
1537
+ * stack is a deliberate short list, so the site writes it out.
1538
+ */
1539
+ newPage?: SeedSection[]
1540
+ pages?: PageDeclaration[]
1541
+ /** True when a catch-all route serves every stored slug. See `SiteManifest.routesAllPages`. */
1542
+ routesAllPages?: boolean
1543
+ /**
1544
+ * Seed content. Pass the same spec you gave `defineBlocks` and the defaults are
1545
+ * collected automatically; pass nothing and no seed is published.
1546
+ */
1547
+ seed?: BlockSpec
1548
+ }): SiteManifest => ({
1549
+ blocks,
1550
+ bridgeVersion: BRIDGE_VERSION,
1551
+ ...(collections ? { collections } : {}),
1552
+ generatedAt: new Date().toISOString(),
1553
+ ...(newPage ? { newPage } : {}),
1554
+ pages,
1555
+ ...(routesAllPages ? { routesAllPages } : {}),
1556
+ ...(seed ? { seed: seedFrom(seed) } : {}),
1557
+ /*
1558
+ * Derived from `seed`, which is the only spec this form receives — `blocks` arrives
1559
+ * already published as schema, with the carriers stripped. That is the honest limit of the
1560
+ * legacy shape: a caller who passes the spec gets starters, one who passes only published
1561
+ * blocks does not, and `defineSite` (which holds the real spec) has no such gap.
1562
+ */
1563
+ ...(() => {
1564
+ if (!seed) return {}
1565
+ const starters = startersFrom(seed, blocks)
1566
+ return Object.keys(starters).length > 0 ? { starters } : {}
1567
+ })(),
1568
+ })
1569
+
1570
+ /**
1571
+ * Collect every `default` in a block spec into one seed section per block.
1572
+ *
1573
+ * Blocks are emitted in spec order, which is the order the page renders them — the
1574
+ * platform indexes sections by position, so a seed in the wrong order would address
1575
+ * every field to the wrong section.
1576
+ *
1577
+ * A block whose fields have no defaults still gets a section. That is deliberate: the
1578
+ * section's *presence* is what gives the site's bridge a `fieldBase` to hang editable
1579
+ * markers on, and without it that block is invisible to the editor even though its
1580
+ * schema is known.
1581
+ *
1582
+ * Exported for `defineSite`, which seeds the blocks it was told to seed and needs the
1583
+ * *same* function rather than a copy of it: the two forms of the same site are asserted to
1584
+ * publish identical output, so a second implementation drifting from this one would break
1585
+ * that identity silently. `BlockSpec` is the wider shape — a label it never reads costs it
1586
+ * nothing — so one signature serves both callers.
1587
+ */
1588
+ export const seedFrom = (spec: BlockSpec): SeedSection[] =>
1589
+ Object.entries(spec).map(([slug, block]) => ({
1590
+ blockType: slug,
1591
+ ...seedFields(block.fields),
1592
+ }))
1593
+
1594
+ /** The seed default of every field in a block, keyed by field name. See `seedFrom`. */
1595
+ const seedFields = (spec: FieldSpec): Record<string, unknown> => {
1596
+ const out: Record<string, unknown> = {}
1597
+ for (const [name, field] of Object.entries(spec)) {
1598
+ const value = (field as { default?: unknown }).default
1599
+ if (value !== undefined) out[name] = value
1600
+ }
1601
+ return out
1602
+ }