@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,53 @@
|
|
|
1
|
+
/** A language's prefix as the platform allows one: `wire.ts`'s rule, which `config.test.ts` holds this file to. */
|
|
2
|
+
const PREFIX = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/
|
|
3
|
+
const isText = (value) => typeof value === 'string'
|
|
4
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
const isPrefix = (value) => isText(value) && PREFIX.test(value)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/site`'s `languages` as the proxy reads them (`languagesSnapshotSchema` in `wire.ts`, which this file cannot import, as
|
|
9
|
+
* plain JS that a Next config loads): the fields the proxy reads and no others, or undefined when they would not parse.
|
|
10
|
+
* `config.test.ts` checks the two agree, case for case.
|
|
11
|
+
*/
|
|
12
|
+
const languagesOf = (value) => {
|
|
13
|
+
if (value === null) return null
|
|
14
|
+
if (!isRecord(value) || !isText(value.original) || !isText(value.default) || !Array.isArray(value.active) || !isRecord(value.countries)) return undefined
|
|
15
|
+
if (!value.active.every((l) => isRecord(l) && isText(l.id) && isPrefix(l.prefix) && isText(l.name))) return undefined
|
|
16
|
+
if (value.inactive !== undefined && !(Array.isArray(value.inactive) && value.inactive.every((l) => isRecord(l) && isText(l.id) && isPrefix(l.prefix)))) return undefined
|
|
17
|
+
if (!Object.values(value.countries).every(isText)) return undefined
|
|
18
|
+
return {
|
|
19
|
+
original: value.original,
|
|
20
|
+
default: value.default,
|
|
21
|
+
active: value.active.map(({ id, prefix, name }) => ({ id, prefix, name })),
|
|
22
|
+
...(value.inactive === undefined ? {} : { inactive: value.inactive.map(({ id, prefix }) => ({ id, prefix })) }),
|
|
23
|
+
countries: { ...value.countries },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bakes the site's languages into a build, so a fresh deploy applies them from its first request:
|
|
29
|
+
* `export default async () => withLanguagesSnapshot(await withRedirectsSnapshot(nextConfig))`, a form every config file type accepts.
|
|
30
|
+
* The languages are `/site`'s `languages`, read from the v1 read API with the site's key and handed to the proxy as
|
|
31
|
+
* `env.U90_LANGUAGES_SNAPSHOT`, as the proxy reads them (`languagesOf`). With no `PLATFORM_URL`, or a `/site` with none, the
|
|
32
|
+
* snapshot holds no languages. A platform that will not answer, takes more than 10 s, or answers languages the proxy could
|
|
33
|
+
* not read, stops the build.
|
|
34
|
+
*/
|
|
35
|
+
export async function withLanguagesSnapshot(config, { env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
36
|
+
const base = env.PLATFORM_URL ? env.PLATFORM_URL.replace(/\/+$/, '') : ''
|
|
37
|
+
let snapshot = { languages: null }
|
|
38
|
+
if (base) {
|
|
39
|
+
// The timeout covers the body as well as the headers: a platform that stalls mid-reply stops the build the same way.
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetchImpl(`${base}/api/u90/v1/site`, { headers: { authorization: `Bearer ${env.PAYLOAD_API_KEY ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(10000) })
|
|
42
|
+
if (!res.ok) throw new Error(`The platform answered ${res.status} for the site's languages, so the build stops rather than deploy without them.`)
|
|
43
|
+
const reply = await res.json()
|
|
44
|
+
const languages = languagesOf(reply?.languages ?? null)
|
|
45
|
+
if (languages === undefined) throw new Error('The platform\'s languages did not parse, so the build stops rather than deploy a snapshot the proxy would throw away.')
|
|
46
|
+
snapshot = { languages }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof Error && err.name === 'TimeoutError') throw new Error('The platform timed out after 10 s on the site\'s languages, so the build stops rather than deploy without them.')
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ...config, env: { ...(config.env ?? {}), U90_LANGUAGES_SNAPSHOT: JSON.stringify(snapshot) } }
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { collapseSeparators } from '../redirects/location'
|
|
2
|
+
import { DRAFT_COUNTRY_PARAM, type SiteLanguages } from './routing'
|
|
3
|
+
|
|
4
|
+
/** What the proxy knows about one request. */
|
|
5
|
+
export type LanguageRequest = {
|
|
6
|
+
pathname: string
|
|
7
|
+
/** The query, with its `?`, or empty. */
|
|
8
|
+
search: string
|
|
9
|
+
/** The `u90_lang` cookie's value. */
|
|
10
|
+
remembered: string | null
|
|
11
|
+
/** The host's country header's value. */
|
|
12
|
+
country: string | null
|
|
13
|
+
/** `u90-country` from the query, honoured only when `draft`. */
|
|
14
|
+
previewCountry: string | null
|
|
15
|
+
/** The request carries Next's draft-mode cookie. */
|
|
16
|
+
draft: boolean
|
|
17
|
+
}
|
|
18
|
+
/** A redirect, or none, and whether the answer varies on the country header. */
|
|
19
|
+
export type LanguageDecision = { redirect: { status: 307 | 308; to: string } | null; vary: boolean }
|
|
20
|
+
|
|
21
|
+
const PASS: LanguageDecision = { redirect: null, vary: false }
|
|
22
|
+
const COUNTRY = /^[A-Z]{2}$/
|
|
23
|
+
const countryOf = (value: string | null): string | null => {
|
|
24
|
+
const code = value?.trim().toUpperCase() ?? ''
|
|
25
|
+
return COUNTRY.test(code) ? code : null
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A path's first segment as the page reads it, and the path after it. Next decodes each segment of a route with
|
|
29
|
+
* `decodeURIComponent` before the page sees it, so `/%65n/about` is `/en/about` there and `/en%2Fabout` is `/en/about` too. A
|
|
30
|
+
* segment that does not decode is kept as it is, and names no prefix. The rest stays as the request spelled it.
|
|
31
|
+
*/
|
|
32
|
+
const firstSegment = (pathname: string): { first: string; rest: string } => {
|
|
33
|
+
const [raw = '', ...others] = pathname.split('/').slice(1)
|
|
34
|
+
let decoded: string
|
|
35
|
+
try { decoded = decodeURIComponent(raw) } catch { return { first: raw, rest: `/${others.join('/')}` } }
|
|
36
|
+
const cut = decoded.indexOf('/')
|
|
37
|
+
if (cut === -1) return { first: decoded, rest: `/${others.join('/')}` }
|
|
38
|
+
// The raw segment holds no `/`, so its first `%2F` is the decoded segment's first `/`.
|
|
39
|
+
return { first: decoded.slice(0, cut), rest: `/${[raw.slice(raw.search(/%2F/i) + 3), ...others].join('/')}` }
|
|
40
|
+
}
|
|
41
|
+
const without = (search: string, name: string): string => {
|
|
42
|
+
const params = new URLSearchParams(search)
|
|
43
|
+
params.delete(name)
|
|
44
|
+
const rest = params.toString()
|
|
45
|
+
return rest ? `?${rest}` : ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Spec §5. The default's own prefix is a 308 to the bare address, and an inactive prefix (a preparing language's included, R12)
|
|
50
|
+
* a 307 to it, so a page never answers at two addresses. Both are decided from the path alone, before the page renders, and from
|
|
51
|
+
* its first segment as the page decodes it (`firstSegment`), so an encoded prefix is redirected as the plain one is.
|
|
52
|
+
* Only `/` is ever chosen for. A public request goes to a remembered active language, else the country's rule, else stays on the
|
|
53
|
+
* default. A draft request is decided by a valid `u90-country` alone, the Canvas's country preview: the remembered choice and
|
|
54
|
+
* the editor's own country are ignored, since a Canvas screen's language is its own (R15). Every answer on `/` varies on the
|
|
55
|
+
* country header. Every run of `/` and `\` in a built path is collapsed, so no redirect can start `//host`.
|
|
56
|
+
*/
|
|
57
|
+
export function decideLanguage(languages: SiteLanguages | null, r: LanguageRequest): LanguageDecision {
|
|
58
|
+
if (!languages) return PASS
|
|
59
|
+
if (/^\/(?:_next|api|_u90)(?:\/|$)/.test(r.pathname)) return PASS
|
|
60
|
+
const segment = firstSegment(r.pathname)
|
|
61
|
+
const first = segment.first
|
|
62
|
+
const rest = collapseSeparators(segment.rest)
|
|
63
|
+
const fallback = languages.active.find((language) => language.id === languages.default)
|
|
64
|
+
if (first && fallback && first === fallback.prefix) return { redirect: { status: 308, to: rest + r.search }, vary: false }
|
|
65
|
+
if (first && (languages.inactive ?? []).some((language) => language.prefix === first)) return { redirect: { status: 307, to: rest + r.search }, vary: false }
|
|
66
|
+
if (r.pathname !== '/') return PASS
|
|
67
|
+
const active = (id: string | null | undefined) => (id ? languages.active.find((language) => language.id === id) : undefined)
|
|
68
|
+
const ruled = (country: string | null) => active(country ? languages.countries[country] : undefined)
|
|
69
|
+
const target = r.draft ? ruled(countryOf(r.previewCountry)) : (active(r.remembered) ?? ruled(countryOf(r.country)))
|
|
70
|
+
if (!target || target.id === languages.default) return { redirect: null, vary: true }
|
|
71
|
+
return { redirect: { status: 307, to: `/${target.prefix}${r.draft ? without(r.search, DRAFT_COUNTRY_PARAM) : r.search}` }, vary: true }
|
|
72
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { siteLocation } from '../redirects/location'
|
|
2
|
+
import { decideLanguage } from './decide'
|
|
3
|
+
import { COUNTRY_HEADER, DRAFT_COUNTRY_PARAM, LANGUAGE_COOKIE, type SiteLanguages } from './routing'
|
|
4
|
+
import { languagesSnapshotSchema } from './wire'
|
|
5
|
+
|
|
6
|
+
/** Next's draft-mode cookie. Its presence is all the proxy can see of draft mode, and all a country preview needs (R7). */
|
|
7
|
+
const DRAFT_COOKIE = '__prerender_bypass'
|
|
8
|
+
const cookieOf = (request: Request, name: string): string | null => {
|
|
9
|
+
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
10
|
+
const at = part.indexOf('=')
|
|
11
|
+
if (at > 0 && part.slice(0, at).trim() === name) {
|
|
12
|
+
try { return decodeURIComponent(part.slice(at + 1).trim()) } catch { return null }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Options = {
|
|
19
|
+
/** The languages baked into the build (`env.U90_LANGUAGES_SNAPSHOT`), as JSON or parsed. Defaults to that variable. */
|
|
20
|
+
snapshot?: string | { languages: SiteLanguages | null } | null
|
|
21
|
+
/** Defaults to `process.env.PLATFORM_URL`. Without one the snapshot is all there is. */
|
|
22
|
+
platformUrl?: string
|
|
23
|
+
/** Defaults to `process.env.PAYLOAD_API_KEY`. */
|
|
24
|
+
apiKey?: string
|
|
25
|
+
fetch?: typeof fetch
|
|
26
|
+
now?: () => number
|
|
27
|
+
/** How long the languages stay fresh before the next request starts a refresh. Defaults to 60. */
|
|
28
|
+
refreshSeconds?: number
|
|
29
|
+
/** The request header the host puts the visitor's country in. Defaults to Vercel's (R4). */
|
|
30
|
+
countryHeader?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The language step of a site's `proxy.ts`, after the access gate and `redirectsProxy`:
|
|
35
|
+
* `return (await redirects(request)) ?? (await languages(request, () => NextResponse.next()))`.
|
|
36
|
+
* Languages start from the build's snapshot and refresh from `/api/u90/v1/site` without ever delaying a request; the last
|
|
37
|
+
* good ones survive a failed or malformed refresh. A site with no languages gets `next()`'s response exactly as it was.
|
|
38
|
+
* Every `Location` is made by the redirects proxy's `siteLocation`, so it is absolute and never leaves the site.
|
|
39
|
+
*/
|
|
40
|
+
export function languagesProxy(o: Options = {}): (request: Request, next: () => Response) => Promise<Response> {
|
|
41
|
+
const platformUrl = o.platformUrl ?? process.env.PLATFORM_URL ?? ''
|
|
42
|
+
const apiKey = o.apiKey ?? process.env.PAYLOAD_API_KEY
|
|
43
|
+
const fetchImpl = o.fetch ?? globalThis.fetch
|
|
44
|
+
const now = o.now ?? Date.now
|
|
45
|
+
const intervalMs = (o.refreshSeconds ?? 60) * 1000
|
|
46
|
+
const header = (o.countryHeader ?? COUNTRY_HEADER).toLowerCase()
|
|
47
|
+
const snapshot = o.snapshot === undefined ? process.env.U90_LANGUAGES_SNAPSHOT : o.snapshot
|
|
48
|
+
|
|
49
|
+
let languages: SiteLanguages | null = null
|
|
50
|
+
if (snapshot) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = languagesSnapshotSchema.safeParse(typeof snapshot === 'string' ? JSON.parse(snapshot) : snapshot)
|
|
53
|
+
if (parsed.success) languages = parsed.data.languages
|
|
54
|
+
else console.warn('U90_LANGUAGES_SNAPSHOT is not a valid set of languages, so the proxy starts with none until the platform answers.')
|
|
55
|
+
} catch {
|
|
56
|
+
console.warn('U90_LANGUAGES_SNAPSHOT is not readable JSON, so the proxy starts with no languages until the platform answers.')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let refreshedAt = 0
|
|
61
|
+
let inFlight = false
|
|
62
|
+
async function refresh() {
|
|
63
|
+
inFlight = true
|
|
64
|
+
refreshedAt = now()
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetchImpl(`${platformUrl.replace(/\/+$/, '')}/api/u90/v1/site`, { headers: { authorization: `Bearer ${apiKey ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
|
|
67
|
+
if (!res.ok) { console.warn(`The platform answered ${res.status} for the site's languages, so the last good ones stay.`); return }
|
|
68
|
+
const body = (await res.json()) as { languages?: unknown } | null
|
|
69
|
+
const parsed = languagesSnapshotSchema.safeParse({ languages: body?.languages ?? null })
|
|
70
|
+
if (parsed.success) languages = parsed.data.languages
|
|
71
|
+
else console.warn('The platform\'s languages did not parse, so the last good ones stay.')
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.warn(`The site's languages could not be refreshed (${err instanceof Error ? err.message : String(err)}), so the last good ones stay.`)
|
|
74
|
+
} finally {
|
|
75
|
+
inFlight = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let warnedUnsafe = false
|
|
80
|
+
return async (request, next) => {
|
|
81
|
+
const url = new URL(request.url)
|
|
82
|
+
if (url.pathname.startsWith('/_next/') || url.pathname.startsWith('/api/')) return next()
|
|
83
|
+
if (platformUrl && !inFlight && now() - refreshedAt >= intervalMs) void refresh()
|
|
84
|
+
const decision = decideLanguage(languages, {
|
|
85
|
+
pathname: url.pathname,
|
|
86
|
+
search: url.search,
|
|
87
|
+
remembered: cookieOf(request, LANGUAGE_COOKIE),
|
|
88
|
+
country: request.headers.get(header),
|
|
89
|
+
previewCountry: url.searchParams.get(DRAFT_COUNTRY_PARAM),
|
|
90
|
+
draft: cookieOf(request, DRAFT_COOKIE) !== null,
|
|
91
|
+
})
|
|
92
|
+
if (decision.redirect) {
|
|
93
|
+
const made = siteLocation(decision.redirect.to, url)
|
|
94
|
+
if ('location' in made && made.location.origin === url.origin) {
|
|
95
|
+
const headers = new Headers({ location: made.location.href })
|
|
96
|
+
if (decision.vary) { headers.set('cache-control', 'private, no-store'); headers.set('vary', header) }
|
|
97
|
+
return new Response(null, { status: decision.redirect.status, headers })
|
|
98
|
+
}
|
|
99
|
+
if (!warnedUnsafe) {
|
|
100
|
+
warnedUnsafe = true
|
|
101
|
+
console.warn(`A language redirect for ${url.pathname} would be under /_next/ or leave the site, so it passed through. Requests like this are not warned about again.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const response = next()
|
|
105
|
+
if (decision.vary) response.headers.append('vary', header)
|
|
106
|
+
return response
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { prefixedRoute } from '../elements/prefix'
|
|
2
|
+
import type { SiteReply } from '../site/wire'
|
|
3
|
+
|
|
4
|
+
/** The languages `/site` answers for a site that has them. */
|
|
5
|
+
export type SiteLanguages = NonNullable<SiteReply['languages']>
|
|
6
|
+
/** The cookie the switcher sets and the proxy reads on `/`: the id of the language a visitor chose. */
|
|
7
|
+
export const LANGUAGE_COOKIE = 'u90_lang'
|
|
8
|
+
/** The site query parameter that previews `/` as a visitor from a country. The proxy honours it in draft mode only. */
|
|
9
|
+
export const DRAFT_COUNTRY_PARAM = 'u90-country'
|
|
10
|
+
/** Vercel's request header for the visitor's country, ISO 3166-1 alpha-2. Next 16 has no request geo of its own. */
|
|
11
|
+
export const COUNTRY_HEADER = 'x-vercel-ip-country'
|
|
12
|
+
|
|
13
|
+
/** Where an address sits: its language, the prefix it was found under (`/fr`, null for the default) and the page's path without it. */
|
|
14
|
+
export type PathLanguage = { id: string; prefix: string | null; rest: string }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The language an address is in. Null when the site has no languages, so the caller reads exactly as it did before them.
|
|
18
|
+
* An active language other than the default answers at its prefix. Every other address is the default's, the default's own
|
|
19
|
+
* prefix and an inactive prefix included: the proxy redirects those before they render, so a page never answers twice.
|
|
20
|
+
*/
|
|
21
|
+
export function languageOfPath(path: string, languages: SiteLanguages | null | undefined): PathLanguage | null {
|
|
22
|
+
if (!languages) return null
|
|
23
|
+
const segments = path.split('/').slice(1)
|
|
24
|
+
const first = segments[0] ?? ''
|
|
25
|
+
const found = first ? languages.active.find((language) => language.prefix === first && language.id !== languages.default) : undefined
|
|
26
|
+
return found ? { id: found.id, prefix: `/${found.prefix}`, rest: `/${segments.slice(1).join('/')}` } : { id: languages.default, prefix: null, rest: path }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The address of the page at `rest` in language `id`: the default's is `rest` itself, and an active language's carries its prefix. */
|
|
30
|
+
export function addressIn(languages: SiteLanguages, id: string, rest: string): string {
|
|
31
|
+
const language = languages.active.find((candidate) => candidate.id === id)
|
|
32
|
+
return !language || id === languages.default ? rest : prefixedRoute(`/${language.prefix}`, rest)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the switcher shows: the language the page is in, and the page's address in every active language, in the settings' order. */
|
|
36
|
+
export type LanguageLinks = { current: string; links: { id: string; name: string; href: string }[] }
|
|
37
|
+
export const languageLinks = (languages: SiteLanguages, rest: string, current: string): LanguageLinks => ({
|
|
38
|
+
current,
|
|
39
|
+
links: languages.active.map((language) => ({ id: language.id, name: language.name, href: addressIn(languages, language.id, rest) })),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a value holds words a reader sees: some string leaf, however deep, with more than whitespace. A number, a boolean
|
|
44
|
+
* and an empty object or list are none. A rich-text node (an object with a string `type`) holds copy only in its `text` and
|
|
45
|
+
* its `children`: its `type`, `format` and a link's `fields` are the original's structure. The platform's translation health reads
|
|
46
|
+
* a value with this too, so a field is missing there exactly when it holds no copy here.
|
|
47
|
+
*/
|
|
48
|
+
export const holdsCopy = (value: unknown): boolean => {
|
|
49
|
+
if (typeof value === 'string') return value.trim() !== ''
|
|
50
|
+
if (Array.isArray(value)) return value.some(holdsCopy)
|
|
51
|
+
if (value === null || typeof value !== 'object') return false
|
|
52
|
+
const node = value as Record<string, unknown>
|
|
53
|
+
return typeof node.type === 'string' ? holdsCopy(node.text) || holdsCopy(node.children) : Object.values(node).some(holdsCopy)
|
|
54
|
+
}
|
|
55
|
+
/** Whether an overlay holds any translated copy: what makes a language's variant of a page worth indexing (ruling R2). */
|
|
56
|
+
export const hasTranslatedCopy = (values: Record<string, unknown> | undefined): boolean => !!values && Object.values(values).some(holdsCopy)
|
|
57
|
+
|
|
58
|
+
/** What a page's metadata says about its language: whether it is the default, its address in each listed language, the bare address for `x-default`, and whether it is offered to search. */
|
|
59
|
+
export type LanguageAlternates = { id: string; isDefault: boolean; alternates: { id: string; path: string }[]; xDefault: string; indexable: boolean }
|
|
60
|
+
/**
|
|
61
|
+
* A page's alternates in each language it is listed in: the default, the original, and each language in `translated` (the
|
|
62
|
+
* platform's list of languages with copy for this page). Without `translated`, every active language, as before. `indexable` is
|
|
63
|
+
* whether the page's own language is listed: an untranslated variant is served, but not offered to search (ruling R2).
|
|
64
|
+
*/
|
|
65
|
+
export const languageAlternates = (languages: SiteLanguages, language: PathLanguage, translated?: string[]): LanguageAlternates => {
|
|
66
|
+
const listed = (id: string) => translated === undefined || id === languages.default || id === languages.original || translated.includes(id)
|
|
67
|
+
return {
|
|
68
|
+
id: language.id,
|
|
69
|
+
isDefault: language.prefix === null,
|
|
70
|
+
alternates: languages.active.filter((l) => listed(l.id)).map((l) => ({ id: l.id, path: addressIn(languages, l.id, language.rest) })),
|
|
71
|
+
xDefault: language.rest,
|
|
72
|
+
indexable: listed(language.id),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Scripts written right to left, by their ISO 15924 code. */
|
|
77
|
+
const RTL_SCRIPTS = new Set(['Adlm', 'Arab', 'Hebr', 'Mand', 'Mend', 'Nkoo', 'Rohg', 'Samr', 'Syrc', 'Thaa', 'Yezi'])
|
|
78
|
+
/** Languages written right to left in their usual script, by their language subtag: read only when ICU's likely subtags give no script. */
|
|
79
|
+
const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ckb'])
|
|
80
|
+
/**
|
|
81
|
+
* `rtl` when the language's likely script is written right to left (`ar`, `he`, `fa`, `az-Arab`), else `ltr`. An id that does not parse is `ltr`.
|
|
82
|
+
* The script is the one ICU's likely subtags give, else the one the id names. A runtime whose ICU gives neither reads the language subtag instead.
|
|
83
|
+
*/
|
|
84
|
+
export function languageDirection(id: string): 'ltr' | 'rtl' {
|
|
85
|
+
try {
|
|
86
|
+
const locale = new Intl.Locale(id)
|
|
87
|
+
const script = locale.maximize().script ?? locale.script
|
|
88
|
+
if (script === undefined) return RTL_LANGUAGES.has(locale.language) ? 'rtl' : 'ltr'
|
|
89
|
+
return RTL_SCRIPTS.has(script) ? 'rtl' : 'ltr'
|
|
90
|
+
} catch {
|
|
91
|
+
return 'ltr'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A language's prefix as the platform allows one (`PREFIX` in the platform's `src/content/languages/settings.ts`): lowercase
|
|
5
|
+
* letters, then optional `-` parts of letters and digits. An empty prefix is refused, since `/` would then be every
|
|
6
|
+
* language's address and the proxy would redirect it to itself.
|
|
7
|
+
*/
|
|
8
|
+
const prefix = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/)
|
|
9
|
+
|
|
10
|
+
/** `/site`'s `languages`, as the proxy accepts it. */
|
|
11
|
+
export const siteLanguagesSchema = z.object({
|
|
12
|
+
original: z.string(),
|
|
13
|
+
default: z.string(),
|
|
14
|
+
active: z.array(z.object({ id: z.string(), prefix, name: z.string() })),
|
|
15
|
+
inactive: z.array(z.object({ id: z.string(), prefix })).optional(),
|
|
16
|
+
countries: z.record(z.string(), z.string()),
|
|
17
|
+
})
|
|
18
|
+
/** What `U90_LANGUAGES_SNAPSHOT` holds: the languages `/site` answered at build, or null for none. */
|
|
19
|
+
export const languagesSnapshotSchema = z.object({ languages: siteLanguagesSchema.nullable() })
|