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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Davlatbek Aliev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # translate-shield
2
+
3
+ ![translate-shield](https://raw.githubusercontent.com/alievdavlat/translate-shield/main/research/screenshots/banner.png)
4
+
5
+ [![npm](https://img.shields.io/npm/v/translate-shield)](https://www.npmjs.com/package/translate-shield)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/translate-shield)](https://bundlephobia.com/package/translate-shield)
7
+ ![dependencies](https://img.shields.io/badge/dependencies-0-2ea44f)
8
+ [![licence](https://img.shields.io/npm/l/translate-shield)](LICENSE)
9
+
10
+ `NotFoundError: Failed to execute 'removeChild' on 'Node'` after a reader turns on Chrome
11
+ translation. Or a counter that freezes while the rest of the app works.
12
+
13
+ Chrome replaces your TextNode with `<font style="vertical-align: inherit">` and detaches the
14
+ original. React still holds the detached node, so `removeChild` and `insertBefore` throw and text
15
+ writes go nowhere.
16
+
17
+ ```bash
18
+ npm install translate-shield
19
+ ```
20
+
21
+ ```ts
22
+ import { initTranslateShield } from 'translate-shield'
23
+
24
+ initTranslateShield()
25
+ ```
26
+
27
+ The whole setup. No-op on the server, armed only after a translator rewrites the page, inert on
28
+ engines that do not need it. No dependencies, about 14 kB packed.
29
+
30
+ Firefox and Edge readers do not hit this bug. Check [Browser support](#browser-support) first.
31
+
32
+ ## What the reader sees
33
+
34
+ Four updates to a value in view, real Chrome, live Dutch translation.
35
+
36
+ | | No protection | The pasted crash guard | A restore-based library | translate-shield |
37
+ |---|---|---|---|---|
38
+ | `NotFoundError` crash | app unmounts | fixed | fixed | fixed |
39
+ | Value keeps updating | frozen | still frozen | yes | yes |
40
+ | Removed text disappears | n/a | stays on screen | yes | yes |
41
+ | Language while updating | n/a | n/a | 150-200 ms of source language per update | 0 ms |
42
+ | Text when the reader looks | `Er zijn 4 lampen!`, stale | n/a | `There are 7 lights!` | `Er zijn 7 lampen!` |
43
+
44
+ First three rows: `research/comparison.json`. Language row: `research/flicker.json`, where
45
+ restore-and-retranslate spends 700 ms of the sequence in source language and mirroring 0 ms. Last
46
+ row: `research/head-to-head-real-chrome.json`.
47
+
48
+ ## Next.js
49
+
50
+ Call the initializer from a client component in the root layout.
51
+
52
+ ```tsx
53
+ 'use client'
54
+
55
+ import { useEffect } from 'react'
56
+ import { initTranslateShield } from 'translate-shield'
57
+
58
+ export const TranslateShield = () => {
59
+ useEffect(() => initTranslateShield().stop, [])
60
+ return null
61
+ }
62
+ ```
63
+
64
+ ```tsx
65
+ import { TranslateShield } from './translate-shield'
66
+
67
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
68
+ return (
69
+ <html lang="en">
70
+ <body>
71
+ <TranslateShield />
72
+ {children}
73
+ </body>
74
+ </html>
75
+ )
76
+ }
77
+ ```
78
+
79
+ A second call returns the existing handle, so a double-invoked development effect leaves one
80
+ shield.
81
+
82
+ ## Protecting a value
83
+
84
+ ```tsx
85
+ import { NoTranslate } from 'translate-shield/react'
86
+
87
+ export const OrderTotal = ({ total }: { total: number }) => (
88
+ <p>
89
+ Order total: <NoTranslate>{formatPrice(total)}</NoTranslate> including VAT
90
+ </p>
91
+ )
92
+ ```
93
+
94
+ The sentence is translated; the price is not. Use it for prices, counters, order numbers,
95
+ tracking codes, IBANs and dates, never prose. Every real engine recorded honours `translate="no"`
96
+ (`research/fingerprints/summary.json`); `NoTranslate` also sets `class="notranslate"`, because
97
+ Yandex translated the class probe anyway (`research/measurements.md`).
98
+
99
+ ## Detecting a translated page
100
+
101
+ ```tsx
102
+ 'use client'
103
+
104
+ import { useTranslationDetected } from 'translate-shield/react'
105
+
106
+ export const TranslationBanner = () => {
107
+ const { isTranslated, engine, lang } = useTranslationDetected()
108
+ if (!isTranslated) return null
109
+ return <p>Machine translated to {lang} by {engine}.</p>
110
+ }
111
+ ```
112
+
113
+ Idle on the server and on the first client render, so no hydration mismatch. `engine` is
114
+ `'google'`, `'yandex'`, `'edge'`, `'firefox'`, or `null`.
115
+
116
+ ## API
117
+
118
+ ### `initTranslateShield(options?): ShieldHandle`
119
+
120
+ | Option | Type | Default | Meaning |
121
+ |---|---|---|---|
122
+ | `root` | `Element` | `document.body` | Subtree to observe |
123
+ | `wrapperTags` | `string[]` | `[]` | Extra wrapper tag names to recognise beyond the built-in fingerprints |
124
+ | `onTranslationDetected` | `(info: TranslationInfo) => void` | none | Called once, with `lang`, `engine`, and `wrapperTag` |
125
+ | `onRecoveredError` | `(error: RecoveredError) => void` | none | Called when `removeChild`, `insertBefore` or `replaceChild` was redirected instead of throwing |
126
+ | `onConflict` | `(surfaces: PatchedSurface[]) => void` | none | Called at install when another shim has already replaced a DOM surface |
127
+ | `debug` | `boolean` | `false` | Log every detection and recovery to the console |
128
+
129
+ `ShieldHandle` carries `stop()`, `isTranslated()`, `engine()`, and `conflicts()`. On the server it
130
+ is inert.
131
+
132
+ Two shims on one page is a silent draw, not a crash: whichever repairs second decides, and the
133
+ other quietly does nothing. `conflicts()` names any DOM surface already replaced when the shield
134
+ installed, and a console warning fires whether or not you pass `onConflict`.
135
+
136
+ ### `mergeIntoTranslated(previousSource, nextSource, translated, locale): string`
137
+
138
+ Writes changed digits into a sentence the translator already produced.
139
+
140
+ | Outcome | What the reader gets |
141
+ |---|---|
142
+ | Merged | the translated sentence, keeping its separators and digit system: `19,99` updated from `29.99` becomes `29,99` |
143
+ | Refused | `nextSource` untranslated, so the value is right and the language is not (`research/head-to-head-real-chrome.json`, Russian arm) |
144
+
145
+ | Refuses when | Because |
146
+ |---|---|
147
+ | `Intl.PluralRules` puts the old and the new value in different categories for `locale` | Russian `4` is `few` and `7` is `many`, so `Здесь 4 лампочки!` never becomes `Здесь 7 лампочки!` |
148
+ | The sentence around the numbers changed, not just the numbers | The translation no longer matches the new source |
149
+ | The digit count changed | The positions no longer line up |
150
+ | The locale is empty or unknown to `Intl.PluralRules` | Grammar cannot be checked |
151
+
152
+ ## Browser support
153
+
154
+ Rows: `research/fingerprints/summary.json`, recorded on Windows 2026-09-02; versions in
155
+ `research/provenance.json`.
156
+
157
+ | Engine | Wrapper | Detaches the original | Does this library help |
158
+ |---|---|---|---|
159
+ | Chrome built-in | `<font style="vertical-align: inherit">` | yes | yes |
160
+ | Google `translate_a/element.js` bundle | `<font style="vertical-align: inherit">` | yes | yes |
161
+ | Yandex | `<ya-tr-span>` | yes | crash and frozen values only |
162
+ | Edge built-in | none, stamps `_msttexthash` / `_msthash` | no | no |
163
+ | Firefox built-in | none, no markers | no | no |
164
+ | Safari built-in | unmeasured | unknown | unknown |
165
+
166
+ Firefox and Edge rewrite the text node in place and leave it connected, so React's writes reach
167
+ the screen. Three qualifications:
168
+
169
+ | Qualification | Evidence |
170
+ |---|---|
171
+ | In-place engines do detach when they merge adjacent text runs | the three-TextNode `interpolated` probe reports two detached nodes on Firefox and on Edge, against three on Chrome and Yandex |
172
+ | On Yandex the detached node already holds translated text, so the language row does not apply there; the crash and freeze fixes do | `research/measurements.md` |
173
+ | Safari does not run on Windows and is unmeasured | the WebKit recording, `google-element-webkit-nl.json`, is the Google bundle under WebKit, not Safari's own translator |
174
+
175
+ ## Limits
176
+
177
+ - It does not translate anything or replace an i18n library.
178
+ - It does not stop the translator rewriting text; `NoTranslate` does.
179
+ - Beyond what `mergeIntoTranslated` verifies, a value merged into a neighbouring sentence is
180
+ unprotected: on refusal it is correct but in the source language.
181
+ - Safari behaviour is unknown, not assumed.
182
+
183
+ Why it works this way: [DESIGN.md](DESIGN.md).
184
+
185
+ ## Prior art
186
+
187
+ | Prior work | What it does |
188
+ |---|---|
189
+ | [facebook/react#11538](https://github.com/facebook/react/issues/11538) | Where the crash was described. shuhei identified the sibling condition in 2018; Dan Abramov closed it as won't-fix the same year. The guard people paste into their apps comes from that thread and stops the crash without unfreezing anything. |
190
+ | [`translation-resilience`](https://www.npmjs.com/package/translation-resilience) by Alex Speller, first published 2026-07-10 | Fixes the crash and the frozen value by restoring the original node and letting the translator catch up. That is the strategy the language row measures. |
191
+ | `react-google-translate-shim` | A similar restore-based approach. |
192
+ | [`eslint-plugin-react-google-translate`](https://www.npmjs.com/package/eslint-plugin-react-google-translate) | Catches the risky JSX patterns at lint time. Worth running alongside this. |
193
+
194
+ The full comparison: `research/article.md`.
195
+
196
+ ## Reproducing the measurements
197
+
198
+ Every figure comes from `research/`, produced by a spec in `tests/specs/`.
199
+
200
+ ```bash
201
+ npx playwright test flicker # research/flicker.json
202
+ npx playwright test visibility-confound # research/visibility-confound.json
203
+ npx playwright test provocation # research/provocation.json
204
+ npx playwright test head-to-head-real-chrome # research/head-to-head-real-chrome.json
205
+ ```
206
+
207
+ Recordings under `research/fingerprints/` come from `research/recorder`, driven by hand in each
208
+ browser (`research/recorder/README.md`).
209
+
210
+ ```bash
211
+ npm run summarise
212
+ npm run typecheck
213
+ npm run build
214
+ npx publint --strict
215
+ npx @arethetypeswrong/cli --pack .
216
+ npx playwright test
217
+ ```
218
+
219
+ ## Licence
220
+
221
+ MIT, Davlatbek Aliev
@@ -0,0 +1,71 @@
1
+ /**
2
+ * One row per translator, and everything else in this file is derived from it.
3
+ * Adding an engine means adding a row, not editing four predicates that have to
4
+ * agree with each other.
5
+ *
6
+ * A bare tag is never enough. Apps emit `<font>` through rendered markdown, CMS
7
+ * content and `dangerouslySetInnerHTML`, so Google's wrapper is only recognised
8
+ * with the inline `vertical-align: inherit` it always carries, read off the
9
+ * `style` attribute because `getComputedStyle` resolves `inherit` to `baseline`.
10
+ *
11
+ * Edge and Firefox rewrite the live text node and inject no wrapper, so they
12
+ * have markers but no tag. They are named for observability; there is nothing
13
+ * for the shield to mirror into.
14
+ */ const ENGINES = [
15
+ {
16
+ name: 'google',
17
+ wrapperTag: 'FONT',
18
+ wrapperStyle: 'vertical-align: inherit'
19
+ },
20
+ {
21
+ name: 'yandex',
22
+ wrapperTag: 'YA-TR-SPAN'
23
+ },
24
+ {
25
+ name: 'edge',
26
+ markerAttributes: [
27
+ '_msttexthash',
28
+ '_msthash'
29
+ ]
30
+ },
31
+ {
32
+ name: 'firefox',
33
+ markerAttributes: [
34
+ 'data-moz-translations-id'
35
+ ]
36
+ }
37
+ ];
38
+ const MARKER_ATTRIBUTES = ENGINES.flatMap((engine)=>engine.markerAttributes ?? []);
39
+ const isText = (node)=>node.nodeType === Node.TEXT_NODE;
40
+ const isElement = (node)=>node.nodeType === Node.ELEMENT_NODE;
41
+ const matchesFingerprint = (element, engine)=>{
42
+ if (engine.wrapperTag !== element.tagName) return false;
43
+ if (!engine.wrapperStyle) return true;
44
+ return (element.getAttribute('style') ?? '').includes(engine.wrapperStyle);
45
+ };
46
+ const engineOwningTag = (tagName)=>ENGINES.find((engine)=>engine.wrapperTag === tagName);
47
+ /**
48
+ * Recognises the element a translator injected in place of a TextNode.
49
+ *
50
+ * `extraTags` widens the set for an engine we do not ship a row for. It cannot
51
+ * narrow or replace a built-in fingerprint: passing `FONT`, the tag every bug
52
+ * report names, must not turn every `<font>` on the page into a wrapper.
53
+ */ const isTranslatorWrapper = (node, extraTags)=>{
54
+ if (!isElement(node)) return false;
55
+ const owner = engineOwningTag(node.tagName);
56
+ if (owner) return matchesFingerprint(node, owner);
57
+ return extraTags.some((tag)=>tag.toUpperCase() === node.tagName);
58
+ };
59
+ /** Names the engine that produced a wrapper the observer already matched. */ const engineOfWrapper = (wrapper)=>engineOwningTag(wrapper.tagName)?.name ?? null;
60
+ /** Names the translator working on a document, wrapper or marker attributes alike. */ const detectEngine = (root)=>{
61
+ for (const engine of ENGINES){
62
+ const selectors = [
63
+ engine.wrapperTag && `${engine.wrapperTag.toLowerCase()}${engine.wrapperStyle ? `[style*="${engine.wrapperStyle}"]` : ''}`,
64
+ ...(engine.markerAttributes ?? []).map((attribute)=>`[${attribute}]`)
65
+ ].filter(Boolean);
66
+ if (selectors.length > 0 && root.querySelector(selectors.join(','))) return engine.name;
67
+ }
68
+ return null;
69
+ };
70
+
71
+ export { MARKER_ATTRIBUTES as M, isElement as a, isTranslatorWrapper as b, detectEngine as d, engineOfWrapper as e, isText as i };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * One row per translator, and everything else in this file is derived from it.
3
+ * Adding an engine means adding a row, not editing four predicates that have to
4
+ * agree with each other.
5
+ *
6
+ * A bare tag is never enough. Apps emit `<font>` through rendered markdown, CMS
7
+ * content and `dangerouslySetInnerHTML`, so Google's wrapper is only recognised
8
+ * with the inline `vertical-align: inherit` it always carries, read off the
9
+ * `style` attribute because `getComputedStyle` resolves `inherit` to `baseline`.
10
+ *
11
+ * Edge and Firefox rewrite the live text node and inject no wrapper, so they
12
+ * have markers but no tag. They are named for observability; there is nothing
13
+ * for the shield to mirror into.
14
+ */ const ENGINES = [
15
+ {
16
+ name: 'google',
17
+ wrapperTag: 'FONT',
18
+ wrapperStyle: 'vertical-align: inherit'
19
+ },
20
+ {
21
+ name: 'yandex',
22
+ wrapperTag: 'YA-TR-SPAN'
23
+ },
24
+ {
25
+ name: 'edge',
26
+ markerAttributes: [
27
+ '_msttexthash',
28
+ '_msthash'
29
+ ]
30
+ },
31
+ {
32
+ name: 'firefox',
33
+ markerAttributes: [
34
+ 'data-moz-translations-id'
35
+ ]
36
+ }
37
+ ];
38
+ const MARKER_ATTRIBUTES = ENGINES.flatMap((engine)=>engine.markerAttributes ?? []);
39
+ const isText = (node)=>node.nodeType === Node.TEXT_NODE;
40
+ const isElement = (node)=>node.nodeType === Node.ELEMENT_NODE;
41
+ const matchesFingerprint = (element, engine)=>{
42
+ if (engine.wrapperTag !== element.tagName) return false;
43
+ if (!engine.wrapperStyle) return true;
44
+ return (element.getAttribute('style') ?? '').includes(engine.wrapperStyle);
45
+ };
46
+ const engineOwningTag = (tagName)=>ENGINES.find((engine)=>engine.wrapperTag === tagName);
47
+ /**
48
+ * Recognises the element a translator injected in place of a TextNode.
49
+ *
50
+ * `extraTags` widens the set for an engine we do not ship a row for. It cannot
51
+ * narrow or replace a built-in fingerprint: passing `FONT`, the tag every bug
52
+ * report names, must not turn every `<font>` on the page into a wrapper.
53
+ */ const isTranslatorWrapper = (node, extraTags)=>{
54
+ if (!isElement(node)) return false;
55
+ const owner = engineOwningTag(node.tagName);
56
+ if (owner) return matchesFingerprint(node, owner);
57
+ return extraTags.some((tag)=>tag.toUpperCase() === node.tagName);
58
+ };
59
+ /** Names the engine that produced a wrapper the observer already matched. */ const engineOfWrapper = (wrapper)=>engineOwningTag(wrapper.tagName)?.name ?? null;
60
+ /** Names the translator working on a document, wrapper or marker attributes alike. */ const detectEngine = (root)=>{
61
+ for (const engine of ENGINES){
62
+ const selectors = [
63
+ engine.wrapperTag && `${engine.wrapperTag.toLowerCase()}${engine.wrapperStyle ? `[style*="${engine.wrapperStyle}"]` : ''}`,
64
+ ...(engine.markerAttributes ?? []).map((attribute)=>`[${attribute}]`)
65
+ ].filter(Boolean);
66
+ if (selectors.length > 0 && root.querySelector(selectors.join(','))) return engine.name;
67
+ }
68
+ return null;
69
+ };
70
+
71
+ exports.MARKER_ATTRIBUTES = MARKER_ATTRIBUTES;
72
+ exports.detectEngine = detectEngine;
73
+ exports.engineOfWrapper = engineOfWrapper;
74
+ exports.isElement = isElement;
75
+ exports.isText = isText;
76
+ exports.isTranslatorWrapper = isTranslatorWrapper;