@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,664 @@
1
+ /**
2
+ * The contract between a UnitNinety site and the platform.
3
+ *
4
+ * These types are **canonical here**, and the platform imports them from this
5
+ * package rather than the other way round. That direction is the whole point: a site
6
+ * repo declaring its own content schema must not depend on platform internals, or
7
+ * every site is coupled to the dashboard's file layout.
8
+ *
9
+ * Changing anything in this file changes what every site repo has to emit, so it is a
10
+ * breaking change to the contract even when TypeScript doesn't say so. The platform
11
+ * re-validates everything at runtime (`src/manifest/validate.ts`), which is what
12
+ * actually enforces it — these types only make it pleasant to get right.
13
+ */
14
+
15
+ declare const VALUE: unique symbol
16
+
17
+ /**
18
+ * A field spec, carrying the type of the value it committed.
19
+ *
20
+ * Phantom — `VALUE` is never assigned and the property never exists at runtime. It is here
21
+ * so `defineSite` can infer `site.values` from the helpers a site wrote, which is what
22
+ * retires the hand-written `Brand` type.
23
+ *
24
+ * It lives in this file because `defineBlocks.ts` and `defineSite.ts` both need it and both
25
+ * already import from here; declaring it in either of them would make the two import each
26
+ * other.
27
+ */
28
+ export type Spec<V> = { [VALUE]?: V }
29
+
30
+ /**
31
+ * The phantom key itself, so a reader can ask whether a type carries a value *at all*.
32
+ *
33
+ * `Spec`'s property is optional, which makes it a *weak type*: an object sharing none of its
34
+ * properties is refused, so `logo: { ratio: 241 / 105 }` does not match `Spec<infer V>` and
35
+ * needs no help from here.
36
+ *
37
+ * **An object with an index signature does.** Weak-type detection is relaxed for one, and a
38
+ * string index signature says nothing about a symbol key, so `Record<string, number>` matches
39
+ * `Spec<infer V>` with `V` inferred as `unknown` — a bare entry read from a lookup table
40
+ * would arrive in `site.values` as `unknown`, which is the widening a hand-written `Brand`
41
+ * type existed to avoid. `SpecKey extends keyof S` is the test that tells "carries nothing"
42
+ * apart from "carries `unknown`", and that case is the one that needs it.
43
+ */
44
+ export type SpecKey = typeof VALUE
45
+
46
+ /**
47
+ * The runtime tag that tells a `block()` entry from any other value.
48
+ *
49
+ * Here for the same reason `Spec` is: `defineSite` stamps it and `defineBlocks` reads it —
50
+ * `blocksField()` takes the blocks of a dynamic zone, and those are written with `block()` and
51
+ * held in a plain const, so it must be able to recognise one. Declared in either module, the
52
+ * two would have to import each other.
53
+ *
54
+ * A symbol rather than a property name, so a site's own data cannot be mistaken for an entry
55
+ * and `JSON.stringify` never carries it anywhere. `Symbol.for` rather than `Symbol()`, so two
56
+ * instances of this package in one bundle — a vendored tarball beside a workspace link — still
57
+ * recognise each other's entries; the registry is keyed by this string.
58
+ */
59
+ export const BLOCK_ENTRY: unique symbol = Symbol.for('u90.block')
60
+
61
+ /**
62
+ * A page section as a site writes it, before `defineSite` knows what it is called.
63
+ *
64
+ * The slug is optional because the document key normally supplies it — `hero: block({…})` is
65
+ * stored as `hero`. A zone's blocks have no document key, so those name their own; see
66
+ * `block()` for when the two must diverge.
67
+ *
68
+ * `F` is left unconstrained here, because `FieldSpec` is declared in `defineBlocks.ts` and
69
+ * this file is upstream of both modules. Every caller restates the constraint it needs.
70
+ */
71
+ export type BlockEntry<F = Record<string, unknown>> = {
72
+ [BLOCK_ENTRY]: true
73
+ fields: F
74
+ label?: string
75
+ /** This block belongs to a layout, not to a page. See `EditorBlock.layoutOnly`. */
76
+ layoutOnly?: boolean
77
+ /** Collections this block draws, by slug. See `EditorBlock.renders`. */
78
+ renders?: string[]
79
+ seed: boolean
80
+ /** Stored slug, when it must differ from the document key. See `block()`. */
81
+ slug?: string
82
+ }
83
+
84
+ /**
85
+ * Field types the dashboard's editors can render.
86
+ *
87
+ * Deliberately a closed set. A site declaring something outside it gets its whole
88
+ * manifest rejected rather than a field that silently can't be edited — the platform
89
+ * would otherwise fall through to a `default` branch, render nothing, and leave a
90
+ * client unable to change something they can see on their own page.
91
+ */
92
+ export type EditorFieldType =
93
+ | 'array'
94
+ | 'blocks'
95
+ | 'checkbox'
96
+ /**
97
+ * A script, an embed snippet, or anything else that is code rather than prose.
98
+ *
99
+ * Stored as a string exactly like `textarea`, and separate from it for the editor's sake
100
+ * alone: a code field gets a monospace control with spellcheck, autocorrect and
101
+ * autocapitalise off, and the rail badges it so a client can tell the one field that
102
+ * configures a third-party widget from the fields that hold their own words.
103
+ *
104
+ * **Badged `E`, for embed, and not `C`.** `C` is the collection badge, and this file's
105
+ * neighbour in the rail already carries the scar from reusing a letter: `B` for a button
106
+ * exists because a button once shared `L` with a list, which made the two rows a client is
107
+ * most likely to be hunting for the only two that looked alike.
108
+ *
109
+ * It changes nothing about rendering. A site decides what to do with the string, the same
110
+ * as it always did — `pbir` passes its reviews script straight to `next/script`.
111
+ */
112
+ | 'code'
113
+ /**
114
+ * A calendar day, stored as `YYYY-MM-DD`.
115
+ *
116
+ * Date-only rather than a timestamp, and that is a decision rather than a simplification:
117
+ * the field exists for a post's published date, which has no timezone, and date-only ISO
118
+ * sorts lexicographically so a site can order by it without parsing. Adding a time
119
+ * component later is additive; taking one away is not.
120
+ */
121
+ | 'date'
122
+ | 'email'
123
+ | 'group'
124
+ | 'number'
125
+ | 'relationship'
126
+ /**
127
+ * A field whose value lives once, in the client's Singles, and is pointed at from here
128
+ * rather than stored on this block. See `reference()` in `defineBlocks.ts`.
129
+ *
130
+ * Not folded into `relationship`: a relationship's `relationTo` names a *collection*, and
131
+ * the dashboard reads it to load picker options. A reference names a **row key inside one
132
+ * reserved collection**, `_singles`, and there is nothing to pick — the client keeps one
133
+ * value per key, not a list of rows to choose among. Sharing the type would put `_singles`
134
+ * into the relationship machinery, offering every client's Single as a relationship
135
+ * target and storing an id where a key belongs.
136
+ */
137
+ | 'reference'
138
+ | 'richText'
139
+ | 'select'
140
+ | 'text'
141
+ | 'textarea'
142
+ | 'upload'
143
+
144
+ export type EditorField = {
145
+ blocks?: EditorBlock[]
146
+ /**
147
+ * The block slugs a zone's children may be, when the site named them instead of embedding
148
+ * them — resolved against the site's declared blocks when read.
149
+ *
150
+ * The alternative to `blocks` above, and the reason both exist: a block whose children may
151
+ * include its own type cannot embed their definitions, because the manifest is serialised and
152
+ * the expansion never terminates. Naming defers the lookup to read time, which is what lets
153
+ * one element contain itself. A slug naming no declared block is carried through the manifest
154
+ * unrebuilt, not dropped — it resolves to nothing once read, where the full block list is
155
+ * known. That keeps the manifest non-lossy: a block declared later, or renamed back, comes
156
+ * good on its own the next time the zone is read, with nothing to republish. In the meantime,
157
+ * a zone offering one fewer choice still beats an editor that will not open.
158
+ */
159
+ childBlocks?: string[]
160
+ /**
161
+ * What a `group` **is**, when its fields are one thing the client edits rather than
162
+ * several — the site *saying so*, rather than the platform inferring it from a name.
163
+ *
164
+ * A group carrying this gets one row in the rail and one panel in the inspector, in
165
+ * place of a row per child. Two of them exist:
166
+ *
167
+ * - `'button'` — its words, where it goes, and whether it opens a new tab. Stamped by
168
+ * `link()`.
169
+ * - `'sentence'` — a line of copy with one link inside it, stamped by `sentence()`.
170
+ * The sentence carries a `{link}` marker saying where the link falls, so the words
171
+ * either side of it are one editable string rather than two.
172
+ *
173
+ * Which groups got the button panel used to be decided by a rule written in the
174
+ * platform — *a group whose name is literally `link`* — and a site could only opt in by
175
+ * choosing that name. That rule spends the name, so a block got **one** button: pbir's
176
+ * reviews band has two, a Submit call to action and a link out to Google, and under the
177
+ * naming rule the second could only be loose fields with nothing saying they were one
178
+ * control.
179
+ *
180
+ * `'sentence'` is what that generalisation bought. pbir's privacy line was three rows —
181
+ * `Before`, `Link`, `After` — because the link falls in the middle of it, and the split
182
+ * was worse than untidy: `text()` in `shape.ts` trims what it is given, so `'See our '`
183
+ * lost the space that separated it from the link and the page rendered
184
+ * `See ourPrivacy Policyfor how we handle your details.` A sentence whose parts are
185
+ * separate fields cannot hold the whitespace between them.
186
+ *
187
+ * **A manifest published before this existed carries no flag**, and the platform's
188
+ * validator is where the old convention still lives: a group named `link` arriving
189
+ * without one is marked a button on the way in. That keeps the compatibility in the one
190
+ * place a stale manifest is already being read, and leaves everything downstream with a
191
+ * single rule — this property, and not a name.
192
+ */
193
+ element?: 'button' | 'sentence' | 'image' | 'video' | 'file'
194
+ poster?: {
195
+ label: string
196
+ constraints?: import('./image').ImageConstraints
197
+ variants?: Record<string, import('./image').ImageVariantDeclaration>
198
+ }
199
+ contractVersion?: 1
200
+ constraints?: import('./image').ImageConstraints
201
+ variants?: Record<string, import('./image').ImageVariantDeclaration>
202
+ /** Shown under the control in the inspector. */
203
+ description?: string
204
+ /**
205
+ * What the empty choice on a single-value `relationship` control is called.
206
+ *
207
+ * Optional, and the platform's own wording — "Nothing selected" — stands when it is
208
+ * absent, because that option is shared by every relationship field and renaming it
209
+ * globally would mislabel all of them. This exists for the fields where empty *means*
210
+ * something: a page's Layout is `null` for "inherit from the page above", so an option
211
+ * reading "Nothing selected" says the opposite of what choosing it does, and the cascade
212
+ * becomes something a client discovers by accident rather than something they were told.
213
+ */
214
+ emptyLabel?: string
215
+ /** Nested fields, for `group` and `array`. */
216
+ fields?: EditorField[]
217
+ hasMany?: boolean
218
+ /**
219
+ * Present when the **target** collection should show this edge from its own side.
220
+ *
221
+ * The edge is stored once, on the side that declares it. This only asks the dashboard
222
+ * to derive a panel over there — "Sector › Capabilities" is a filter over rows the
223
+ * dashboard has already loaded, never a second copy of the reference. Two copies of one
224
+ * fact is the bug that reports itself as "I removed it and it's still showing".
225
+ *
226
+ * `name` may not collide with a real field on the target collection, or the derived
227
+ * panel and a stored value would fight over one key.
228
+ */
229
+ inverse?: { label: string; name: string }
230
+ label: string
231
+ /** Eligible website copy is localized by default; false opts this field out. */
232
+ localized?: boolean
233
+ /**
234
+ * Becomes part of a dotted path (`0.slides.1.title`), so it must be an identifier:
235
+ * letters, digits and underscores, starting with a letter. A name containing a `.`
236
+ * would address a different field than it claims to.
237
+ */
238
+ name: string
239
+ options?: { label: string; value: string }[]
240
+ /**
241
+ * Renders the control read-only. Never *grants* anything — permissions come from
242
+ * the platform, and omitting this cannot widen them.
243
+ */
244
+ readOnly?: boolean
245
+ relationTo?: string
246
+ /**
247
+ * The key of the Single this field points at, present only on a `type: 'reference'` field.
248
+ *
249
+ * **Not `relationTo`.** `relationTo` names a collection the dashboard's relationship
250
+ * control loads options from; this names a row inside `_singles`, which is not a
251
+ * collection a client picks from — it is the one place a scalar like a phone number or
252
+ * an ABN is kept, reused wherever a block points at it. Reusing `relationTo` here would
253
+ * make every client's Singles selectable as a relationship target, and would store an id
254
+ * where this needs a stable key instead.
255
+ *
256
+ * The site names the **default** key; nothing here resolves it to a value yet — that is
257
+ * later work, on egress. See `reference()` in `defineBlocks.ts`.
258
+ */
259
+ referenceKey?: string
260
+ required?: boolean
261
+ type: EditorFieldType
262
+ }
263
+
264
+ export type EditorBlock = {
265
+ fields: EditorField[]
266
+ label: string
267
+ /**
268
+ * This block belongs to a layout, not to a page.
269
+ *
270
+ * `seed: false` keeps a block out of the seed; it does not keep it out of the picker, so a
271
+ * client could add their own header as a page section and get two of them. The platform
272
+ * still learns nothing about what this block *is* — only that the site says it is not a
273
+ * page section, which is the site's call in exactly the way every other declaration is.
274
+ */
275
+ layoutOnly?: boolean
276
+ /**
277
+ * Collections this block draws, by slug.
278
+ *
279
+ * The block is the thing rendering, so it is the thing that declares it — and a list,
280
+ * because one block legitimately draws more than one: a "What we do" section whose columns
281
+ * are `sectors` and whose lists inside them are `capabilities`.
282
+ *
283
+ * What it buys is the editor knowing where rows belong. Without it the dashboard appends
284
+ * every declared collection to the end of the rail at the same level as the page's own
285
+ * sections, which says nothing true: those rows are drawn *by* a section, and a client
286
+ * clicking the section that draws them found none of them there.
287
+ *
288
+ * Absent rather than empty when a block draws nothing, so a site that never declares one
289
+ * publishes exactly the manifest it published before.
290
+ */
291
+ renders?: string[]
292
+ /** Stored on every section as `blockType`. Must be an identifier. */
293
+ slug: string
294
+ }
295
+
296
+ /**
297
+ * A repeatable set of rows the client manages as records rather than as page copy —
298
+ * projects, team members, testimonials.
299
+ *
300
+ * The distinction from a block's `array` field is *ownership*, not shape. An array
301
+ * belongs to the section it sits in: reordering the page moves it, and deleting the
302
+ * section deletes it. A collection's rows are their own records, listed in the CMS,
303
+ * addressable individually, and rendered by whichever sections ask for them.
304
+ *
305
+ * Rows are stored one per database row, all collections of all tenants in the same
306
+ * table, keyed by this `slug` — so declaring one here costs no migration on the
307
+ * platform, exactly like declaring a block.
308
+ */
309
+ /**
310
+ * Where a collection's rows are rendered on the site.
311
+ *
312
+ * **The platform cannot work this out.** A row is a record in a jsonb column; whether
313
+ * it also has a page of its own, and at what address, is the site's own routing and
314
+ * lives nowhere else. Without this the dashboard can offer "edit this project" and have
315
+ * no idea which screen to open — which is the whole difference between a CMS list and
316
+ * editing a thing where it actually appears.
317
+ *
318
+ * Same bargain as `pages` and the block schema one level up: the site declares it, the
319
+ * platform renders from the declaration, and adding a detail route is a site deploy
320
+ * rather than a platform change.
321
+ */
322
+ export type CollectionRoutes = {
323
+ /**
324
+ * Routes that render rows of this collection alongside other content — a home page
325
+ * carousel, an index. Rows appear here without having a page to themselves.
326
+ */
327
+ alsoOn?: string[]
328
+ /**
329
+ * The address of a single row's own page, with `:slug` standing in for the value of
330
+ * `slugField` — e.g. `/projects/:slug`. Omitted when rows have no page of their own.
331
+ */
332
+ detailPath?: string
333
+ /**
334
+ * Which of `fields` fills `:slug`. Required if `detailPath` uses one.
335
+ *
336
+ * **A row with nothing in that field falls back to its `titleField`, slugified** —
337
+ * lowercased, non-alphanumerics collapsed to hyphens. That is declared behaviour rather
338
+ * than a platform guess, and it is load-bearing: a collection's seed is captured only on
339
+ * a tenant's first pair, so rows predating a later-added slug field hold nothing in it
340
+ * and would otherwise all resolve to `/projects/`. A site whose addresses come from
341
+ * anything else should populate the slug field.
342
+ */
343
+ slugField?: string
344
+ }
345
+
346
+ export type ManifestCollection = {
347
+ /** Where these rows show up on the site. See `CollectionRoutes`. */
348
+ appearsOn?: CollectionRoutes
349
+ /** Shown under the row list in the CMS. */
350
+ description?: string
351
+ /**
352
+ * Second column in the CMS list — the field that best distinguishes one row from
353
+ * another at a glance. Omitted, the column is blank.
354
+ */
355
+ detailField?: string
356
+ fields: EditorField[]
357
+ /** Singular noun for buttons and empty states, e.g. "project". */
358
+ itemNoun?: string
359
+ label: string
360
+ /** Stored on every row as its `collection`. Must be an identifier. */
361
+ slug: string
362
+ /**
363
+ * The site's committed rows, for seeding a tenant on its first pair — the same
364
+ * bargain as a block's `default`, one level up. Each entry is one row's values.
365
+ */
366
+ seed?: Record<string, unknown>[]
367
+ /**
368
+ * Which field titles a row in the CMS list. Must name one of `fields`; a row whose
369
+ * value there is empty falls back to a positional label.
370
+ */
371
+ titleField: string
372
+ }
373
+
374
+ /**
375
+ * The types a form field may declare. A closed set, on the same reasoning as
376
+ * `EditorFieldType`: the platform's submission validator and the dashboard's renderer both
377
+ * switch on it, and a site inventing a tenth type gets that field silently dropped rather
378
+ * than mis-rendered.
379
+ */
380
+ export type FormFieldType =
381
+ 'checkbox' | 'date' | 'email' | 'number' | 'select' | 'tel' | 'text' | 'textarea'
382
+
383
+ /**
384
+ * One field of a form, as the platform stores and validates against it.
385
+ *
386
+ * `role` promotes a field into the column of that name on every submission — `email`,
387
+ * `name` or `phone` — so the inbox can sort and search leads without unpacking every row's
388
+ * free-form data. A form free to declare none of these falls back to a field literally
389
+ * named `email`/`name`/`phone`; see `promote()` on the platform.
390
+ */
391
+ export type FormFieldDeclaration = {
392
+ label: string
393
+ name: string
394
+ options?: string[]
395
+ required?: boolean
396
+ role?: 'email' | 'name' | 'phone'
397
+ type: FormFieldType
398
+ }
399
+
400
+ /**
401
+ * What a site declares about one form it collects — a contact form, a careers application.
402
+ *
403
+ * Mirrors `ManifestCollection`'s bargain one level along: the site says what it collects and
404
+ * who should hear about it, the platform stores submissions against the declaration and
405
+ * validates every write to `POST /api/u90/forms/[slug]` against this `fields` snapshot.
406
+ */
407
+ export type FormDeclaration = {
408
+ fields: FormFieldDeclaration[]
409
+ label: string
410
+ /**
411
+ * Seeds the handling half — who is emailed — on first sight only, and never again. A
412
+ * client who repoints the recipients in the dashboard keeps their choice across every
413
+ * later redeploy; this is only ever read once, the moment the form is first synced.
414
+ */
415
+ recipients?: string[]
416
+ /** Stored on every submission as its `form`. Must be an identifier. */
417
+ slug: string
418
+ }
419
+
420
+ /**
421
+ * One route this site serves, and where it sits in the site's own structure.
422
+ *
423
+ * The platform draws its sitemap from these, so a client can see their whole site as a
424
+ * tree and add a page inside any branch of it. Which means the *shape* has to come from
425
+ * here: a path is not a hierarchy. JDSi's `/infrastructure` is a top-level route that
426
+ * belongs under "What We Do" in the menu, and `/about-us/our-team` is nested in both — no
427
+ * rule over paths gets both right, because the answer is the site's information
428
+ * architecture and that lives in the site's own nav.
429
+ *
430
+ * Same bargain as the block schema: the site declares, the platform renders what it is
431
+ * given, and changing the structure is a site deploy rather than a platform change. Use
432
+ * `pagesFromNav` rather than writing these by hand — the nav is already the tree.
433
+ */
434
+ export type PageDeclaration = {
435
+ label: string
436
+ /**
437
+ * What this page starts with, if the site commits content for it.
438
+ *
439
+ * The top-level `seed` on the manifest is the *home* page's, and for a long time it was the
440
+ * only one a site could send — every other declared route arrived at a new tenant empty,
441
+ * however much committed copy the repo held for it. That is right for a route the client is
442
+ * meant to write, and wrong for one the site already renders in full: a legal page whose
443
+ * words live in the repo seeded as a blank, so pairing produced a page that said nothing
444
+ * where the site said plenty.
445
+ *
446
+ * Optional and additive. A site that sends none behaves exactly as before, and the home
447
+ * page keeps arriving through the top-level `seed` — this does not replace it, and a page
448
+ * carrying both is seeded from this one, because the specific beats the general.
449
+ */
450
+ seed?: (Record<string, unknown> & { blockType: string })[]
451
+ /**
452
+ * The slug of the page this one sits under.
453
+ *
454
+ * Must be another declared page's `slug`. Omitted means top level, which the sitemap
455
+ * hangs off the landing page. A parent naming a page that isn't declared, or a cycle,
456
+ * rejects the whole manifest — a tree the platform can't lay out is not a tree.
457
+ */
458
+ parent?: string
459
+ /**
460
+ * Route path with no leading slash — `about-us`, `about-us/our-team`. The landing page
461
+ * is `home`, matching the slug the platform's own page documents use.
462
+ */
463
+ slug: string
464
+ }
465
+
466
+ /**
467
+ * The postMessage protocol version between a site and the dashboard.
468
+ *
469
+ * The dashboard refuses to render its editor when a site reports a version it doesn't
470
+ * speak, because the alternative is edits appearing to save and never showing up. Bump
471
+ * this only alongside a change to the message shapes in `bridge.tsx`, and expect every
472
+ * site to need a `site-kit` update — that is exactly what it is for.
473
+ *
474
+ * **2 — `u90:content` carries `entries`.** Collection rows are editable on Canvas, so the
475
+ * dashboard pushes the rows alongside the page's sections and `useLiveContent` returns
476
+ * them. A site on version 1 renders its rows from the server fetch and ignores the push:
477
+ * a client's keystrokes into a project would do nothing while the page around them
478
+ * updated live, which is exactly what this version number exists to refuse loudly.
479
+ *
480
+ * **3 — `u90:content` carries `layout`.** The chrome a page is drawn inside — its header,
481
+ * its footer — is its own document, and the dashboard now pushes its blocks beside the
482
+ * page's sections so a tagline in a header edits live like anything else on the screen.
483
+ *
484
+ * That much is additive, and on its own would not have moved this number: a site on 2
485
+ * ignores a key it does not read. It bumps because of the other half of the feature, which
486
+ * ships *here* — a site marks its chrome by calling `layoutField`, so a site on 2 marks no
487
+ * layout field at all and its Canvas would show a header the client can see and cannot
488
+ * touch, with nothing saying why. Refusing that site and naming both versions says what
489
+ * has to happen, which is that this package needs re-vendoring. The refusal is a choice to
490
+ * keep the two halves in step, not a wire format they truly disagree about.
491
+ */
492
+ export const BRIDGE_VERSION = 3
493
+
494
+ /**
495
+ * The browsing-context name Canvas gives every site iframe.
496
+ *
497
+ * `window.parent !== window` is the ordinary framed check, but Vercel Sandbox previews run in a
498
+ * sandboxed cross-origin iframe whose child can report itself as its own parent. The name is an
499
+ * explicit identity that survives the preview route's redirects without leaking a secret into
500
+ * the URL. It only enables the bridge; every inbound message is still restricted to the site's
501
+ * configured platform origin.
502
+ */
503
+ export const CANVAS_FRAME_NAME = 'u90-canvas'
504
+
505
+ /** A named arrangement of blocks with one content slot. Absent on a site that declares none. */
506
+ export type LayoutDeclaration = {
507
+ default?: boolean
508
+ entries: (Record<string, unknown> & { blockType: string })[]
509
+ label: string
510
+ /** The declaration key. Stable across re-seeds; the label is not. */
511
+ name: string
512
+ }
513
+
514
+ /**
515
+ * One shared scalar a site says its client starts with.
516
+ *
517
+ * Not a category the platform invented and not a shape it understands: the label is the
518
+ * client's word for the fact, the value is whatever the site shipped with, and the platform
519
+ * learns nothing about what any of them mean beyond whether it is a piece of text or a URL.
520
+ * That is the whole difference from `business()`, which named the facts itself and so could
521
+ * only ever describe the sites it had been written for.
522
+ *
523
+ * The key is the identifier a block's `referenceKey` points at, so it obeys the rule field
524
+ * names obey — see `singles()` in `defineBlocks.ts`.
525
+ */
526
+ export type SingleDeclaration = {
527
+ /** The identifier a `reference()` names. Stable across re-seeds; the label is not. */
528
+ key: string
529
+ /** What the client recognises the value by in their Singles tab. Theirs to change. */
530
+ label: string
531
+ type: 'link' | 'text'
532
+ /** What the site ships with, and what the seeded row starts as. */
533
+ value: string
534
+ }
535
+
536
+ /**
537
+ * What a site publishes at `GET /api/u90/manifest`.
538
+ *
539
+ * The platform fetches this, validates it, and renders its editor from it. It is never
540
+ * accepted over any other route: one authenticated way in is what makes validation
541
+ * worth having.
542
+ */
543
+ export type SiteManifest = {
544
+ blocks: EditorBlock[]
545
+ bridgeVersion: number
546
+ /**
547
+ * Repeatable record types this site renders from. Optional: a site with none is
548
+ * exactly the site this contract described before they existed.
549
+ */
550
+ collections?: ManifestCollection[]
551
+ /** ISO 8601. Only shown to an operator, to judge how fresh a schema is. */
552
+ generatedAt: string
553
+ /**
554
+ * The sections a page starts with when a client adds one in the dashboard.
555
+ *
556
+ * Distinct from `seed`, which is the copy this site already serves and is captured once on
557
+ * a tenant's first pair. This is applied on **every** create, forever, and lands as a
558
+ * draft. The platform cannot invent it: it has no way to know which of a site's blocks
559
+ * belong on a page it did not design — a client-added page should not open with a hero
560
+ * carousel — so the site says, and a site that says nothing gets a blank page.
561
+ *
562
+ * **Position is the address.** An editable element is `${sectionIndex}.${fieldPath}`, so
563
+ * this array's order is the identity of every field on the new page. Reordering it changes
564
+ * what existing pages' stored content means.
565
+ *
566
+ * Give the leaves literal placeholder words rather than leaving them empty. Everywhere else
567
+ * in this contract a blank field means "keep the copy the component ships with", but a
568
+ * brand-new page has no committed copy underneath it — so blank means nothing renders, and
569
+ * an element with no text is one the editing bridge cannot make clickable.
570
+ */
571
+ newPage?: SeedSection[]
572
+ /**
573
+ * What one fresh instance of each block contains, keyed by block slug.
574
+ *
575
+ * Read when a client adds a section in the dashboard. The platform stores exactly this,
576
+ * then lets them edit it.
577
+ *
578
+ * **The platform cannot invent it, and an empty section is worse than none.** A section
579
+ * whose leaves are blank renders nothing, and an element with no text is one the editing
580
+ * bridge cannot make clickable — so appending a hollow block would add an invisible
581
+ * section a client can neither see nor select. The same reasoning `newPage` carries, per
582
+ * block rather than per page.
583
+ *
584
+ * **Here rather than on `EditorBlock`, and that is not filing.** `EditorBlock` is schema,
585
+ * and schema is carrier-independent: a site may declare a default through `default` or
586
+ * through the positional `value` form, and `defineSite` and `defineBlocks` must publish
587
+ * the same fields either way — `verify:define-site` asserts exactly that. Content hung off
588
+ * a block would make its schema depend on which carrier the site happened to use. So this
589
+ * sits beside `seed` and `newPage`, with the other content, and is validated like them.
590
+ *
591
+ * A block absent from this map declares no defaults. The dashboard still offers it and
592
+ * says it will arrive empty, rather than hiding a block the site can render.
593
+ */
594
+ starters?: Record<string, Record<string, unknown>>
595
+ /**
596
+ * Routes the site serves, offered to clients as link targets and drawn as the sitemap.
597
+ *
598
+ * `parent` is what makes it a tree rather than a list; see `PageDeclaration`.
599
+ */
600
+ pages: PageDeclaration[]
601
+ /**
602
+ * True when the site serves a page at **every** slug the platform stores — a catch-all
603
+ * route rather than one hand-built route per address.
604
+ *
605
+ * The dashboard's page tree marks a page it holds that this manifest does not declare as
606
+ * "Not routed", which is the honest thing to say when each address needs its own file and
607
+ * a deploy. With a catch-all it is false, and telling a client the page they just added is
608
+ * unreachable — when it is live — is worse than saying nothing at all.
609
+ *
610
+ * It does not replace `pages`: those are still what the nav lists, what a link picker
611
+ * offers, and what the tree's shape comes from. This only changes what an *undeclared*
612
+ * page is called.
613
+ */
614
+ routesAllPages?: boolean
615
+ /**
616
+ * The site's own committed content, for seeding a tenant on its first pair.
617
+ *
618
+ * Optional, and only ever read once: a tenant that already has a page keeps it.
619
+ * The point is that a client's first session shows their real copy rather than a
620
+ * grid of empty fields — the site looks finished, so the editor should too.
621
+ *
622
+ * It has to come from the site because nothing else can know it. The manifest
623
+ * describes field *names and types*; the values live in the site's own config, and
624
+ * the rendered page cannot be scraped for them either — the editable-field markers
625
+ * only appear once content exists, so a fresh site emits none.
626
+ */
627
+ seed?: SeedSection[]
628
+ /**
629
+ * The site's starting layouts. Seeded once per tenant, client-owned after that — so this
630
+ * is a *starting set*, not a description of what the tenant currently has. See the
631
+ * layouts design on why re-seeding is additive and keyed on `name`.
632
+ */
633
+ layouts?: LayoutDeclaration[]
634
+ /**
635
+ * The shared values this site starts with. Seeded once per tenant, client-owned after
636
+ * that — so this is a *starting set*, like `layouts`, and never a description of what the
637
+ * tenant currently holds.
638
+ *
639
+ * A site that points at a Single with `reference()` and declares nothing here is legal and
640
+ * renders correctly off its committed fallbacks; what it costs the client is discovery,
641
+ * which is the whole reason this key exists. The reverse is legal too: a value declared
642
+ * here that no block references yet is seeded and left alone.
643
+ */
644
+ singles?: SingleDeclaration[]
645
+ /**
646
+ * The forms this site collects, declared once and maintained afterwards by the platform's
647
+ * own sync — a redeploy replaces `fields` on the stored row, and leaves the label, the
648
+ * recipients and whether the client switched it off alone. Optional, on the same terms as
649
+ * `collections`: a site with none is exactly the site this contract described before forms
650
+ * existed.
651
+ */
652
+ forms?: FormDeclaration[]
653
+ }
654
+
655
+ /** One seeded section: a block slug plus values matching that block's fields. */
656
+ export type SeedSection = Record<string, unknown> & { blockType: string }
657
+
658
+ /** The three values a site needs, as returned by `POST /api/u90/pair`. */
659
+ export type PairedCredentials = {
660
+ apiKey: string
661
+ platformUrl: string
662
+ revalidateSecret: string
663
+ tenantSlug: string
664
+ }