@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,216 @@
|
|
|
1
|
+
import { resolveLocaleRequest } from './routing'
|
|
2
|
+
import {
|
|
3
|
+
canonicalLocale,
|
|
4
|
+
localePrefix,
|
|
5
|
+
RESERVED_LOCALE_PREFIXES,
|
|
6
|
+
validateLanguageSettings,
|
|
7
|
+
} from './settings'
|
|
8
|
+
import type {
|
|
9
|
+
LanguageSettings,
|
|
10
|
+
LocalizableField,
|
|
11
|
+
TranslationDocument,
|
|
12
|
+
TranslationOwner,
|
|
13
|
+
} from './types'
|
|
14
|
+
import { isSemanticallyEmpty, resolveField, languageFieldCandidate } from './resolution'
|
|
15
|
+
|
|
16
|
+
export type LanguageRuntimeOwner = TranslationOwner & {
|
|
17
|
+
/** Published owner logical/dashboard path, retained for collection route discovery. */
|
|
18
|
+
path?: string
|
|
19
|
+
source: Record<string, unknown>
|
|
20
|
+
fields: LocalizableField[]
|
|
21
|
+
translations: Record<string, TranslationDocument>
|
|
22
|
+
}
|
|
23
|
+
export type LanguageSnapshot = {
|
|
24
|
+
version: 1
|
|
25
|
+
/** Runtime fallback diagnostic; successful endpoint snapshots omit this. */
|
|
26
|
+
status?: 'unavailable'
|
|
27
|
+
draft?: true
|
|
28
|
+
settings: LanguageSettings | null
|
|
29
|
+
owners: LanguageRuntimeOwner[]
|
|
30
|
+
}
|
|
31
|
+
export const LANGUAGE_PREFERENCE_COOKIE = 'u90-language'
|
|
32
|
+
export const LANGUAGE_VERSION = 1 as const
|
|
33
|
+
|
|
34
|
+
/** Explicitly mount this adapter before passing it to createManifestRoute. No country is trusted by default. */
|
|
35
|
+
export function createLanguageRuntime(options: {
|
|
36
|
+
load: (request?: Request) => Promise<LanguageSnapshot>
|
|
37
|
+
knownPaths:
|
|
38
|
+
readonly string[] | ((path: string, snapshot: LanguageSnapshot) => boolean | Promise<boolean>)
|
|
39
|
+
/** Configured dynamic route templates, for prefix collision validation (not route existence). */
|
|
40
|
+
routePatterns?: readonly string[]
|
|
41
|
+
getTrustedCountry?: (request: Request) => string | undefined
|
|
42
|
+
/** Optional durable, site-scoped published snapshot for serverless cold starts. Never supply draft data. */
|
|
43
|
+
lastPublished?: LanguageSnapshot
|
|
44
|
+
onPublished?: (snapshot: LanguageSnapshot) => void
|
|
45
|
+
reservedPrefixes?: readonly string[]
|
|
46
|
+
}) {
|
|
47
|
+
const usable = (snapshot: LanguageSnapshot) =>
|
|
48
|
+
snapshot?.version === 1 &&
|
|
49
|
+
Array.isArray(snapshot.owners) &&
|
|
50
|
+
(snapshot.settings === null || validateLanguageSettings(snapshot.settings).ok)
|
|
51
|
+
let lastPublished =
|
|
52
|
+
options.lastPublished &&
|
|
53
|
+
!options.lastPublished.draft &&
|
|
54
|
+
!options.lastPublished.status &&
|
|
55
|
+
usable(options.lastPublished)
|
|
56
|
+
? structuredClone(options.lastPublished)
|
|
57
|
+
: undefined
|
|
58
|
+
const load = async (request?: Request): Promise<LanguageSnapshot> => {
|
|
59
|
+
try {
|
|
60
|
+
const snapshot = await options.load(request)
|
|
61
|
+
if (!usable(snapshot)) throw new Error('Invalid Languages snapshot')
|
|
62
|
+
if (!snapshot.draft && !snapshot.status) {
|
|
63
|
+
lastPublished = structuredClone(snapshot)
|
|
64
|
+
options.onPublished?.(structuredClone(snapshot))
|
|
65
|
+
}
|
|
66
|
+
return snapshot
|
|
67
|
+
} catch {
|
|
68
|
+
// Only a successful published read establishes configuration. Cold-start legacy is not cached.
|
|
69
|
+
return lastPublished
|
|
70
|
+
? { ...structuredClone(lastPublished), status: 'unavailable' }
|
|
71
|
+
: { version: 1, settings: null, owners: [], status: 'unavailable' }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const known = async (path: string, snapshot: LanguageSnapshot) =>
|
|
75
|
+
typeof options.knownPaths === 'function'
|
|
76
|
+
? options.knownPaths(path, snapshot)
|
|
77
|
+
: options.knownPaths.some((p) => p.replace(/\/$/, '') === path.replace(/\/$/, ''))
|
|
78
|
+
return {
|
|
79
|
+
languageVersion: LANGUAGE_VERSION,
|
|
80
|
+
load,
|
|
81
|
+
async handleRequest(request: Request): Promise<Response | null> {
|
|
82
|
+
const url = new URL(request.url)
|
|
83
|
+
const first = url.pathname.split('/').filter(Boolean)[0]?.toLowerCase() ?? ''
|
|
84
|
+
if (
|
|
85
|
+
[...RESERVED_LOCALE_PREFIXES, ...(options.reservedPrefixes ?? [])].includes(first) ||
|
|
86
|
+
url.pathname.split('/').at(-1)?.includes('.')
|
|
87
|
+
)
|
|
88
|
+
return null
|
|
89
|
+
// A failed configuration read must never masquerade as disabled Languages.
|
|
90
|
+
const snapshot = await load(request)
|
|
91
|
+
const { settings } = snapshot
|
|
92
|
+
if (!settings?.enabled) return null
|
|
93
|
+
for (const language of settings.languages)
|
|
94
|
+
if (await known(`/${localePrefix(language.locale)}`, snapshot))
|
|
95
|
+
throw new Error('Language prefix collides with a public route')
|
|
96
|
+
const checked = validateLanguageSettings(settings, {
|
|
97
|
+
pagePaths: [
|
|
98
|
+
...(Array.isArray(options.knownPaths) ? options.knownPaths : []),
|
|
99
|
+
...(options.routePatterns ?? []),
|
|
100
|
+
],
|
|
101
|
+
reservedPrefixes: options.reservedPrefixes,
|
|
102
|
+
})
|
|
103
|
+
if (!checked.ok) throw new Error(checked.problems.join(' '))
|
|
104
|
+
const configured = settings.languages.find((l) => localePrefix(l.locale) === first)
|
|
105
|
+
const logical = configured
|
|
106
|
+
? '/' + url.pathname.split('/').filter(Boolean).slice(1).join('/')
|
|
107
|
+
: url.pathname
|
|
108
|
+
if (!(await known(logical, snapshot))) return new Response('Not found', { status: 404 })
|
|
109
|
+
// Draft authority comes only from the authenticated snapshot loader. Preparing an inactive
|
|
110
|
+
// language must keep its explicit preview URL; visitor query/cookie values cannot opt in.
|
|
111
|
+
if (snapshot.draft === true && configured) return null
|
|
112
|
+
let preferredLocale: string | undefined
|
|
113
|
+
const cookie = request.headers
|
|
114
|
+
.get('cookie')
|
|
115
|
+
?.split(';')
|
|
116
|
+
.map((v) => v.trim())
|
|
117
|
+
.find((v) => v.startsWith(`${LANGUAGE_PREFERENCE_COOKIE}=`))
|
|
118
|
+
try {
|
|
119
|
+
preferredLocale = cookie
|
|
120
|
+
? decodeURIComponent(cookie.slice(LANGUAGE_PREFERENCE_COOKIE.length + 1))
|
|
121
|
+
: undefined
|
|
122
|
+
} catch {
|
|
123
|
+
/* malformed preferences are ignored */
|
|
124
|
+
}
|
|
125
|
+
const result = resolveLocaleRequest(settings, {
|
|
126
|
+
path: url.pathname + url.search,
|
|
127
|
+
knownPaths: [logical],
|
|
128
|
+
preferredLocale,
|
|
129
|
+
country: configured ? undefined : options.getTrustedCountry?.(request),
|
|
130
|
+
reservedPrefixes: options.reservedPrefixes,
|
|
131
|
+
})
|
|
132
|
+
return result.kind === 'redirect'
|
|
133
|
+
? new Response(null, {
|
|
134
|
+
status: result.status,
|
|
135
|
+
headers: {
|
|
136
|
+
Location: new URL(result.target, url.origin).href,
|
|
137
|
+
'Cache-Control': result.cacheControl,
|
|
138
|
+
Vary: 'Cookie',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
: null
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const assign = (source: Record<string, unknown>, path: string[], value: unknown) => {
|
|
147
|
+
let cursor: Record<string, unknown> = source
|
|
148
|
+
for (const segment of path.slice(0, -1)) {
|
|
149
|
+
if (['__proto__', 'prototype', 'constructor'].includes(segment))
|
|
150
|
+
throw new TypeError('Unsafe field path')
|
|
151
|
+
cursor[segment] ??= {}
|
|
152
|
+
cursor = cursor[segment] as Record<string, unknown>
|
|
153
|
+
}
|
|
154
|
+
const last = path.at(-1)!
|
|
155
|
+
if (['__proto__', 'prototype', 'constructor'].includes(last))
|
|
156
|
+
throw new TypeError('Unsafe field path')
|
|
157
|
+
cursor[last] = structuredClone(value)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply only schema-enumerated copy slots, to raw values before reference/media hydration. */
|
|
161
|
+
export function overlayLanguageOwner({
|
|
162
|
+
owner,
|
|
163
|
+
settings,
|
|
164
|
+
locale,
|
|
165
|
+
}: {
|
|
166
|
+
owner: LanguageRuntimeOwner
|
|
167
|
+
settings: LanguageSettings
|
|
168
|
+
locale: string
|
|
169
|
+
}) {
|
|
170
|
+
const source = structuredClone(owner.source)
|
|
171
|
+
const resolvedLocales: Record<string, string> = {}
|
|
172
|
+
let translated = 0
|
|
173
|
+
for (const slot of owner.fields) {
|
|
174
|
+
const candidate = (language: string) => {
|
|
175
|
+
if (language === settings.originalLocale) return slot.value
|
|
176
|
+
const field = owner.translations[language]?.fields[slot.address]
|
|
177
|
+
return languageFieldCandidate(slot, field)
|
|
178
|
+
}
|
|
179
|
+
const requested = candidate(locale)
|
|
180
|
+
const target = owner.translations[locale]?.fields[slot.address]
|
|
181
|
+
const keepsOriginal =
|
|
182
|
+
locale !== settings.originalLocale &&
|
|
183
|
+
target?.keepOriginal &&
|
|
184
|
+
target.sourceHash === slot.sourceHash
|
|
185
|
+
// Keep original resolves useful copy, but does not establish a translated public variant.
|
|
186
|
+
if (!keepsOriginal && !isSemanticallyEmpty(requested, slot.field.type)) translated++
|
|
187
|
+
const value = resolveField({
|
|
188
|
+
requested,
|
|
189
|
+
fallback: candidate(settings.defaultLocale),
|
|
190
|
+
original: slot.value,
|
|
191
|
+
type: slot.field.type,
|
|
192
|
+
})
|
|
193
|
+
if (value === undefined) continue
|
|
194
|
+
const chosen =
|
|
195
|
+
[locale, settings.defaultLocale, settings.originalLocale].find(
|
|
196
|
+
(language) => !isSemanticallyEmpty(candidate(language), slot.field.type),
|
|
197
|
+
) ?? settings.originalLocale
|
|
198
|
+
const chosenField = owner.translations[chosen]?.fields[slot.address]
|
|
199
|
+
resolvedLocales[slot.address] =
|
|
200
|
+
chosenField?.keepOriginal && chosenField.sourceHash === slot.sourceHash
|
|
201
|
+
? settings.originalLocale
|
|
202
|
+
: chosen
|
|
203
|
+
let path = slot.positionalPath.split('.')
|
|
204
|
+
if (owner.kind === 'page' && !slot.address.startsWith('seo.')) path = ['content', ...path]
|
|
205
|
+
if (owner.kind === 'layout') path = ['data', 'entries', ...path]
|
|
206
|
+
if (owner.kind === 'entry') path = ['data', ...path.slice(1)]
|
|
207
|
+
assign(source, path, value)
|
|
208
|
+
}
|
|
209
|
+
return { source, whollyFallback: translated === 0, resolvedLocales }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function languagePreferenceCookie(locale: string, secure = false): string {
|
|
213
|
+
const canonical = canonicalLocale(locale)
|
|
214
|
+
if (!canonical) throw new TypeError('A valid locale is required')
|
|
215
|
+
return `${LANGUAGE_PREFERENCE_COOKIE}=${encodeURIComponent(canonical)}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`
|
|
216
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { SeoMetadata } from '../seo'
|
|
2
|
+
import { localePath } from './routing'
|
|
3
|
+
import type { LanguageSettings } from './types'
|
|
4
|
+
export function languageMetadataFor({
|
|
5
|
+
metadata,
|
|
6
|
+
settings,
|
|
7
|
+
locale,
|
|
8
|
+
logicalPath,
|
|
9
|
+
origin,
|
|
10
|
+
whollyFallback = false,
|
|
11
|
+
indexableLocales,
|
|
12
|
+
}: {
|
|
13
|
+
metadata: SeoMetadata
|
|
14
|
+
settings: LanguageSettings | null
|
|
15
|
+
locale: string
|
|
16
|
+
logicalPath: string
|
|
17
|
+
origin: string
|
|
18
|
+
whollyFallback?: boolean
|
|
19
|
+
/** Equivalent locales with indexable copy; compute from each raw owner, not just the current one. */
|
|
20
|
+
indexableLocales?: readonly string[]
|
|
21
|
+
}): SeoMetadata {
|
|
22
|
+
if (!settings?.enabled) return metadata
|
|
23
|
+
const languages = Object.fromEntries(
|
|
24
|
+
settings.languages
|
|
25
|
+
.filter(
|
|
26
|
+
(l) =>
|
|
27
|
+
l.active &&
|
|
28
|
+
metadata.robots?.index !== false &&
|
|
29
|
+
(!indexableLocales || indexableLocales.includes(l.locale)) &&
|
|
30
|
+
!(whollyFallback && l.locale === locale),
|
|
31
|
+
)
|
|
32
|
+
.map((l) => [l.locale, new URL(localePath(logicalPath, l.locale), origin).href]),
|
|
33
|
+
)
|
|
34
|
+
languages['x-default'] = new URL(logicalPath, origin).href
|
|
35
|
+
return {
|
|
36
|
+
...metadata,
|
|
37
|
+
alternates: {
|
|
38
|
+
...metadata.alternates,
|
|
39
|
+
canonical: new URL(localePath(logicalPath, locale), origin).href,
|
|
40
|
+
languages,
|
|
41
|
+
},
|
|
42
|
+
openGraph: { ...metadata.openGraph, locale },
|
|
43
|
+
...(whollyFallback ? { robots: { follow: false, index: false } } : {}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function languageSitemapFrom({
|
|
47
|
+
entries,
|
|
48
|
+
settings,
|
|
49
|
+
origin,
|
|
50
|
+
allowIndexing,
|
|
51
|
+
}: {
|
|
52
|
+
entries: readonly {
|
|
53
|
+
logicalPath: string
|
|
54
|
+
lastModified?: string
|
|
55
|
+
noindex?: boolean
|
|
56
|
+
whollyFallbackLocales?: readonly string[]
|
|
57
|
+
}[]
|
|
58
|
+
settings: LanguageSettings | null
|
|
59
|
+
origin: string
|
|
60
|
+
allowIndexing: boolean
|
|
61
|
+
}) {
|
|
62
|
+
if (!allowIndexing) return []
|
|
63
|
+
return entries
|
|
64
|
+
.filter((e) => !e.noindex)
|
|
65
|
+
.flatMap((entry) =>
|
|
66
|
+
settings?.enabled
|
|
67
|
+
? settings.languages
|
|
68
|
+
.filter((l) => l.active && !entry.whollyFallbackLocales?.includes(l.locale))
|
|
69
|
+
.map((l) => ({
|
|
70
|
+
url: new URL(localePath(entry.logicalPath, l.locale), origin).href,
|
|
71
|
+
...(entry.lastModified ? { lastModified: entry.lastModified } : {}),
|
|
72
|
+
alternates: {
|
|
73
|
+
languages: languageMetadataFor({
|
|
74
|
+
metadata: {},
|
|
75
|
+
settings,
|
|
76
|
+
origin,
|
|
77
|
+
locale: l.locale,
|
|
78
|
+
logicalPath: entry.logicalPath,
|
|
79
|
+
indexableLocales: settings.languages
|
|
80
|
+
.filter(
|
|
81
|
+
(language) =>
|
|
82
|
+
language.active && !entry.whollyFallbackLocales?.includes(language.locale),
|
|
83
|
+
)
|
|
84
|
+
.map((language) => language.locale),
|
|
85
|
+
}).alternates?.languages,
|
|
86
|
+
},
|
|
87
|
+
}))
|
|
88
|
+
: [
|
|
89
|
+
{
|
|
90
|
+
url: new URL(entry.logicalPath, origin).href,
|
|
91
|
+
...(entry.lastModified ? { lastModified: entry.lastModified } : {}),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { LanguageDefinition, LanguageSettings } from './types'
|
|
2
|
+
|
|
3
|
+
export const ISO_COUNTRY_CODES: readonly string[] = Object.freeze(
|
|
4
|
+
'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(
|
|
5
|
+
' ',
|
|
6
|
+
),
|
|
7
|
+
)
|
|
8
|
+
const ISO_COUNTRIES = new Set(ISO_COUNTRY_CODES)
|
|
9
|
+
|
|
10
|
+
export const RESERVED_LOCALE_PREFIXES = [
|
|
11
|
+
'_next',
|
|
12
|
+
'api',
|
|
13
|
+
'assets',
|
|
14
|
+
'callback',
|
|
15
|
+
'media',
|
|
16
|
+
'oauth',
|
|
17
|
+
'preview',
|
|
18
|
+
'robots.txt',
|
|
19
|
+
'sitemap.xml',
|
|
20
|
+
'u90',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export const canonicalLocale = (value: unknown): null | string => {
|
|
24
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.length > 80) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return Intl.getCanonicalLocales(value)[0] ?? null
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const localePrefix = (locale: string): string => {
|
|
35
|
+
const canonical = canonicalLocale(locale)
|
|
36
|
+
if (!canonical) throw new TypeError(`Invalid BCP 47 locale: ${locale}`)
|
|
37
|
+
return canonical.toLowerCase()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const activeLanguage = (settings: LanguageSettings, locale: unknown): LanguageDefinition | undefined => {
|
|
41
|
+
if (typeof locale !== 'string') return undefined
|
|
42
|
+
const canonical = canonicalLocale(locale)
|
|
43
|
+
if (!canonical) return undefined
|
|
44
|
+
return settings.languages.find(
|
|
45
|
+
(language) => language.active && language.locale.toLowerCase() === canonical.toLowerCase(),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const activeLocale = (settings: LanguageSettings, locale: unknown): null | string =>
|
|
50
|
+
activeLanguage(settings, locale)?.locale ?? null
|
|
51
|
+
|
|
52
|
+
export const assignedCountryLocale = (settings: LanguageSettings, country: unknown): null | string => {
|
|
53
|
+
if (typeof country !== 'string') return null
|
|
54
|
+
const canonicalCountry = country.toUpperCase()
|
|
55
|
+
if (!ISO_COUNTRIES.has(canonicalCountry)) return null
|
|
56
|
+
return activeLocale(settings, settings.countries[canonicalCountry])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type LanguageSettingsValidation =
|
|
60
|
+
| { ok: false; problems: string[] }
|
|
61
|
+
| { ok: true; settings: LanguageSettings }
|
|
62
|
+
|
|
63
|
+
const topLevel = (path: string): string =>
|
|
64
|
+
path.split(/[?#]/, 1)[0]?.split('/').filter(Boolean)[0]?.toLowerCase() ?? ''
|
|
65
|
+
|
|
66
|
+
export const validateLanguageSettings = (
|
|
67
|
+
value: unknown,
|
|
68
|
+
options: { pagePaths?: readonly string[]; reservedPrefixes?: readonly string[] } = {},
|
|
69
|
+
): LanguageSettingsValidation => {
|
|
70
|
+
const problems: string[] = []
|
|
71
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
72
|
+
return { ok: false, problems: ['Language settings must be an object'] }
|
|
73
|
+
}
|
|
74
|
+
const candidate = value as Partial<LanguageSettings>
|
|
75
|
+
if (!Array.isArray(candidate.languages)) {
|
|
76
|
+
return { ok: false, problems: ['Language settings must contain a languages array'] }
|
|
77
|
+
}
|
|
78
|
+
const seen = new Set<string>()
|
|
79
|
+
const languages: LanguageDefinition[] = []
|
|
80
|
+
for (const [index, language] of candidate.languages.entries()) {
|
|
81
|
+
if (!language || typeof language !== 'object' || Array.isArray(language)) {
|
|
82
|
+
problems.push(`languages[${index}] must be an object`)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const canonical = canonicalLocale(language.locale)
|
|
86
|
+
if (!canonical) {
|
|
87
|
+
problems.push(`languages[${index}].locale is not a valid BCP 47 locale`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const key = canonical.toLowerCase()
|
|
91
|
+
if (seen.has(key)) problems.push(`languages[${index}].locale duplicates ${canonical}`)
|
|
92
|
+
seen.add(key)
|
|
93
|
+
const label = typeof language.label === 'string' ? language.label.trim() : ''
|
|
94
|
+
if (label.length === 0) {
|
|
95
|
+
problems.push(`languages[${index}].label is required`)
|
|
96
|
+
}
|
|
97
|
+
if (typeof language.active !== 'boolean') problems.push(`languages[${index}].active must be boolean`)
|
|
98
|
+
languages.push({ active: language.active === true, label, locale: canonical })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const byLocale = new Map(languages.map((language) => [language.locale.toLowerCase(), language]))
|
|
102
|
+
const original = canonicalLocale(candidate.originalLocale)
|
|
103
|
+
const defaultLocale = canonicalLocale(candidate.defaultLocale)
|
|
104
|
+
if (!original || !byLocale.has(original.toLowerCase())) {
|
|
105
|
+
problems.push('originalLocale must name a configured language')
|
|
106
|
+
}
|
|
107
|
+
const defaultLanguage = defaultLocale ? byLocale.get(defaultLocale.toLowerCase()) : undefined
|
|
108
|
+
if (!defaultLanguage) problems.push('defaultLocale must name a configured language')
|
|
109
|
+
else if (!defaultLanguage.active) problems.push('defaultLocale must name an active language')
|
|
110
|
+
if (candidate.enabled && languages.every((language) => !language.active)) {
|
|
111
|
+
problems.push('Enabled language settings require at least one active language')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const reserved = new Set(
|
|
115
|
+
[...RESERVED_LOCALE_PREFIXES, ...(options.reservedPrefixes ?? [])].map((prefix) =>
|
|
116
|
+
prefix.replace(/^\/+/, '').toLowerCase(),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
const pagePrefixes = new Set((options.pagePaths ?? []).map(topLevel).filter(Boolean))
|
|
120
|
+
for (const language of languages) {
|
|
121
|
+
const prefix = localePrefix(language.locale)
|
|
122
|
+
if (reserved.has(prefix)) problems.push(`${language.locale} collides with a reserved route prefix`)
|
|
123
|
+
if (pagePrefixes.has(prefix)) problems.push(`${language.locale} collides with an existing page path`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const countries: Record<string, string> = {}
|
|
127
|
+
if (!candidate.countries || typeof candidate.countries !== 'object' || Array.isArray(candidate.countries)) {
|
|
128
|
+
problems.push('countries must be an object')
|
|
129
|
+
} else {
|
|
130
|
+
for (const [country, locale] of Object.entries(candidate.countries)) {
|
|
131
|
+
if (!ISO_COUNTRIES.has(country)) {
|
|
132
|
+
problems.push(`${country} is not an ISO 3166-1 alpha-2 country code in uppercase`)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const canonical = canonicalLocale(locale)
|
|
136
|
+
const language = canonical ? byLocale.get(canonical.toLowerCase()) : undefined
|
|
137
|
+
if (!language?.active) {
|
|
138
|
+
problems.push(`countries.${country} must name an active configured language`)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
countries[country] = language.locale
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (typeof candidate.enabled !== 'boolean') problems.push('enabled must be boolean')
|
|
145
|
+
if (problems.length > 0) return { ok: false, problems }
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
settings: {
|
|
149
|
+
countries,
|
|
150
|
+
defaultLocale: defaultLanguage!.locale,
|
|
151
|
+
enabled: candidate.enabled!,
|
|
152
|
+
languages,
|
|
153
|
+
originalLocale: byLocale.get(original!.toLowerCase())!.locale,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const fallbackLocales = (settings: LanguageSettings, requestedLocale: string): string[] => {
|
|
159
|
+
if (!settings.enabled) return []
|
|
160
|
+
const output: string[] = []
|
|
161
|
+
const seen = new Set<string>()
|
|
162
|
+
for (const candidate of [requestedLocale, settings.defaultLocale, settings.originalLocale]) {
|
|
163
|
+
const canonical = canonicalLocale(candidate)
|
|
164
|
+
if (!canonical || seen.has(canonical.toLowerCase())) continue
|
|
165
|
+
seen.add(canonical.toLowerCase())
|
|
166
|
+
output.push(
|
|
167
|
+
settings.languages.find((language) => language.locale.toLowerCase() === canonical.toLowerCase())
|
|
168
|
+
?.locale ?? canonical,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return output
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type LanguageLifecycleResult =
|
|
175
|
+
| { ok: false; problem: string }
|
|
176
|
+
| { ok: true; settings: LanguageSettings }
|
|
177
|
+
|
|
178
|
+
export const deactivateLanguage = (
|
|
179
|
+
settings: LanguageSettings,
|
|
180
|
+
locale: string,
|
|
181
|
+
options: { defaultLocale?: string } = {},
|
|
182
|
+
): LanguageLifecycleResult => {
|
|
183
|
+
const canonical = canonicalLocale(locale)
|
|
184
|
+
const found = canonical
|
|
185
|
+
? settings.languages.find((language) => language.locale.toLowerCase() === canonical.toLowerCase())
|
|
186
|
+
: undefined
|
|
187
|
+
if (!found) return { ok: false, problem: 'The language is not configured' }
|
|
188
|
+
const replacement = options.defaultLocale ?? settings.defaultLocale
|
|
189
|
+
if (found.locale.toLowerCase() === replacement.toLowerCase()) {
|
|
190
|
+
return { ok: false, problem: 'Choose another active default language before deactivating the default' }
|
|
191
|
+
}
|
|
192
|
+
const replacementLocale = activeLocale(settings, replacement)
|
|
193
|
+
if (!replacementLocale) return { ok: false, problem: 'The replacement default language must be active' }
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
settings: {
|
|
197
|
+
...settings,
|
|
198
|
+
countries: Object.fromEntries(
|
|
199
|
+
Object.entries(settings.countries).filter(
|
|
200
|
+
([, assigned]) => assigned.toLowerCase() !== found.locale.toLowerCase(),
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
defaultLocale: replacementLocale,
|
|
204
|
+
languages: settings.languages.map((language) =>
|
|
205
|
+
language.locale.toLowerCase() === found.locale.toLowerCase()
|
|
206
|
+
? { ...language, active: false }
|
|
207
|
+
: { ...language },
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { EditorField } from '../contract'
|
|
2
|
+
|
|
3
|
+
export const STABLE_ID_KEY = '_u90id' as const
|
|
4
|
+
|
|
5
|
+
export type LanguageDefinition = {
|
|
6
|
+
active: boolean
|
|
7
|
+
label: string
|
|
8
|
+
locale: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LanguageSettings = {
|
|
12
|
+
countries: Record<string, string>
|
|
13
|
+
defaultLocale: string
|
|
14
|
+
enabled: boolean
|
|
15
|
+
languages: LanguageDefinition[]
|
|
16
|
+
originalLocale: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TranslationOwner = {
|
|
20
|
+
id: string
|
|
21
|
+
kind: 'entry' | 'layout' | 'page' | 'seo' | 'single'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TranslationField = {
|
|
25
|
+
keepOriginal?: boolean
|
|
26
|
+
needsReview?: boolean
|
|
27
|
+
/** Monotonic per-address revision, so writes to different fields do not conflict. */
|
|
28
|
+
revision: number
|
|
29
|
+
sourceHash: string
|
|
30
|
+
value?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TranslationDocument = {
|
|
34
|
+
fields: Record<string, TranslationField>
|
|
35
|
+
/** Revision of the source structure used to map positional paths to stable addresses. */
|
|
36
|
+
structureRevision: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LocalizableField = {
|
|
40
|
+
address: string
|
|
41
|
+
field: EditorField
|
|
42
|
+
positionalPath: string
|
|
43
|
+
sourceHash: string
|
|
44
|
+
value: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LanguageFieldHealth = {
|
|
48
|
+
address: string
|
|
49
|
+
missingRequired: boolean
|
|
50
|
+
missingSource: boolean
|
|
51
|
+
missingTranslation: boolean
|
|
52
|
+
needsReview: boolean
|
|
53
|
+
sourceChanged: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LanguageHealth = {
|
|
57
|
+
fields: LanguageFieldHealth[]
|
|
58
|
+
keptOriginal: number
|
|
59
|
+
missingOptional: number
|
|
60
|
+
missingRequired: number
|
|
61
|
+
missingSource: number
|
|
62
|
+
needsReview: number
|
|
63
|
+
ready: boolean
|
|
64
|
+
sourceChanged: number
|
|
65
|
+
total: number
|
|
66
|
+
translated: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type IdentityFactory = () => string
|
|
70
|
+
export type ContentNode = Record<string, unknown> & { [STABLE_ID_KEY]?: string }
|
|
71
|
+
|
|
72
|
+
export type IdentityResult =
|
|
73
|
+
| { changed: boolean; content: ContentNode[]; ok: true }
|
|
74
|
+
| { ok: false; problem: string }
|
|
75
|
+
|
|
76
|
+
export type TranslationPatch = {
|
|
77
|
+
address: string
|
|
78
|
+
expectedFieldRevision: number
|
|
79
|
+
expectedStructureRevision: number
|
|
80
|
+
field: Omit<TranslationField, 'revision'>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type TranslationPatchResult =
|
|
84
|
+
| { ok: false; problem: string }
|
|
85
|
+
| { ok: true; translation: TranslationDocument }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './languages/fields'
|
|
2
|
+
export * from './languages/hash'
|
|
3
|
+
export * from './languages/health'
|
|
4
|
+
export * from './languages/identity'
|
|
5
|
+
export * from './languages/resolution'
|
|
6
|
+
export * from './languages/routing'
|
|
7
|
+
export * from './languages/settings'
|
|
8
|
+
export * from './languages/types'
|