@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,164 @@
1
+ import { MEDIA_SANDBOX } from '../test-preview/media'
2
+ import { createDraftBinding, previewBuildId } from '../test-preview/binding'
3
+ import { timingSafeEqual } from 'node:crypto'
4
+ import { manifestHash, type SiteManifest } from '../site'
5
+ import { createTestPreviewRuntime, EXPECTED_PREVIEW_PARAM, previewResponseHeaders, requestPreviewToken, type TestPreviewContext, type TestPreviewRuntime } from '../test-preview/runtime'
6
+ import { TEST_PREVIEW_ACTIONS, verifyGuardAttestation } from '../test-preview/guard'
7
+ import { isTestPreviewToken } from '../test-preview/protocol.mjs'
8
+ import type { NextAdapters } from './adapters'
9
+ import type { Env } from './read'
10
+
11
+ const SENTINEL = 'http://u90.invalid'
12
+ /**
13
+ * A redirect target from a query string, reduced to a path on this site (R35, R41). The candidate is
14
+ * resolved the way a browser will resolve the `Location` — against a sentinel origin, so tab,
15
+ * newline and carriage return are stripped, a backslash reads as a slash and `//host` is a host —
16
+ * and kept only if it is still on that origin; anything else, or anything that does not parse, is
17
+ * `/`. What goes out is rebuilt from the parsed parts, so no control character reaches the header,
18
+ * and with the pathname's leading slashes collapsed to one: a dot segment can normalise a path that
19
+ * is still on the sentinel origin to a leading `//` (`/.//evil.example/x`, `/x/..//evil.example`
20
+ * and `/%2e//evil.example` all resolve to a pathname of `//evil.example…`), which a browser reads
21
+ * as protocol-relative — another host. exit-preview needs no secret, so without this every site
22
+ * would be an open redirect.
23
+ */
24
+ export const safePath = (value: string | null): string => {
25
+ if (!value) return '/'
26
+ try {
27
+ const url = new URL(value, SENTINEL)
28
+ return url.origin === SENTINEL ? '/' + url.pathname.replace(/^\/+/, '') + url.search + url.hash : '/'
29
+ } catch {
30
+ return '/'
31
+ }
32
+ }
33
+ /** Explicit exit recovers ordinary navigation; retain other query bytes and the fragment. */
34
+ const exitPath = (value: string | null): string => {
35
+ const local = new URL(safePath(value), SENTINEL)
36
+ const query = local.search.slice(1).split('&').filter(part => !new URLSearchParams(part).has(EXPECTED_PREVIEW_PARAM))
37
+ return local.pathname + (query.some(Boolean) ? '?' + query.join('&') : '') + local.hash
38
+ }
39
+ /** Constant time, so a wrong secret cannot be found a byte at a time; `timingSafeEqual` needs equal lengths. */
40
+ const sameSecret = (provided: string | null, secret: string | undefined): boolean => {
41
+ if (!secret || provided === null) return false
42
+ const a = Buffer.from(provided), b = Buffer.from(secret)
43
+ return a.length === b.length && timingSafeEqual(a, b)
44
+ }
45
+ const bearer = (req: Request): string | null => {
46
+ const header = req.headers.get('authorization')
47
+ return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : null
48
+ }
49
+
50
+ const MEDIA_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-security-policy', 'x-content-type-options', 'referrer-policy'] as const
51
+ const MEDIA_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
52
+ const MEDIA_HEADERS_TIMEOUT_MS = 25_000
53
+
54
+ export function createHandlers(manifest: SiteManifest, env: Env, adapters: NextAdapters, fetchImpl: typeof fetch = fetch, testPreview: TestPreviewRuntime = createTestPreviewRuntime({ env, fetch: fetchImpl }), now?: () => number, buildId?: string) {
55
+ const authed = (req: Request) => sameSecret(bearer(req), env.REVALIDATE_SECRET)
56
+ const previewJson = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json', ...previewResponseHeaders } })
57
+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
58
+ /**
59
+ * `/api/u90/media/...` on the site's own origin, forwarded server-to-server to the platform (spec D5). It
60
+ * can only ever fetch the platform's media route: two or three plain segments, nothing else. Only the media
61
+ * headers come back, and the sandbox is reapplied if missing, because on the client's origin an SVG that
62
+ * could run script would sit beside the site's own cookies (spec D7).
63
+ */
64
+ const media = async (parts: readonly string[], context: TestPreviewContext | null): Promise<Response> => {
65
+ if ((parts.length !== 2 && parts.length !== 3) || !parts.every((p) => MEDIA_SEGMENT.test(p) && !p.includes('..'))) return json({ error: 'not found' }, 404)
66
+ if (!env.PLATFORM_URL) return json({ error: 'media is not configured' }, 503)
67
+ if (context) {
68
+ if (parts[0] !== context.claims.branchMediaKey && parts[0] !== context.trust.mediaKey) return json({ error: 'not found' }, 404)
69
+ parts = [context.trust.mediaKey, ...parts.slice(1)]
70
+ }
71
+ let upstream: Response
72
+ // The timeout covers only the wait for headers. A signal still armed would stay attached to the body and cut a long
73
+ // original — a video, a big PDF, a slow visitor — off mid-stream, after its content-length had already gone out.
74
+ // A first render of a crop can take seconds, so it is generous.
75
+ const controller = new AbortController()
76
+ const timer = setTimeout(() => controller.abort(), MEDIA_HEADERS_TIMEOUT_MS)
77
+ try {
78
+ // A redirect is never followed off the media route. `identity`: fetch decompresses a gzip body but keeps the
79
+ // upstream's compressed content-length, so ask for none.
80
+ upstream = await fetchImpl(`${env.PLATFORM_URL.replace(/\/+$/, '')}/api/u90/media/${parts.join('/')}`, { cache: 'no-store', headers: { 'accept-encoding': 'identity' }, redirect: 'manual', signal: controller.signal })
81
+ clearTimeout(timer)
82
+ } catch (error) {
83
+ clearTimeout(timer)
84
+ console.error('[u90] media passthrough failed:', error instanceof Error ? error.name : 'unknown')
85
+ return json({ error: 'media is unavailable' }, 503)
86
+ }
87
+ // The platform's media route never redirects. Forwarded bare, a 3xx would reach the visitor without its Location.
88
+ if (upstream.status >= 300 && upstream.status <= 399) {
89
+ void upstream.body?.cancel().catch(() => {})
90
+ return json({ error: 'media is unavailable' }, 502)
91
+ }
92
+ const headers = new Headers()
93
+ const encoded = upstream.headers.has('content-encoding')
94
+ for (const name of MEDIA_HEADERS) {
95
+ if (name === 'content-length' && encoded) continue // the length of bytes the body no longer is
96
+ const value = upstream.headers.get(name)
97
+ if (value !== null) headers.set(name, value)
98
+ }
99
+ if (!upstream.ok && !headers.has('cache-control')) headers.set('cache-control', 'no-store') // a refusal or an outage must not be kept by the site's CDN
100
+ if (context) for (const [key, value] of Object.entries(previewResponseHeaders)) headers.set(key, value)
101
+ if (!headers.has('content-security-policy')) headers.set('content-security-policy', MEDIA_SANDBOX)
102
+ if (!headers.has('x-content-type-options')) headers.set('x-content-type-options', 'nosniff')
103
+ return new Response(upstream.body, { status: upstream.status, headers })
104
+ }
105
+ const last = async (ctx: { params: Promise<{ u90: string[] }> }) => ((await ctx.params).u90 ?? []).at(-1)
106
+ return {
107
+ GET: async (req: Request, ctx: { params: Promise<{ u90: string[] }> }) => {
108
+ const all = (await ctx.params).u90 ?? []
109
+ // A query string is ignored, not refused: Vercel's image optimiser adds `?dpl=<deployment>` to every local
110
+ // fetch, and refusing it broke every optimised media image. `media()` builds the platform URL from the path
111
+ // segments alone, so the platform is always asked for the bare path and its cache keeps one entry per path.
112
+ const path = new URL(req.url).pathname
113
+ if (path === '/api/u90/test-preview' || path === '/api/u90/test-preview-capabilities') {
114
+ if (!verifyGuardAttestation(req, env, now?.())) return previewJson({ error: 'test preview guard is required' }, 403)
115
+ const identity = previewBuildId({ env, ...(buildId === undefined ? {} : { buildId }) })
116
+ const reason = !adapters.draftEnabled ? 'draft_mode_check_unavailable' : !identity ? 'build_identity_unavailable' : !adapters.draftBinding || !adapters.enableDraftBinding ? 'draft_binding_unavailable' : undefined
117
+ if (path.endsWith('-capabilities')) return new Response(JSON.stringify({ version: 1, protocol: 'u90tp1', guard: true, supported: !reason, ...(reason ? { reason } : {}), actions: reason ? [] : TEST_PREVIEW_ACTIONS, manifestHash: await manifestHash(manifest), deploymentId: identity }), { headers: { 'content-type': 'application/json', ...previewResponseHeaders } })
118
+ if (reason) return previewJson({ error: 'test preview setup unsupported', reason }, 403)
119
+ const url = new URL(req.url)
120
+ try {
121
+ const context = await testPreview.resolve(url.searchParams.get('token'))
122
+ if (!context) return previewJson({ error: 'invalid test session' }, 403)
123
+ await adapters.enableDraft(context.token)
124
+ const { bypass } = await adapters.draftBinding!()
125
+ if (!bypass || !(await adapters.draft()).enabled) return previewJson({ error: 'draft mode setup failed' }, 403)
126
+ const binding = createDraftBinding(env, identity!, context.token, bypass)
127
+ await adapters.enableDraftBinding!(binding, Math.max(1, context.claims.expiresAt - (now?.() ?? Math.floor(Date.now() / 1000))))
128
+ const target = new URL(safePath(url.searchParams.get('path')), 'https://u90.invalid')
129
+ target.searchParams.set(EXPECTED_PREVIEW_PARAM, '1')
130
+ return new Response(null, { status: 307, headers: { location: target.pathname + target.search + target.hash, ...previewResponseHeaders } })
131
+ } catch { return previewJson({ error: 'test preview refused' }, 403) }
132
+ }
133
+ let context: TestPreviewContext | null = null
134
+ if (path !== '/api/u90/exit-preview') {
135
+ try { context = await testPreview.resolve(requestPreviewToken(req)) } catch { return previewJson({ error: 'test preview refused' }, 403) }
136
+ }
137
+ if (all[0] === 'media') return media(all.slice(1), context)
138
+ if (context) return previewJson({ error: 'operation disabled in test preview' }, 403)
139
+ const seg = await last(ctx); const url = new URL(req.url)
140
+ if (seg === 'manifest') return authed(req) ? json(manifest) : json({ error: 'unauthorised' }, 401)
141
+ if (seg === 'preview') {
142
+ if (!sameSecret(url.searchParams.get('secret'), env.REVALIDATE_SECRET)) return json({ error: 'unauthorised' }, 401)
143
+ if (isTestPreviewToken(url.searchParams.get('token'))) return json({ error: 'use test-preview entry' }, 403)
144
+ await adapters.enableDraft(url.searchParams.get('token') ?? env.REVALIDATE_SECRET!)
145
+ adapters.redirect(safePath(url.searchParams.get('path')))
146
+ }
147
+ if (seg === 'exit-preview') { await adapters.disableDraft(); adapters.redirect(exitPath(url.searchParams.get('to'))) }
148
+ return json({ error: 'not found' }, 404)
149
+ },
150
+ POST: async (req: Request, ctx: { params: Promise<{ u90: string[] }> }) => {
151
+ try { if (await testPreview.resolve(requestPreviewToken(req))) return previewJson({ error: 'operation disabled in test preview' }, 403) } catch { return previewJson({ error: 'test preview refused' }, 403) }
152
+ const seg = await last(ctx)
153
+ if (seg !== 'revalidate') return json({ error: 'not found' }, 404)
154
+ if (!authed(req)) return json({ error: 'unauthorised' }, 401)
155
+ let body: unknown
156
+ try { body = await req.json() } catch { return json({ error: 'invalid body' }, 400) }
157
+ const rawTags = (body as { tags?: unknown } | null)?.tags
158
+ if (rawTags !== undefined && !(Array.isArray(rawTags) && rawTags.every((t) => typeof t === 'string'))) return json({ error: 'invalid body' }, 400)
159
+ const tags = rawTags as string[] | undefined
160
+ for (const t of tags ?? []) await adapters.revalidateTag(t)
161
+ return json({ ok: true, revalidated: tags ?? [] })
162
+ },
163
+ }
164
+ }
@@ -0,0 +1,6 @@
1
+ export { createU90, type U90, type U90Options } from './createU90'
2
+ export { PREVIEW_COOKIE, type NextAdapters } from './adapters'
3
+ export { DRAFT_LOCALE_PARAM } from '../elements'
4
+ export type { RouteRead } from '../site'
5
+ export { U90Bridge } from '../bridge/U90Bridge'
6
+ export type { TestPreviewContext } from '../test-preview/runtime'
@@ -0,0 +1,207 @@
1
+ import { assemble, replyFrom, type Reply, type ReplyTarget, type RouteRead, type Site, type SiteReply, type SitemapReply } from '../site'
2
+ import { seedSite, type Store } from '../site/store'
3
+ import type { SiteLanguages } from '../languages/routing'
4
+ import { sourceMediaPaths } from '../test-preview/media'
5
+ import type { TestPreviewContext, TestPreviewRuntime } from '../test-preview/runtime'
6
+ import type { NextAdapters } from './adapters'
7
+
8
+ export type Env = Partial<Record<'PLATFORM_URL' | 'PAYLOAD_API_KEY' | 'REVALIDATE_SECRET' | 'SITE_URL' | 'VERCEL_DEPLOYMENT_ID' | 'VERCEL_URL' | 'U90_TEST_PREVIEW_BUILD_ID', string>>
9
+ /** `running` is the running code's `manifestHash`, compared with each reply's to tell whether the platform is behind a deploy. */
10
+ type Deps = { site: Site; fetch: typeof fetch; env: Env; adapters: NextAdapters; running: Promise<string>; testPreview?: TestPreviewRuntime }
11
+
12
+ const missing = (): RouteRead => ({ status: 'missing', tags: [] })
13
+ // Keyed by site identity, not a single module-level slot: more than one site (e.g. under test) must not share a store.
14
+ const defaultsStores = new WeakMap<Site, Promise<Store>>()
15
+ const defaults = (site: Site): Promise<Store> => {
16
+ const cached = defaultsStores.get(site)
17
+ if (cached) return cached
18
+ const store = seedSite(site)
19
+ defaultsStores.set(site, store)
20
+ return store
21
+ }
22
+ let warned = false
23
+ /** Read clients that have warned they read behind a deploy: once each, not once per read. */
24
+ const warnedStale = new WeakSet<Deps>()
25
+
26
+ /**
27
+ * URL + bearer + preview token + `cache: 'no-store'`, shared by every platform read; throws on any
28
+ * non-2xx so callers see one failure mode.
29
+ *
30
+ * `draft` is null for a published read. A draft read asks for `draft=1` and carries the preview
31
+ * cookie's token as `X-U90-Preview-Token`, and no header at all when the request has no preview
32
+ * cookie, rather than a made-up token.
33
+ *
34
+ * Built by concatenation (spec §4: `GET {PLATFORM_URL}/api/u90/v1/…`), not `new URL(path, base)`:
35
+ * a leading-slash `path` resolves against `base`'s origin only, silently discarding any path
36
+ * `PLATFORM_URL` itself carries. Concatenation keeps a path prefix (R33).
37
+ */
38
+ type Draft = { token: string | null; test?: TestPreviewContext } | null
39
+ async function platformFetch(d: Deps, endpoint: string, draft: Draft, locale?: string): Promise<unknown> {
40
+ const url = new URL(`${(draft?.test?.platformOrigin ?? d.env.PLATFORM_URL!).replace(/\/+$/, '')}/api/u90/v1${endpoint}`)
41
+ if (draft) url.searchParams.set('draft', '1')
42
+ // Only a caller that passes a locale sends one: a tenant with no language settings answers 400 to any `locale`, and a read without it is byte-for-byte the read sites made before languages.
43
+ if (locale) url.searchParams.set('locale', locale)
44
+ const res = await d.fetch(url, { headers: { authorization: `Bearer ${draft?.test?.token ?? d.env.PAYLOAD_API_KEY ?? ''}`, 'x-u90-media': 'paths', ...(draft?.token ? { 'x-u90-preview-token': draft.token } : {}) }, cache: 'no-store', ...(draft?.test ? { redirect: 'error' as const } : {}) })
45
+ if (!res.ok) throw new Error(`u90 read ${endpoint}: ${res.status}`)
46
+ const body = await res.json()
47
+ return draft?.test ? sourceMediaPaths(body, draft.test) : body
48
+ }
49
+ const endpointOf = (t: ReplyTarget): string =>
50
+ 'route' in t ? `/route?path=${encodeURIComponent(t.route)}` : 'page' in t ? `/page/${encodeURIComponent(t.page)}` : `/entry/${encodeURIComponent(t.entry.collection)}/${encodeURIComponent(t.entry.slug)}`
51
+ /** How a language reaches the reply's links: in their path (`prefix`, from the address), or in their query (`draftLocale`, draft only). */
52
+ type Carry = { draftLocale?: string; prefix?: string }
53
+ /**
54
+ * A reply assembled with the running code. The code's defaults are seeded only when the platform is behind a deploy.
55
+ * `carry.draftLocale` is passed only by a read in draft mode that asked for a language: internal link hrefs then keep the language the reply answered in.
56
+ * `carry.prefix` is passed by a read whose language came from the address: page and entry links then carry that prefix.
57
+ */
58
+ async function assembled(d: Deps, target: ReplyTarget, reply: Reply, draft: boolean, carry: Carry = {}): Promise<RouteRead> {
59
+ const stale = reply.manifest !== (await d.running)
60
+ if (stale && !warnedStale.has(d)) {
61
+ warnedStale.add(d)
62
+ console.warn("[u90] the platform holds a different manifest from this deploy; declared pages, layouts and Singles it lacks render from the code's defaults")
63
+ }
64
+ const draftLocale = carry.draftLocale ? (reply.status === 'ok' && reply.locale ? reply.locale : carry.draftLocale) : undefined
65
+ return assemble(d.site, reply, { draft, target, stale, defaults: stale ? await defaults(d.site) : null, ...(draftLocale ? { draftLocale } : {}), ...(carry.prefix ? { localePrefix: carry.prefix } : {}) })
66
+ }
67
+ /** Renders from the declarations' defaults: only a site with no PLATFORM_URL does (spec §17). */
68
+ async function fromDefaults(d: Deps, target: ReplyTarget, reason: string): Promise<RouteRead> {
69
+ if (!warned) { console.warn(`[u90] ${reason}; rendering from defaults`); warned = true }
70
+ const reply = replyFrom(d.site, await defaults(d.site), target, await d.running)
71
+ return { ...assemble(d.site, reply, { draft: false, target, stale: false, defaults: null }), unavailable: true }
72
+ }
73
+
74
+ /**
75
+ * R5: a `'use cache'` function has every outer-scope variable it references captured and bound
76
+ * as a serialisable cache argument. `d` holds functions (`fetch`, `adapters`) and is not
77
+ * serialisable, so `cachedRead` cannot close over it: `d` is registered here, at module scope, and
78
+ * looked up by a string key instead. Only `instance` and the JSON of the target, both strings,
79
+ * cross into the cached function.
80
+ */
81
+ const registry = new Map<string, Deps>()
82
+ /**
83
+ * The `target` argument of `cachedRead`, and so the part of its cache key that names what is read. A read without a
84
+ * locale is the target's own JSON, the key it had before languages, so no existing entry or tag moves; a read with one
85
+ * adds `locale` to that JSON, so each language is its own entry, and `prefix` too when the language came from the address,
86
+ * since its links differ from a read of the same language without one.
87
+ */
88
+ export const cacheTarget = (target: ReplyTarget, locale: string | undefined, prefix?: string): string =>
89
+ JSON.stringify(locale ? { ...target, locale, ...(prefix ? { prefix } : {}) } : target)
90
+ export function parseCacheTarget(key: string): { target: ReplyTarget; locale: string | undefined; prefix?: string } {
91
+ const { locale, prefix, ...target } = JSON.parse(key) as ReplyTarget & { locale?: string; prefix?: string }
92
+ return { target: target as ReplyTarget, locale, ...(prefix ? { prefix } : {}) }
93
+ }
94
+ /** What a read can be asked for besides its target. `prefix` is the address prefix of the language asked for (`/fr`), when the language came from the address: page and entry links then carry it. */
95
+ export type ReadOptions = { locale?: string; prefix?: string }
96
+ /**
97
+ * Next keeps at most this many tags from one `cacheTag(...)` call (`NEXT_CACHE_TAG_MAX_ITEMS`) and
98
+ * drops the rest, so a read's tags go in calls of 128; Next accumulates tags across calls within
99
+ * one cache scope.
100
+ */
101
+ const CACHE_TAG_MAX_ITEMS = 128
102
+ async function cachedRead(instance: string, target: string): Promise<RouteRead> {
103
+ 'use cache'
104
+ const d = registry.get(instance)
105
+ if (!d) throw new Error(`u90: unknown read client "${instance}"`)
106
+ const { target: t, locale, prefix } = parseCacheTarget(target)
107
+ const reply = (await platformFetch(d, endpointOf(t), null, locale)) as Reply
108
+ for (let i = 0; i < reply.tags.length; i += CACHE_TAG_MAX_ITEMS) d.adapters.cacheTag(...reply.tags.slice(i, i + CACHE_TAG_MAX_ITEMS))
109
+ d.adapters.cacheLife(reply.status === 'ok' ? 'max' : 'minutes') // a missing read expires on its own as well as on `site`
110
+ return assembled(d, t, reply, false, prefix ? { prefix } : {})
111
+ }
112
+ /** The site-wide SEO settings, cached until `site` is revalidated. */
113
+ async function cachedSite(instance: string): Promise<SiteReply> {
114
+ 'use cache'
115
+ const d = registry.get(instance)
116
+ if (!d) throw new Error(`u90: unknown read client "${instance}"`)
117
+ const settings = (await platformFetch(d, '/site', null)) as SiteReply
118
+ d.adapters.cacheTag('site')
119
+ d.adapters.cacheLife('max')
120
+ return settings
121
+ }
122
+
123
+ /** isEnabled is permitted inside `use cache`; production can cache this false result without cookie reads.
124
+ * Next bypasses cache scopes in draft, so a preview evaluates the mode again and resolves its cookie outside cache. */
125
+ async function draftEnabled(instance: string): Promise<boolean> {
126
+ 'use cache'
127
+ const d = registry.get(instance)
128
+ if (!d) throw new Error(`u90: unknown read client "${instance}"`)
129
+ return d.adapters.draftEnabled ? d.adapters.draftEnabled() : false
130
+ }
131
+
132
+ export function createRead(d: Deps) {
133
+ const instance = String(registry.size) // deterministic per module evaluation; a site calls createU90 once
134
+ registry.set(instance, d)
135
+ /**
136
+ * This request's `/site`, read once however many callers ask (`perRequest`). Outside draft `cachedSite` is cached until
137
+ * `site` is revalidated; in draft Next re-runs every `'use cache'` function, so it is one uncached read per request.
138
+ */
139
+ const currentDraft = async (): Promise<Draft> => {
140
+ const { enabled, token } = await d.adapters.draft()
141
+ const test = await d.testPreview?.resolve(token)
142
+ if (test) return { token, test }
143
+ return enabled && token ? { token } : null
144
+ }
145
+ const siteOnce = d.adapters.perRequest(async () => {
146
+ const draft = await draftEnabled(instance) ? await currentDraft() : null
147
+ return draft?.test ? (await platformFetch(d, '/site', draft)) as SiteReply : cachedSite(instance)
148
+ })
149
+ const read = async (target: ReplyTarget, { locale, prefix }: ReadOptions = {}): Promise<RouteRead> => {
150
+ const draft = await currentDraft()
151
+ if (!d.env.PLATFORM_URL) return fromDefaults(d, target, 'PLATFORM_URL is not set')
152
+ // A paired site's failed read throws (spec §17): pages in the cache keep being served, an uncached
153
+ // page shows an error, and a build fails rather than publish defaults in place of the platform's content.
154
+ const { enabled, token } = await d.adapters.draft()
155
+ // A prefix rides only with the locale it names, as `cacheTarget` keys it: a prefix alone is no language.
156
+ const carry: Carry = locale && prefix ? { prefix } : locale ? { draftLocale: locale } : {}
157
+ if (draft) return assembled(d, target, (await platformFetch(d, endpointOf(target), draft, locale)) as Reply, true, carry)
158
+ // Draft mode with no preview token (a draft cookie left behind): what is published, uncached and unmarked.
159
+ if (enabled) return assembled(d, target, (await platformFetch(d, endpointOf(target), null, locale)) as Reply, false, carry)
160
+ return cachedRead(instance, cacheTarget(target, locale, prefix))
161
+ }
162
+ return {
163
+ route: (path: string, opts?: ReadOptions) => read({ route: path }, opts),
164
+ // a code page the running code does not declare is missing without asking
165
+ page: (key: string, opts?: ReadOptions) => (Object.hasOwn(d.site.pages, key) ? read({ page: key }, opts) : Promise.resolve(missing())),
166
+ entry: (collection: string, slug: string, opts?: ReadOptions) => read({ entry: { collection, slug } }, opts),
167
+ /**
168
+ * The platform's site-wide SEO settings (branch draft in test preview); `null` for a site with no platform, whose code's `seo` is all
169
+ * there is. One read per request, shared with `languages()`, and cached outside draft.
170
+ */
171
+ site: async (): Promise<SiteReply | null> => {
172
+ if (!d.env.PLATFORM_URL) return null
173
+ if (!d.adapters.draftEnabled || await draftEnabled(instance)) await currentDraft()
174
+ return siteOnce()
175
+ },
176
+ /**
177
+ * The site's languages, from the same per-request `/site` read as `site()`: cached until `site` is revalidated outside
178
+ * draft, read once per request in draft. Null for a site with no platform, one with no languages, or a `/site` that
179
+ * cannot be read, in which case every address is the default's. Outside draft, a cache-scoped mode check avoids
180
+ * cookie reads, so a native root layout remains cacheable. Custom adapters missing that check are inspected
181
+ * outside cache and test credentials are refused before the availability fallback.
182
+ */
183
+ languages: async (): Promise<SiteLanguages | null> => {
184
+ if (!d.env.PLATFORM_URL) return null
185
+ // Refusal happens outside the ordinary availability fallback.
186
+ if (!d.adapters.draftEnabled || await draftEnabled(instance)) await currentDraft()
187
+ try {
188
+ return (await siteOnce()).languages ?? null
189
+ } catch (error) {
190
+ console.warn(`[u90] the site's languages could not be read, so every address reads in the default language: ${error instanceof Error ? error.message : String(error)}`)
191
+ return null
192
+ }
193
+ },
194
+ /** Internal legacy html compatibility: published root attributes without request draft APIs.
195
+ * Incomplete adapters cannot enter editable preview; all public reads still reject test tokens. */
196
+ legacyHtmlLanguages: async (): Promise<SiteLanguages | null> => {
197
+ if (!d.env.PLATFORM_URL) return null
198
+ try { return (await siteOnce()).languages ?? null } catch { return null }
199
+ },
200
+ sitemap: async (): Promise<SitemapReply> => {
201
+ const draft = await currentDraft()
202
+ if (d.env.PLATFORM_URL) return (await platformFetch(d, '/sitemap', draft?.test ? draft : null)) as SitemapReply
203
+ const entries = Object.values(d.site.collections).flatMap((c) => (c.route ? (c.entries ?? []).map((e) => ({ path: c.route!.replace(':slug', e.slug), lastModified: null })) : []))
204
+ return { entries: [...Object.values(d.site.pages).map((p) => ({ path: p.route, lastModified: null })), ...entries] }
205
+ },
206
+ }
207
+ }
@@ -0,0 +1,46 @@
1
+ import { createElement, Fragment, type ReactElement, type ReactNode } from 'react'
2
+ import type { Site } from '../site'
3
+ import type { RouteRead } from '../site'
4
+ import type { LanguageLinks } from '../languages/routing'
5
+ import { LanguageProvider } from '../switcher/LanguageSwitcher'
6
+ import { DraftBlocks } from '../bridge/DraftBlocks'
7
+
8
+ type BlockItem = { _id: string; _block: string; $path?: string } & Record<string, unknown>
9
+ /** What a block declared with `entry:` receives, when the document being rendered is an entry page. */
10
+ type EntryProp = { id: string; slug?: string; route?: string; fields?: Record<string, unknown> }
11
+
12
+ export function createRender(site: Site) {
13
+ const Blocks = ({ value, slot, entry }: { value: unknown; slot?: ReactNode; entry?: EntryProp }): ReactElement => {
14
+ const items = (Array.isArray(value) ? value : []) as BlockItem[]
15
+ const renderedItems = items.map((item) => {
16
+ if (item._block === 'SLOT') return createElement(Fragment, { key: item._id }, slot)
17
+ const def = Object.hasOwn(site.blocks, item._block) ? site.blocks[item._block] : undefined
18
+ if (!def) return item.$path ? createElement('div', { key: item._id, 'data-u90-block': item.$path, 'data-u90-missing': item._block }, `Block "${item._block}" was removed`) : null
19
+ const { _id, _block, $path, ...fields } = item
20
+ // zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
21
+ const props: Record<string, unknown> = { ...fields }
22
+ for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry })
23
+ // a block declared with `entry:` renders inside that collection's entry pages and receives the entry itself
24
+ if (def.entry) props.entry = entry
25
+ const rendered = createElement(def.component as never, { key: _id, ...props } as never)
26
+ return $path ? createElement('div', { key: _id, 'data-u90-block': $path, style: { display: 'contents' } }, rendered) : rendered
27
+ })
28
+ return items.some((item) => item.$path)
29
+ ? createElement(DraftBlocks, { items: items.map((item, index) => ({ id: item._id, path: item.$path, content: renderedItems[index] })) })
30
+ : createElement(Fragment, null, ...renderedItems)
31
+ }
32
+ const Layout = ({ chain, layouts, children }: { chain: string[]; layouts: Record<string, unknown>; children: ReactNode }): ReactElement =>
33
+ chain.reduceRight<ReactElement>((inner, key) => createElement(Blocks, { value: (layouts[key] as { blocks: unknown })?.blocks, slot: inner }), createElement(Fragment, null, children))
34
+ /** `language`, on a language site's page only, puts the page's language and its addresses within reach of `LanguageSwitcher`. Without it the tree is the one it always was. */
35
+ const render = (read: RouteRead, language?: LanguageLinks): ReactElement => {
36
+ const doc = read.read as { blocks?: unknown; fields?: Record<string, unknown> } | undefined
37
+ const document = read.document
38
+ // an entry page's document carries no `route`; the collection declaration supplies the pattern
39
+ const entry: EntryProp | undefined = document?.kind === 'entry'
40
+ ? { id: document.id, slug: document.slug, route: document.collection ? site.collections[document.collection]?.route?.replace(':slug', document.slug) : undefined, fields: doc?.fields }
41
+ : undefined
42
+ const tree = <Layout chain={document?.layoutChain ?? []} layouts={read.layouts ?? {}}><Blocks value={doc?.blocks} entry={entry} /></Layout>
43
+ return language ? createElement(LanguageProvider, { value: language }, tree) : tree
44
+ }
45
+ return { Blocks, Layout, render }
46
+ }
@@ -0,0 +1,171 @@
1
+ import type { Metadata, MetadataRoute } from 'next'
2
+ import { createElement, Fragment, Suspense, type ReactElement } from 'react'
3
+ import { metadataFor, type PageSeoInput, type RouteSeo, type Site } from '../site'
4
+ import { robotsFor } from '../seo/robots'
5
+ import { sitemapFor } from '../seo/sitemap'
6
+ import { DRAFT_LOCALE_PARAM } from '../elements'
7
+ import { withDraftLocale } from '../elements/leaves/link'
8
+ import { languageAlternates, languageDirection, languageLinks, languageOfPath, type LanguageLinks, type PathLanguage, type SiteLanguages } from '../languages/routing'
9
+ import type { NextAdapters } from './adapters'
10
+ import type { createRead, ReadOptions } from './read'
11
+ import type { createRender } from './render'
12
+
13
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>
14
+ /** Next's page props. `searchParams` is optional so a caller that passes only `params` renders as before. */
15
+ type PageParams = { params: Promise<{ slug?: string[] }>; searchParams?: SearchParams }
16
+ /** A language id as the platform spells one (`fr`, `fr-CA`), of at most 35 characters, the length the Canvas keeps for a screen's language. Any other value of the parameter is no language. */
17
+ const LOCALE_RE = /^(?=.{2,35}$)[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/
18
+
19
+ export function createRoutes(site: Site, read: ReturnType<typeof createRead>, r: ReturnType<typeof createRender>, adapters: NextAdapters, siteUrl: string | undefined) {
20
+ const pathOf = async (params: Promise<{ slug?: string[] }>) => '/' + ((await params).slug ?? []).join('/')
21
+ /**
22
+ * The language a draft render reads in: `DRAFT_LOCALE_PARAM` from the site's own address, in draft mode only. Outside draft
23
+ * `searchParams` is never awaited, so a published render reads, caches and renders exactly as it did before languages.
24
+ */
25
+ const draftLocale = async (searchParams: SearchParams | undefined): Promise<string | undefined> => {
26
+ if (!searchParams || !(await adapters.draft()).enabled) return undefined
27
+ const value = (await searchParams)[DRAFT_LOCALE_PARAM]
28
+ return typeof value === 'string' && LOCALE_RE.test(value) ? value : undefined
29
+ }
30
+ /** The read options for an address: a draft `DRAFT_LOCALE_PARAM` first, then the address's own language unless it is the default. None otherwise, so the read is the one sites made before languages. */
31
+ const readOptions = (language: PathLanguage | null, draft: string | undefined): ReadOptions | undefined =>
32
+ draft ? { locale: draft } : language?.prefix ? { locale: language.id, prefix: language.prefix } : undefined
33
+ /** Where a request reads: its address, the path the platform knows the page by, and the read options for its language. */
34
+ const placed = async (params: Promise<{ slug?: string[] }>, searchParams: SearchParams | undefined, languages: SiteLanguages | null) => {
35
+ const path = await pathOf(params)
36
+ const language = languageOfPath(path, languages)
37
+ const draft = await draftLocale(searchParams)
38
+ return { path, language, draft, rest: language?.rest ?? path, options: readOptions(language, draft) }
39
+ }
40
+ /**
41
+ * The switcher's links for a page of a language site: the language it is shown in (a draft `DRAFT_LOCALE_PARAM` first) and its
42
+ * address in each active language. In draft mode every link carries its language as `DRAFT_LOCALE_PARAM` on the unprefixed path,
43
+ * as internal links in a translated frame do, the default's included (R17): a Canvas screen that follows one changes its
44
+ * language, the default's link to a screen with no language, and never its path to a prefixed one it would edit as the base.
45
+ */
46
+ const switcherLinks = async (languages: SiteLanguages, language: PathLanguage, rest: string, draft: string | undefined): Promise<LanguageLinks> => {
47
+ const links = languageLinks(languages, rest, draft ?? language.id)
48
+ if (!(await adapters.draft()).enabled) return links
49
+ return { ...links, links: links.links.map((link) => ({ ...link, href: withDraftLocale(rest, link.id) })) }
50
+ }
51
+ /**
52
+ * The platform's site-wide SEO, or null when there is no platform or `/site` cannot be read. A
53
+ * failed read is never thrown into the page: the page keeps the manifest's defaults and sets no
54
+ * robots, so a platform hiccup cannot take a site out of search.
55
+ */
56
+ const siteSeo = () => read.site().catch((error: unknown) => {
57
+ console.warn(`[u90] the site-wide SEO settings could not be read, so this page uses the site's own defaults: ${error instanceof Error ? error.message : String(error)}`)
58
+ return null
59
+ })
60
+
61
+ /**
62
+ * R6: the async body of the page route. `pageBody` itself touches request-time state
63
+ * (`adapters.draft()`, backed by `draftMode()`/`cookies()`), so Cache Components requires a
64
+ * Suspense boundary above it — `page` below provides that boundary and never calls `pageBody`
65
+ * directly. `react-dom/server`'s `renderToString` cannot render an async component, which is
66
+ * why tests call `pageBody` (awaiting it themselves) rather than rendering `page`.
67
+ */
68
+ const pageBody = async ({ params, searchParams }: PageParams): Promise<ReactElement> => {
69
+ const languages = await read.languages()
70
+ const { path, language, draft, rest, options } = await placed(params, searchParams, languages)
71
+ const rr = await read.route(rest, options)
72
+ if (rr.status === 'missing') { if (!(await adapters.draft()).enabled) adapters.notFound(); return createElement('main', { 'data-u90-empty': path }, 'This page has no content yet.') }
73
+ const body = r.render(rr, languages && language ? await switcherLinks(languages, language, rest, draft) : undefined)
74
+ // Draft only, with a language from `DRAFT_LOCALE_PARAM`: the root layout cannot see the query, so the bridge puts this on `<html>`.
75
+ return draft ? createElement(Fragment, null, createElement('span', { hidden: true, 'data-u90-language': draft, 'data-u90-dir': languageDirection(draft) }), body) : body
76
+ }
77
+ const PageBody = async ({ params, searchParams }: PageParams): Promise<ReactElement> => pageBody({ params, searchParams })
78
+ const page = ({ params, searchParams }: PageParams): ReactElement => createElement(Suspense, { fallback: null }, createElement(PageBody, { params, searchParams }))
79
+
80
+ type FragmentParams = { searchParams: SearchParams }
81
+ /**
82
+ * The async body of the fragment page: one block, rendered on its own, for the Canvas to drop in
83
+ * while a newly added block is still saving. It reads draft state, so like `pageBody` it sits
84
+ * under a Suspense boundary (`fragment`) and is what the tests await.
85
+ *
86
+ * Draft mode is the gate, and the check reads `.enabled`: `adapters.draft()` resolves to
87
+ * `{ enabled, token }`, an object that is always truthy. The page is fetched with the draft cookie,
88
+ * not a secret, and a reader already in draft mode can see the whole draft page, so rendering one
89
+ * of its blocks shows them nothing new. An unknown block, a missing path or a reader not in draft
90
+ * renders nothing rather than an error; the Canvas treats an empty page as a failed fetch and
91
+ * leaves its skeleton for the refresh to replace.
92
+ *
93
+ * It makes no platform read, so `DRAFT_LOCALE_PARAM` changes nothing here: in a translated frame the block renders the same
94
+ * skeleton, and the frame's refresh then reads the page in that frame's language.
95
+ */
96
+ const fragmentBody = async ({ searchParams }: FragmentParams): Promise<ReactElement | null> => {
97
+ if (!(await adapters.draft()).enabled) return null
98
+ const params = await searchParams
99
+ const name = typeof params.block === 'string' ? params.block : ''
100
+ const path = typeof params.path === 'string' ? params.path : ''
101
+ if (!path || !Object.hasOwn(site.blocks, name)) return null
102
+ return <r.Blocks value={[{ _id: `preview-${path}`, _block: name, $path: path }]} />
103
+ }
104
+ const FragmentBody = async ({ searchParams }: FragmentParams): Promise<ReactElement | null> => fragmentBody({ searchParams })
105
+ /**
106
+ * Mounted by the site at `app/%5Fu90/fragment/page.tsx`, or inside a route group such as `app/(kit)/%5Fu90/fragment/page.tsx`
107
+ * when the site's root layout is `app/[[...slug]]/layout.tsx`, as a language site's is (a folder named `_u90` would be a
108
+ * private folder and serve nothing; `%5F` is Next's spelling of a leading underscore). Either serves `/_u90/fragment`.
109
+ */
110
+ const fragment = ({ searchParams }: FragmentParams): ReactElement => createElement(Suspense, { fallback: null }, createElement(FragmentBody, { searchParams }))
111
+
112
+ return {
113
+ page,
114
+ pageBody,
115
+ fragment,
116
+ fragmentBody,
117
+ metadata: async ({ params, searchParams }: PageParams): Promise<Metadata> => {
118
+ // `/site` before the route, because which route is read depends on its languages, which come from `read.languages()` as the page's do.
119
+ // `site()` and `languages()` share the request's one `/site` read, which the page's body shares too.
120
+ const [settings, languages] = await Promise.all([siteSeo(), read.languages()])
121
+ const { path, language, rest, options } = await placed(params, searchParams, languages)
122
+ const rr = await read.route(rest, options)
123
+ // R24: under Cache Components, `notFound()` inside the streamed body is a soft 404 (still
124
+ // a 200 response) — noindex a missing route explicitly rather than relying on status alone.
125
+ if (rr.status === 'missing') return { title: undefined, robots: { index: false, follow: false } }
126
+ const doc = rr.document
127
+ // Spec §8: the page's stored title, then its key, then its slug. An SEO title, when set, is the whole title.
128
+ const pageTitle = filled(doc?.title) ?? (doc?.key ? humanKey(doc.key) : doc?.slug) ?? ''
129
+ return metadataFor({ site: settings, manifest: site.seo, page: doc?.seo ? pageSeoInput(doc.seo) : null, pageTitle, origin: siteUrl, path, ...(language && languages ? { language: languageAlternates(languages, language, rr.translated) } : {}) })
130
+ },
131
+ sitemap: async (): Promise<MetadataRoute.Sitemap> => {
132
+ await adapters.connection() // R6: evaluated per request rather than cached at build
133
+ const [{ entries }, languages] = await Promise.all([read.sitemap(), read.languages()])
134
+ return sitemapFor(siteUrl, entries, languages)
135
+ },
136
+ /**
137
+ * `<html lang dir>` for a language site's root layout, mounted at `app/[[...slug]]/layout.tsx` so the address is a root
138
+ * parameter (`next/root-params`): the address's language, or `fallback` for a site with no languages and whenever `/site`
139
+ * cannot be read. `fallback` is `'en'` unless given, so a site whose default language is not English passes its default
140
+ * (`html({ params }, 'fr')`), or a page in its default would be marked English while the platform is down. It reads `params`
141
+ * and the request's one `/site` (`read.languages()`), and never calls draft mode or reads a cookie. Outside draft that
142
+ * `/site` is cached; in draft Next re-runs it uncached, once per request. Awaiting `params` outside Suspense means an
143
+ * address `rootParams` doesn't list has no static shell, so the layout exports `instant = false` for Cache Components'
144
+ * development checks.
145
+ */
146
+ html: async ({ params }: { params: Promise<{ slug?: string[] }> }, fallback = 'en'): Promise<{ lang: string; dir: 'ltr' | 'rtl' }> => {
147
+ // Incomplete legacy adapters retain root-attribute compatibility, but cannot start editable tests.
148
+ const languages = await (adapters.draftEnabled ? read.languages() : read.legacyHtmlLanguages())
149
+ const lang = languageOfPath(await pathOf(params), languages)?.id ?? fallback
150
+ return { lang, dir: languageDirection(lang) }
151
+ },
152
+ /** The root layout's `generateStaticParams`: Cache Components needs one value of each root parameter, and `/` is that value. Every other address renders on request. */
153
+ rootParams: async (): Promise<{ slug: string[] }[]> => [{ slug: [] }],
154
+ robots: async (): Promise<MetadataRoute.Robots> => {
155
+ await adapters.connection()
156
+ return robotsFor({ origin: siteUrl, site: await siteSeo() })
157
+ },
158
+ }
159
+ }
160
+ /** A value that holds more than whitespace, or null. */
161
+ const filled = (s: string | undefined): string | null => (s && s.trim() !== '' ? s.trim() : null)
162
+ const humanKey = (k: string) => k.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
163
+ /**
164
+ * A read document's SEO as `metadataFor` takes it. The reply's `ogImage` media id was resolved to
165
+ * the media's URL when the reply was assembled (`site/assemble.ts`). A v1 media record carries no
166
+ * alt text, so the image has none.
167
+ */
168
+ const pageSeoInput = (s: RouteSeo): PageSeoInput => ({
169
+ title: s.title ?? null, description: s.description ?? null, ogTitle: s.ogTitle ?? null, ogDescription: s.ogDescription ?? null,
170
+ ogImage: s.ogImage ? { url: s.ogImage } : null, canonical: s.canonical ?? null, noindex: s.noindex === true,
171
+ })