@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- 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.0 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
|
+
}
|