@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.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -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 +1015 -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 +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -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 +13 -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,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
|
+
})
|