@unitninety/site-kit 0.0.0-stage → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,247 @@
1
+ import type { EditorBlock, EditorField } from '../contract'
2
+ import {
3
+ STABLE_ID_KEY,
4
+ type ContentNode,
5
+ type IdentityFactory,
6
+ type IdentityResult,
7
+ } from './types'
8
+
9
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
10
+ typeof value === 'object' && value !== null && !Array.isArray(value)
11
+
12
+ const validId = (value: unknown): value is string =>
13
+ typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
14
+
15
+ const defaultIdentity: IdentityFactory = () => crypto.randomUUID()
16
+
17
+ const childrenOf = (field: EditorField, blocks: EditorBlock[]): EditorBlock[] => {
18
+ if (field.blocks) return field.blocks
19
+ const bySlug = new Map(blocks.map((block) => [block.slug, block]))
20
+ return (field.childBlocks ?? []).flatMap((slug) => {
21
+ const block = bySlug.get(slug)
22
+ return block ? [block] : []
23
+ })
24
+ }
25
+
26
+ type Options = {
27
+ blocks: EditorBlock[]
28
+ createId: IdentityFactory
29
+ forceFresh: boolean
30
+ refuseIdentityFree: boolean
31
+ seen: Set<string>
32
+ }
33
+
34
+ type State = { changed: boolean; problem: null | string }
35
+
36
+ const freshId = (options: Options): string => {
37
+ for (let attempts = 0; attempts < 100; attempts += 1) {
38
+ const candidate = options.createId()
39
+ if (validId(candidate) && !options.seen.has(candidate)) {
40
+ options.seen.add(candidate)
41
+ return candidate
42
+ }
43
+ }
44
+ throw new Error('The identity factory did not produce a fresh stable identifier')
45
+ }
46
+
47
+ const priorById = (rows: unknown[] | undefined): Map<string, ContentNode> => {
48
+ const result = new Map<string, ContentNode>()
49
+ for (const row of rows ?? []) {
50
+ if (isRecord(row) && validId(row[STABLE_ID_KEY])) result.set(row[STABLE_ID_KEY], row as ContentNode)
51
+ }
52
+ return result
53
+ }
54
+
55
+ const walkFields = (
56
+ value: ContentNode,
57
+ fields: EditorField[],
58
+ previous: ContentNode | undefined,
59
+ options: Options,
60
+ state: State,
61
+ depth: number,
62
+ ): ContentNode => {
63
+ if (depth > 32) {
64
+ state.problem = 'Content nesting exceeds the stable-identity traversal limit'
65
+ return value
66
+ }
67
+ let output = value
68
+ const write = (name: string, child: unknown): void => {
69
+ if (output === value) output = { ...value }
70
+ output[name] = child
71
+ }
72
+
73
+ for (const field of fields) {
74
+ if (state.problem) break
75
+ const current = value[field.name]
76
+ const prior = previous?.[field.name]
77
+ if (field.type === 'group') {
78
+ if (isRecord(current)) {
79
+ const nested = walkFields(
80
+ current as ContentNode,
81
+ field.fields ?? [],
82
+ isRecord(prior) ? (prior as ContentNode) : undefined,
83
+ options,
84
+ state,
85
+ depth + 1,
86
+ )
87
+ if (nested !== current) write(field.name, nested)
88
+ }
89
+ continue
90
+ }
91
+ if (field.type !== 'array' && field.type !== 'blocks') continue
92
+ if (current === undefined || current === null) continue
93
+ if (!Array.isArray(current)) {
94
+ state.problem = `${field.name} is repeatable content but is not an array`
95
+ break
96
+ }
97
+ const blocks = field.type === 'blocks' ? childrenOf(field, options.blocks) : []
98
+ const nested = walkRows(
99
+ current,
100
+ Array.isArray(prior) ? prior : undefined,
101
+ (row) =>
102
+ field.type === 'array'
103
+ ? field.fields ?? []
104
+ : blocks.find((block) => block.slug === row.blockType)?.fields ?? [],
105
+ options,
106
+ state,
107
+ depth + 1,
108
+ )
109
+ if (nested !== current) write(field.name, nested)
110
+ }
111
+ return output
112
+ }
113
+
114
+ const walkRows = (
115
+ rows: unknown[],
116
+ previous: unknown[] | undefined,
117
+ fieldsFor: (row: ContentNode) => EditorField[],
118
+ options: Options,
119
+ state: State,
120
+ depth: number,
121
+ ): ContentNode[] => {
122
+ const previousIds = priorById(previous)
123
+ const suppliedCount = rows.filter((row) => isRecord(row) && validId(row[STABLE_ID_KEY])).length
124
+ if (
125
+ options.refuseIdentityFree &&
126
+ (previous?.length ?? 0) > 0 &&
127
+ rows.length > 0 &&
128
+ suppliedCount === 0
129
+ ) {
130
+ state.problem =
131
+ 'Refusing an identity-free rewrite of existing repeatable content; migrate identities first or preserve _u90id values'
132
+ return rows.filter(isRecord) as ContentNode[]
133
+ }
134
+
135
+ let changed = false
136
+ const seenHere = new Set<string>()
137
+ const output: ContentNode[] = []
138
+ for (const raw of rows) {
139
+ if (!isRecord(raw)) {
140
+ state.problem = 'Repeatable content contains a non-object item that cannot carry a stable identity'
141
+ return output
142
+ }
143
+ const row = raw as ContentNode
144
+ const supplied = row[STABLE_ID_KEY]
145
+ const duplicate = validId(supplied) && seenHere.has(supplied)
146
+ const collides = validId(supplied) && (duplicate || options.seen.has(supplied))
147
+ if (options.refuseIdentityFree && !options.forceFresh && collides) {
148
+ state.problem =
149
+ 'Refusing duplicate stable identities during reconciliation; prepare copies with duplicateWithFreshIdentities before insertion'
150
+ return output
151
+ }
152
+ let identity: string
153
+ if (!options.forceFresh && validId(supplied) && !duplicate && !options.seen.has(supplied)) {
154
+ identity = supplied
155
+ options.seen.add(identity)
156
+ } else {
157
+ identity = freshId(options)
158
+ changed = true
159
+ }
160
+ seenHere.add(identity)
161
+ const identified = supplied === identity ? row : { ...row, [STABLE_ID_KEY]: identity }
162
+ const prior = !options.forceFresh && !duplicate ? previousIds.get(identity) : undefined
163
+ const nested = walkFields(identified, fieldsFor(identified), prior, options, state, depth)
164
+ if (nested !== row) changed = true
165
+ output.push(nested)
166
+ if (state.problem) return output
167
+ }
168
+ if (!changed && output.every((row, index) => row === rows[index])) return rows as ContentNode[]
169
+ state.changed = true
170
+ return output
171
+ }
172
+
173
+ const identify = ({
174
+ blocks,
175
+ content,
176
+ createId = defaultIdentity,
177
+ forceFresh = false,
178
+ previous,
179
+ refuseIdentityFree = false,
180
+ rootFields,
181
+ }: {
182
+ blocks: EditorBlock[]
183
+ content: unknown[]
184
+ createId?: IdentityFactory
185
+ forceFresh?: boolean
186
+ previous?: unknown[]
187
+ refuseIdentityFree?: boolean
188
+ rootFields?: EditorField[]
189
+ }): IdentityResult => {
190
+ const state: State = { changed: false, problem: null }
191
+ const options: Options = { blocks, createId, forceFresh, refuseIdentityFree, seen: new Set() }
192
+ try {
193
+ const walked = walkRows(
194
+ content,
195
+ previous,
196
+ (row) => rootFields ?? blocks.find((block) => block.slug === row.blockType)?.fields ?? [],
197
+ options,
198
+ state,
199
+ 0,
200
+ )
201
+ return state.problem
202
+ ? { ok: false, problem: state.problem }
203
+ : { changed: state.changed, content: walked, ok: true }
204
+ } catch (error) {
205
+ return { ok: false, problem: error instanceof Error ? error.message : String(error) }
206
+ }
207
+ }
208
+
209
+ /** Explicit one-time migration for content that has never carried identities. */
210
+ export const ensureStableIdentities = (options: {
211
+ blocks: EditorBlock[]
212
+ content: unknown[]
213
+ createId?: IdentityFactory
214
+ }): IdentityResult => identify(options)
215
+
216
+ /** Existing repeatables must supply identities; positional correspondence is never inferred. */
217
+ export const reconcileStableIdentities = (options: {
218
+ blocks: EditorBlock[]
219
+ createId?: IdentityFactory
220
+ incoming: unknown[]
221
+ previous: unknown[]
222
+ }): IdentityResult =>
223
+ identify({
224
+ blocks: options.blocks,
225
+ content: options.incoming,
226
+ createId: options.createId,
227
+ previous: options.previous,
228
+ refuseIdentityFree: true,
229
+ })
230
+
231
+ /** Clone one section or repeatable row while replacing every identity in its subtree. */
232
+ export const duplicateWithFreshIdentities = ({
233
+ blocks,
234
+ createId = defaultIdentity,
235
+ fields,
236
+ value,
237
+ }: {
238
+ blocks: EditorBlock[]
239
+ createId?: IdentityFactory
240
+ /** Required when `value` is a repeatable row rather than a section with `blockType`. */
241
+ fields?: EditorField[]
242
+ value: Record<string, unknown>
243
+ }): ContentNode => {
244
+ const result = identify({ blocks, content: [value], createId, forceFresh: true, rootFields: fields })
245
+ if (!result.ok) throw new Error(result.problem)
246
+ return result.content[0]!
247
+ }
@@ -0,0 +1,81 @@
1
+ import { localePrefix, RESERVED_LOCALE_PREFIXES } from './settings'
2
+ import { localePath } from './routing'
3
+ import type { LanguageDefinition } from './types'
4
+ export function languageLinks({
5
+ languages,
6
+ locale,
7
+ logicalPath,
8
+ }: {
9
+ languages: readonly LanguageDefinition[]
10
+ locale: string
11
+ logicalPath: string
12
+ }) {
13
+ return languages
14
+ .filter((language) => language.active)
15
+ .map((language) => {
16
+ let label = language.label
17
+ try {
18
+ label =
19
+ new Intl.DisplayNames([language.locale], { type: 'language' }).of(language.locale) ??
20
+ label
21
+ } catch {
22
+ /* use configured label on runtimes without this locale */
23
+ }
24
+ return {
25
+ locale: language.locale,
26
+ label,
27
+ href: localePath(logicalPath, language.locale),
28
+ current: language.locale === locale,
29
+ }
30
+ })
31
+ }
32
+
33
+ /** Localize a destination at render time; stored routes and external/factual links stay shared. */
34
+ export function languageHref({
35
+ href,
36
+ locale,
37
+ settings,
38
+ origin,
39
+ }: {
40
+ href: string
41
+ locale: string
42
+ settings: import('./types').LanguageSettings | null
43
+ origin?: string
44
+ }): string {
45
+ if (!settings?.enabled || href.startsWith('#') || href.startsWith('?')) return href
46
+ let path = href
47
+ if (!href.startsWith('/')) {
48
+ if (!origin) return href
49
+ try {
50
+ const url = new URL(href, origin)
51
+ if (url.origin !== new URL(origin).origin) return href
52
+ path = url.pathname + url.search + url.hash
53
+ } catch {
54
+ return href
55
+ }
56
+ }
57
+ if (path.startsWith('//') || path.includes('\\')) return href
58
+ const [pathname] = path.split(/[?#]/)
59
+ const first = pathname.split('/').filter(Boolean)[0]
60
+ if (
61
+ RESERVED_LOCALE_PREFIXES.some((prefix) => prefix === first) ||
62
+ pathname.split('/').at(-1)?.includes('.')
63
+ )
64
+ return href
65
+ const configured = settings.languages.find((l) => localePrefix(l.locale) === first?.toLowerCase())
66
+ if (configured) path = path.slice(first.length + 1) || '/'
67
+ if (!path.startsWith('/')) path = '/' + path
68
+ return localePath(path, locale)
69
+ }
70
+
71
+ export function languageDirection(locale: string): 'ltr' | 'rtl' {
72
+ try {
73
+ const language = new Intl.Locale(locale) as Intl.Locale & {
74
+ textInfo?: { direction: string }
75
+ getTextInfo?: () => { direction: string }
76
+ }
77
+ return (language.getTextInfo?.() ?? language.textInfo)?.direction === 'rtl' ? 'rtl' : 'ltr'
78
+ } catch {
79
+ return 'ltr'
80
+ }
81
+ }
@@ -0,0 +1,56 @@
1
+ import type { EditorField } from '../contract'
2
+ import type { LocalizableField, TranslationField } from './types'
3
+
4
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
5
+ typeof value === 'object' && value !== null && !Array.isArray(value)
6
+
7
+ /** Empty rich text means a tree with no non-whitespace text nodes, not merely an empty object. */
8
+ export const isSemanticallyEmpty = (
9
+ value: unknown,
10
+ type?: EditorField['type'],
11
+ ): boolean => {
12
+ if (value === undefined || value === null) return true
13
+ if (typeof value === 'string') return value.trim().length === 0
14
+ if (type === 'richText' || isRecord(value)) {
15
+ let prose = ''
16
+ const visit = (node: unknown): void => {
17
+ if (typeof node === 'string') {
18
+ prose += node
19
+ } else if (Array.isArray(node)) {
20
+ node.forEach(visit)
21
+ } else if (isRecord(node)) {
22
+ if (typeof node.text === 'string') prose += node.text
23
+ if (node.root !== undefined) visit(node.root)
24
+ if (node.children !== undefined) visit(node.children)
25
+ }
26
+ }
27
+ visit(value)
28
+ return prose.trim().length === 0
29
+ }
30
+ return false
31
+ }
32
+
33
+ /** Requested, then configured default, then original; empty candidates never stop fallback. */
34
+ export const resolveField = <T>({
35
+ fallback,
36
+ original,
37
+ requested,
38
+ type,
39
+ }: {
40
+ fallback?: T
41
+ original?: T
42
+ requested?: T
43
+ type?: EditorField['type']
44
+ }): T | undefined => {
45
+ for (const candidate of [requested, fallback, original]) {
46
+ if (!isSemanticallyEmpty(candidate, type)) return candidate
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ /** A valid Keep original acknowledgment takes precedence over a stored target value. */
52
+ export const languageFieldCandidate = (
53
+ source: Pick<LocalizableField, 'sourceHash' | 'value'>,
54
+ target: Pick<TranslationField, 'sourceHash' | 'keepOriginal' | 'value'> | undefined,
55
+ ): unknown =>
56
+ target?.keepOriginal && target.sourceHash === source.sourceHash ? source.value : target?.value
@@ -0,0 +1,169 @@
1
+ import {
2
+ activeLocale,
3
+ assignedCountryLocale,
4
+ canonicalLocale,
5
+ localePrefix,
6
+ RESERVED_LOCALE_PREFIXES,
7
+ } from './settings'
8
+ import type { LanguageSettings } from './types'
9
+
10
+ export const selectLocale = (
11
+ settings: LanguageSettings,
12
+ request: { country?: string; explicitLocale?: string; preferredLocale?: string },
13
+ ): null | string => {
14
+ if (!settings.enabled) return null
15
+ const explicit = activeLocale(settings, request.explicitLocale)
16
+ if (explicit) return explicit
17
+ const requestedConfiguredLocale =
18
+ typeof request.explicitLocale === 'string' ? canonicalLocale(request.explicitLocale) : null
19
+ if (
20
+ requestedConfiguredLocale &&
21
+ settings.languages.some(
22
+ (language) => language.locale.toLowerCase() === requestedConfiguredLocale.toLowerCase(),
23
+ )
24
+ ) {
25
+ return activeLocale(settings, settings.defaultLocale)
26
+ }
27
+ return (
28
+ activeLocale(settings, request.preferredLocale) ??
29
+ assignedCountryLocale(settings, request.country) ??
30
+ activeLocale(settings, settings.defaultLocale)
31
+ )
32
+ }
33
+
34
+ const splitPath = (path: string): { pathname: string; suffix: string } => {
35
+ const at = path.search(/[?#]/)
36
+ return at < 0 ? { pathname: path, suffix: '' } : { pathname: path.slice(0, at), suffix: path.slice(at) }
37
+ }
38
+
39
+ const siteRelativePath = (path: string): { pathname: string; suffix: string } => {
40
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
41
+ throw new TypeError('A site-relative path beginning with one slash is required')
42
+ }
43
+ const parts = splitPath(path)
44
+ return { pathname: parts.pathname || '/', suffix: parts.suffix }
45
+ }
46
+
47
+ const wellFormedPathname = (pathname: string): boolean => {
48
+ if (pathname.length > 1 && pathname.includes('//')) return false
49
+ if (/[\u0000-\u0020\u007f]/.test(pathname) || /%(?![0-9a-f]{2})/i.test(pathname)) return false
50
+ if (/%(?:2f|5c)/i.test(pathname)) return false
51
+ try {
52
+ return pathname
53
+ .split('/')
54
+ .filter(Boolean)
55
+ .every((part) => {
56
+ const decoded = decodeURIComponent(part)
57
+ return decoded !== '.' && decoded !== '..'
58
+ })
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ export const localePath = (path: string, locale: string): string => {
65
+ const prefix = localePrefix(locale)
66
+ const { pathname, suffix } = siteRelativePath(path)
67
+ const segments = pathname.split('/').filter(Boolean)
68
+ if (segments[0]?.toLowerCase() === prefix) {
69
+ segments[0] = prefix
70
+ return `/${segments.join('/')}${suffix}`
71
+ }
72
+ return pathname === '/' ? `/${prefix}${suffix}` : `/${prefix}${pathname}${suffix}`
73
+ }
74
+
75
+ const firstSegment = (pathname: string): string =>
76
+ pathname.split('/').filter(Boolean)[0]?.toLowerCase() ?? ''
77
+
78
+ const excludedPath = (pathname: string, reservedPrefixes: readonly string[]): boolean => {
79
+ const reserved = new Set(
80
+ [...RESERVED_LOCALE_PREFIXES, ...reservedPrefixes].map((value) => value.toLowerCase()),
81
+ )
82
+ if (reserved.has(firstSegment(pathname))) return true
83
+ return (pathname.split('/').filter(Boolean).at(-1) ?? '').includes('.')
84
+ }
85
+
86
+ const knownPath = (
87
+ pathname: string,
88
+ knownPaths: readonly string[] | ((pathname: string) => boolean),
89
+ ): boolean =>
90
+ typeof knownPaths === 'function'
91
+ ? knownPaths(pathname)
92
+ : knownPaths.some(
93
+ (known) =>
94
+ (splitPath(known).pathname || '/').replace(/\/$/, '') === pathname.replace(/\/$/, ''),
95
+ )
96
+
97
+ export type LocaleRequestResult =
98
+ | { kind: 'pass'; locale: null | string; logicalPath: string }
99
+ | {
100
+ cacheControl: 'private, no-store'
101
+ kind: 'redirect'
102
+ locale: string
103
+ status: 307
104
+ target: string
105
+ }
106
+
107
+ /**
108
+ * Decide whether one public request passes or redirects. Callers supply route existence so
109
+ * unknown paths, assets, callbacks and preview handlers can never become redirect targets.
110
+ */
111
+ export const resolveLocaleRequest = (
112
+ settings: LanguageSettings,
113
+ request: {
114
+ country?: string
115
+ knownPaths: readonly string[] | ((pathname: string) => boolean)
116
+ path: string
117
+ preferredLocale?: string
118
+ reservedPrefixes?: readonly string[]
119
+ },
120
+ ): LocaleRequestResult => {
121
+ const { pathname, suffix } = siteRelativePath(request.path)
122
+ if (!settings.enabled) return { kind: 'pass', locale: null, logicalPath: request.path }
123
+ if (!wellFormedPathname(pathname)) {
124
+ return { kind: 'pass', locale: null, logicalPath: request.path }
125
+ }
126
+
127
+ const segments = pathname.split('/').filter(Boolean)
128
+ const configured = settings.languages.find(
129
+ (language) => localePrefix(language.locale) === segments[0]?.toLowerCase(),
130
+ )
131
+ if (configured) {
132
+ const logicalPathname = segments.length <= 1 ? '/' : `/${segments.slice(1).join('/')}`
133
+ const logicalPath = `${logicalPathname}${suffix}`
134
+ if (configured.active) return { kind: 'pass', locale: configured.locale, logicalPath }
135
+ if (
136
+ excludedPath(logicalPathname, request.reservedPrefixes ?? []) ||
137
+ !knownPath(logicalPathname, request.knownPaths)
138
+ ) {
139
+ return { kind: 'pass', locale: null, logicalPath: request.path }
140
+ }
141
+ const locale = activeLocale(settings, settings.defaultLocale)
142
+ return locale
143
+ ? {
144
+ cacheControl: 'private, no-store',
145
+ kind: 'redirect',
146
+ locale,
147
+ status: 307,
148
+ target: localePath(logicalPath, locale),
149
+ }
150
+ : { kind: 'pass', locale: null, logicalPath: request.path }
151
+ }
152
+
153
+ if (
154
+ excludedPath(pathname, request.reservedPrefixes ?? []) ||
155
+ !knownPath(pathname, request.knownPaths)
156
+ ) {
157
+ return { kind: 'pass', locale: null, logicalPath: request.path }
158
+ }
159
+ const locale = selectLocale(settings, request)
160
+ return locale
161
+ ? {
162
+ cacheControl: 'private, no-store',
163
+ kind: 'redirect',
164
+ locale,
165
+ status: 307,
166
+ target: localePath(request.path, locale),
167
+ }
168
+ : { kind: 'pass', locale: null, logicalPath: request.path }
169
+ }