@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,153 @@
1
+ import { MEDIA } from './caret'
2
+ import { FIELD_ATTR, RICH_ATTR } from './shape'
3
+
4
+ type HintKind = 'fallback' | 'empty'
5
+ const HINT_ATTR = 'data-u90-language-hint'
6
+
7
+ /** Own only preview decorations: never insert a placeholder into an editable field. */
8
+ export function createLanguageHints() {
9
+ let hints = new Map<string, HintKind>()
10
+ const marked = new Map<HTMLElement, string | null>()
11
+ const cues = new Map<HTMLElement, HTMLSpanElement>()
12
+ let style: HTMLStyleElement | null = null
13
+ let frame = 0
14
+ let disposed = false
15
+
16
+ const remove = (element: HTMLElement) => {
17
+ const previous = marked.get(element)
18
+ if (previous == null) element.removeAttribute(HINT_ATTR)
19
+ else element.setAttribute(HINT_ATTR, previous)
20
+ marked.delete(element)
21
+ sizes.unobserve(element)
22
+ cues.get(element)?.remove()
23
+ cues.delete(element)
24
+ }
25
+
26
+ const sync = () => {
27
+ frame = 0
28
+ if (disposed) return
29
+ const current = new Set<HTMLElement>()
30
+ if (hints.size) {
31
+ for (const element of document.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`)) {
32
+ const kind = hints.get(element.getAttribute(FIELD_ATTR) ?? '')
33
+ if (!kind || element.matches(MEDIA)) continue
34
+ current.add(element)
35
+ if (!marked.has(element)) {
36
+ marked.set(element, element.getAttribute(HINT_ATTR))
37
+ sizes.observe(element)
38
+ }
39
+ if (element.getAttribute(HINT_ATTR) !== kind) element.setAttribute(HINT_ATTR, kind)
40
+
41
+ // A fallback can disappear during local editing before its next content push.
42
+ // The cue follows the rendered words and lives outside the field's DOM tree.
43
+ const visibility = getComputedStyle(element).visibility
44
+ const empty =
45
+ !element.textContent?.trim() &&
46
+ !element.querySelector(MEDIA) &&
47
+ element.getClientRects().length > 0 &&
48
+ visibility !== 'hidden' &&
49
+ visibility !== 'collapse'
50
+ let cue = cues.get(element)
51
+ if (empty) {
52
+ if (!cue) {
53
+ cue = document.createElement('span')
54
+ cue.setAttribute('data-u90-language-cue', '')
55
+ cue.setAttribute('aria-hidden', 'true')
56
+ cue.textContent = 'Translation missing'
57
+ document.body.append(cue)
58
+ cues.set(element, cue)
59
+ }
60
+ const rect = element.getBoundingClientRect()
61
+ cue.style.left = `${Math.max(0, rect.left)}px`
62
+ cue.style.top = `${rect.top}px`
63
+ } else if (cue) {
64
+ cue.remove()
65
+ cues.delete(element)
66
+ }
67
+ }
68
+ }
69
+ for (const element of marked.keys()) if (!current.has(element)) remove(element)
70
+ if (current.size && !style) {
71
+ style = document.createElement('style')
72
+ style.setAttribute('data-u90-language-hints', '')
73
+ style.textContent = `
74
+ [${HINT_ATTR}] {
75
+ text-decoration-line: underline !important;
76
+ text-decoration-style: dotted !important;
77
+ text-decoration-color: #d97706 !important;
78
+ text-underline-offset: .2em !important;
79
+ background-color: rgb(245 158 11 / .08) !important;
80
+ }
81
+ [data-u90-language-cue] {
82
+ position: fixed !important;
83
+ z-index: 2147483646 !important;
84
+ pointer-events: none !important;
85
+ user-select: none !important;
86
+ display: inline-block !important;
87
+ font: 500 11px/1.4 system-ui, sans-serif !important;
88
+ color: #92400e !important;
89
+ background: #fffbeb !important;
90
+ border: 1px dotted #d97706 !important;
91
+ border-radius: 3px !important;
92
+ padding: 1px 4px !important;
93
+ max-width: calc(100vw - 8px) !important;
94
+ }
95
+ `
96
+ document.head.append(style)
97
+ } else if (!current.size && style) {
98
+ style.remove()
99
+ style = null
100
+ }
101
+ }
102
+ const schedule = () => {
103
+ if (!disposed && !frame) frame = requestAnimationFrame(sync)
104
+ }
105
+ const sizes = new ResizeObserver(schedule)
106
+ const mutations = new MutationObserver((records) => {
107
+ // Positioning the overlay must not schedule another position update indefinitely.
108
+ if (
109
+ records.some(
110
+ (record) =>
111
+ !(record.target instanceof Element) ||
112
+ !record.target.closest('[data-u90-language-cue], style[data-u90-language-hints]'),
113
+ )
114
+ )
115
+ schedule()
116
+ })
117
+ mutations.observe(document.documentElement, {
118
+ subtree: true,
119
+ childList: true,
120
+ characterData: true,
121
+ attributes: true,
122
+ attributeFilter: [FIELD_ATTR, RICH_ATTR, 'hidden', 'class', 'style'],
123
+ })
124
+ window.addEventListener('resize', schedule)
125
+ document.addEventListener('scroll', schedule, true)
126
+
127
+ return {
128
+ update(value: unknown) {
129
+ hints = new Map(
130
+ (Array.isArray(value) ? value : []).flatMap((item) =>
131
+ item &&
132
+ typeof item.field === 'string' &&
133
+ (item.kind === 'fallback' || item.kind === 'empty')
134
+ ? [[item.field, item.kind as HintKind]]
135
+ : [],
136
+ ),
137
+ )
138
+ // Clear obsolete hints immediately; the observer handles the following React commit.
139
+ if (frame) cancelAnimationFrame(frame)
140
+ sync()
141
+ },
142
+ dispose() {
143
+ disposed = true
144
+ mutations.disconnect()
145
+ sizes.disconnect()
146
+ window.removeEventListener('resize', schedule)
147
+ document.removeEventListener('scroll', schedule, true)
148
+ if (frame) cancelAnimationFrame(frame)
149
+ for (const element of marked.keys()) remove(element)
150
+ style?.remove()
151
+ },
152
+ }
153
+ }
@@ -0,0 +1,131 @@
1
+ import type { EditorBlock, EditorField } from '../contract'
2
+ import { sourceFingerprint } from './hash'
3
+ import { STABLE_ID_KEY, type ContentNode, type LocalizableField } from './types'
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ typeof value === 'object' && value !== null && !Array.isArray(value)
7
+
8
+ const childrenOf = (field: EditorField, blocks: EditorBlock[]): EditorBlock[] => {
9
+ if (field.blocks) return field.blocks
10
+ const bySlug = new Map(blocks.map((block) => [block.slug, block]))
11
+ return (field.childBlocks ?? []).flatMap((slug) => {
12
+ const block = bySlug.get(slug)
13
+ return block ? [block] : []
14
+ })
15
+ }
16
+
17
+ const eligible = (field: EditorField, parent?: EditorField): boolean => {
18
+ if (field.localized === false) return false
19
+ if (field.type !== 'text' && field.type !== 'textarea' && field.type !== 'richText') return false
20
+ if (parent?.element === 'button') return field.name === 'label'
21
+ if (parent?.element === 'sentence') return field.name === 'label' || field.name === 'text'
22
+ return true
23
+ }
24
+
25
+ const requireIdentity = (value: ContentNode, path: string): string => {
26
+ const identity = value[STABLE_ID_KEY]
27
+ if (typeof identity !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(identity)) {
28
+ throw new Error(`${path} has no valid ${STABLE_ID_KEY}; run ensureStableIdentities first`)
29
+ }
30
+ return identity
31
+ }
32
+
33
+ const segment = (kind: 'field' | 'item' | 'section', value: string): string =>
34
+ `${kind}:${encodeURIComponent(value)}`
35
+
36
+ type Walk = {
37
+ address: string[]
38
+ fields: EditorField[]
39
+ parent?: EditorField
40
+ positional: string[]
41
+ value: ContentNode
42
+ }
43
+
44
+ const collect = (walk: Walk, blocks: EditorBlock[], output: LocalizableField[]): void => {
45
+ for (const field of walk.fields) {
46
+ const value = walk.value[field.name]
47
+ const address = [...walk.address, segment('field', field.name)]
48
+ const positional = [...walk.positional, field.name]
49
+ if (eligible(field, walk.parent)) {
50
+ output.push({
51
+ address: address.join('/'),
52
+ field,
53
+ positionalPath: positional.join('.'),
54
+ sourceHash: sourceFingerprint(value),
55
+ value,
56
+ })
57
+ continue
58
+ }
59
+ if (field.type === 'group' && isRecord(value)) {
60
+ collect(
61
+ { address, fields: field.fields ?? [], parent: field, positional, value: value as ContentNode },
62
+ blocks,
63
+ output,
64
+ )
65
+ continue
66
+ }
67
+ if ((field.type === 'array' || field.type === 'blocks') && Array.isArray(value)) {
68
+ const childBlocks = field.type === 'blocks' ? childrenOf(field, blocks) : []
69
+ value.forEach((item, index) => {
70
+ if (!isRecord(item)) return
71
+ const identity = requireIdentity(item as ContentNode, positional.concat(String(index)).join('.'))
72
+ const fields =
73
+ field.type === 'array'
74
+ ? field.fields ?? []
75
+ : childBlocks.find((block) => block.slug === item.blockType)?.fields ?? []
76
+ collect(
77
+ {
78
+ address: [...address, segment('item', identity)],
79
+ fields,
80
+ positional: [...positional, String(index)],
81
+ value: item as ContentNode,
82
+ },
83
+ blocks,
84
+ output,
85
+ )
86
+ })
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Enumerate source values using stable addresses; fallbacks are never materialized here. */
92
+ export const localizableFields = ({
93
+ blocks,
94
+ content,
95
+ }: {
96
+ blocks: EditorBlock[]
97
+ content: unknown[]
98
+ }): LocalizableField[] => {
99
+ const output: LocalizableField[] = []
100
+ content.forEach((section, index) => {
101
+ if (!isRecord(section)) return
102
+ const node = section as ContentNode
103
+ const identity = requireIdentity(node, String(index))
104
+ const block = blocks.find((candidate) => candidate.slug === node.blockType)
105
+ if (!block) return
106
+ collect(
107
+ {
108
+ address: [segment('section', identity)],
109
+ fields: block.fields,
110
+ positional: [String(index)],
111
+ value: node,
112
+ },
113
+ blocks,
114
+ output,
115
+ )
116
+ })
117
+ return output
118
+ }
119
+
120
+ /** Map a transient Canvas positional path to its durable translation address. */
121
+ export const stableAddressFor = ({
122
+ blocks,
123
+ content,
124
+ positionalPath,
125
+ }: {
126
+ blocks: EditorBlock[]
127
+ content: unknown[]
128
+ positionalPath: string
129
+ }): null | string =>
130
+ localizableFields({ blocks, content }).find((field) => field.positionalPath === positionalPath)
131
+ ?.address ?? null
@@ -0,0 +1,98 @@
1
+ const canonicalValue = (value: unknown, seen: Set<object>): string => {
2
+ if (value === undefined) return 'undefined'
3
+ if (value === null) return 'null'
4
+ if (typeof value === 'string') return JSON.stringify(value)
5
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
6
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : JSON.stringify(String(value))
7
+ if (typeof value !== 'object') return JSON.stringify(String(value))
8
+ if (seen.has(value)) throw new TypeError('Cannot fingerprint a cyclic source value')
9
+ seen.add(value)
10
+ const result = Array.isArray(value)
11
+ ? `[${value.map((item) => canonicalValue(item, seen)).join(',')}]`
12
+ : `{${Object.keys(value as Record<string, unknown>)
13
+ .sort()
14
+ .map(
15
+ (key) =>
16
+ `${JSON.stringify(key)}:${canonicalValue((value as Record<string, unknown>)[key], seen)}`,
17
+ )
18
+ .join(',')}}`
19
+ seen.delete(value)
20
+ return result
21
+ }
22
+
23
+ const CONSTANTS = [
24
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
25
+ 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
26
+ 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
27
+ 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
28
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
29
+ 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
30
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
31
+ 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
32
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
33
+ 0xc67178f2,
34
+ ] as const
35
+
36
+ const rotateRight = (value: number, bits: number): number =>
37
+ (value >>> bits) | (value << (32 - bits))
38
+
39
+ /** A small runtime-neutral SHA-256 implementation keeps this shared domain browser-safe. */
40
+ const sha256 = (input: string): string => {
41
+ const bytes = [...new TextEncoder().encode(input)]
42
+ const bitLength = bytes.length * 8
43
+ bytes.push(0x80)
44
+ while (bytes.length % 64 !== 56) bytes.push(0)
45
+ const high = Math.floor(bitLength / 0x1_0000_0000)
46
+ const low = bitLength >>> 0
47
+ for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff)
48
+ for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff)
49
+
50
+ const hash = [
51
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
52
+ 0x5be0cd19,
53
+ ]
54
+ const words = new Array<number>(64)
55
+ for (let offset = 0; offset < bytes.length; offset += 64) {
56
+ for (let index = 0; index < 16; index += 1) {
57
+ const at = offset + index * 4
58
+ words[index] =
59
+ ((bytes[at]! << 24) | (bytes[at + 1]! << 16) | (bytes[at + 2]! << 8) | bytes[at + 3]!) >>> 0
60
+ }
61
+ for (let index = 16; index < 64; index += 1) {
62
+ const before = words[index - 15]!
63
+ const far = words[index - 2]!
64
+ const s0 = rotateRight(before, 7) ^ rotateRight(before, 18) ^ (before >>> 3)
65
+ const s1 = rotateRight(far, 17) ^ rotateRight(far, 19) ^ (far >>> 10)
66
+ words[index] = (words[index - 16]! + s0 + words[index - 7]! + s1) >>> 0
67
+ }
68
+ let [a, b, c, d, e, f, g, h] = hash
69
+ for (let index = 0; index < 64; index += 1) {
70
+ const upper = rotateRight(e!, 6) ^ rotateRight(e!, 11) ^ rotateRight(e!, 25)
71
+ const choose = (e! & f!) ^ (~e! & g!)
72
+ const t1 = (h! + upper + choose + CONSTANTS[index]! + words[index]!) >>> 0
73
+ const lower = rotateRight(a!, 2) ^ rotateRight(a!, 13) ^ rotateRight(a!, 22)
74
+ const majority = (a! & b!) ^ (a! & c!) ^ (b! & c!)
75
+ const t2 = (lower + majority) >>> 0
76
+ h = g
77
+ g = f
78
+ f = e
79
+ e = (d! + t1) >>> 0
80
+ d = c
81
+ c = b
82
+ b = a
83
+ a = (t1 + t2) >>> 0
84
+ }
85
+ hash[0] = (hash[0]! + a!) >>> 0
86
+ hash[1] = (hash[1]! + b!) >>> 0
87
+ hash[2] = (hash[2]! + c!) >>> 0
88
+ hash[3] = (hash[3]! + d!) >>> 0
89
+ hash[4] = (hash[4]! + e!) >>> 0
90
+ hash[5] = (hash[5]! + f!) >>> 0
91
+ hash[6] = (hash[6]! + g!) >>> 0
92
+ hash[7] = (hash[7]! + h!) >>> 0
93
+ }
94
+ return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
95
+ }
96
+
97
+ export const sourceFingerprint = (value: unknown): string =>
98
+ sha256(canonicalValue(value, new Set()))
@@ -0,0 +1,97 @@
1
+ import { isSemanticallyEmpty } from './resolution'
2
+ import type {
3
+ LanguageFieldHealth,
4
+ LanguageHealth,
5
+ LocalizableField,
6
+ TranslationDocument,
7
+ TranslationPatch,
8
+ TranslationPatchResult,
9
+ } from './types'
10
+
11
+ /** Evaluate stored target values only. Rendering fallbacks never count as translations. */
12
+ export const evaluateLanguageHealth = ({
13
+ fields,
14
+ translation,
15
+ }: {
16
+ fields: LocalizableField[]
17
+ translation: Pick<TranslationDocument, 'fields'>
18
+ }): LanguageHealth => {
19
+ const health: LanguageHealth = {
20
+ fields: [],
21
+ keptOriginal: 0,
22
+ missingOptional: 0,
23
+ missingRequired: 0,
24
+ missingSource: 0,
25
+ needsReview: 0,
26
+ ready: true,
27
+ sourceChanged: 0,
28
+ total: 0,
29
+ translated: 0,
30
+ }
31
+ for (const source of fields) {
32
+ const sourceEmpty = isSemanticallyEmpty(source.value, source.field.type)
33
+ if (sourceEmpty && !source.field.required) continue
34
+ health.total += 1
35
+ const target = translation.fields[source.address]
36
+ const targetEmpty = !target || isSemanticallyEmpty(target.value, source.field.type)
37
+ const sourceChanged = Boolean(target && target.sourceHash !== source.sourceHash)
38
+ const kept = Boolean(target?.keepOriginal && !sourceChanged)
39
+ const missingTranslation = targetEmpty && !kept
40
+ const missingRequired = Boolean(source.field.required && missingTranslation)
41
+ const missingSource = Boolean(source.field.required && sourceEmpty)
42
+ const needsReview = target?.needsReview === true
43
+
44
+ if (!targetEmpty) health.translated += 1
45
+ if (kept) health.keptOriginal += 1
46
+ if (missingRequired) health.missingRequired += 1
47
+ if (missingTranslation && !source.field.required) health.missingOptional += 1
48
+ if (missingSource) health.missingSource += 1
49
+ if (needsReview) health.needsReview += 1
50
+ if (sourceChanged) health.sourceChanged += 1
51
+
52
+ const fieldHealth: LanguageFieldHealth = {
53
+ address: source.address,
54
+ missingRequired,
55
+ missingSource,
56
+ missingTranslation,
57
+ needsReview,
58
+ sourceChanged,
59
+ }
60
+ health.fields.push(fieldHealth)
61
+ if (Object.values(fieldHealth).some((flag) => flag === true)) health.ready = false
62
+ }
63
+ return health
64
+ }
65
+
66
+ /** Apply one field write only when the caller's structure/translation revision is current. */
67
+ export const applyTranslationPatch = (
68
+ translation: TranslationDocument,
69
+ patch: TranslationPatch,
70
+ ): TranslationPatchResult => {
71
+ if (!Number.isSafeInteger(patch.expectedFieldRevision) || patch.expectedFieldRevision < 0) {
72
+ return { ok: false, problem: 'Expected field revision must be a non-negative safe integer' }
73
+ }
74
+ if (
75
+ !Number.isSafeInteger(patch.expectedStructureRevision) ||
76
+ patch.expectedStructureRevision < 0
77
+ ) {
78
+ return { ok: false, problem: 'Expected structure revision must be a non-negative safe integer' }
79
+ }
80
+ if (translation.structureRevision !== patch.expectedStructureRevision) {
81
+ return {
82
+ ok: false,
83
+ problem: `Stale structure revision: expected ${patch.expectedStructureRevision}, current ${translation.structureRevision}`,
84
+ }
85
+ }
86
+ if (patch.address.trim().length === 0) return { ok: false, problem: 'Translation address is required' }
87
+ const currentRevision = translation.fields[patch.address]?.revision ?? 0
88
+ if (currentRevision !== patch.expectedFieldRevision) {
89
+ return {
90
+ ok: false,
91
+ problem: `Stale field revision: expected ${patch.expectedFieldRevision}, current ${currentRevision}`,
92
+ }
93
+ }
94
+ const fields = { ...translation.fields }
95
+ fields[patch.address] = { ...patch.field, revision: currentRevision + 1 }
96
+ return { ok: true, translation: { fields, structureRevision: translation.structureRevision } }
97
+ }