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