@unitninety/site-kit 0.0.0-stage → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,623 @@
1
+ import {
2
+ defineBlocks,
3
+ startersFrom,
4
+ defineCollections,
5
+ LAYOUT,
6
+ namedFieldSpec,
7
+ seedFrom,
8
+ SINGLES,
9
+ SLOT_BLOCK,
10
+ type CollectionSpec,
11
+ type FieldSpec,
12
+ type LayoutEntry,
13
+ type SinglesEntry,
14
+ } from './defineBlocks'
15
+ import { BLOCK_ENTRY, BRIDGE_VERSION } from './contract'
16
+ import type {
17
+ BlockEntry,
18
+ CollectionRoutes,
19
+ EditorBlock,
20
+ FormDeclaration,
21
+ FormFieldDeclaration,
22
+ LayoutDeclaration,
23
+ ManifestCollection,
24
+ PageDeclaration,
25
+ SeedSection,
26
+ SingleDeclaration,
27
+ SiteManifest,
28
+ Spec,
29
+ SpecKey,
30
+ } from './contract'
31
+
32
+ /**
33
+ * How a site states its content once.
34
+ *
35
+ * `brand.ts` holds what the site says; `blocks.ts` used to hold how a client may change it;
36
+ * a hand-written `Brand` type held the shape a third time. This collapses the three into one
37
+ * document, on one rule:
38
+ *
39
+ * **A wrapped entry is modelled. A bare entry is committed and never modelled.**
40
+ *
41
+ * By default every homepage section is a `block()`, and a repeating record the
42
+ * page names (people, projects, stories) is a `collection()` plus a
43
+ * `relationship` on the block that renders it (`renders: ['people']`). Header,
44
+ * nav and footer stay bare chrome.
45
+ *
46
+ * export const site = defineSite({
47
+ * hero: block({ headline: text('Building Western Australia.') }),
48
+ * logo: { src: '/brand/logo-mark.png', ratio: 241 / 105 },
49
+ * })
50
+ *
51
+ * export const brand = site.values // every component imports this, unchanged
52
+ *
53
+ * `brand.ts`'s values are **not** seed. Components render `text(cms, committed)` — CMS over
54
+ * committed — and the contract guarantees that an unreachable platform renders the site
55
+ * exactly as it does with no CMS at all. The values stay compiled into the bundle forever.
56
+ * The database is the truth for edits; this is the floor underneath them.
57
+ */
58
+
59
+ /** `Symbol.for`, so two instances of this package agree on it. See `BLOCK_ENTRY`. */
60
+ const COLLECTION: unique symbol = Symbol.for('u90.collection')
61
+
62
+ /*
63
+ * `BLOCK_ENTRY` and `BlockEntry` live in `contract.ts` rather than here, because
64
+ * `blocksField()` has to recognise a `block()` — a zone's blocks are written with it and held
65
+ * in a plain const — and `defineBlocks.ts` importing this module would make the two circular.
66
+ * Re-exported so a site still reads the type from where it reads `block()`.
67
+ */
68
+ export type { BlockEntry } from './contract'
69
+
70
+ export type CollectionOptions<R extends Record<string, unknown>[] = Record<string, unknown>[]> = {
71
+ appearsOn?: CollectionRoutes
72
+ description?: string
73
+ detailField?: string
74
+ itemNoun?: string
75
+ label?: string
76
+ /** One object per committed row, in the order the site renders them. */
77
+ rows?: R
78
+ /**
79
+ * Stored slug, when it must differ from the document key. Mirrors `block()`'s override and
80
+ * exists for the same reason, one level along.
81
+ *
82
+ * A collection's rows and a site's *committed* array of the same records are routinely not
83
+ * the same shape: jdsi's `brand.projects` carries the photograph and the href its
84
+ * components render, while the `projects` collection's rows carry the write-up, the
85
+ * standfirst and the page slug. Neither derives from the other, both must exist, and only
86
+ * one can own the document key. **A slug keys stored rows, so it is permanent** — move the
87
+ * key and pin the slug.
88
+ */
89
+ slug?: string
90
+ titleField: string
91
+ }
92
+
93
+ export type CollectionEntry<
94
+ F extends FieldSpec = FieldSpec,
95
+ R extends Record<string, unknown>[] = Record<string, unknown>[],
96
+ > = CollectionOptions<R> & {
97
+ [COLLECTION]: true
98
+ fields: F
99
+ }
100
+
101
+ /**
102
+ * A section of a page.
103
+ *
104
+ * Declaration order is the home page's section order: the platform indexes stored sections
105
+ * by position, so the order these are written in is the order `page.tsx` renders them.
106
+ *
107
+ * `{ seed: false }` publishes the block without putting it on the home page — `pageHeader`
108
+ * and `pageBody` are available to every page and seeded onto none. That is also what makes
109
+ * them usable as `newPage`: a block absent from the home seed is what a *new* page starts as.
110
+ *
111
+ * `{ layoutOnly: true }` keeps the block out of the page picker too, which `seed: false`
112
+ * alone does not — a client could still add a chrome block, like a header, as an ordinary
113
+ * page section and end up with two. See `EditorBlock.layoutOnly`.
114
+ *
115
+ * `{ slug }` overrides the stored slug when it must differ from the document key. Two
116
+ * entries cannot share a key in one object, but a site may legitimately have a *block* and a
117
+ * *collection* of the same name — jdsi's home page has an empty `projects` section and a
118
+ * `projects` collection of records. **A slug keys stored content, so it is permanent**; the
119
+ * key is only how the brand document addresses the entry. When they must diverge, move the
120
+ * key and pin the slug.
121
+ */
122
+ export const block = <F extends FieldSpec>(
123
+ fields: F,
124
+ options: {
125
+ label?: string
126
+ layoutOnly?: boolean
127
+ renders?: string[]
128
+ seed?: boolean
129
+ slug?: string
130
+ } = {},
131
+ ): BlockEntry<F> => ({
132
+ [BLOCK_ENTRY]: true,
133
+ fields,
134
+ ...(options.label === undefined ? {} : { label: options.label }),
135
+ ...(options.layoutOnly === undefined ? {} : { layoutOnly: options.layoutOnly }),
136
+ ...(options.renders === undefined ? {} : { renders: options.renders }),
137
+ seed: options.seed ?? true,
138
+ ...(options.slug === undefined ? {} : { slug: options.slug }),
139
+ })
140
+
141
+ /**
142
+ * A repeatable record type — projects, team, sectors.
143
+ *
144
+ * Rows are a list, so they attach here rather than to any one field; there is no sensible
145
+ * way to spell "six rows" as a value on each field. Rows are frequently derived from data a
146
+ * block also uses, which inside one `defineSite` call would be circular — hoist the shared
147
+ * array to a const above the call and read it from both places.
148
+ */
149
+ export const collection = <
150
+ F extends FieldSpec,
151
+ R extends Record<string, unknown>[] = Record<string, unknown>[],
152
+ >(
153
+ fields: F,
154
+ options: CollectionOptions<R>,
155
+ ): CollectionEntry<F, R> => ({
156
+ [COLLECTION]: true,
157
+ fields,
158
+ ...options,
159
+ })
160
+
161
+ export type FormEntry = {
162
+ __kind: 'form'
163
+ fields: FormFieldDeclaration[]
164
+ label: string
165
+ recipients?: string[]
166
+ slug?: string
167
+ }
168
+
169
+ /**
170
+ * A form this site collects — a contact form, a careers application.
171
+ *
172
+ * Unlike `block()` and `collection()`, a form commits no copy to `site.values`: there is
173
+ * nothing for a component to render, only a field schema the platform validates submissions
174
+ * against and a mailing list it seeds once. Marked with a plain `__kind` rather than a
175
+ * shared symbol like `BLOCK_ENTRY` — nothing outside this module ever needs to recognise a
176
+ * `form()` entry the way `blocksField()` needs to recognise a `block()` inside a zone, so
177
+ * there is no circular-import reason to hoist the tag into `contract.ts`.
178
+ */
179
+ export const form = (declaration: {
180
+ fields: FormFieldDeclaration[]
181
+ label: string
182
+ recipients?: string[]
183
+ slug?: string
184
+ }): FormEntry => ({ ...declaration, __kind: 'form' as const })
185
+
186
+ export type SiteSpec = Record<string, unknown>
187
+
188
+ /**
189
+ * The committed value of one entry — what it contributes to `site.values`.
190
+ *
191
+ * A block resolves to an object of its fields' values. A collection resolves to its rows,
192
+ * and to `[]` when it has none, matching `values[key] = rows ?? []` below. A field helper
193
+ * resolves to the value it carries. Anything else — a bare entry — is carried through
194
+ * exactly as written, which is what makes `mark.ratio` a `number` rather than something the
195
+ * CMS has an opinion about.
196
+ *
197
+ * That a carrier keeps its field map is not decoration: this recovers a carrier's committed
198
+ * shape by inferring the parameter, so a carrier that had thrown its map away would resolve
199
+ * `site.values.hero` to the carrier itself. A site reading one of its own fields would then
200
+ * get `TS2339` on the field and a clean typecheck on `.fields`, which is `undefined` at
201
+ * runtime — the wrong half of the pair being the one that compiles.
202
+ *
203
+ * A layout resolves to `never`, and so does a form, for the same reason: the loop in
204
+ * `defineSite` diverts a layout entry into `layouts` and a form entry into `forms`, and
205
+ * never writes `values[key]` for either, so there is no committed value to name a type for.
206
+ * `never` alone does not make `site.values.standard` a compile error — TypeScript happily reads
207
+ * a property typed `never` — so `SiteValues` below drops the key entirely rather than leaning
208
+ * on this branch to do that work.
209
+ *
210
+ * **It reads carriers, never nested `fields`.** A block's own `fields` is still the map the
211
+ * author wrote, so that one is walked. A `group()`'s or `array()`'s is not: both call
212
+ * `namedFields` on their children at construction time, so what survives on the descriptor
213
+ * is an `EditorField[]` — no carriers, and the author's key names demoted to a `name`
214
+ * property. Those helpers state the type they commit on the way out, and this reads it from
215
+ * there. Recursing into a nested `fields` would infer the shape of the editor's schema
216
+ * instead of the shape of the site's content.
217
+ */
218
+ export type Value<S> =
219
+ S extends BlockEntry<infer F>
220
+ ? { [K in keyof F]: Value<F[K]> }
221
+ : S extends CollectionEntry<FieldSpec, infer R>
222
+ ? R
223
+ : S extends LayoutEntry | SinglesEntry | FormEntry
224
+ ? never
225
+ : SpecKey extends keyof S
226
+ ? S extends Spec<infer V>
227
+ ? V
228
+ : never
229
+ : S
230
+
231
+ /**
232
+ * Every entry of a site spec, resolved to its committed value. See `Value`.
233
+ *
234
+ * The `as` clause drops a layout or form key rather than leaving it mapped to `Value`'s
235
+ * `never`: a property typed `never` still typechecks to read (`{ x: never }`'s `x` is legal
236
+ * to access, just impossible to produce), so keeping the key would leave `site.values.careers`
237
+ * compiling clean and then reading `undefined` at runtime — the exact failure `Value`'s
238
+ * carrier branches exist to avoid, reintroduced for a form key. Removing the key instead
239
+ * makes that access `TS2339: Property does not exist`, which is what `defineSite`'s loop
240
+ * actually does: a form entry is diverted into `forms` and `values[key]` is never written.
241
+ */
242
+ export type SiteValues<S> = {
243
+ [K in keyof S as S[K] extends LayoutEntry | SinglesEntry | FormEntry ? never : K]: Value<S[K]>
244
+ }
245
+
246
+ export type Site<V = Record<string, unknown>> = {
247
+ blocks: EditorBlock[]
248
+ collections: ManifestCollection[]
249
+ /** Forms this site collects. Absent on a site that declares none. */
250
+ forms?: FormDeclaration[]
251
+ /** Named arrangements of blocks with one content slot. Absent on a site that declares none. */
252
+ layouts?: LayoutDeclaration[]
253
+ seed: SeedSection[]
254
+ /** The shared values this site starts with. Absent on a site that declares none. */
255
+ singles?: SingleDeclaration[]
256
+ /** What one fresh instance of each block contains. See `SiteManifest.starters`. */
257
+ starters: Record<string, Record<string, unknown>>
258
+ values: V
259
+ }
260
+
261
+ /*
262
+ * `LayoutDeclaration` lives in `contract.ts` rather than here, for the same reason
263
+ * `BlockEntry` does: it belongs to `SiteManifest`, the platform's own half of the contract,
264
+ * not just to `Site`. Re-exported so a site still reads the type from where it reads
265
+ * `layout()`.
266
+ */
267
+ export type {
268
+ FormDeclaration,
269
+ FormFieldDeclaration,
270
+ LayoutDeclaration,
271
+ SingleDeclaration,
272
+ } from './contract'
273
+
274
+ /** `FieldSpec` restated, because `contract.ts` cannot name it. See `BlockEntry`. */
275
+ const isBlock = (entry: unknown): entry is BlockEntry<FieldSpec> =>
276
+ entry !== null && typeof entry === 'object' && BLOCK_ENTRY in entry
277
+
278
+ const isCollection = (entry: unknown): entry is CollectionEntry =>
279
+ entry !== null && typeof entry === 'object' && COLLECTION in entry
280
+
281
+ const isForm = (entry: unknown): entry is FormEntry =>
282
+ entry !== null && typeof entry === 'object' && (entry as { __kind?: unknown }).__kind === 'form'
283
+
284
+ export const isLayout = (entry: unknown): entry is LayoutEntry =>
285
+ entry !== null && typeof entry === 'object' && LAYOUT in entry
286
+
287
+ export const isSingles = (entry: unknown): entry is SinglesEntry =>
288
+ entry !== null && typeof entry === 'object' && SINGLES in entry
289
+
290
+ /** The two a Single may be. Restated here rather than imported for the reason
291
+ * `dashboard/singles/store.ts` restates it: this polices what a *site* declares. */
292
+ const SINGLE_TYPES = ['link', 'text']
293
+
294
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]*$/
295
+
296
+ /**
297
+ * Refuse to build rather than serve a manifest the platform will reject.
298
+ *
299
+ * A site that fails to build is recoverable in minutes. A site that builds and serves a
300
+ * manifest the platform rejects shows the client an empty editor, which they read as "my
301
+ * content has vanished" — the failure the platform's own schema notes single out as the one
302
+ * worth any amount of noise to avoid.
303
+ *
304
+ * Every rule here is re-checked by `src/manifest/validate.ts` on the platform. This is not
305
+ * a substitute for that; it moves the same failure from a client's screen to a build log.
306
+ */
307
+ const validate = (spec: SiteSpec, collections: CollectionSpec): void => {
308
+ const declared = new Set(Object.keys(collections))
309
+
310
+ const walkFields = (fields: FieldSpec, where: string): void => {
311
+ for (const [name, field] of Object.entries(fields)) {
312
+ if (!IDENTIFIER.test(name)) {
313
+ throw new Error(
314
+ `${where}: field name "${name}" is not an identifier. A name becomes part of a dotted path (\`0.slides.1.title\`), so a name containing a "." addresses a different field than it claims to.`,
315
+ )
316
+ }
317
+ const nested = (field as { fields?: unknown }).fields
318
+ const relationTo = (field as { relationTo?: string }).relationTo
319
+ if (
320
+ relationTo !== undefined &&
321
+ relationTo !== 'pages' &&
322
+ relationTo !== 'media' &&
323
+ !declared.has(relationTo)
324
+ ) {
325
+ throw new Error(
326
+ `${where}.${name}: relationTo "${relationTo}" names no collection this site declares. Declare it with collection(), or the platform rejects the whole manifest rather than render a picker with nothing in it.`,
327
+ )
328
+ }
329
+ if (nested !== undefined) walkFields(namedFieldSpec(nested), `${where}.${name}`)
330
+ }
331
+ }
332
+
333
+ for (const [key, entry] of Object.entries(spec)) {
334
+ if (isBlock(entry)) walkFields(entry.fields, key)
335
+ if (isCollection(entry)) {
336
+ walkFields(entry.fields, key)
337
+ if (!(entry.titleField in entry.fields)) {
338
+ throw new Error(
339
+ `${key}: titleField "${entry.titleField}" names no field on this collection. The CMS list would render a blank first column and no row would be nameable.`,
340
+ )
341
+ }
342
+ }
343
+ }
344
+
345
+ for (const [key, entry] of Object.entries(spec)) {
346
+ if (!isBlock(entry)) continue
347
+
348
+ for (const slug of entry.renders ?? []) {
349
+ if (!declared.has(slug)) {
350
+ throw new Error(
351
+ `${key}: renders "${slug}", which names no collection this site declares. The rail would show nothing under this block, which reads to a client as their content having gone.`,
352
+ )
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ export const defineSite = <S extends SiteSpec>(spec: S): Site<SiteValues<S>> => {
359
+ const blockSpec: Record<
360
+ string,
361
+ { fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
362
+ > = {}
363
+ const seedSpec: Record<
364
+ string,
365
+ { fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
366
+ > = {}
367
+ const collectionSpec: CollectionSpec = {}
368
+ const forms: FormDeclaration[] = []
369
+ const values: Record<string, unknown> = {}
370
+ const layouts: LayoutDeclaration[] = []
371
+ const singles: SingleDeclaration[] = []
372
+ const singleKeys = new Set<string>()
373
+
374
+ for (const [key, entry] of Object.entries(spec)) {
375
+ if (isBlock(entry)) {
376
+ const declared = {
377
+ fields: entry.fields,
378
+ ...(entry.label === undefined ? {} : { label: entry.label }),
379
+ ...(entry.layoutOnly === undefined ? {} : { layoutOnly: entry.layoutOnly }),
380
+ ...(entry.renders === undefined ? {} : { renders: entry.renders }),
381
+ }
382
+ // The slug keys stored content; the document key is only how `brand` addresses it.
383
+ const slug = entry.slug ?? key
384
+ if (slug in blockSpec) {
385
+ throw new Error(
386
+ `Two blocks resolve to the slug "${slug}". A slug keys stored content, so two sections sharing one would be unresolvable.`,
387
+ )
388
+ }
389
+ blockSpec[slug] = declared
390
+ if (entry.seed) seedSpec[slug] = declared
391
+ values[key] = blockValues(entry.fields)
392
+ continue
393
+ }
394
+
395
+ if (isCollection(entry)) {
396
+ const { fields, rows, slug: override, ...rest } = entry
397
+ // The slug keys stored rows; the document key is only how `brand` addresses them.
398
+ const slug = override ?? key
399
+ if (slug in collectionSpec) {
400
+ throw new Error(
401
+ `Two collections resolve to the slug "${slug}". A slug keys stored rows, so two record types sharing one would be unresolvable.`,
402
+ )
403
+ }
404
+ collectionSpec[slug] = {
405
+ ...rest,
406
+ fields,
407
+ ...(rows === undefined ? {} : { seed: rows }),
408
+ }
409
+ values[key] = rows ?? []
410
+ continue
411
+ }
412
+
413
+ if (isForm(entry)) {
414
+ const { fields, label, recipients, slug: override } = entry
415
+ // The slug keys stored submissions; the document key is only how `brand` addresses it.
416
+ const slug = override ?? key
417
+ // Match the platform manifest identifier grammar before it could discard a declaration.
418
+ const identifier = /^[a-zA-Z][a-zA-Z0-9_]*$/
419
+ if (typeof slug !== 'string' || !identifier.test(slug)) {
420
+ throw new Error(
421
+ `Invalid form slug "${slug}" for "${key}". Use a letter followed by letters, digits or underscores.`,
422
+ )
423
+ }
424
+ for (const field of fields) {
425
+ if (typeof field.name !== 'string' || !identifier.test(field.name)) {
426
+ throw new Error(
427
+ `Invalid field name "${field.name}" in form "${slug}". Use a letter followed by letters, digits or underscores.`,
428
+ )
429
+ }
430
+ }
431
+ if (forms.some((declared) => declared.slug === slug)) {
432
+ throw new Error(
433
+ `Two forms resolve to the slug "${slug}". A slug keys stored submissions, so two forms sharing one would be unresolvable.`,
434
+ )
435
+ }
436
+ forms.push({ fields, label, slug, ...(recipients === undefined ? {} : { recipients }) })
437
+ continue
438
+ }
439
+
440
+ if (isLayout(entry)) {
441
+ const slots = entry.entries.filter((item) => item.blockType === SLOT_BLOCK).length
442
+ if (slots === 0) {
443
+ throw new Error(
444
+ `Layout "${key}" has no content slot. Put SLOT where the page's own sections go — without one the layout renders the chrome and none of the page, which is silent at runtime.`,
445
+ )
446
+ }
447
+ if (slots > 1) {
448
+ throw new Error(
449
+ `Layout "${key}" has two content slots. A page's sections are one list, so there is no answer to which slot they go in until multi-slot exists.`,
450
+ )
451
+ }
452
+ layouts.push({ ...entry.meta, entries: entry.entries, name: key })
453
+ continue
454
+ }
455
+
456
+ if (isSingles(entry)) {
457
+ /*
458
+ * Refused at build time rather than left for the platform, for the reason `validate`
459
+ * below gives: a site that fails to build is recoverable in minutes, and a site that
460
+ * builds and publishes a declaration the platform drops leaves a client with a value
461
+ * they cannot see and no line anywhere saying so. Each of these mirrors a rule
462
+ * `dashboard/singles/store.ts` enforces on a key a client types, because a seeded row
463
+ * and a typed one are the same row.
464
+ */
465
+ for (const [key, spec] of Object.entries(entry.declared)) {
466
+ if (!IDENTIFIER.test(key)) {
467
+ throw new Error(
468
+ `singles: "${key}" is not a name a block can point at. A key is what a reference() names, so it starts with a letter and holds only letters, numbers and underscores.`,
469
+ )
470
+ }
471
+ if (singleKeys.has(key)) {
472
+ throw new Error(
473
+ `Two declarations resolve to the single "${key}". Two rows answering one key would leave it undecided which one a block is showing, and the client editing one would be shown the other.`,
474
+ )
475
+ }
476
+ if (!SINGLE_TYPES.includes((spec as { type?: string } | undefined)?.type ?? '')) {
477
+ throw new Error(
478
+ `singles.${key}: type "${(spec as { type?: string } | undefined)?.type}" is neither "text" nor "link". An image is not a Single — it lives in the media library, pointed at by an upload field on the block that draws it.`,
479
+ )
480
+ }
481
+ if (typeof spec.label !== 'string' || spec.label.trim() === '') {
482
+ throw new Error(
483
+ `singles.${key}: no label. The label is what the client recognises this value by in a list of them, and a row without one is unnameable.`,
484
+ )
485
+ }
486
+ singleKeys.add(key)
487
+ singles.push({
488
+ key,
489
+ label: spec.label.trim(),
490
+ type: spec.type,
491
+ value: typeof spec.value === 'string' ? spec.value : '',
492
+ })
493
+ }
494
+ continue
495
+ }
496
+
497
+ values[key] = entry
498
+ }
499
+
500
+ validate(spec, collectionSpec)
501
+
502
+ /*
503
+ * Computed once, ahead of the return, because `startersFrom` needs the same full block
504
+ * library `blocks` below publishes — a slug zone (`of: [...]`) only names which block its
505
+ * children may be, and resolving that name to the block's own fields needs every block this
506
+ * site declared, not just the one the zone sits on. `blockSpec` alone is not enough for
507
+ * that: it is this function's own author-facing map, still keyed by field maps rather than
508
+ * the published `EditorField[]` shape `seedZone` walks.
509
+ */
510
+ const allBlocks = defineBlocks(blockSpec)
511
+
512
+ return {
513
+ blocks: allBlocks,
514
+ collections: defineCollections(collectionSpec),
515
+ ...(forms.length === 0 ? {} : { forms }),
516
+ ...(layouts.length === 0 ? {} : { layouts }),
517
+ // `defineBlocks`'s own seed collector, not a copy of it: it emits one section per block
518
+ // in spec order, which is what `seedSpec`'s insertion order carries, and the two ways of
519
+ // writing one site are asserted to seed identically. Two implementations of that could
520
+ // only drift.
521
+ seed: seedFrom(seedSpec),
522
+ ...(singles.length === 0 ? {} : { singles }),
523
+ /*
524
+ * `blockSpec`, not `seedSpec`: a starter belongs to every block a client can add, and
525
+ * `seedSpec` holds only the ones that seed — `block({…}, { seed: false })` is excluded
526
+ * there and must not be excluded here. `allBlocks` alongside it is what lets a zone
527
+ * field's own committed children (a slug-named one included) come out translated into
528
+ * the shape the platform stores rather than passed through as the site's own literal.
529
+ */
530
+ starters: startersFrom(blockSpec, allBlocks),
531
+ // Every branch above writes exactly what `Value` says it writes; the loop is keyed by a
532
+ // runtime tag and there is no way to say that to the checker.
533
+ values: values as SiteValues<S>,
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Build the published manifest from a site, plus the things a site knows and a spec cannot.
539
+ *
540
+ * `pages` comes from `pagesFromNav(brand.nav)`, which reads `site.values` — so it cannot be
541
+ * an entry in the spec without being circular. `newPage` is a deliberate short list of
542
+ * starting sections rather than anything derivable: the seed emits one section per block in
543
+ * declaration order, which is the home page's layout and wrong for every other page.
544
+ */
545
+ export const manifestFrom = ({
546
+ newPage,
547
+ pages = [],
548
+ routesAllPages,
549
+ site,
550
+ }: {
551
+ newPage?: SeedSection[]
552
+ pages?: PageDeclaration[]
553
+ routesAllPages?: boolean
554
+ site: Site<unknown>
555
+ }): SiteManifest => {
556
+ const declared = new Set(site.blocks.map((entry) => entry.slug))
557
+ for (const section of newPage ?? []) {
558
+ if (!declared.has(section.blockType)) {
559
+ throw new Error(
560
+ `newPage names block "${section.blockType}", which this site does not declare. A page created from it renders as nothing at all.`,
561
+ )
562
+ }
563
+ }
564
+
565
+ /*
566
+ * The same guard for a page that commits its own content, and it matters more here.
567
+ *
568
+ * `newPage` is wrong in front of the client the first time they add a page, which is at
569
+ * least loud. A page seed is written once, on a tenant's first pair, and the platform drops
570
+ * a section it cannot validate rather than refusing the page — so a `blockType` this site
571
+ * does not declare would arrive as a page that is simply missing its words, months after
572
+ * anyone looked at this file. Raised at module load, where the call is.
573
+ */
574
+ for (const page of pages) {
575
+ for (const section of page.seed ?? []) {
576
+ if (!declared.has(section.blockType)) {
577
+ throw new Error(
578
+ `page "${page.slug}" seeds block "${section.blockType}", which this site does not declare. It would be dropped on pairing and the page would arrive empty.`,
579
+ )
580
+ }
581
+ }
582
+ }
583
+
584
+ return {
585
+ blocks: site.blocks,
586
+ bridgeVersion: BRIDGE_VERSION,
587
+ ...(site.collections.length > 0 ? { collections: site.collections } : {}),
588
+ ...(site.forms === undefined ? {} : { forms: site.forms }),
589
+ generatedAt: new Date().toISOString(),
590
+ ...(site.layouts === undefined ? {} : { layouts: site.layouts }),
591
+ ...(newPage ? { newPage } : {}),
592
+ pages,
593
+ ...(routesAllPages ? { routesAllPages } : {}),
594
+ ...(site.seed.length > 0 ? { seed: site.seed } : {}),
595
+ ...(site.singles === undefined ? {} : { singles: site.singles }),
596
+ ...(Object.keys(site.starters).length > 0 ? { starters: site.starters } : {}),
597
+ }
598
+ }
599
+
600
+ /**
601
+ * The committed value of every field in a block, keyed by field name.
602
+ *
603
+ * Reads exactly one carrier — `value` — and nothing else. A group carries its own `value`
604
+ * already: `group()` aggregates it from its children at construction time, before
605
+ * `namedFields` strips `value` from those children on their way into the group's schema. By
606
+ * the time this walks a group's `fields`, they are schema-only, so there is nothing left
607
+ * there to recurse into.
608
+ *
609
+ * **Near-identical to `defineBlocks`'s `seedFields`, and deliberately not the same function.**
610
+ * They read different carriers, and that difference is the whole design: `value` is what the
611
+ * component renders when the platform is unreachable, `default` is what the platform stores
612
+ * once at pair time. `upload()` and `link()` exist precisely because the two diverge. Folding
613
+ * them into one function taking a carrier name would put the two halves of that distinction
614
+ * behind one argument, and the next reader would have to guess which call meant which.
615
+ */
616
+ const blockValues = (fields: FieldSpec): Record<string, unknown> => {
617
+ const out: Record<string, unknown> = {}
618
+ for (const [name, field] of Object.entries(fields)) {
619
+ const value = (field as { value?: unknown }).value
620
+ if (value !== undefined) out[name] = value
621
+ }
622
+ return out
623
+ }