@unitninety/site-kit 0.0.0-stage → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,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'