translate-shield 0.1.0

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/dist/index.cjs ADDED
@@ -0,0 +1,508 @@
1
+ Object.defineProperty(exports, '__esModule', { value: true });
2
+
3
+ var dom = require('./dom-dauvp8pk.cjs');
4
+
5
+ const NATIVE = /\[native code\]/;
6
+ const isPatched = (candidate)=>typeof candidate === 'function' && !NATIVE.test(Function.prototype.toString.call(candidate));
7
+ const setterOf = (target, property)=>Object.getOwnPropertyDescriptor(target, property)?.set;
8
+ /**
9
+ * Names the DOM surfaces another shim has already replaced.
10
+ *
11
+ * Two shims on one page is not a crash, it is a silent draw. Ours writes the new
12
+ * value into the translator's wrapper; a restore-based shim puts the original
13
+ * node back and deletes that wrapper. Whichever runs second decides, and the
14
+ * loser degrades to doing nothing. Reporting it is the only way a developer
15
+ * finds out, because nothing throws.
16
+ */ const findConflicts = ()=>{
17
+ if (typeof Node === 'undefined') return [];
18
+ const surfaces = [
19
+ [
20
+ 'Node.prototype.removeChild',
21
+ Node.prototype.removeChild
22
+ ],
23
+ [
24
+ 'Node.prototype.insertBefore',
25
+ Node.prototype.insertBefore
26
+ ],
27
+ [
28
+ 'Node.prototype.replaceChild',
29
+ Node.prototype.replaceChild
30
+ ],
31
+ [
32
+ 'Node.prototype.nodeValue',
33
+ setterOf(Node.prototype, 'nodeValue')
34
+ ],
35
+ [
36
+ 'Node.prototype.textContent',
37
+ setterOf(Node.prototype, 'textContent')
38
+ ],
39
+ [
40
+ 'CharacterData.prototype.data',
41
+ setterOf(CharacterData.prototype, 'data')
42
+ ],
43
+ [
44
+ 'Element.prototype.attachShadow',
45
+ Element.prototype.attachShadow
46
+ ]
47
+ ];
48
+ return surfaces.filter(([, value])=>isPatched(value)).map(([name])=>name);
49
+ };
50
+
51
+ const NUMBER_PATTERN = /\d+(?:[.,\s]\d+)*/g;
52
+ const TRANSLATED_NUMBER_PATTERN = /\p{Nd}+(?:[.,\s]\p{Nd}+)*/gu;
53
+ const DIGIT_ZEROS = [
54
+ 0x0030,
55
+ 0x0660,
56
+ 0x06f0,
57
+ 0x0966,
58
+ 0x09e6,
59
+ 0x0a66,
60
+ 0x0ae6,
61
+ 0x0b66,
62
+ 0x0be6,
63
+ 0x0c66,
64
+ 0x0ce6,
65
+ 0x0d66,
66
+ 0x0e50,
67
+ 0x0ed0,
68
+ 0x0f20,
69
+ 0x1040,
70
+ 0x17e0,
71
+ 0x1810,
72
+ 0xff10
73
+ ];
74
+ const numbersOf = (value)=>value.match(NUMBER_PATTERN) ?? [];
75
+ const skeletonOf = (value)=>value.replace(NUMBER_PATTERN, '');
76
+ const digitsOf = (value)=>value.replace(/\D/g, '');
77
+ const zeroCodePointFor = (codePoint)=>{
78
+ for (const zero of DIGIT_ZEROS){
79
+ if (codePoint >= zero && codePoint <= zero + 9) return zero;
80
+ }
81
+ return null;
82
+ };
83
+ const asNumber = (value)=>{
84
+ const digits = digitsOf(value);
85
+ if (!digits) return null;
86
+ const fraction = value.match(/[.,](\d{1,2})$/)?.[1];
87
+ if (!fraction) return Number(digits);
88
+ const whole = digits.slice(0, digits.length - fraction.length);
89
+ return Number(`${whole || '0'}.${fraction}`);
90
+ };
91
+ /**
92
+ * True when swapping one value for another cannot change the grammatical number
93
+ * of the sentence. Russian "4 lampochki" (few) and "7 lampochek" (many) take
94
+ * different noun forms, so a digit swap there produces fluent-looking wrong text.
95
+ */ const ruleCache = new Map();
96
+ const pluralRulesFor = (locale)=>{
97
+ const cached = ruleCache.get(locale);
98
+ if (cached !== undefined) return cached;
99
+ let rules = null;
100
+ try {
101
+ rules = new Intl.PluralRules(locale);
102
+ } catch {
103
+ rules = null;
104
+ }
105
+ ruleCache.set(locale, rules);
106
+ return rules;
107
+ };
108
+ const keepsPluralCategory = (previous, next, locale)=>{
109
+ if (!locale) return false;
110
+ const rules = pluralRulesFor(locale);
111
+ if (!rules) return false;
112
+ return previous.every((value, index)=>{
113
+ const before = asNumber(value);
114
+ const after = asNumber(next[index] ?? '');
115
+ if (before === null || after === null) return false;
116
+ return rules.select(before) === rules.select(after);
117
+ });
118
+ };
119
+ /**
120
+ * Writes new digits into a number the translator already formatted, keeping that
121
+ * number's own separators and digit system, so "19,99" updated from "29.99"
122
+ * becomes "29,99" rather than "29.99".
123
+ */ const reskinNumber = (translatedNumber, sourceDigits)=>{
124
+ const characters = Array.from(translatedNumber);
125
+ const digitCount = characters.filter((character)=>zeroCodePointFor(character.codePointAt(0) ?? 0) !== null).length;
126
+ if (digitCount !== sourceDigits.length) return null;
127
+ let index = 0;
128
+ return characters.map((character)=>{
129
+ const zero = zeroCodePointFor(character.codePointAt(0) ?? 0);
130
+ if (zero === null) return character;
131
+ const digit = Number(sourceDigits[index] ?? '0');
132
+ index += 1;
133
+ return String.fromCodePoint(zero + digit);
134
+ }).join('');
135
+ };
136
+ /**
137
+ * Merges a new source value into an already translated string. The merge only
138
+ * happens when it cannot change meaning: the sentence around the numbers is
139
+ * identical, every number keeps its plural category in the target language, and
140
+ * the digits line up one for one. Otherwise the correct value is returned in the
141
+ * source language, because a visibly untranslated number beats a fluent-looking
142
+ * wrong one.
143
+ */ const mergeIntoTranslated = (previousSource, nextSource, translated, locale)=>{
144
+ if (!translated) return nextSource;
145
+ if (previousSource === nextSource) return translated;
146
+ if (skeletonOf(previousSource) !== skeletonOf(nextSource)) return nextSource;
147
+ const previousNumbers = numbersOf(previousSource);
148
+ const nextNumbers = numbersOf(nextSource);
149
+ if (previousNumbers.length !== nextNumbers.length) return nextSource;
150
+ if (!keepsPluralCategory(previousNumbers, nextNumbers, locale)) return nextSource;
151
+ const translatedNumbers = translated.match(TRANSLATED_NUMBER_PATTERN) ?? [];
152
+ if (translatedNumbers.length !== nextNumbers.length) return nextSource;
153
+ const replacements = translatedNumbers.map((translatedNumber, index)=>reskinNumber(translatedNumber, digitsOf(nextNumbers[index] ?? '')));
154
+ if (replacements.includes(null)) return nextSource;
155
+ let cursor = 0;
156
+ return translated.replace(TRANSLATED_NUMBER_PATTERN, (match)=>{
157
+ const replacement = replacements[cursor];
158
+ cursor += 1;
159
+ return replacement ?? match;
160
+ });
161
+ };
162
+
163
+ const INTERCEPTED_PROPERTIES = [
164
+ 'nodeValue',
165
+ 'data',
166
+ 'textContent'
167
+ ];
168
+ let links = new WeakMap();
169
+ let wrappers = new WeakSet();
170
+ let forwarders = null;
171
+ let natives = null;
172
+ const nativeDescriptorOf = (property)=>{
173
+ if (property === 'data') return Object.getOwnPropertyDescriptor(CharacterData.prototype, 'data');
174
+ return Object.getOwnPropertyDescriptor(Node.prototype, property);
175
+ };
176
+ /**
177
+ * Resolves the native accessors once, on first use rather than at module scope.
178
+ * Reading `Node.prototype` when the module loads throws `ReferenceError: Node is
179
+ * not defined` on the server, which would break any app importing this from
180
+ * shared code, so nothing here may run before a DOM exists.
181
+ */ const nativeAccessors = ()=>{
182
+ if (natives) return natives;
183
+ if (typeof Node === 'undefined') return null;
184
+ natives = {
185
+ nodeValue: nativeDescriptorOf('nodeValue'),
186
+ data: nativeDescriptorOf('data'),
187
+ textContent: nativeDescriptorOf('textContent')
188
+ };
189
+ return natives;
190
+ };
191
+ const readProperty = (node, property)=>{
192
+ const getter = nativeAccessors()?.[property]?.get;
193
+ if (!getter) return '';
194
+ return String(getter.call(node) ?? '');
195
+ };
196
+ const forwardWrite = (node, next)=>{
197
+ const link = links.get(node);
198
+ if (!link?.wrapper.isConnected) return;
199
+ const translated = readProperty(link.wrapper, 'textContent');
200
+ const merged = mergeIntoTranslated(link.sourceText, next, translated, document.documentElement.lang);
201
+ link.sourceText = next;
202
+ if (merged === translated) return;
203
+ nativeAccessors()?.textContent?.set?.call(link.wrapper, merged);
204
+ };
205
+ /**
206
+ * Builds the forwarding accessors once per property. They close over nothing
207
+ * per node, so a full-page translation linking thousands of text nodes reuses
208
+ * three descriptors instead of allocating nine objects for each one.
209
+ */ const forwardingDescriptors = ()=>{
210
+ if (forwarders) return forwarders;
211
+ const accessors = nativeAccessors();
212
+ if (!accessors) return null;
213
+ const build = (property)=>{
214
+ const native = accessors[property];
215
+ if (!native?.get || !native.set) return null;
216
+ const nativeGet = native.get;
217
+ const nativeSet = native.set;
218
+ return {
219
+ configurable: true,
220
+ enumerable: false,
221
+ get () {
222
+ return nativeGet.call(this);
223
+ },
224
+ set (value) {
225
+ nativeSet.call(this, value);
226
+ forwardWrite(this, String(value ?? ''));
227
+ }
228
+ };
229
+ };
230
+ const nodeValue = build('nodeValue');
231
+ const data = build('data');
232
+ const textContent = build('textContent');
233
+ if (!nodeValue || !data || !textContent) return null;
234
+ forwarders = {
235
+ nodeValue,
236
+ data,
237
+ textContent
238
+ };
239
+ return forwarders;
240
+ };
241
+ /**
242
+ * Links a TextNode the translator detached to the element that replaced it, so
243
+ * later framework writes reach the node the user actually sees.
244
+ */ const linkNodes = (detached, wrapper)=>{
245
+ wrappers.add(wrapper);
246
+ const existing = links.get(detached);
247
+ if (existing) {
248
+ existing.wrapper = wrapper;
249
+ return;
250
+ }
251
+ links.set(detached, {
252
+ wrapper,
253
+ sourceText: readProperty(detached, 'nodeValue')
254
+ });
255
+ const descriptors = forwardingDescriptors();
256
+ if (!descriptors) return;
257
+ INTERCEPTED_PROPERTIES.forEach((property)=>{
258
+ Object.defineProperty(detached, property, descriptors[property]);
259
+ });
260
+ };
261
+ const wrapperFor = (node)=>{
262
+ if (!dom.isText(node)) return null;
263
+ const link = links.get(node);
264
+ if (!link?.wrapper.isConnected) return null;
265
+ return link.wrapper;
266
+ };
267
+ const isWrapper = (node)=>dom.isElement(node) && wrappers.has(node);
268
+ /**
269
+ * Drops every link, which turns each intercepted accessor into a pass-through:
270
+ * the getter still delegates to the native one and the setter forwards nowhere.
271
+ * The own properties stay defined, because deleting them would need a list of
272
+ * every node ever linked, and that list grows for the lifetime of a page that
273
+ * keeps translating.
274
+ */ const releaseInterceptors = ()=>{
275
+ links = new WeakMap();
276
+ wrappers = new WeakSet();
277
+ forwarders = null;
278
+ };
279
+
280
+ /**
281
+ * Makes the three DOM calls that take a child reference survive nodes a
282
+ * translator detached. Real children take the native path untouched; detached
283
+ * ones are redirected to the visible wrapper instead of throwing NotFoundError.
284
+ *
285
+ * `appendChild` is deliberately not patched. It takes no reference child, so it
286
+ * cannot raise the NotFoundError this repairs; wrapping it would cost every
287
+ * insertion on the page and buy nothing.
288
+ */ const patchDom = (report)=>{
289
+ const nativeRemoveChild = Node.prototype.removeChild;
290
+ const nativeInsertBefore = Node.prototype.insertBefore;
291
+ const nativeReplaceChild = Node.prototype.replaceChild;
292
+ Node.prototype.removeChild = function removeChild(child) {
293
+ if (child.parentNode === this) {
294
+ nativeRemoveChild.call(this, child);
295
+ return child;
296
+ }
297
+ const wrapper = wrapperFor(child);
298
+ if (wrapper && wrapper.parentNode === this) {
299
+ nativeRemoveChild.call(this, wrapper);
300
+ report({
301
+ method: 'removeChild',
302
+ redirected: true
303
+ });
304
+ return child;
305
+ }
306
+ report({
307
+ method: 'removeChild',
308
+ redirected: false
309
+ });
310
+ return child;
311
+ };
312
+ Node.prototype.insertBefore = function insertBefore(node, reference) {
313
+ if (!reference || reference.parentNode === this) {
314
+ nativeInsertBefore.call(this, node, reference);
315
+ return node;
316
+ }
317
+ const wrapper = wrapperFor(reference);
318
+ if (wrapper && wrapper.parentNode === this) {
319
+ nativeInsertBefore.call(this, node, wrapper);
320
+ report({
321
+ method: 'insertBefore',
322
+ redirected: true
323
+ });
324
+ return node;
325
+ }
326
+ nativeInsertBefore.call(this, node, null);
327
+ report({
328
+ method: 'insertBefore',
329
+ redirected: false
330
+ });
331
+ return node;
332
+ };
333
+ Node.prototype.replaceChild = function replaceChild(node, child) {
334
+ if (child.parentNode === this) {
335
+ nativeReplaceChild.call(this, node, child);
336
+ return child;
337
+ }
338
+ const wrapper = wrapperFor(child);
339
+ if (wrapper && wrapper.parentNode === this) {
340
+ nativeReplaceChild.call(this, node, wrapper);
341
+ report({
342
+ method: 'replaceChild',
343
+ redirected: true
344
+ });
345
+ return child;
346
+ }
347
+ report({
348
+ method: 'replaceChild',
349
+ redirected: false
350
+ });
351
+ return child;
352
+ };
353
+ return ()=>{
354
+ Node.prototype.removeChild = nativeRemoveChild;
355
+ Node.prototype.insertBefore = nativeInsertBefore;
356
+ Node.prototype.replaceChild = nativeReplaceChild;
357
+ };
358
+ };
359
+
360
+ const OBSERVE_OPTIONS = {
361
+ childList: true,
362
+ subtree: true
363
+ };
364
+ const pushInto = (bucket, key, value)=>{
365
+ const existing = bucket.get(key);
366
+ if (!existing) {
367
+ bucket.set(key, [
368
+ value
369
+ ]);
370
+ return;
371
+ }
372
+ existing.push(value);
373
+ };
374
+ /**
375
+ * Watches for the translator's TextNode-to-wrapper swap and links each pair.
376
+ * Pairing is positional per parent: within one observer batch the n-th injected
377
+ * wrapper belongs to the n-th detached TextNode of that parent.
378
+ *
379
+ * Open shadow roots are observed too. Chrome and Yandex translate through the
380
+ * boundary and detach the nodes inside it, and a `MutationObserver` on a host
381
+ * element never reports what happens in its shadow root. `attachShadow` is
382
+ * wrapped so roots opened after install are picked up as well: attaching a
383
+ * shadow root produces no mutation record, so there is nothing else to watch.
384
+ */ const startObserver = ({ root, wrapperTags, onTranslationDetected })=>{
385
+ let detected = false;
386
+ const observedRoots = new WeakSet();
387
+ const observer = new MutationObserver((records)=>{
388
+ const detachedTexts = new Map();
389
+ const injectedWrappers = new Map();
390
+ records.forEach((record)=>{
391
+ if (isWrapper(record.target)) return;
392
+ record.removedNodes.forEach((node)=>{
393
+ if (dom.isText(node)) pushInto(detachedTexts, record.target, node);
394
+ });
395
+ record.addedNodes.forEach((node)=>{
396
+ if (dom.isTranslatorWrapper(node, wrapperTags)) {
397
+ pushInto(injectedWrappers, record.target, node);
398
+ return;
399
+ }
400
+ if (dom.isElement(node)) observeShadowRootsUnder(node);
401
+ });
402
+ });
403
+ if (injectedWrappers.size === 0) return;
404
+ injectedWrappers.forEach((elements, parent)=>{
405
+ const texts = detachedTexts.get(parent) ?? [];
406
+ elements.forEach((wrapper, index)=>{
407
+ const detachedText = texts[index];
408
+ if (!detachedText) return;
409
+ linkNodes(detachedText, wrapper);
410
+ });
411
+ });
412
+ if (detected) return;
413
+ detected = true;
414
+ const [firstBatch] = injectedWrappers.values();
415
+ const wrapper = firstBatch?.[0];
416
+ onTranslationDetected({
417
+ lang: document.documentElement.lang,
418
+ engine: wrapper ? dom.engineOfWrapper(wrapper) : null,
419
+ wrapperTag: wrapper?.tagName ?? ''
420
+ });
421
+ });
422
+ function observeRoot(node) {
423
+ if (observedRoots.has(node)) return;
424
+ observedRoots.add(node);
425
+ observer.observe(node, OBSERVE_OPTIONS);
426
+ }
427
+ function observeShadowRootsUnder(scope) {
428
+ const hosts = dom.isElement(scope) ? [
429
+ scope,
430
+ ...scope.querySelectorAll('*')
431
+ ] : scope.querySelectorAll('*');
432
+ hosts.forEach((host)=>{
433
+ if (!host.shadowRoot) return;
434
+ observeRoot(host.shadowRoot);
435
+ observeShadowRootsUnder(host.shadowRoot);
436
+ });
437
+ }
438
+ const nativeAttachShadow = Element.prototype.attachShadow;
439
+ Element.prototype.attachShadow = function attachShadow(init) {
440
+ const shadow = nativeAttachShadow.call(this, init);
441
+ if (init.mode === 'open' && root.contains(this)) observeRoot(shadow);
442
+ return shadow;
443
+ };
444
+ observeRoot(root);
445
+ observeShadowRootsUnder(root);
446
+ return ()=>{
447
+ Element.prototype.attachShadow = nativeAttachShadow;
448
+ observer.disconnect();
449
+ };
450
+ };
451
+
452
+ const inertHandle = {
453
+ stop: ()=>undefined,
454
+ isTranslated: ()=>false,
455
+ engine: ()=>null,
456
+ conflicts: ()=>[]
457
+ };
458
+ let activeHandle = null;
459
+ /**
460
+ * Installs the shield: keeps the DOM operations React relies on from throwing
461
+ * once a translator has rewritten the page, and mirrors framework text updates
462
+ * into the elements the user actually sees.
463
+ */ const initTranslateShield = (options = {})=>{
464
+ if (typeof document === 'undefined') return inertHandle;
465
+ if (activeHandle) return activeHandle;
466
+ const { root = document.body || document.documentElement, wrapperTags = [], debug = false } = options;
467
+ if (!root) return inertHandle;
468
+ let translation = null;
469
+ const conflicts = findConflicts();
470
+ if (conflicts.length > 0) {
471
+ options.onConflict?.(conflicts);
472
+ console.warn(`[translate-shield] another shim already replaced ${conflicts.join(', ')}. Both will run, but only one repair strategy can win and the other silently does nothing. Install one.`);
473
+ }
474
+ const report = (error)=>{
475
+ options.onRecoveredError?.(error);
476
+ if (!debug) return;
477
+ console.warn('[translate-shield] recovered', error.method, {
478
+ redirected: error.redirected
479
+ });
480
+ };
481
+ const handleTranslationDetected = (info)=>{
482
+ translation = info;
483
+ options.onTranslationDetected?.(info);
484
+ if (!debug) return;
485
+ console.warn('[translate-shield] translation detected', info);
486
+ };
487
+ const restoreDom = patchDom(report);
488
+ const stopObserver = startObserver({
489
+ root,
490
+ wrapperTags,
491
+ onTranslationDetected: handleTranslationDetected
492
+ });
493
+ activeHandle = {
494
+ stop: ()=>{
495
+ stopObserver();
496
+ restoreDom();
497
+ releaseInterceptors();
498
+ activeHandle = null;
499
+ },
500
+ isTranslated: ()=>translation !== null,
501
+ engine: ()=>translation?.engine ?? null,
502
+ conflicts: ()=>conflicts
503
+ };
504
+ return activeHandle;
505
+ };
506
+
507
+ exports.initTranslateShield = initTranslateShield;
508
+ exports.mergeIntoTranslated = mergeIntoTranslated;
@@ -0,0 +1,21 @@
1
+ import { S as ShieldOptions, a as ShieldHandle } from './types-fjbg60f8.js';
2
+ export { P as PatchedSurface, R as RecoveredError, T as TranslationInfo, b as TranslatorEngine } from './types-fjbg60f8.js';
3
+
4
+ /**
5
+ * Merges a new source value into an already translated string. The merge only
6
+ * happens when it cannot change meaning: the sentence around the numbers is
7
+ * identical, every number keeps its plural category in the target language, and
8
+ * the digits line up one for one. Otherwise the correct value is returned in the
9
+ * source language, because a visibly untranslated number beats a fluent-looking
10
+ * wrong one.
11
+ */
12
+ declare const mergeIntoTranslated: (previousSource: string, nextSource: string, translated: string, locale: string) => string;
13
+
14
+ /**
15
+ * Installs the shield: keeps the DOM operations React relies on from throwing
16
+ * once a translator has rewritten the page, and mirrors framework text updates
17
+ * into the elements the user actually sees.
18
+ */
19
+ declare const initTranslateShield: (options?: ShieldOptions) => ShieldHandle;
20
+
21
+ export { ShieldHandle, ShieldOptions, initTranslateShield, mergeIntoTranslated };
@@ -0,0 +1,21 @@
1
+ import { S as ShieldOptions, a as ShieldHandle } from './types-fjbg60f8.js';
2
+ export { P as PatchedSurface, R as RecoveredError, T as TranslationInfo, b as TranslatorEngine } from './types-fjbg60f8.js';
3
+
4
+ /**
5
+ * Merges a new source value into an already translated string. The merge only
6
+ * happens when it cannot change meaning: the sentence around the numbers is
7
+ * identical, every number keeps its plural category in the target language, and
8
+ * the digits line up one for one. Otherwise the correct value is returned in the
9
+ * source language, because a visibly untranslated number beats a fluent-looking
10
+ * wrong one.
11
+ */
12
+ declare const mergeIntoTranslated: (previousSource: string, nextSource: string, translated: string, locale: string) => string;
13
+
14
+ /**
15
+ * Installs the shield: keeps the DOM operations React relies on from throwing
16
+ * once a translator has rewritten the page, and mirrors framework text updates
17
+ * into the elements the user actually sees.
18
+ */
19
+ declare const initTranslateShield: (options?: ShieldOptions) => ShieldHandle;
20
+
21
+ export { ShieldHandle, ShieldOptions, initTranslateShield, mergeIntoTranslated };