@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,993 @@
1
+ import { verifyWithDeadline, type VerifyVisitor } from './verification'
2
+ /**
3
+ * Reading content from the platform.
4
+ *
5
+ * Two properties this layer guarantees, both deliberate and both load-bearing:
6
+ *
7
+ * - **Every field is optional.** A site keeps the copy and assets it ships with as
8
+ * defaults and only overrides what the CMS actually returns, so a missing field
9
+ * changes nothing.
10
+ * - **A failure is never fatal.** If the platform is unreachable, slow or
11
+ * misconfigured, this returns an empty result and the site renders exactly as it
12
+ * does with no CMS at all. A CMS outage must not take a client's site down.
13
+ *
14
+ * Server-side only — it holds the API key. The shapes and merge helpers the components
15
+ * need are in `./shape`, which is safe to import from a client component.
16
+ */
17
+
18
+ import type { LanguageSnapshot } from './languages/runtime'
19
+
20
+ export type LocaleReadOptions = { locale?: string }
21
+
22
+ import { PREVIEW_TOKEN_COOKIE, PREVIEW_UNTIL_COOKIE, previewWindowClosed } from './preview'
23
+
24
+ /** One section as stored: a `blockType` plus whatever the site's schema declares. */
25
+ export type Section = Record<string, unknown> & { blockType?: string }
26
+
27
+ /** A media reference the platform hydrated on egress — an absolute url, never a bare id. */
28
+ export type SeoMedia = { alt: string; url: null | string }
29
+
30
+ /** One page's own SEO group, as the platform resolved it. Every field explicit — see `readSeo`. */
31
+ export type PageSeo = {
32
+ canonical: null | string
33
+ description: null | string
34
+ noindex: boolean
35
+ ogDescription: null | string
36
+ ogImage: null | SeoMedia
37
+ ogTitle: null | string
38
+ title: null | string
39
+ }
40
+
41
+ /** The seven days `openingHoursSpecification` addresses, spelled the way schema.org wants them. */
42
+ export type OpeningHoursDay =
43
+ 'friday' | 'monday' | 'saturday' | 'sunday' | 'thursday' | 'tuesday' | 'wednesday'
44
+
45
+ /**
46
+ * One day's hours, as stored. `opens`/`closes` are 24-hour `HH:MM` — the one format that
47
+ * cannot mean two things (no am/pm to mistype, no locale to get wrong), and exactly what
48
+ * schema.org's own `opens`/`closes` properties expect, so the emitter never has to convert.
49
+ * Both are null while `closed` is true, or while a client has not filled the row in yet.
50
+ */
51
+ export type OpeningHoursRow = {
52
+ closed: boolean
53
+ closes: null | string
54
+ day: OpeningHoursDay
55
+ opens: null | string
56
+ }
57
+
58
+ /** The organisation half of the site-wide SEO singleton — for JSON-LD and contact metadata. */
59
+ export type SeoOrganisation = {
60
+ address: null | string
61
+ email: null | string
62
+ /**
63
+ * The free-text hours a client typed before the structured editor existed — "Mo-Fr
64
+ * 07:00-17:00", "7am-5pm daily", anything. Never parsed, never shown in the dashboard's own
65
+ * editor, and read only as a fallback: `organisationJsonLd` emits it verbatim, the old
66
+ * unstructured way, when `openingHours` below is empty — so a tenant who has not touched the
67
+ * new editor yet does not silently lose the hours they already published.
68
+ */
69
+ legacyOpeningHours: null | string
70
+ legalName: null | string
71
+ logo: null | SeoMedia
72
+ /** Empty means "not set" — the same as every other absent field here, not "closed every day". */
73
+ openingHours: OpeningHoursRow[]
74
+ phone: null | string
75
+ sameAs: string[]
76
+ }
77
+
78
+ /** The tenant's site-wide SEO singleton, as the platform resolved it. */
79
+ export type SiteSeo = {
80
+ allowIndexing: boolean
81
+ bingVerification: null | string
82
+ description: null | string
83
+ googleVerification: null | string
84
+ locale: null | string
85
+ ogImage: null | SeoMedia
86
+ organisation: null | SeoOrganisation
87
+ siteName: null | string
88
+ titleTemplate: null | string
89
+ twitterCard: 'summary' | 'summary_large_image'
90
+ twitterHandle: null | string
91
+ }
92
+
93
+ /** One published page's address and when it last changed, for `sitemap.xml`. */
94
+ export type SitemapEntry = { lastModified: null | string; slug: string }
95
+
96
+ /**
97
+ * One row of a collection, as the platform stores it.
98
+ *
99
+ * The id travels with the values because it is what makes a row addressable: the
100
+ * editing bridge marks a field as `@projects.42.name`, and without the id a site
101
+ * could only say "the third project", which stops being true the moment a client
102
+ * reorders them.
103
+ */
104
+ export type CollectionRow = {
105
+ data: Record<string, unknown>
106
+ id: number | string
107
+ }
108
+
109
+ export type ContentResult = {
110
+ /**
111
+ * `blockType` → its index in the sections array.
112
+ *
113
+ * Editable elements are addressed as `${sectionIndex}.${fieldPath}`, and the index
114
+ * has to come from the data rather than from render order — a reordered page would
115
+ * otherwise point every edit at the wrong section.
116
+ */
117
+ index: Record<string, number>
118
+ /**
119
+ * The layout this page renders inside, resolved by the platform. `null` when the platform
120
+ * said nothing, which a site answers with its own committed layout — the same guarantee
121
+ * every other reader here makes.
122
+ */
123
+ layout: null | {
124
+ entries: (Record<string, unknown> & { blockType: string })[]
125
+ /**
126
+ * Which layout document this is, when the platform said — the `12` in the
127
+ * `~12.0.tagline` a site's chrome stamps through `layoutField`.
128
+ *
129
+ * Optional, because only the editor needs it: a visitor's header renders identically
130
+ * without one, and `layoutField` emits no attribute at all for an `undefined` id. A
131
+ * site's own committed fallback layout has none by construction and never will — it is
132
+ * a declaration in a repo, not a row, and an edit has nothing to address.
133
+ */
134
+ id?: string
135
+ }
136
+ sections: Section[]
137
+ /**
138
+ * This page's own SEO, as the platform resolved it. `null` when the platform said nothing.
139
+ *
140
+ * `ogImage` arrives as a resolved reference with an ABSOLUTE url rather than an id, because
141
+ * the platform hydrates it on egress — `depth` cannot populate it here, and a relative
142
+ * `og:image` is resolved against the sharing service rather than the site, so every unfurl
143
+ * would 404. See `hydrateSeoMedia` on the platform.
144
+ */
145
+ seo: null | PageSeo
146
+ /**
147
+ * The page's own title, as stored on the platform.
148
+ *
149
+ * Carried because `applyTitleTemplate` substitutes it into `%page%`, and without it a site
150
+ * would have to reconstruct a title from its slug — which is how `about-us` ends up in a
151
+ * browser tab. Not for rendering: a site's own heading comes from its blocks.
152
+ */
153
+ title: null | string
154
+ /**
155
+ * Which of the three answers this is.
156
+ *
157
+ * - `ok` — the platform answered and this page exists.
158
+ * - `missing` — it answered, and there is no published page at this slug.
159
+ * - `unavailable` — we could not ask, or the answer was unusable.
160
+ *
161
+ * **Only `missing` may become a 404.** A page and an outage used to be indistinguishable
162
+ * here — both returned an empty result — which is fine for a hand-built page that falls
163
+ * back to its committed copy, and wrong for a catch-all route: 404ing on an empty result
164
+ * turns one CMS outage into a site of 404s, and 200ing on a genuinely absent page hands
165
+ * search engines an empty document for every typo'd URL.
166
+ *
167
+ * A hand-built page can keep ignoring this. It renders its committed copy either way,
168
+ * which is the guarantee that has always mattered most here.
169
+ */
170
+ status: 'missing' | 'ok' | 'unavailable'
171
+ }
172
+
173
+ /** Could not ask, or could not use the answer. Never a 404 — see `ContentResult.status`. */
174
+ const EMPTY: ContentResult = {
175
+ index: {},
176
+ layout: null,
177
+ seo: null,
178
+ sections: [],
179
+ status: 'unavailable',
180
+ title: null,
181
+ }
182
+
183
+ /** Asked, and there is no published page at that slug. The only result a 404 may follow. */
184
+ const MISSING: ContentResult = {
185
+ index: {},
186
+ layout: null,
187
+ seo: null,
188
+ sections: [],
189
+ status: 'missing',
190
+ title: null,
191
+ }
192
+
193
+ /**
194
+ * Give up rather than hold a page render open on a slow CMS — but not before it has woken up.
195
+ *
196
+ * This was 4000, which a cold platform can miss. The platform is a serverless function with
197
+ * Payload, a Postgres pool and a schema to initialise; the first request after an idle spell
198
+ * is slow in a way that has nothing to do with anything being wrong. Missing it meant the site
199
+ * rendered its committed fallback copy and 404'd its collection routes — silently, and
200
+ * intermittently, which is the worst combination to debug.
201
+ *
202
+ * 8 seconds, and one retry, because a cold start is a single event: the attempt that pays for
203
+ * the wake-up fails and the next one is fast. A page that takes nine seconds once an hour is a
204
+ * far better trade than one that quietly drops half its content.
205
+ */
206
+ const TIMEOUT_MS = 8000
207
+
208
+ /** One retry, immediately. See TIMEOUT_MS — the second attempt meets a warm function. */
209
+ const ATTEMPTS = 2
210
+
211
+ export type ContentClientOptions = {
212
+ /** Select this site even when the authenticated actor can read several tenants. */
213
+ tenantId?: number | string
214
+ apiKey?: string
215
+ /** Defaults to the universal collection. */
216
+ collection?: string
217
+ platformUrl?: string
218
+ /** Next's `draftMode()`/`cookies()`, when the site supports preview. */
219
+ preview?: {
220
+ cookies: () => Promise<{ get: (name: string) => undefined | { value: string } }>
221
+ draftMode: () => Promise<{ isEnabled: boolean }>
222
+ }
223
+ }
224
+
225
+ const indexSections = (sections: Section[]): Record<string, number> => {
226
+ const index: Record<string, number> = {}
227
+ sections.forEach((section, position) => {
228
+ if (section.blockType && index[section.blockType] === undefined) {
229
+ index[section.blockType] = position
230
+ }
231
+ })
232
+ return index
233
+ }
234
+
235
+ /**
236
+ * `layout` only ever reaches a site because `attachResolvedLayout` on the platform put it
237
+ * there — there is no schema between us enforcing its shape. Read it as untrusted: a
238
+ * platform version skew, a hand-edited response, or a resolved row missing `entries`
239
+ * entirely must fall back to `null` here rather than hand a site something it renders a
240
+ * blank page over. `null` is also what every unresolved case already means — no layout
241
+ * configured, no default, a deleted row — so a malformed one joins that list rather than
242
+ * getting a category of its own.
243
+ */
244
+ const readLayout = (value: unknown): ContentResult['layout'] => {
245
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
246
+ const entries = (value as { entries?: unknown }).entries
247
+ if (!Array.isArray(entries)) return null
248
+ const rows = entries as (Record<string, unknown> & { blockType: string })[]
249
+ /*
250
+ * The id is the half a site cannot work out for itself, and the half it may be handed
251
+ * nothing for. Kept when it is there — as a string, since the address it prefixes is text
252
+ * and the platform's row ids are `number | string` — and simply absent when it is not.
253
+ * Refusing the layout over a missing id would blank a visitor's header for a reason only
254
+ * the editor has.
255
+ */
256
+ const id = (value as { id?: unknown }).id
257
+ return typeof id === 'number' || typeof id === 'string'
258
+ ? { entries: rows, id: String(id) }
259
+ : { entries: rows }
260
+ }
261
+
262
+ /** A media reference the platform hydrated, or null. Never a bare id — see `ContentResult.seo`. */
263
+ const readSeoMedia = (value: unknown): null | SeoMedia => {
264
+ if (!value || typeof value !== 'object') return null
265
+ const record = value as { alt?: unknown; url?: unknown }
266
+ if (typeof record.url !== 'string' || record.url === '') return null
267
+ return { alt: typeof record.alt === 'string' ? record.alt : '', url: record.url }
268
+ }
269
+
270
+ const OPENING_HOURS_DAYS: readonly OpeningHoursDay[] = [
271
+ 'monday',
272
+ 'tuesday',
273
+ 'wednesday',
274
+ 'thursday',
275
+ 'friday',
276
+ 'saturday',
277
+ 'sunday',
278
+ ]
279
+
280
+ /** `07:00`, `17:30` — 24-hour `HH:MM`. Anything else is not a time this emitter can trust. */
281
+ const isOpeningHoursTime = (value: unknown): value is string =>
282
+ typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
283
+
284
+ /**
285
+ * One row of the platform's `organisation.openingHours` array, believed field by field. An
286
+ * unrecognised `day` (a platform ahead of this package's enum, or a hand-edited response) drops
287
+ * the row rather than passing through a value `organisationJsonLd` cannot map to schema.org's
288
+ * `dayOfWeek`. A malformed time is dropped the same way `opens`/`closes` are on `PageSeo` —
289
+ * treated as absent, not as a reason to fail the whole row.
290
+ */
291
+ const readOpeningHoursRow = (value: unknown): null | OpeningHoursRow => {
292
+ if (!value || typeof value !== 'object') return null
293
+ const record = value as Record<string, unknown>
294
+ if (!OPENING_HOURS_DAYS.includes(record.day as OpeningHoursDay)) return null
295
+ return {
296
+ closed: record.closed === true,
297
+ closes: isOpeningHoursTime(record.closes) ? record.closes : null,
298
+ day: record.day as OpeningHoursDay,
299
+ opens: isOpeningHoursTime(record.opens) ? record.opens : null,
300
+ }
301
+ }
302
+
303
+ /** The platform's `organisation.openingHours` array, believed row by row. Never throws on junk. */
304
+ const readOpeningHours = (value: unknown): OpeningHoursRow[] =>
305
+ Array.isArray(value)
306
+ ? value.map(readOpeningHoursRow).filter((row): row is OpeningHoursRow => row !== null)
307
+ : []
308
+
309
+ /**
310
+ * A page's SEO group, believed field by field.
311
+ *
312
+ * Every field is optional on the way in and explicit on the way out, so `metadataFor` never
313
+ * has to distinguish "absent" from "empty" — both are null here, and both mean inherit.
314
+ */
315
+ const readSeo = (value: unknown): null | PageSeo => {
316
+ if (!value || typeof value !== 'object') return null
317
+ const record = value as Record<string, unknown>
318
+ const text = (key: string): null | string => {
319
+ const found = record[key]
320
+ return typeof found === 'string' && found.trim() !== '' ? found : null
321
+ }
322
+ return {
323
+ canonical: text('canonical'),
324
+ description: text('description'),
325
+ noindex: record.noindex === true,
326
+ ogDescription: text('ogDescription'),
327
+ ogImage: readSeoMedia(record.ogImage),
328
+ ogTitle: text('ogTitle'),
329
+ title: text('title'),
330
+ }
331
+ }
332
+
333
+ export const createContentClient = ({
334
+ apiKey = process.env.PAYLOAD_API_KEY,
335
+ collection = 'pages',
336
+ platformUrl = process.env.PLATFORM_URL,
337
+ preview,
338
+ tenantId,
339
+ }: ContentClientOptions = {}) => {
340
+ /**
341
+ * Say so, once, when this site cannot reach its CMS at all.
342
+ *
343
+ * Both readers return empty on failure so that a CMS outage renders the site's committed
344
+ * fallbacks instead of an error page. That is the right behaviour for a visitor and it was
345
+ * indistinguishable from an empty CMS for everybody else: a site missing `PLATFORM_URL`
346
+ * renders its seed copy, looks entirely fine, and 404s every collection detail route —
347
+ * with nothing in any log to say why. It cost an hour of looking in the wrong place.
348
+ *
349
+ * Once per process, because these are read on every request of every render.
350
+ */
351
+ let warned = false
352
+ const unconfigured = (): boolean => {
353
+ if (platformUrl && apiKey) return false
354
+ if (!warned) {
355
+ warned = true
356
+ console.error(
357
+ `[u90] This site cannot read its CMS: ${
358
+ !platformUrl && !apiKey
359
+ ? 'PLATFORM_URL and PAYLOAD_API_KEY are both unset'
360
+ : !platformUrl
361
+ ? 'PLATFORM_URL is unset'
362
+ : 'PAYLOAD_API_KEY is unset'
363
+ }. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.1 pair\` writes both.`,
364
+ )
365
+ }
366
+ return true
367
+ }
368
+ /**
369
+ * Whether this request should read staged content, and whose staged content.
370
+ *
371
+ * Shared by both readers so they can never disagree — a page showing the draft while
372
+ * its projects showed the published rows would be a preview that lies in exactly the
373
+ * place a client is most likely to be checking.
374
+ */
375
+ const resolvePreview = async (
376
+ request?: Request,
377
+ ): Promise<{ isDraft: boolean; token?: string }> => {
378
+ if (!preview) return { isDraft: false }
379
+ // Proxy does not have the render request's draft-mode provider. The explicit request
380
+ // carries only a preview attempt: the platform still authenticates its JWT and tenant.
381
+ if (request) {
382
+ const values = new Map<string, string>()
383
+ for (const entry of request.headers.get('cookie')?.split(';') ?? []) {
384
+ const separator = entry.indexOf('=')
385
+ if (separator < 0) continue
386
+ try {
387
+ values.set(
388
+ entry.slice(0, separator).trim(),
389
+ decodeURIComponent(entry.slice(separator + 1).trim()),
390
+ )
391
+ } catch {
392
+ /* Ignore malformed cookies. */
393
+ }
394
+ }
395
+ if (
396
+ !values.get('__prerender_bypass') ||
397
+ previewWindowClosed(values.get(PREVIEW_UNTIL_COOKIE))
398
+ )
399
+ return { isDraft: false }
400
+ return {
401
+ isDraft: Boolean(values.get(PREVIEW_TOKEN_COOKIE)),
402
+ token: values.get(PREVIEW_TOKEN_COOKIE),
403
+ }
404
+ }
405
+ try {
406
+ const isDraft = (await preview.draftMode()).isEnabled
407
+ if (!isDraft) return { isDraft: false }
408
+
409
+ const jar = await preview.cookies()
410
+
411
+ /*
412
+ * Next's draft cookie says a preview was started. It does not say one is still
413
+ * running, and nothing expires it — see `./preview`. A lapsed window reads as
414
+ * published, which is both the safe answer and the one a visitor would get.
415
+ */
416
+ if (previewWindowClosed(jar.get(PREVIEW_UNTIL_COOKIE)?.value)) return { isDraft: false }
417
+
418
+ return { isDraft, token: jar.get(PREVIEW_TOKEN_COOKIE)?.value }
419
+ } catch {
420
+ // Outside a request scope, e.g. at build time. Treat as published.
421
+ return { isDraft: false }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * The site key remains the primary tenant boundary. The platform independently verifies
427
+ * the preview session and its editor permissions for that same explicit tenant; it never
428
+ * substitutes the editor's potentially broader access for the site's own access.
429
+ * Custom headers survive cross-origin redirects, so token-bearing requests reject them.
430
+ */
431
+ const contentAuth = (
432
+ isDraft: boolean,
433
+ token?: string,
434
+ ): Pick<RequestInit, 'headers' | 'redirect'> => ({
435
+ headers: {
436
+ Authorization: `users API-Key ${apiKey}`,
437
+ ...(isDraft && token ? { 'X-U90-Preview-Token': token } : {}),
438
+ },
439
+ ...(isDraft && token ? { redirect: 'error' as const } : {}),
440
+ })
441
+
442
+ /**
443
+ * Authentication and authorization are different failures. A valid site key can be
444
+ * refused because of tenant access or an editor's preview permissions.
445
+ */
446
+ const explain = (status: number, what: string): string => {
447
+ if (status === 401) {
448
+ return `[u90] The platform could not authenticate the request (401) reading ${what}. Check the site's configured credentials and any active preview session.`
449
+ }
450
+ if (status === 403) {
451
+ return `[u90] The platform denied access (403) reading ${what}. Check the site's tenant permissions and, for preview reads, the signed-in editor's permission to preview this tenant.`
452
+ }
453
+ if (status === 404) {
454
+ return `[u90] The platform has no ${what} (404). Check PLATFORM_URL points at the platform and that this tenant owns that content.`
455
+ }
456
+ if (status >= 500) {
457
+ return `[u90] The platform failed (${status}) reading ${what}. Rendering fallback copy.`
458
+ }
459
+ return `[u90] platform returned ${status} for ${what}`
460
+ }
461
+
462
+ /**
463
+ * `fetch` with one retry, and a log that says which attempt failed.
464
+ *
465
+ * Retries a timeout or a network error — the cold-start shapes. A *response* is never
466
+ * retried, however bad its status: a 403 will be a 403 again, and hammering the platform
467
+ * because it said no is how one broken credential becomes a load problem.
468
+ */
469
+ const fetchWithRetry = async (
470
+ url: URL,
471
+ init: RequestInit,
472
+ what: string,
473
+ ): Promise<null | Response> => {
474
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
475
+ const controller = new AbortController()
476
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
477
+ try {
478
+ return await fetch(url, { ...init, signal: controller.signal })
479
+ } catch (error) {
480
+ const last = attempt === ATTEMPTS
481
+ const aborted = (error as { name?: string })?.name === 'AbortError'
482
+ console.error(
483
+ `[u90] ${aborted ? `timed out after ${TIMEOUT_MS}ms` : 'could not reach the platform'} reading ${what} (attempt ${attempt}/${ATTEMPTS})${
484
+ last ? ' — rendering fallback copy' : ', retrying'
485
+ }`,
486
+ )
487
+ if (last) return null
488
+ } finally {
489
+ clearTimeout(timeout)
490
+ }
491
+ }
492
+ return null
493
+ }
494
+
495
+ const getPage = async (slug: string, options: LocaleReadOptions = {}): Promise<ContentResult> => {
496
+ if (unconfigured()) return EMPTY
497
+
498
+ const { isDraft, token } = await resolvePreview()
499
+
500
+ const url = new URL(`/api/${collection}`, platformUrl)
501
+ if (tenantId !== undefined) url.searchParams.set('where[tenant][equals]', String(tenantId))
502
+ if (options.locale) url.searchParams.set('u90Locale', options.locale)
503
+ url.searchParams.set('where[slug][equals]', slug)
504
+ url.searchParams.set('limit', '1')
505
+ /*
506
+ * `depth=0` deliberately. Uploads inside `content` are ids in a JSON column, so
507
+ * depth cannot populate them — the platform resolves them itself on the way out,
508
+ * and asking for depth only makes it join relations nothing here reads.
509
+ */
510
+ url.searchParams.set('depth', '0')
511
+
512
+ if (isDraft && token) {
513
+ url.searchParams.set('draft', 'true')
514
+ } else {
515
+ /*
516
+ * The published row is what a visitor must see. Drafts live in a separate
517
+ * versions table and the published row is left untouched when one is saved, so
518
+ * this filter is the thing keeping unpublished work off the live site.
519
+ */
520
+ url.searchParams.set('where[_status][equals]', 'published')
521
+ }
522
+
523
+ try {
524
+ const response = await fetchWithRetry(
525
+ url,
526
+ {
527
+ ...contentAuth(isDraft, token),
528
+ ...(isDraft
529
+ ? { cache: 'no-store' as const }
530
+ : {
531
+ next: {
532
+ tags: [
533
+ collection,
534
+ `${collection}:${slug}`,
535
+ ...(options.locale ? ['language-settings', 'translations', 'entries'] : []),
536
+ ],
537
+ },
538
+ }),
539
+ },
540
+ `${collection}/${slug}`,
541
+ )
542
+
543
+ if (!response) return EMPTY
544
+
545
+ if (!response.ok) {
546
+ console.error(explain(response.status, `${collection}/${slug}`))
547
+ return EMPTY
548
+ }
549
+
550
+ const body = (await response.json()) as {
551
+ docs?: { content?: Section[]; layout?: unknown; seo?: unknown; title?: unknown }[]
552
+ }
553
+
554
+ /*
555
+ * No document is a *different answer* from no sections, and only this branch knows
556
+ * which happened: the platform replied, and the published-only filter matched nothing.
557
+ * A page that exists with an empty `content` array is `ok`, and renders as a page with
558
+ * no sections — which is what a client sees between adding a page and writing it.
559
+ */
560
+ const doc = body.docs?.[0]
561
+ if (!doc) return MISSING
562
+
563
+ const sections = doc.content ?? []
564
+
565
+ return {
566
+ index: indexSections(sections),
567
+ layout: readLayout(doc.layout),
568
+ seo: readSeo((doc as { seo?: unknown }).seo),
569
+ sections,
570
+ status: 'ok',
571
+ title: typeof doc.title === 'string' && doc.title !== '' ? doc.title : null,
572
+ }
573
+ } catch (error) {
574
+ console.error(
575
+ `[u90] could not load ${collection}/${slug}: ${
576
+ error instanceof Error ? error.message : String(error)
577
+ }`,
578
+ )
579
+ return EMPTY
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Read one of this site's collections — its projects, its team — in the order the
585
+ * client arranged them.
586
+ *
587
+ * Same two guarantees as `getPage`: a failure returns an empty list rather than
588
+ * throwing, so a CMS outage renders the site's committed fallbacks instead of an
589
+ * error page.
590
+ *
591
+ * Rows stage like pages do. This used to read them unfiltered, because `entries` had
592
+ * no draft cycle and saving a row was publishing it — which meant a client's edit to
593
+ * a project reached visitors the moment they typed it, while the page around it
594
+ * waited for Publish. Both halves are staged now, and both are read the same way.
595
+ */
596
+ const readAllPages = async <T>(url: URL, init: RequestInit, label: string): Promise<T[]> => {
597
+ const docs: T[] = []
598
+ let page = 1
599
+ while (true) {
600
+ url.searchParams.set('page', String(page))
601
+ const response = await fetchWithRetry(url, init, label)
602
+ if (!response?.ok)
603
+ throw new Error(response ? explain(response.status, label) : 'Content unavailable')
604
+ const body = (await response.json()) as {
605
+ docs?: T[]
606
+ hasNextPage?: boolean
607
+ nextPage?: number
608
+ }
609
+ docs.push(...(body.docs ?? []))
610
+ if (!body.hasNextPage) return docs
611
+ const next = body.nextPage ?? page + 1
612
+ if (!Number.isInteger(next) || next <= page) throw new Error('Invalid content pagination')
613
+ page = next
614
+ }
615
+ }
616
+
617
+ const getCollection = async (
618
+ slug: string,
619
+ options: LocaleReadOptions = {},
620
+ ): Promise<CollectionRow[]> => {
621
+ if (unconfigured()) return []
622
+
623
+ const { isDraft, token } = await resolvePreview()
624
+
625
+ const url = new URL('/api/entries', platformUrl)
626
+ if (tenantId !== undefined) url.searchParams.set('where[tenant][equals]', String(tenantId))
627
+ if (options.locale) url.searchParams.set('u90Locale', options.locale)
628
+ url.searchParams.set('where[collection][equals]', slug)
629
+ // The order the client dragged them into. Ties fall back to creation order.
630
+ url.searchParams.set('sort', 'sortOrder')
631
+ url.searchParams.set('limit', '200')
632
+ // Uploads inside `data` are ids in a JSON column; the platform resolves them on
633
+ // the way out, exactly as it does for a page's sections.
634
+ url.searchParams.set('depth', '0')
635
+
636
+ if (isDraft && token) {
637
+ url.searchParams.set('draft', 'true')
638
+ } else {
639
+ // The filter that keeps a staged row off the live site, exactly as on `getPage`.
640
+ url.searchParams.set('where[_status][equals]', 'published')
641
+ }
642
+
643
+ try {
644
+ const docs = await readAllPages<{ data?: unknown; id: number | string }>(
645
+ url,
646
+ {
647
+ ...contentAuth(isDraft, token),
648
+ ...(isDraft
649
+ ? { cache: 'no-store' as const }
650
+ : {
651
+ next: {
652
+ tags: [
653
+ 'entries',
654
+ `entries:${slug}`,
655
+ ...(options.locale ? ['language-settings', 'translations'] : []),
656
+ ],
657
+ },
658
+ }),
659
+ },
660
+ `entries/${slug}`,
661
+ )
662
+
663
+ return docs.map((doc) => ({
664
+ data:
665
+ doc.data && typeof doc.data === 'object' && !Array.isArray(doc.data)
666
+ ? (doc.data as Record<string, unknown>)
667
+ : {},
668
+ id: doc.id,
669
+ }))
670
+ } catch (error) {
671
+ console.error(
672
+ `[u90] could not load entries/${slug}: ${
673
+ error instanceof Error ? error.message : String(error)
674
+ }`,
675
+ )
676
+ return []
677
+ }
678
+ }
679
+
680
+ /**
681
+ * The tenant's site-wide SEO, or null.
682
+ *
683
+ * Tagged `site-seo`, which is the same string the platform sends as `collection` when the
684
+ * row changes — so the revalidate route already purges this with no change of its own. Get
685
+ * that string wrong and a client's save appears to work and changes nothing until the cache
686
+ * expires on its own.
687
+ *
688
+ * No `_status` filter and no draft branch: this collection has no drafts on the platform. A
689
+ * site-wide setting is configuration, and there is no staged copy of it to choose between.
690
+ *
691
+ * Null on any failure, never a throw. A site whose layout throws renders an error page
692
+ * instead of the client's website, and missing SEO is not worth that.
693
+ */
694
+ const getSiteSeo = async (options: LocaleReadOptions = {}): Promise<null | SiteSeo> => {
695
+ if (unconfigured()) return null
696
+
697
+ const { isDraft, token } = options.locale
698
+ ? await resolvePreview()
699
+ : { isDraft: false, token: undefined }
700
+ const url = new URL('/api/site-seo', platformUrl)
701
+ if (tenantId !== undefined) url.searchParams.set('where[tenant][equals]', String(tenantId))
702
+ if (options.locale) url.searchParams.set('u90Locale', options.locale)
703
+ if (isDraft && token) url.searchParams.set('draft', 'true')
704
+ url.searchParams.set('limit', '1')
705
+ // depth=1 so `ogImage` and `organisation.logo` arrive as documents rather than ids. These
706
+ // are real relationships on a small single row, so the join is cheap and there is no JSON
707
+ // column in the way — unlike a page's `content`, which is why `getPage` stays at depth 0.
708
+ url.searchParams.set('depth', '1')
709
+
710
+ try {
711
+ const response = await fetchWithRetry(
712
+ url,
713
+ {
714
+ ...contentAuth(isDraft, token),
715
+ // Spread rather than a literal property: `next` isn't in DOM's `RequestInit`, and
716
+ // this package deliberately carries no Next types to widen it — see `getPage`.
717
+ ...(isDraft
718
+ ? { cache: 'no-store' as const }
719
+ : {
720
+ next: {
721
+ tags: [
722
+ 'site-seo',
723
+ ...(options.locale ? ['language-settings', 'translations'] : []),
724
+ ],
725
+ },
726
+ }),
727
+ },
728
+ 'site-seo',
729
+ )
730
+
731
+ if (!response) return null
732
+ if (!response.ok) {
733
+ console.error(explain(response.status, 'site-seo'))
734
+ return null
735
+ }
736
+
737
+ const body = (await response.json()) as { docs?: Record<string, unknown>[] }
738
+ const row = body.docs?.[0]
739
+ if (!row) return null
740
+
741
+ const text = (key: string): null | string => {
742
+ const found = row[key]
743
+ return typeof found === 'string' && found.trim() !== '' ? found : null
744
+ }
745
+
746
+ const organisation = (row.organisation ?? null) as null | Record<string, unknown>
747
+
748
+ return {
749
+ allowIndexing: row.allowIndexing === true,
750
+ bingVerification: text('bingVerification'),
751
+ description: text('description'),
752
+ googleVerification: text('googleVerification'),
753
+ locale: text('locale'),
754
+ ogImage: readSeoMedia(row.ogImage),
755
+ organisation: organisation
756
+ ? {
757
+ address:
758
+ typeof organisation.address === 'string' && organisation.address.trim() !== ''
759
+ ? organisation.address
760
+ : null,
761
+ email:
762
+ typeof organisation.email === 'string' && organisation.email.trim() !== ''
763
+ ? organisation.email
764
+ : null,
765
+ legacyOpeningHours:
766
+ typeof organisation.legacyOpeningHours === 'string' &&
767
+ organisation.legacyOpeningHours.trim() !== ''
768
+ ? organisation.legacyOpeningHours
769
+ : null,
770
+ legalName:
771
+ typeof organisation.legalName === 'string' && organisation.legalName.trim() !== ''
772
+ ? organisation.legalName
773
+ : null,
774
+ logo: readSeoMedia(organisation.logo),
775
+ openingHours: readOpeningHours(organisation.openingHours),
776
+ phone:
777
+ typeof organisation.phone === 'string' && organisation.phone.trim() !== ''
778
+ ? organisation.phone
779
+ : null,
780
+ sameAs: Array.isArray(organisation.sameAs)
781
+ ? (organisation.sameAs as { url?: unknown }[])
782
+ .map((entry) => (typeof entry?.url === 'string' ? entry.url : ''))
783
+ .filter((entry) => entry !== '')
784
+ : [],
785
+ }
786
+ : null,
787
+ siteName: text('siteName'),
788
+ titleTemplate: text('titleTemplate'),
789
+ twitterCard: row.twitterCard === 'summary' ? 'summary' : 'summary_large_image',
790
+ twitterHandle: text('twitterHandle'),
791
+ }
792
+ } catch {
793
+ return null
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Every published page's address and when it last changed, for `sitemap.xml`.
799
+ *
800
+ * `noindex` pages are dropped here rather than at the caller, so a sitemap and a meta tag
801
+ * can never contradict each other — a page listed in a sitemap and refused in a robots tag
802
+ * is an error crawlers report.
803
+ *
804
+ * Published only, never drafts: a sitemap is for visitors and crawlers, and a staged page
805
+ * has no address a visitor can reach.
806
+ */
807
+ const getSitemapEntries = async (): Promise<SitemapEntry[]> => {
808
+ if (unconfigured()) return []
809
+
810
+ const url = new URL(`/api/${collection}`, platformUrl)
811
+ if (tenantId !== undefined) url.searchParams.set('where[tenant][equals]', String(tenantId))
812
+ url.searchParams.set('where[_status][equals]', 'published')
813
+ url.searchParams.set('limit', '500')
814
+ url.searchParams.set('depth', '0')
815
+
816
+ try {
817
+ const docs = await readAllPages<{
818
+ seo?: { noindex?: unknown }
819
+ slug?: unknown
820
+ updatedAt?: unknown
821
+ }>(
822
+ url,
823
+ {
824
+ headers: { Authorization: `users API-Key ${apiKey}` },
825
+ // Spread rather than a literal property — see the note in `getSiteSeo`.
826
+ ...{ next: { tags: [collection] } },
827
+ },
828
+ 'sitemap',
829
+ )
830
+
831
+ return docs
832
+ .filter((doc) => typeof doc.slug === 'string' && doc.slug !== '')
833
+ .filter((doc) => doc.seo?.noindex !== true)
834
+ .map((doc) => ({
835
+ lastModified: typeof doc.updatedAt === 'string' ? doc.updatedAt : null,
836
+ slug: doc.slug as string,
837
+ }))
838
+ } catch {
839
+ return []
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Post a submission to one of this site's declared forms. Server-side only — it uses the
845
+ * API key.
846
+ *
847
+ * A browser action proof (or a retained retry grant) is mandatory. The platform chooses
848
+ * and assesses the configured provider. Proof and grant travel separately from answers.
849
+ * Keep requestId and the onGrant result for an identical retry after an uncertain response.
850
+ */
851
+ const submitForm = async (
852
+ slug: string,
853
+ data: Record<string, unknown>,
854
+ options?: {
855
+ protection?: {
856
+ honeypot?: string
857
+ /** Deprecated: public Forms use aggregate admission without a signed visitor cookie. */
858
+ clientIdentity?: string
859
+ verification?: {
860
+ proof?: string | null
861
+ grant?: string
862
+ requestId?: string
863
+ /** Supplemental only; never replaces the central assessment. */
864
+ verifyVisitor?: VerifyVisitor
865
+ onGrant?: (grant: string, requestId: string) => void
866
+ }
867
+ }
868
+ },
869
+ ): Promise<boolean> => {
870
+ if (unconfigured()) return false
871
+ try {
872
+ const protection = options?.protection
873
+ const honeypot = protection?.honeypot
874
+ const identity = protection?.clientIdentity
875
+ if (
876
+ honeypot !== undefined &&
877
+ (typeof honeypot !== 'string' || honeypot.length > 256 || honeypot !== '')
878
+ )
879
+ return false
880
+ if (
881
+ identity !== undefined &&
882
+ (typeof identity !== 'string' ||
883
+ !identity ||
884
+ new TextEncoder().encode(identity).length > 256 ||
885
+ /[\u0000-\u001f\u007f]/.test(identity))
886
+ )
887
+ return false
888
+ const url = new URL(`/api/u90/forms/${encodeURIComponent(slug)}`, platformUrl)
889
+ const headers = {
890
+ Authorization: `users API-Key ${apiKey}`,
891
+ 'Content-Type': 'application/json',
892
+ ...(identity === undefined
893
+ ? {}
894
+ : { 'x-u90-protection-client': encodeURIComponent(identity) }),
895
+ ...(honeypot === undefined ? {} : { 'x-u90-protection-honeypot': honeypot }),
896
+ }
897
+ const verification = protection?.verification
898
+ if (!verification) return false
899
+ const requestId = verification.requestId ?? crypto.randomUUID()
900
+ let grant = verification.grant
901
+ if (!grant) {
902
+ const proof = verification.proof
903
+ if (proof !== null && (!proof || new TextEncoder().encode(proof).length > 4096))
904
+ return false
905
+ if (
906
+ proof !== null &&
907
+ verification.verifyVisitor &&
908
+ !(await verifyWithDeadline(verification.verifyVisitor, url.href, proof)).ok
909
+ )
910
+ return false
911
+ const response = await fetch(url, {
912
+ method: 'POST',
913
+ headers,
914
+ body: JSON.stringify({
915
+ values: data,
916
+ verification: { requestId, mode: 'verify', proof },
917
+ }),
918
+ redirect: 'error',
919
+ cache: 'no-store',
920
+ signal: AbortSignal.timeout(15000),
921
+ })
922
+ if (!response.ok) return false
923
+ const result = await response.json()
924
+ if (typeof result.grant !== 'string' || result.grant.length > 4096) return false
925
+ grant = result.grant
926
+ verification.onGrant?.(grant!, requestId)
927
+ }
928
+ const response = await fetch(url, {
929
+ method: 'POST',
930
+ headers,
931
+ redirect: 'error',
932
+ cache: 'no-store',
933
+ signal: AbortSignal.timeout(30000),
934
+ body: JSON.stringify({ values: data, verification: { requestId, mode: 'submit', grant } }),
935
+ })
936
+ return response.ok
937
+ } catch {
938
+ return false
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Post a contact-form submission. Server-side only — it uses the API key.
944
+ *
945
+ * Uses the lazily created contact form and the same mandatory verification options as
946
+ * submitForm. Sites must coordinate their browser proof integration with this upgrade.
947
+ */
948
+ const submitEnquiry = async (
949
+ enquiry: {
950
+ email: string
951
+ message: string
952
+ name: string
953
+ phone?: string
954
+ sourcePage?: string
955
+ },
956
+ options?: Parameters<typeof submitForm>[2],
957
+ ): Promise<boolean> => submitForm('contact', enquiry, options)
958
+
959
+ const getLanguages = async (request?: Request): Promise<LanguageSnapshot> => {
960
+ if (unconfigured() || tenantId === undefined)
961
+ throw new Error('Languages needs configured credentials and an explicit tenantId')
962
+ const { isDraft, token } = await resolvePreview(request)
963
+ const url = new URL(`/api/languages/${isDraft && token ? 'preview' : 'published'}`, platformUrl)
964
+ url.searchParams.set('tenant', String(tenantId))
965
+ const response = await fetchWithRetry(
966
+ url,
967
+ {
968
+ ...contentAuth(isDraft, token),
969
+ cache: 'no-store',
970
+ },
971
+ 'languages',
972
+ )
973
+ if (!response) throw new Error('Languages unavailable (network)')
974
+ if (!response.ok) throw new Error(explain(response.status, 'languages'))
975
+ const result = (await response.json()) as LanguageSnapshot
976
+ if (
977
+ result.version !== 1 ||
978
+ !Array.isArray(result.owners) ||
979
+ !(result.settings === null || typeof result.settings === 'object')
980
+ )
981
+ throw new Error('Invalid Languages response')
982
+ return result
983
+ }
984
+ return {
985
+ getCollection,
986
+ getPage,
987
+ getSitemapEntries,
988
+ getSiteSeo,
989
+ getLanguages,
990
+ submitEnquiry,
991
+ submitForm,
992
+ }
993
+ }