@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,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
|
+
}
|