@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { siteLocation } from './location'
|
|
2
|
+
import { matchRedirect, type RedirectMatch } from './match'
|
|
3
|
+
import { redirectsReplySchema, type RedirectRule } from './wire'
|
|
4
|
+
|
|
5
|
+
type Options = {
|
|
6
|
+
/** The rules baked into the build (`env.U90_REDIRECTS_SNAPSHOT`), as JSON or already parsed. Defaults to that variable. */
|
|
7
|
+
snapshot?: string | { rules: RedirectRule[]; generatedAt: string } | null
|
|
8
|
+
/** Defaults to `process.env.PLATFORM_URL`. Without one the snapshot is all there is. */
|
|
9
|
+
platformUrl?: string
|
|
10
|
+
/** Defaults to `process.env.PAYLOAD_API_KEY`. */
|
|
11
|
+
apiKey?: string
|
|
12
|
+
fetch?: typeof fetch
|
|
13
|
+
now?: () => number
|
|
14
|
+
/** How long rules stay fresh before the next request starts a refresh. Defaults to 60. */
|
|
15
|
+
refreshSeconds?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The redirect step of a site's `proxy.ts`: `return (await redirects(request)) ?? next()`.
|
|
20
|
+
* It runs before routing, so a moved address never renders. Rules start from the build's snapshot and
|
|
21
|
+
* refresh from the v1 read API with the site's key. A refresh never delays a request: the request that
|
|
22
|
+
* finds the rules stale is answered from the rules it has, and the next one sees the new ones. The last
|
|
23
|
+
* good rules survive a failed or malformed refresh, and the next attempt waits a full interval.
|
|
24
|
+
* Next's own paths (`/_next/`) and the API (`/api/`) are never redirected.
|
|
25
|
+
*/
|
|
26
|
+
export function redirectsProxy(o: Options = {}): (request: Request) => Promise<Response | undefined> {
|
|
27
|
+
const platformUrl = o.platformUrl ?? process.env.PLATFORM_URL ?? ''
|
|
28
|
+
const apiKey = o.apiKey ?? process.env.PAYLOAD_API_KEY
|
|
29
|
+
const fetchImpl = o.fetch ?? globalThis.fetch
|
|
30
|
+
const now = o.now ?? Date.now
|
|
31
|
+
const intervalMs = (o.refreshSeconds ?? 60) * 1000
|
|
32
|
+
const snapshot = o.snapshot === undefined ? process.env.U90_REDIRECTS_SNAPSHOT : o.snapshot
|
|
33
|
+
|
|
34
|
+
let rules: readonly RedirectRule[] = []
|
|
35
|
+
if (snapshot) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = redirectsReplySchema.safeParse(typeof snapshot === 'string' ? JSON.parse(snapshot) : snapshot)
|
|
38
|
+
if (parsed.success) rules = parsed.data.rules
|
|
39
|
+
else console.warn('U90_REDIRECTS_SNAPSHOT is not a valid set of redirect rules, so the proxy starts with none until the platform answers.')
|
|
40
|
+
} catch {
|
|
41
|
+
console.warn('U90_REDIRECTS_SNAPSHOT is not readable JSON, so the proxy starts with no redirect rules until the platform answers.')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let refreshedAt = 0
|
|
46
|
+
let inFlight = false
|
|
47
|
+
async function refresh() {
|
|
48
|
+
inFlight = true
|
|
49
|
+
refreshedAt = now()
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(`${platformUrl.replace(/\/+$/, '')}/api/u90/v1/redirects`, { headers: { authorization: `Bearer ${apiKey ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
|
|
52
|
+
if (!res.ok) { console.warn(`The platform answered ${res.status} for the site's redirect rules, so the last good rules stay.`); return }
|
|
53
|
+
const parsed = redirectsReplySchema.safeParse(await res.json())
|
|
54
|
+
if (parsed.success) rules = parsed.data.rules
|
|
55
|
+
else console.warn('The platform\'s redirect rules did not parse, so the last good rules stay.')
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.warn(`The site's redirect rules could not be refreshed (${err instanceof Error ? err.message : String(err)}), so the last good rules stay.`)
|
|
58
|
+
} finally {
|
|
59
|
+
inFlight = false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Each warning once per rule: `unusable:<source>` for a destination no URL could be made of, `unsafe:<source>` for a refused Location. */
|
|
64
|
+
const warned = new Set<string>()
|
|
65
|
+
const warnOnce = (key: string, message: string) => {
|
|
66
|
+
if (warned.has(key)) return
|
|
67
|
+
warned.add(key)
|
|
68
|
+
console.warn(message)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The `Location` is made safe by `siteLocation`: absolute, every run of `/` and `\` in a same-origin path collapsed, and a
|
|
72
|
+
// same-origin path under `/_next/` refused. A refused or unusable destination passes the request through rather than answer 500.
|
|
73
|
+
const answer = (m: RedirectMatch | null, requestUrl: URL): Response | undefined => {
|
|
74
|
+
if (!m) return undefined
|
|
75
|
+
if (m.status === 410) return new Response('Gone', { status: 410, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
|
76
|
+
const made = siteLocation(m.destination, requestUrl)
|
|
77
|
+
if ('refused' in made) {
|
|
78
|
+
if (made.refused === 'unusable') warnOnce(`unusable:${m.rule.source}`, `The redirect from ${m.rule.source} has a destination that is not a usable address, so requests for it pass through.`)
|
|
79
|
+
else warnOnce(`unsafe:${m.rule.source}`, `A request matched the redirect from ${m.rule.source} but its Location would be under /_next/ or leave the site, so it passed through. Requests like this are not warned about again for this rule.`)
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
return new Response(null, { status: m.status, headers: { location: made.location.href } })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return async (request) => {
|
|
86
|
+
const url = new URL(request.url)
|
|
87
|
+
if (url.pathname.startsWith('/_next/') || url.pathname.startsWith('/api/')) return undefined
|
|
88
|
+
if (platformUrl && !inFlight && now() - refreshedAt >= intervalMs) void refresh()
|
|
89
|
+
return answer(matchRedirect(rules, url.pathname + url.search), url)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** The most rules a reply may carry. */
|
|
4
|
+
export const MAX_REDIRECT_RULES = 2000
|
|
5
|
+
|
|
6
|
+
/** What a rule does when it matches. `410` means gone and carries no destination. */
|
|
7
|
+
export const redirectStatusSchema = z.union([z.literal(301), z.literal(302), z.literal(410)])
|
|
8
|
+
export const redirectMatchTypeSchema = z.enum(['exact', 'prefix', 'pattern'])
|
|
9
|
+
|
|
10
|
+
/** One rule as the platform serves it. `source` is already normalised by `normalisePath` when the platform stores it. */
|
|
11
|
+
export const redirectRuleSchema = z.object({
|
|
12
|
+
source: z.string().startsWith('/'),
|
|
13
|
+
/** A same-origin path, or an absolute URL for an off-site move. Empty for `410`. */
|
|
14
|
+
destination: z.string(),
|
|
15
|
+
status: redirectStatusSchema,
|
|
16
|
+
matchType: redirectMatchTypeSchema,
|
|
17
|
+
preserveQuery: z.boolean(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** What `GET /api/u90/v1/redirects` returns. */
|
|
21
|
+
export const redirectsReplySchema = z.object({
|
|
22
|
+
rules: z.array(redirectRuleSchema).max(MAX_REDIRECT_RULES),
|
|
23
|
+
/** When the platform generated it. Carried for diagnostics, never used for matching. */
|
|
24
|
+
generatedAt: z.string(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export type RedirectStatus = z.infer<typeof redirectStatusSchema>
|
|
28
|
+
export type RedirectMatchType = z.infer<typeof redirectMatchTypeSchema>
|
|
29
|
+
export type RedirectRule = z.infer<typeof redirectRuleSchema>
|
|
30
|
+
export type RedirectsReply = z.infer<typeof redirectsReplySchema>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OpeningHoursDay, OpeningHoursRow, SiteReply } from '../site/wire'
|
|
2
|
+
|
|
3
|
+
/** A value trimmed, or null when it is blank or absent. */
|
|
4
|
+
const filled = (value: null | string | undefined): null | string => {
|
|
5
|
+
const trimmed = (value ?? '').trim()
|
|
6
|
+
return trimmed === '' ? null : trimmed
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type OpeningHoursSpec = {
|
|
10
|
+
'@type': 'OpeningHoursSpecification'
|
|
11
|
+
closes: string
|
|
12
|
+
dayOfWeek: OpeningHoursDay[]
|
|
13
|
+
opens: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `organisation.openingHours`, as schema.org `OpeningHoursSpecification` entries.
|
|
18
|
+
*
|
|
19
|
+
* A closed day, or a day with no `opens`/`closes` pair, has nothing schema.org can use and is
|
|
20
|
+
* dropped rather than emitted with a null time: Google reports a spec entry with a missing time as
|
|
21
|
+
* an error, which is worse than saying nothing about that day.
|
|
22
|
+
*
|
|
23
|
+
* Days sharing an identical `{opens, closes}` collapse into one entry listing every `dayOfWeek`
|
|
24
|
+
* that applies, which schema.org allows.
|
|
25
|
+
*/
|
|
26
|
+
const openingHoursSpecification = (rows: readonly OpeningHoursRow[]): OpeningHoursSpec[] => {
|
|
27
|
+
const groups = new Map<string, OpeningHoursSpec>()
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (row.closed || row.opens === null || row.closes === null) continue
|
|
30
|
+
const key = `${row.opens}|${row.closes}`
|
|
31
|
+
const existing = groups.get(key)
|
|
32
|
+
if (existing) existing.dayOfWeek.push(row.day)
|
|
33
|
+
else groups.set(key, { '@type': 'OpeningHoursSpecification', closes: row.closes, dayOfWeek: [row.day], opens: row.opens })
|
|
34
|
+
}
|
|
35
|
+
return [...groups.values()]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The organisation, as schema.org JSON-LD, or null.
|
|
40
|
+
*
|
|
41
|
+
* The name is the organisation's `legalName`, else the site name. Without either, or without an
|
|
42
|
+
* organisation, there is none: a business panel with no name is malformed structured data, and
|
|
43
|
+
* Google reports it as an error against the site.
|
|
44
|
+
*/
|
|
45
|
+
export const organisationJsonLd = ({ origin, site }: { origin: string; site: SiteReply | null }): Record<string, unknown> | null => {
|
|
46
|
+
const organisation = site?.organisation
|
|
47
|
+
const name = filled(organisation?.legalName) ?? filled(site?.siteName)
|
|
48
|
+
if (!organisation || !name) return null
|
|
49
|
+
|
|
50
|
+
const openingHoursSpec = openingHoursSpecification(organisation.openingHours)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@type': 'Organization',
|
|
55
|
+
name,
|
|
56
|
+
url: origin.replace(/\/$/, ''),
|
|
57
|
+
...(organisation.logo?.url ? { logo: organisation.logo.url.startsWith('/') && !organisation.logo.url.startsWith('//') ? new URL(organisation.logo.url, origin).toString() : organisation.logo.url } : {}),
|
|
58
|
+
...(filled(organisation.phone) ? { telephone: organisation.phone } : {}),
|
|
59
|
+
...(filled(organisation.email) ? { email: organisation.email } : {}),
|
|
60
|
+
...(filled(organisation.address) ? { address: organisation.address } : {}),
|
|
61
|
+
// Structured hours win when there are any. The free-text field is the fallback for a tenant
|
|
62
|
+
// who has not used the structured editor, emitted as a string.
|
|
63
|
+
...(openingHoursSpec.length > 0
|
|
64
|
+
? { openingHoursSpecification: openingHoursSpec }
|
|
65
|
+
: filled(organisation.legacyOpeningHours)
|
|
66
|
+
? { openingHours: organisation.legacyOpeningHours }
|
|
67
|
+
: {}),
|
|
68
|
+
...(organisation.sameAs.length > 0 ? { sameAs: organisation.sameAs } : {}),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import type { LanguageAlternates } from '../languages/routing'
|
|
3
|
+
import type { ReplySeo, SiteReply } from '../site/wire'
|
|
4
|
+
import { applyTitleTemplate } from './title'
|
|
5
|
+
|
|
6
|
+
/** A value trimmed, or null when it is blank or absent: an empty field falls back rather than overriding. */
|
|
7
|
+
const filled = (value: string | null | undefined): string | null => {
|
|
8
|
+
const trimmed = (value ?? '').trim()
|
|
9
|
+
return trimmed === '' ? null : trimmed
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A page's SEO as v1's reply gives it (`ReplySeo`), with its `ogImage` media id resolved to the media's URL. */
|
|
13
|
+
export type PageSeoInput = Omit<ReplySeo, 'ogImage'> & { ogImage: { url: string; alt?: string } | null }
|
|
14
|
+
|
|
15
|
+
export type MetadataInput = {
|
|
16
|
+
/** The platform's site-wide SEO settings; null for a site with no platform, or when `/site` could not be read. */
|
|
17
|
+
site: SiteReply | null
|
|
18
|
+
/** The site code's own `seo` defaults, used field by field where the platform has set nothing: its template, share image and organisation name. */
|
|
19
|
+
manifest: { titleTemplate?: string; defaultImage?: string; organisation?: { name?: string } } | null
|
|
20
|
+
page: PageSeoInput | null
|
|
21
|
+
/** The page's own title: its stored title, else its key or slug. The template is applied to it. */
|
|
22
|
+
pageTitle: string
|
|
23
|
+
/** The site's origin. Without one, and with no canonical of the page's own, no canonical is set. */
|
|
24
|
+
origin?: string
|
|
25
|
+
/** The path this page answers at, e.g. `/about-us` or `/`. */
|
|
26
|
+
path: string
|
|
27
|
+
/** The page's language, when the site has languages: `path` is then that language's own address (`/fr/about`). */
|
|
28
|
+
language?: LanguageAlternates
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A path made absolute against the site's URL, as v1's sitemap lists it and its canonicals name it:
|
|
33
|
+
* the home page is `https://example.com/`, with its slash.
|
|
34
|
+
*/
|
|
35
|
+
export const siteUrlFor = (origin: string, path: string): string => new URL(path, origin).toString()
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* v1's indexing rule. A site whose platform turned indexing off is neither indexed nor followed.
|
|
39
|
+
* A page that asks not to be listed is still followed. A site the platform said nothing about
|
|
40
|
+
* (no platform, or a `/site` read that failed) sets no robots at all: a failed read must never
|
|
41
|
+
* take a site out of search.
|
|
42
|
+
*/
|
|
43
|
+
const v1Robots = (site: SiteReply | null, page: PageSeoInput | null): Metadata['robots'] =>
|
|
44
|
+
site?.allowIndexing === false ? { follow: false, index: false } : page?.noindex === true ? { follow: true, index: false } : undefined
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Next metadata for a v1 page: the page's SEO over the platform's site-wide SEO over the manifest's
|
|
48
|
+
* defaults, with v1's indexing rule. The page's SEO title is the whole title, as the platform's
|
|
49
|
+
* SEO screen shows it; without one, the platform's template, else the manifest's, is applied to
|
|
50
|
+
* the page's own title, with the platform's site name, else the manifest's organisation name, as
|
|
51
|
+
* `%site%`. The platform answers null for a template its tenant never chose, so a site keeps its
|
|
52
|
+
* manifest's until the tenant sets one. Every string counts as set only when it holds more than
|
|
53
|
+
* whitespace, so a person who clears a field gets the fallback rather than an empty value, and a
|
|
54
|
+
* key with no value is left out.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* A language's Open Graph locale, `language_TERRITORY` as Open Graph requires: a bare language takes its likeliest
|
|
58
|
+
* territory (`fr` is `fr_FR`), and an id the runtime cannot place keeps its own spelling with `_` for `-`.
|
|
59
|
+
*/
|
|
60
|
+
export function ogLocale(id: string): string {
|
|
61
|
+
try {
|
|
62
|
+
const { language, region } = new Intl.Locale(id).maximize()
|
|
63
|
+
if (region) return `${language}_${region}`
|
|
64
|
+
} catch {
|
|
65
|
+
// not a language tag the runtime knows
|
|
66
|
+
}
|
|
67
|
+
return id.replace(/-/g, '_')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function metadataFor(i: MetadataInput): Metadata {
|
|
71
|
+
const { site, manifest, page } = i
|
|
72
|
+
const siteName = filled(site?.siteName) ?? filled(manifest?.organisation?.name)
|
|
73
|
+
const title = filled(page?.title) ?? filled(applyTitleTemplate({ pageTitle: i.pageTitle, siteName, template: filled(site?.titleTemplate) ?? filled(manifest?.titleTemplate) }))
|
|
74
|
+
const description = filled(page?.description) ?? filled(site?.description)
|
|
75
|
+
// Social fields fall back to the resolved title and description, never the raw template.
|
|
76
|
+
const ogTitle = filled(page?.ogTitle) ?? title
|
|
77
|
+
const ogDescription = filled(page?.ogDescription) ?? description
|
|
78
|
+
const siteImage = site?.ogImage?.url ? { url: site.ogImage.url, alt: site.ogImage.alt } : null
|
|
79
|
+
const defaultImage = filled(manifest?.defaultImage)
|
|
80
|
+
const image = page?.ogImage ?? siteImage ?? (defaultImage ? { url: defaultImage } : null)
|
|
81
|
+
/** A share image given as a path (kit 1.5 media) is absolute against the site's own origin; a URL, protocol-relative included, is left alone. */
|
|
82
|
+
const imageUrl = image?.url && image.url.startsWith('/') && !image.url.startsWith('//') && i.origin !== undefined ? siteUrlFor(i.origin, image.url) : image?.url
|
|
83
|
+
const google = filled(site?.googleVerification)
|
|
84
|
+
const bing = filled(site?.bingVerification)
|
|
85
|
+
const verification: Record<string, string> = {}
|
|
86
|
+
if (google) verification['google-site-verification'] = google
|
|
87
|
+
if (bing) verification['msvalidate.01'] = bing
|
|
88
|
+
const handle = filled(site?.twitterHandle)
|
|
89
|
+
// R9: a page in a language other than the default names itself, never a canonical stored on the shared base document.
|
|
90
|
+
const own = i.language !== undefined && !i.language.isDefault
|
|
91
|
+
const locale = own ? ogLocale(i.language!.id) : filled(site?.locale)
|
|
92
|
+
const self = i.origin !== undefined ? siteUrlFor(i.origin, i.path) : null
|
|
93
|
+
const stored = own ? null : filled(page?.canonical)
|
|
94
|
+
const canonical = stored ?? self
|
|
95
|
+
// hreflang only on a page that is its own canonical (Google ignores it elsewhere), and only on a variant offered to search (ruling R2).
|
|
96
|
+
const namesItself = stored === null || (self !== null && i.origin !== undefined && siteUrlFor(i.origin, stored) === self)
|
|
97
|
+
const listed = i.language !== undefined && i.origin !== undefined && i.language.indexable && namesItself
|
|
98
|
+
// One language alone has no alternate to name, as its sitemap entry names none.
|
|
99
|
+
const languages = listed && i.language!.alternates.length > 1
|
|
100
|
+
? { ...Object.fromEntries(i.language!.alternates.map((a) => [a.id, siteUrlFor(i.origin!, a.path)])), 'x-default': siteUrlFor(i.origin!, i.language!.xDefault) }
|
|
101
|
+
: null
|
|
102
|
+
const alternateLocale = listed ? i.language!.alternates.filter((a) => a.id !== i.language!.id).map((a) => ogLocale(a.id)) : []
|
|
103
|
+
const robots = v1Robots(site, page) ?? (i.language && !i.language.indexable ? { follow: true, index: false } : undefined)
|
|
104
|
+
return {
|
|
105
|
+
...(canonical || languages ? { alternates: { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) } } : {}),
|
|
106
|
+
...(description ? { description } : {}),
|
|
107
|
+
openGraph: {
|
|
108
|
+
...(ogDescription ? { description: ogDescription } : {}),
|
|
109
|
+
...(imageUrl ? { images: [{ ...(image?.alt !== undefined ? { alt: image.alt } : {}), url: imageUrl }] } : {}),
|
|
110
|
+
...(alternateLocale.length ? { alternateLocale } : {}),
|
|
111
|
+
...(locale ? { locale } : {}),
|
|
112
|
+
...(siteName ? { siteName } : {}),
|
|
113
|
+
...(ogTitle ? { title: ogTitle } : {}),
|
|
114
|
+
type: 'website',
|
|
115
|
+
},
|
|
116
|
+
...(Object.keys(verification).length > 0 ? { other: verification } : {}),
|
|
117
|
+
...(robots ? { robots } : {}),
|
|
118
|
+
...(title ? { title } : {}),
|
|
119
|
+
twitter: {
|
|
120
|
+
card: site?.twitterCard ?? 'summary_large_image',
|
|
121
|
+
...(ogDescription ? { description: ogDescription } : {}),
|
|
122
|
+
...(imageUrl ? { images: [imageUrl] } : {}),
|
|
123
|
+
...(handle ? { site: handle.startsWith('@') ? handle : `@${handle}` } : {}),
|
|
124
|
+
...(ogTitle ? { title: ogTitle } : {}),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next'
|
|
2
|
+
import type { SiteReply } from '../site/wire'
|
|
3
|
+
import { siteUrlFor } from './metadata'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `robots.txt`: every crawler is allowed, and the sitemap named, unless the platform's
|
|
7
|
+
* `allowIndexing` is off, when everything is disallowed and no sitemap is named. A `/site` read that
|
|
8
|
+
* failed (`site` null) allows crawling, so a platform hiccup never takes a site out of search. With
|
|
9
|
+
* no origin there is no absolute sitemap URL to name.
|
|
10
|
+
*/
|
|
11
|
+
export const robotsFor = ({ origin, site }: { origin: string | undefined; site: SiteReply | null }): MetadataRoute.Robots =>
|
|
12
|
+
site?.allowIndexing === false
|
|
13
|
+
? { rules: { userAgent: '*', disallow: '/' } }
|
|
14
|
+
: { rules: { userAgent: '*', allow: '/' }, ...(origin ? { sitemap: siteUrlFor(origin, '/sitemap.xml') } : {}) }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next'
|
|
2
|
+
import { addressIn, type SiteLanguages } from '../languages/routing'
|
|
3
|
+
import type { SitemapReply } from '../site/wire'
|
|
4
|
+
import { siteUrlFor } from './metadata'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The platform's sitemap entries as Next's `sitemap.xml` takes them: absolute against the site's origin, or as stored without
|
|
8
|
+
* one. With languages, each entry is listed at its address in each language the platform lists it in (every active language
|
|
9
|
+
* when it lists none), in the settings' order. Each url of an entry listed in more than one language names the others and
|
|
10
|
+
* x-default (plan 4 Task 4 minor).
|
|
11
|
+
*/
|
|
12
|
+
export const sitemapFor = (origin: string | undefined, entries: SitemapReply['entries'], languages?: SiteLanguages | null): MetadataRoute.Sitemap =>
|
|
13
|
+
entries.flatMap((e) => {
|
|
14
|
+
const urlOf = (path: string) => (origin ? siteUrlFor(origin, path) : path)
|
|
15
|
+
const lastModified = e.lastModified ? { lastModified: e.lastModified } : {}
|
|
16
|
+
if (!languages) return [{ url: urlOf(e.path), ...lastModified }]
|
|
17
|
+
// The platform lists an entry's languages (ruling R2); a platform from before says nothing, and every active language is listed.
|
|
18
|
+
const listed = languages.active.filter((language) => e.languages === undefined || e.languages.includes(language.id))
|
|
19
|
+
const urls = Object.fromEntries(listed.map((language) => [language.id, urlOf(addressIn(languages, language.id, e.path))]))
|
|
20
|
+
const alternates = listed.length > 1 ? { alternates: { languages: { ...urls, 'x-default': urlOf(e.path) } } } : {}
|
|
21
|
+
return listed.map((language) => ({ url: urls[language.id]!, ...lastModified, ...alternates }))
|
|
22
|
+
})
|
package/src/seo/title.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A value that holds more than whitespace, trimmed; anything else is unset. */
|
|
2
|
+
const filled = (s: string | null): string | null => (s && s.trim() !== '' ? s.trim() : null)
|
|
3
|
+
/** The separators a template puts between the page and the site: `—`, `|`, `·`, `:` and `-`. */
|
|
4
|
+
const SEPARATOR = '[—|·:-]'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A page's title through a template. `%page%` and `%s` are the page title, `%site%` the site name.
|
|
8
|
+
* With no site name, `%site%` goes and so does the separator beside it. A template with no page
|
|
9
|
+
* token is used as written. Substitution is one pass, so a title holding `$&` or a token is
|
|
10
|
+
* inserted as written.
|
|
11
|
+
*/
|
|
12
|
+
export const applyTitleTemplate = ({ pageTitle, siteName, template }: { pageTitle: string | null; siteName: string | null; template: string | null }): string => {
|
|
13
|
+
const page = filled(pageTitle)
|
|
14
|
+
const site = filled(siteName)
|
|
15
|
+
const shape = filled(template)
|
|
16
|
+
if (!shape) return page ?? site ?? ''
|
|
17
|
+
if (!page) return site ?? ''
|
|
18
|
+
const withSite = site
|
|
19
|
+
? shape
|
|
20
|
+
: shape.replace(new RegExp(`\\s*${SEPARATOR}\\s*%site%`), '').replace(new RegExp(`%site%\\s*${SEPARATOR}\\s*`), '').replace('%site%', '')
|
|
21
|
+
return withSite.replace(/%page%|%site%|%s/g, (token) => (token === '%site%' ? (site ?? '') : page)).trim()
|
|
22
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cacheLife, cacheTag, revalidateTag } from 'next/cache'
|
|
2
|
+
import { cookies, draftMode } from 'next/headers'
|
|
3
|
+
import { notFound, redirect } from 'next/navigation'
|
|
4
|
+
import { connection } from 'next/server'
|
|
5
|
+
import { cache } from 'react'
|
|
6
|
+
import { PREVIEW_BINDING_COOKIE } from '../test-preview/binding'
|
|
7
|
+
import { isTestPreviewToken } from '../test-preview/protocol.mjs'
|
|
8
|
+
|
|
9
|
+
export type NextAdapters = {
|
|
10
|
+
cacheTag(...tags: string[]): void; cacheLife(profile: string): void
|
|
11
|
+
/** Lightweight draft-mode check, called inside a cache scope; must not read cookies/headers. */
|
|
12
|
+
draftEnabled?(): Promise<boolean>
|
|
13
|
+
draft(): Promise<{ enabled: boolean; token: string | null }>
|
|
14
|
+
/** Entry-only cookie access, after enabling draft; never called by ordinary reads. */
|
|
15
|
+
draftBinding?(): Promise<{ bypass: string | null; binding: string | null }>
|
|
16
|
+
enableDraftBinding?(binding: string, maxAge: number): Promise<void>
|
|
17
|
+
enableDraft(token: string): Promise<void>; disableDraft(): Promise<void>
|
|
18
|
+
revalidateTag(tag: string): Promise<void>; notFound(): never; redirect(url: string): never
|
|
19
|
+
/** Cache Components: reading this inside `routes.sitemap` forces per-request evaluation instead of build-time caching (R6). */
|
|
20
|
+
connection(): Promise<void>
|
|
21
|
+
/** `fn` called at most once per request, its promise shared by every caller in that request: React's `cache`, which Next scopes to one request across `generateMetadata`, layouts and the page. */
|
|
22
|
+
perRequest<T>(fn: () => Promise<T>): () => Promise<T>
|
|
23
|
+
}
|
|
24
|
+
export const PREVIEW_COOKIE = 'u90-preview-token'
|
|
25
|
+
/**
|
|
26
|
+
* `cookies()` is a request-time API: calling it makes the render that called it dynamic, so reading
|
|
27
|
+
* the preview cookie on every read made every production render dynamic. `draftMode().isEnabled`
|
|
28
|
+
* is not (Next lets a `'use cache'` scope read it), so it is asked first and the cookie is read
|
|
29
|
+
* only in draft. The two request APIs are parameters so a test can see which were called.
|
|
30
|
+
*/
|
|
31
|
+
export async function readDraft(
|
|
32
|
+
mode: () => Promise<{ isEnabled: boolean }>,
|
|
33
|
+
jar: () => Promise<{ get(name: string): { value: string } | undefined }>,
|
|
34
|
+
): Promise<{ enabled: boolean; token: string | null }> {
|
|
35
|
+
if (!(await mode()).isEnabled) return { enabled: false, token: null }
|
|
36
|
+
return { enabled: true, token: (await jar()).get(PREVIEW_COOKIE)?.value ?? null }
|
|
37
|
+
}
|
|
38
|
+
export const nextAdapters: NextAdapters = {
|
|
39
|
+
cacheTag: (...tags) => cacheTag(...tags),
|
|
40
|
+
cacheLife: (profile) => cacheLife(profile as never),
|
|
41
|
+
draftEnabled: async () => (await draftMode()).isEnabled,
|
|
42
|
+
draft: () => readDraft(draftMode, cookies),
|
|
43
|
+
enableDraft: (token) => writeDraft(draftMode, cookies, token),
|
|
44
|
+
draftBinding: async () => { const jar = await cookies(); return { bypass: jar.get('__prerender_bypass')?.value ?? null, binding: jar.get(PREVIEW_BINDING_COOKIE)?.value ?? null } },
|
|
45
|
+
enableDraftBinding: async (binding, maxAge) => { (await cookies()).set(PREVIEW_BINDING_COOKIE, binding, { httpOnly: true, sameSite: 'none', secure: true, path: '/', maxAge }) },
|
|
46
|
+
disableDraft: () => clearDraft(draftMode, cookies),
|
|
47
|
+
revalidateTag: async (tag) => { revalidateTag(tag, { expire: 0 }) },
|
|
48
|
+
notFound: () => notFound(), redirect: (url) => redirect(url),
|
|
49
|
+
connection: () => connection(),
|
|
50
|
+
perRequest: (fn) => cache(fn),
|
|
51
|
+
}
|
|
52
|
+
/** Public-cookie API injection keeps native cookie retention and cleanup testable without Next internals. */
|
|
53
|
+
type DraftCookieOptions = { httpOnly: true; sameSite: 'none'; secure: true; path: '/'; maxAge?: number }
|
|
54
|
+
export async function writeDraft(
|
|
55
|
+
mode: () => Promise<{ enable(): void }>,
|
|
56
|
+
jar: () => Promise<{ set(name: string, value: string, options: DraftCookieOptions): void }>,
|
|
57
|
+
token: string,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
(await mode()).enable()
|
|
60
|
+
const options: DraftCookieOptions = { httpOnly: true, sameSite: 'none', secure: true, path: '/', ...(isTestPreviewToken(token) ? {} : { maxAge: 12 * 3600 }) }
|
|
61
|
+
// A test token stays as an expired-session tombstone until explicit exit/re-entry or browser-session end.
|
|
62
|
+
const store = await jar()
|
|
63
|
+
store.set(PREVIEW_COOKIE, token, options)
|
|
64
|
+
}
|
|
65
|
+
export async function clearDraft(
|
|
66
|
+
mode: () => Promise<{ disable(): void }>,
|
|
67
|
+
jar: () => Promise<{ delete(name: string): void }>,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
(await mode()).disable()
|
|
70
|
+
const store = await jar()
|
|
71
|
+
store.delete(PREVIEW_COOKIE)
|
|
72
|
+
store.delete(PREVIEW_BINDING_COOKIE)
|
|
73
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isTestPreviewToken } from '../test-preview/protocol.mjs'
|
|
2
|
+
import { createTestPreviewRuntime, requestPreviewToken, TestPreviewRefused, type TestPreviewOptions } from '../test-preview/runtime'
|
|
3
|
+
import { createSiteFeatures, type SiteFeaturesOptions } from '../features/server/gateway'
|
|
4
|
+
import { failure } from '../features/server/http'
|
|
5
|
+
import { manifestFrom, manifestHash, type Site } from '../site'
|
|
6
|
+
import { nextAdapters, type NextAdapters } from './adapters'
|
|
7
|
+
import { createHandlers } from './handlers'
|
|
8
|
+
import { createRead, type Env } from './read'
|
|
9
|
+
import { createRender } from './render'
|
|
10
|
+
import { createRoutes } from './routes'
|
|
11
|
+
|
|
12
|
+
export type U90Options = {
|
|
13
|
+
testPreview?: Pick<TestPreviewOptions, 'now' | 'trustTimeoutMs' | 'buildId'>
|
|
14
|
+
features?: Pick<SiteFeaturesOptions, 'verifyVisitor' | 'resolveClientIdentity'>
|
|
15
|
+
fetch?: typeof fetch
|
|
16
|
+
env?: Env
|
|
17
|
+
adapters?: Partial<NextAdapters>
|
|
18
|
+
}
|
|
19
|
+
export function createU90(site: Site, options: U90Options = {}) {
|
|
20
|
+
const env: Env = options.env ?? {
|
|
21
|
+
PLATFORM_URL: process.env.PLATFORM_URL,
|
|
22
|
+
PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY,
|
|
23
|
+
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET,
|
|
24
|
+
SITE_URL: process.env.SITE_URL,
|
|
25
|
+
VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
|
|
26
|
+
VERCEL_URL: process.env.VERCEL_URL,
|
|
27
|
+
U90_TEST_PREVIEW_BUILD_ID: process.env.U90_TEST_PREVIEW_BUILD_ID,
|
|
28
|
+
}
|
|
29
|
+
const adapters = {
|
|
30
|
+
...nextAdapters, ...options.adapters,
|
|
31
|
+
// A custom draft adapter must explicitly provide the lightweight check to support tests.
|
|
32
|
+
draftEnabled: options.adapters?.draftEnabled ?? (options.adapters?.draft ? undefined : nextAdapters.draftEnabled),
|
|
33
|
+
}
|
|
34
|
+
const runtime = createTestPreviewRuntime({ env, fetch: options.fetch, ...options.testPreview })
|
|
35
|
+
const testPreview = {
|
|
36
|
+
resolve: async (token: string | null) => {
|
|
37
|
+
if (!adapters.draftEnabled && isTestPreviewToken(token)) throw new TestPreviewRefused()
|
|
38
|
+
return runtime.resolve(token)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
const manifest = manifestFrom(site) // throws SiteDeclarationError at module evaluation → next build fails
|
|
42
|
+
const runtimeRead = createRead({
|
|
43
|
+
site,
|
|
44
|
+
fetch: options.fetch ?? fetch,
|
|
45
|
+
env,
|
|
46
|
+
adapters,
|
|
47
|
+
running: manifestHash(manifest),
|
|
48
|
+
testPreview,
|
|
49
|
+
})
|
|
50
|
+
// The published-only compatibility reader is private to legacy html root attributes.
|
|
51
|
+
const { legacyHtmlLanguages: _privateReader, ...read } = runtimeRead
|
|
52
|
+
const r = createRender(site)
|
|
53
|
+
const legacy = createHandlers(manifest, env, adapters, options.fetch ?? fetch, testPreview, options.testPreview?.now, options.testPreview?.buildId)
|
|
54
|
+
const features =
|
|
55
|
+
env.PLATFORM_URL && env.PAYLOAD_API_KEY && env.SITE_URL
|
|
56
|
+
? createSiteFeatures({
|
|
57
|
+
env: {
|
|
58
|
+
PLATFORM_URL: env.PLATFORM_URL,
|
|
59
|
+
PAYLOAD_API_KEY: env.PAYLOAD_API_KEY,
|
|
60
|
+
SITE_URL: env.SITE_URL,
|
|
61
|
+
},
|
|
62
|
+
fetch: options.fetch,
|
|
63
|
+
...options.features,
|
|
64
|
+
})
|
|
65
|
+
: undefined
|
|
66
|
+
const reserved = (request: Request) => {
|
|
67
|
+
try {
|
|
68
|
+
return /^\/api\/u90\/features(?:\/|$)/.test(decodeURIComponent(new URL(request.url).pathname))
|
|
69
|
+
} catch {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const featureRequest = async (request: Request) => {
|
|
74
|
+
try {
|
|
75
|
+
const context = await testPreview.resolve(requestPreviewToken(request))
|
|
76
|
+
const gateway = context ? createSiteFeatures({ env: { PLATFORM_URL: context.platformOrigin, PAYLOAD_API_KEY: context.token, SITE_URL: context.siteOrigin }, fetch: options.fetch, ...options.features, visitorCookieName: context.visitorCookieName, testPreview: true }) : features
|
|
77
|
+
return gateway ? gateway[request.method === 'POST' ? 'POST' : 'GET'](request) : failure('unavailable')
|
|
78
|
+
} catch { return failure('forbidden') }
|
|
79
|
+
}
|
|
80
|
+
const handlers = {
|
|
81
|
+
GET: (request: Request, ctx: Parameters<typeof legacy.GET>[1]) => reserved(request) ? featureRequest(request) : legacy.GET(request, ctx),
|
|
82
|
+
POST: (request: Request, ctx: Parameters<typeof legacy.POST>[1]) => reserved(request) ? featureRequest(request) : legacy.POST(request, ctx),
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
read,
|
|
86
|
+
/** Verified server-only context; never serialize its token into client props. */
|
|
87
|
+
testPreview: async () => testPreview.resolve((await adapters.draft()).token),
|
|
88
|
+
routes: createRoutes(site, runtimeRead, r, adapters, env.SITE_URL),
|
|
89
|
+
handlers,
|
|
90
|
+
render: r.render,
|
|
91
|
+
Blocks: r.Blocks,
|
|
92
|
+
Layout: r.Layout,
|
|
93
|
+
manifest,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export type U90 = ReturnType<typeof createU90>
|